Commit 70aa7f1240579b8f78535c4fd93e80990215c82f

Authored by 朱焱飞
0 parents

feat: 初始化AI驱动的PMS模块,包含LLM集成、智能代理、API接口和前端界面。

Showing 65 changed files with 10123 additions and 0 deletions
.env 0 → 100644
  1 +++ a/.env
  1 +# OpenAI 兼容模式 (智谱AI glm-4-7)
  2 +OPENAI_COMPAT_API_KEY=ed148f06-82f0-4fca-991d-05ae7296e110
  3 +OPENAI_COMPAT_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
  4 +OPENAI_COMPAT_MODEL=glm-4-7-251222
  5 +
  6 +# 豆包配置 (备选)
  7 +DOUBAO_API_KEY=
  8 +DOUBAO_MODEL=doubao-pro
  9 +
  10 +# 数据库配置
  11 +MYSQL_HOST=172.26.154.169
  12 +MYSQL_PORT=3302
  13 +MYSQL_USER=test
  14 +MYSQL_PASSWORD=mysql@pwd123
  15 +MYSQL_DATABASE=fw_pms
  16 +
  17 +# 定时任务配置
  18 +SCHEDULER_CRON_HOUR=2
  19 +SCHEDULER_CRON_MINUTE=0
  20 +
  21 +# 日志配置
  22 +LOG_LEVEL=INFO
.gitignore 0 → 100644
  1 +++ a/.gitignore
  1 +# Python
  2 +__pycache__/
  3 +*.py[cod]
  4 +*$py.class
  5 +*.so
  6 +.Python
  7 +build/
  8 +develop-eggs/
  9 +dist/
  10 +downloads/
  11 +eggs/
  12 +.eggs/
  13 +lib/
  14 +lib64/
  15 +parts/
  16 +sdist/
  17 +var/
  18 +wheels/
  19 +*.egg-info/
  20 +.installed.cfg
  21 +*.egg
  22 +
  23 +# Virtual environments
  24 +venv/
  25 +.venv/
  26 +ENV/
  27 +env/
  28 +
  29 +# IDE
  30 +.idea/
  31 +.vscode/
  32 +*.swp
  33 +*.swo
  34 +*~
  35 +.project
  36 +.pydevproject
  37 +.settings/
  38 +
  39 +# Testing
  40 +.tox/
  41 +.nox/
  42 +.coverage
  43 +.coverage.*
  44 +htmlcov/
  45 +.pytest_cache/
  46 +.hypothesis/
  47 +
  48 +# Mypy
  49 +.mypy_cache/
  50 +.dmypy.json
  51 +dmypy.json
  52 +
  53 +# Ruff
  54 +.ruff_cache/
  55 +
  56 +# Jupyter
  57 +.ipynb_checkpoints/
  58 +
  59 +# OS
  60 +.DS_Store
  61 +Thumbs.db
  62 +
  63 +# Logs
  64 +*.log
  65 +logs/
  66 +
  67 +# Local development
  68 +*.local
  69 +*.bak
README.md 0 → 100644
  1 +++ a/README.md
  1 +# fw-pms-ai
  2 +
  3 +AI 配件系统 - 基于 Python + LangChain + LangGraph
  4 +
  5 +## 项目简介
  6 +
  7 +本项目是 `fw-pms` 的 AI 扩展模块,使用大语言模型 (LLM) 和 Agent 技术,为配件管理系统提供智能化的补货建议能力。
  8 +
  9 +## 核心技术
  10 +
  11 +### LangChain + LangGraph
  12 +
  13 +| 技术 | 作用 |
  14 +|------|------|
  15 +| **LangChain** | LLM 框架,提供模型抽象、Prompt 管理、消息格式化 |
  16 +| **LangGraph** | Agent 工作流编排,管理状态机、定义节点和边、支持条件分支 |
  17 +| **SQL Agent** | 自定义 Text-to-SQL 实现,支持错误重试和 LLM 数据分析 |
  18 +
  19 +```mermaid
  20 +graph LR
  21 + A[用户请求] --> B[LangGraph Agent]
  22 + B --> C[FetchPartRatio]
  23 + C --> D[SQLAgent<br/>LLM分析]
  24 + D --> E[AllocateBudget]
  25 + E --> F[GenerateReport<br/>LLM]
  26 + F --> G[SaveResult]
  27 +```
  28 +
  29 +详细架构图见 [docs/architecture.md](docs/architecture.md)
  30 +
  31 +## 功能模块
  32 +
  33 +### ✅ 已实现
  34 +
  35 +| 模块 | 功能 | 说明 |
  36 +|------|------|------|
  37 +| **SQL Agent** | LLM 分析 | 直接分析 part_ratio 数据生成补货建议 |
  38 +| **补货分配** | Replenishment | 转换 LLM 建议为补货明细 |
  39 +| **分析报告** | Report | LLM 生成库存分析报告 |
  40 +
  41 +### 🚧 计划中
  42 +
  43 +| 模块 | 功能 |
  44 +|------|------|
  45 +| 预测引擎 | 基于历史销量预测未来需求 |
  46 +| 异常检测 | 识别数据异常 |
  47 +
  48 +## 项目结构
  49 +
  50 +```
  51 +fw-pms-ai/
  52 +├── src/fw_pms_ai/
  53 +│ ├── agent/ # LangGraph Agent
  54 +│ │ ├── state.py # Agent 状态定义
  55 +│ │ ├── nodes.py # 工作流节点
  56 +│ │ ├── sql_agent.py # SQL Agent(Text-to-SQL + 建议生成)
  57 +│ │ └── replenishment.py
  58 +│ ├── api/ # FastAPI 接口
  59 +│ │ ├── app.py # 应用入口
  60 +│ │ └── routes/ # 路由模块
  61 +│ ├── config/ # 配置管理
  62 +│ ├── llm/ # LLM 集成
  63 +│ │ ├── base.py # 抽象基类
  64 +│ │ ├── glm.py # 智谱 GLM
  65 +│ │ ├── doubao.py # 豆包
  66 +│ │ ├── openai_compat.py
  67 +│ │ └── anthropic_compat.py
  68 +│ ├── models/ # 数据模型
  69 +│ │ ├── task.py # 任务和明细模型
  70 +│ │ ├── execution_log.py # 执行日志模型
  71 +│ │ ├── part_ratio.py # 库销比模型
  72 +│ │ ├── part_summary.py # 配件汇总模型
  73 +│ │ ├── sql_result.py # SQL执行结果模型
  74 +│ │ └── suggestion.py # 补货建议模型
  75 +│ ├── services/ # 业务服务
  76 +│ │ ├── db.py # 数据库连接
  77 +│ │ ├── data_service.py # 数据查询服务
  78 +│ │ └── result_writer.py # 结果写入服务
  79 +│ ├── scheduler/ # 定时任务
  80 +│ └── main.py
  81 +├── prompts/ # AI Prompt 文件
  82 +│ ├── sql_agent.md # SQL Agent 系统提示词
  83 +│ ├── suggestion.md # 补货建议提示词
  84 +│ ├── suggestion_system.md
  85 +│ ├── part_shop_analysis.md
  86 +│ └── part_shop_analysis_system.md
  87 +├── ui/ # 前端静态文件
  88 +├── sql/ # 数据库迁移脚本
  89 +├── pyproject.toml
  90 +└── README.md
  91 +```
  92 +
  93 +
  94 +## 工作流程
  95 +
  96 +```
  97 +1. FetchPartRatio - 从 part_ratio 表获取库销比数据
  98 +2. SQLAgent - LLM 分析数据,生成补货建议
  99 +3. AllocateBudget - 转换建议为补货明细
  100 +4. GenerateReport - LLM 生成分析报告
  101 +5. SaveResult - 写入数据库
  102 +```
  103 +
  104 +### 业务术语
  105 +
  106 +| 术语 | 定义 | 处理 |
  107 +|------|------|------|
  108 +| **呆滞件** | 有库存,90天无销量 | 不做计划 |
  109 +| **低频件** | 无库存,月均销量<1 | 不做计划 |
  110 +| **缺货件** | 无库存,月均销量≥1 | 需要补货 |
  111 +
  112 +## 数据表说明
  113 +
  114 +| 表名 | 说明 |
  115 +|------|------|
  116 +| `part_ratio` | 配件库销比数据(来源表) |
  117 +| `ai_replenishment_task` | 任务记录 |
  118 +| `ai_replenishment_detail` | 配件级别补货建议 |
  119 +| `ai_replenishment_report` | 分析报告 |
  120 +| `ai_task_execution_log` | 任务执行日志 |
  121 +| `ai_llm_suggestion_detail` | LLM 建议明细 |
  122 +
  123 +## 快速开始
  124 +
  125 +### 1. 安装依赖
  126 +
  127 +```bash
  128 +cd fw-pms-ai
  129 +pip install -e .
  130 +```
  131 +
  132 +### 2. 配置环境变量
  133 +
  134 +```bash
  135 +cp .env.example .env
  136 +```
  137 +
  138 +必填配置项:
  139 +- `GLM_API_KEY` / `ANTHROPIC_API_KEY` - LLM API Key
  140 +- `MYSQL_HOST`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE`
  141 +
  142 +### 3. 初始化数据库
  143 +
  144 +```bash
  145 +mysql -u root -p fw_pms < sql/init.sql
  146 +mysql -u root -p fw_pms < sql/v2_add_log_tables.sql
  147 +```
  148 +
  149 +### 4. 运行
  150 +
  151 +```bash
  152 +# 启动定时任务调度器
  153 +fw-pms-ai
  154 +
  155 +# 立即执行一次
  156 +fw-pms-ai --run-once
  157 +
  158 +# 指定参数
  159 +fw-pms-ai --run-once --group-id 2
  160 +```
  161 +
  162 +## AI Prompt 文件
  163 +
  164 +Prompt 文件存放在 `prompts/` 目录:
  165 +
  166 +| 文件 | 用途 |
  167 +|------|------|
  168 +| `suggestion.md` | 补货建议生成(含业务术语定义) |
  169 +| `analyze_inventory.md` | 库存分析 |
  170 +| `generate_report.md` | 报告生成 |
  171 +
  172 +## 开发
  173 +
  174 +```bash
  175 +# 安装开发依赖
  176 +pip install -e ".[dev]"
  177 +
  178 +# 运行测试
  179 +pytest tests/ -v
  180 +```
docs/architecture.md 0 → 100644
  1 +++ a/docs/architecture.md
  1 +# fw-pms-ai 系统架构
  2 +
  3 +## 技术栈
  4 +
  5 +| 组件 | 技术 |
  6 +|------|------|
  7 +| 编程语言 | Python 3.11+ |
  8 +| Agent 框架 | LangChain + LangGraph |
  9 +| LLM | 智谱 GLM / 豆包 / OpenAI 兼容接口 |
  10 +| 数据库 | MySQL |
  11 +| API 框架 | FastAPI |
  12 +| 任务调度 | APScheduler |
  13 +
  14 +---
  15 +
  16 +## 系统架构图
  17 +
  18 +```mermaid
  19 +flowchart TB
  20 + subgraph API ["FastAPI API 层"]
  21 + A[/tasks endpoint/]
  22 + end
  23 +
  24 + subgraph Agent ["LangGraph Agent"]
  25 + direction TB
  26 + B[fetch_part_ratio] --> C[sql_agent]
  27 + C --> D{需要重试?}
  28 + D -->|是| C
  29 + D -->|否| E[allocate_budget]
  30 + E --> F[END]
  31 + end
  32 +
  33 + subgraph Services ["业务服务层"]
  34 + G[DataService]
  35 + H[ResultWriter]
  36 + end
  37 +
  38 + subgraph LLM ["LLM 集成"]
  39 + I[GLM]
  40 + J[Doubao]
  41 + K[OpenAI Compat]
  42 + end
  43 +
  44 + subgraph DB ["数据存储"]
  45 + L[(MySQL)]
  46 + end
  47 +
  48 + A --> Agent
  49 + B --> G
  50 + C --> LLM
  51 + E --> H
  52 + G --> L
  53 + H --> L
  54 +```
  55 +
  56 +---
  57 +
  58 +## 工作流节点说明
  59 +
  60 +| 节点 | 职责 | 输入 | 输出 |
  61 +|------|------|------|------|
  62 +| `fetch_part_ratio` | 获取商家组合的配件库销比数据 | dealer_grouping_id | part_ratios[] |
  63 +| `sql_agent` | LLM 分析配件数据,生成补货建议 | part_ratios[] | llm_suggestions[], part_results[] |
  64 +| `allocate_budget` | 转换 LLM 建议为补货明细 | llm_suggestions[] | details[] |
  65 +
  66 +---
  67 +
  68 +## 核心数据流
  69 +
  70 +```mermaid
  71 +sequenceDiagram
  72 + participant API
  73 + participant Agent
  74 + participant SQLAgent
  75 + participant LLM
  76 + participant DB
  77 +
  78 + API->>Agent: 创建任务
  79 + Agent->>DB: 保存任务记录
  80 + Agent->>DB: 查询 part_ratio
  81 + Agent->>SQLAgent: 分析配件数据
  82 +
  83 + loop 每个配件
  84 + SQLAgent->>LLM: 发送分析请求
  85 + LLM-->>SQLAgent: 返回补货建议
  86 + end
  87 +
  88 + SQLAgent-->>Agent: 汇总建议
  89 + Agent->>DB: 保存补货明细
  90 + Agent->>DB: 更新任务状态
  91 + Agent-->>API: 返回结果
  92 +```
  93 +
  94 +---
  95 +
  96 +## 目录结构
  97 +
  98 +```
  99 +src/fw_pms_ai/
  100 +├── agent/ # LangGraph 工作流
  101 +│ ├── state.py # 状态定义 (TypedDict)
  102 +│ ├── nodes.py # 工作流节点
  103 +│ ├── sql_agent.py # SQL Agent 实现
  104 +│ └── replenishment.py # 主入口
  105 +├── api/ # REST API
  106 +├── config/ # 配置管理
  107 +├── llm/ # LLM 适配器
  108 +├── models/ # 数据模型
  109 +├── services/ # 业务服务
  110 +└── scheduler/ # 定时任务
  111 +```
  112 +
  113 +---
  114 +
  115 +## 数据库表结构
  116 +
  117 +| 表名 | 用途 |
  118 +|------|------|
  119 +| `part_ratio` | 配件库销比数据(只读) |
  120 +| `ai_replenishment_task` | 任务记录 |
  121 +| `ai_replenishment_detail` | 补货明细 |
  122 +| `ai_replenishment_part_summary` | 配件级汇总 |
  123 +| `ai_task_execution_log` | 执行日志 |
prompts/part_shop_analysis.md 0 → 100644
  1 +++ a/prompts/part_shop_analysis.md
  1 +# 单配件多门店分析提示词
  2 +
  3 +## 配件信息
  4 +
  5 +- **配件编码**: {part_code}
  6 +- **配件名称**: {part_name}
  7 +- **成本价**: ¥{cost_price}
  8 +- **单位**: {unit}
  9 +
  10 +## 商家组合
  11 +
  12 +- **商家组合名称**: {dealer_grouping_name}
  13 +- **统计日期**: {statistics_date}
  14 +- **目标库销比**: {target_ratio}(基于商家组合计算的基准库销比,用于计算目标库存)
  15 +
  16 +---
  17 +
  18 +## 各门店数据
  19 +
  20 +{shop_data}
  21 +
  22 +## 字段说明
  23 +
  24 +| 字段名 | 含义 | 用途 |
  25 +|--------|------|------|
  26 +| shop_id | 门店ID | 唯一标识 |
  27 +| shop_name | 门店名称 | 显示用 |
  28 +| valid_storage_cnt | 有效库存数量 | 当前可用库存 |
  29 +| avg_sales_cnt | 月均销量 | 基于90天销售数据计算 |
  30 +| out_stock_cnt | 90天出库数量 | 判断呆滞件的依据 |
  31 +| out_times | 90天内出库次数 | 判断低频件的依据(< 3次为低频件) |
  32 +| out_duration | 平均出库时长(天) | 最近两次出库间隔天数(≥ 30天为低频件) |
  33 +| current_ratio | 当前库销比 | = 有效库存 / 月均销量 |
  34 +
  35 +---
  36 +
  37 +## 分析任务
  38 +
  39 +请按以下三层决策逻辑逐步分析:
  40 +
  41 +### 1️⃣ 配件级判断(商家组合维度)
  42 +- 汇总所有门店的库存和销量,计算配件在商家组合内的整体库销比
  43 +- 判断该配件是否需要补货,需要补多少个
  44 +- 生成配件级决策理由
  45 +
  46 +### 2️⃣ 门店级分配
  47 +- 将总补货数量分配到各门店
  48 +- 优先分配给库销比最低的门店
  49 +- 公式: suggest_cnt = ceil({target_ratio} × 月均销量 - 当前库存)
  50 +
  51 +### 3️⃣ 为每个门店提供专业的决策理由
  52 +
  53 +**理由必须包含以下要素**:
  54 +1. **状态判定标签**(括起来):急需补货 / 建议补货 / 可选补货 / 库存充足 / 呆滞件 / 低频件-xxx
  55 +2. **关键指标数据**:当前库存X件、月均销量Y件、库销比Z
  56 +3. **缺口分析**(补货时):目标库销比需备货A件,缺口B件
  57 +4. **天数说明**:当前库存可支撑约X天 / 补货后可支撑约Y天
  58 +5. **紧迫程度**:风险说明或建议优先级说明
  59 +6. **排除原因**(不补货时):引用具体的排除规则数据
  60 +
  61 +---
  62 +
  63 +## 输出格式
  64 +
  65 +直接输出JSON对象,**不要**包含 ```json 标记或任何其他文字:
  66 +
  67 +{{
  68 + "part_code": "{part_code}",
  69 + "part_name": "{part_name}",
  70 + "need_replenishment": true或false,
  71 + "total_storage_cnt": 商家组合内总库存(数字),
  72 + "total_avg_sales_cnt": 商家组合内总月均销量(数字),
  73 + "group_current_ratio": 商家组合级库销比(数字,保留2位小数),
  74 + "total_suggest_cnt": 所有门店建议数量之和,
  75 + "total_suggest_amount": 所有门店建议金额之和,
  76 + "part_decision_reason": "【配件决策】该配件在商家组合内总库存X件,月均总销量Y件,整体库销比Z。{{决策说明}}。该配件共涉及K家门店,其中J家门店需要补货。",
  77 + "shop_count": 涉及门店总数(整数),
  78 + "shop_suggestions": [
  79 + {{
  80 + "shop_id": 门店ID(整数),
  81 + "shop_name": "门店名称",
  82 + "current_storage_cnt": 当前库存(数字),
  83 + "avg_sales_cnt": 月均销量(数字),
  84 + "current_ratio": 当前库销比(数字,保留2位小数),
  85 + "suggest_cnt": 建议采购数量(整数),
  86 + "suggest_amount": 建议采购金额(数字),
  87 + "priority": 优先级(1=高/2=中/3=低),
  88 + "reason": "专业详尽的补货理由,包含关键数据指标(参见理由撰写规范)"
  89 + }}
  90 + ],
  91 + "priority": 配件整体优先级(1=高/2=中/3=低),
  92 + "confidence": 置信度(0.0-1.0)
  93 +}}
  94 +
  95 +---
  96 +
  97 +## 理由示例
  98 +
  99 +### 补货理由示例:
  100 +
  101 +**高优先级**:
  102 +```
  103 +「急需补货」当前库存0件,月均销量8.2件,库销比0.00,缺口分析:目标库销比{target_ratio}需备货X件,补货后可支撑约Y天销售。库存已告罄且销量活跃,存在严重缺货风险,建议立即补货。
  104 +```
  105 +
  106 +**中优先级**:
  107 +```
  108 +「建议补货」当前库存2件,月均销量5.3件,库销比0.38,缺口分析:目标库销比{target_ratio}需备货X件,实际缺口Y件,补货后可支撑约Z天销售。当前库存仅够约11天销售,低于安全库销比0.5,建议尽快补货。
  109 +```
  110 +
  111 +**低优先级**:
  112 +```
  113 +「可选补货」当前库存4件,月均销量3.5件,库销比1.14,优化建议:目标库销比{target_ratio}需备货X件,缺口Y件,补货后可支撑约Z天销售。库存处于安全边界,可根据资金情况酌情补货。
  114 +```
  115 +
  116 +### 不补货理由示例:
  117 +
  118 +**库存充足**:
  119 +```
  120 +「库存充足」当前库存8件,月均销量4.0件,库销比2.00,可支撑约60天销售,超过安全阈值({target_ratio}个月),无需补货。
  121 +```
  122 +
  123 +**低频件**:
  124 +```
  125 +「低频件-需求不足」当前库存0件,月均销量0.3件,月均销量不足1件(阈值≥1),周转需求过低,暂不纳入补货计划。
  126 +```
  127 +
  128 +**呆滞件**:
  129 +```
  130 +「呆滞件」当前库存5件,但90天内无任何销售(月均销量0),库存滞销风险高,建议安排清理处置,暂不补货。
  131 +```
  132 +
  133 +---
  134 +
  135 +## 重要约束
  136 +
  137 +1. **shop_suggestions 必须包含所有输入门店**,无论是否需要补货
  138 +2. **suggest_cnt = 0 的门店也必须返回**,并提供详细的不补货理由(如"库存充足"、"低频件"、"呆滞件"等)
  139 +3. **total_suggest_cnt 必须等于**所有门店 suggest_cnt 之和
  140 +4. **呆滞件/低频件的 suggest_cnt 必须为 0**
  141 +5. **低频件判定(满足任一即为低频件,不纳入补货)**:
  142 + - `avg_sales_cnt < 1`: 月均销量 < 1
  143 + - `out_times < 3`:90天内出库次数不足3次
  144 + - `out_duration >= 30`:平均出库时长 ≥ 30天
  145 +6. **输出必须是合法的JSON**,可被直接解析
  146 +7. **理由必须专业详尽**,包含具体数据指标,贴合采购人员专业度
prompts/part_shop_analysis_system.md 0 → 100644
  1 +++ a/prompts/part_shop_analysis_system.md
  1 +# 单配件多门店补货分析专家
  2 +
  3 +## 角色定义
  4 +
  5 +你是一位资深的汽车4S店配件库存管理专家,拥有以下专业能力:
  6 +- 精通库销比分析与补货决策
  7 +- 熟悉汽车配件供应链特点(季节性、周期性、区域差异)
  8 +- 擅长多门店库存协调优化
  9 +- 具备成本控制和资金周转意识
  10 +
  11 +## 决策原则
  12 +
  13 +1. **数据驱动**: 仅基于提供的数据做出判断,不做任何假设或猜测
  14 +2. **保守策略**: 宁可少补不要多补,避免积压风险
  15 +3. **优先级区分**: 急需 > 建议 > 可选,资源有限时优先处理高优先级
  16 +4. **全面覆盖**: 对每一个门店都必须给出分析结论
  17 +
  18 +---
  19 +
  20 +## 核心分析框架
  21 +
  22 +### Step 1: 门店状态分类
  23 +
  24 +按以下标准对每个门店进行分类:
  25 +
  26 +| 状态 | 条件 | 处理方式 |
  27 +|------|------|----------|
  28 +| 🔴 急需补货 | 库销比 < 0.5 且月均销量 ≥ 1 | 高优先级补货 |
  29 +| 🟡 建议补货 | 库销比 0.5-1.0 且月均销量 ≥ 1 | 中优先级补货 |
  30 +| 🟢 可选补货 | 库销比 1.0-{target_ratio} 且月均销量 ≥ 1 | 低优先级补货 |
  31 +| ⚪ 无需补货 | 库销比 > {target_ratio} | 不补货 |
  32 +
  33 +### Step 2: 排除规则(强制执行)
  34 +
  35 +以下情况**绝对不补货**,suggest_cnt 必须为 0:
  36 +
  37 +1. **呆滞件**: `valid_storage_cnt > 0` 且 `avg_sales_cnt = 0`
  38 + - 特征:有库存但90天无任何销售
  39 + - 原因:库存积压风险,需清理而非补货
  40 +
  41 +2. **低频件**: `valid_storage_cnt = 0` 且满足**以下任一条件**:
  42 + - A. `avg_sales_cnt < 1` (月均销量 < 1)
  43 + - B. `out_times < 3` (90天内出库次数 < 3)
  44 + - C. `out_duration >= 30` (平均出库间隔 ≥ 30天)
  45 + - 原因:需求过低、周转太慢或间隔过长,不纳入补货计划
  46 +
  47 +3. **库存充足**: 库销比 > {target_ratio}
  48 + - 特征:库存可支撑{target_ratio}个月以上销售
  49 + - 原因:无需额外补货
  50 +
  51 +### Step 3: 补货量计算
  52 +
  53 +```
  54 +初步缺口 = 目标库销比({target_ratio}) × 月均销量 - 当前有效库存
  55 +
  56 +补货量规则:
  57 +1. 如果 初步缺口 > 1:建议数量 = floor(初步缺口) // 向下取整,保守策略
  58 +2. 如果 0 < 初步缺口 <= 1:建议数量 = 1 // 最小补货量
  59 +3. 如果 初步缺口 <= 0:建议数量 = 0 // 无需补货
  60 +```
  61 +
  62 +**计算示例**:
  63 +- **Case A**: 月销量=1.0, 库存=0, 目标=1.13 -> 缺口=1.13 -> 建议=1 (向下取整)
  64 +- **Case B**: 月销量=5.0, 库存=4, 目标=1.13 -> 缺口=1.65 -> 建议=1 (向下取整)
  65 +- **Case C**: 月销量=5.0, 库存=1, 目标=1.13 -> 缺口=4.65 -> 建议=4 (向下取整)
  66 +- **Case D**: 月销量=1.0, 库存=0.5, 目标=1.13 -> 缺口=0.63 -> 建议=1 (最小补货)
  67 +
  68 +### Step 4: 优先级判定
  69 +
  70 +| 优先级 | 条件 | 说明 |
  71 +|--------|------|------|
  72 +| 1 (高) | 库销比 < 0.5 且月均销量 ≥ 1 | 急需补货,缺货风险高 |
  73 +| 2 (中) | 库销比 0.5-1.0 且月均销量 ≥ 1 | 建议补货,库存偏低 |
  74 +| 3 (低) | 库销比 1.0-{target_ratio} 且月均销量 ≥ 1 | 可选补货,安全库存边界 |
  75 +
  76 +---
  77 +
  78 +## 理由撰写规范(重要)
  79 +
  80 +### 补货理由必须包含的关键数据
  81 +
  82 +理由字段必须**采用专业的采购语言**,并**引用具体数据指标**,格式如下:
  83 +
  84 +**补货理由模板**:
  85 +```
  86 +「{状态判定}」当前库存{X}件,月均销量{Y}件,库销比{Z},
  87 +{缺口/充足}分析:目标库销比{target_ratio}需备货{A}件,补货{B}件后可支撑{C}天销售。
  88 +{紧迫程度说明}。
  89 +```
  90 +
  91 +**不补货理由模板**:
  92 +```
  93 +「{排除类型}」{排除原因的关键数据说明},
  94 +{不补货依据}。
  95 +```
  96 +
  97 +### 理由撰写示例
  98 +
  99 +**✅ 高优先级补货**:
  100 +```
  101 +「急需补货」当前库存0件,月均销量8.2件,库销比0.00,
  102 +缺口分析:目标库销比{target_ratio}需备货X件,按目标补货后可支撑约Y天销售。
  103 +库存已告罄且销量活跃,存在严重缺货风险,建议立即补货。
  104 +```
  105 +
  106 +**✅ 中优先级补货**:
  107 +```
  108 +「建议补货」当前库存2件,月均销量5.3件,库销比0.38,
  109 +缺口分析:目标库销比{target_ratio}需备货X件,实际缺口Y件,补货后可支撑约Z天销售。
  110 +当前库存仅够约11天销售,低于安全库销比0.5,建议尽快补货。
  111 +```
  112 +
  113 +**✅ 低优先级补货**:
  114 +```
  115 +「可选补货」当前库存4件,月均销量3.5件,库销比1.14,
  116 +优化建议:目标库销比{target_ratio}需备货X件,缺口Y件,补货后可支撑约Z天销售。
  117 +库存处于安全边界,可根据资金情况酌情补货。
  118 +```
  119 +
  120 +**✅ 无需补货**:
  121 +```
  122 +「库存充足」当前库存8件,月均销量4.0件,库销比2.00,
  123 +可支撑约60天销售,超过安全阈值({target_ratio}个月),无需补货。
  124 +```
  125 +
  126 +**✅ 低频件排除**:
  127 +```
  128 +「低频件-出库次数不足」90天内仅出库2次(阈值≥3次),
  129 +出库间隔约36天,周转频率过低,暂不纳入补货计划。
  130 +```
  131 +
  132 +**✅ 呆滞件排除**:
  133 +```
  134 +「呆滞件」当前库存5件,但90天内无任何出库记录,
  135 +库存滞销风险高,建议安排清理处置,暂不补货。
  136 +```
  137 +
  138 +**✅ 低需求排除**:
  139 +```
  140 +「低频件-需求不足」当前库存0件,月均销量0.3件,
  141 +月均销量不足1件(阈值≥1件),需求过低不值得备货。
  142 +```
  143 +
  144 +---
  145 +
  146 +## 输出要求
  147 +
  148 +1. **纯JSON输出**: 只输出JSON对象,不要有任何其他文字、解释或代码块标记
  149 +2. **完整覆盖**: 对输入的每个门店都必须在shop_suggestions中体现(无论是否需要补货)
  150 +3. **理由专业详尽**: reason字段必须按照上述模板撰写,包含关键数据指标
  151 +
  152 +## 输出质量自检
  153 +
  154 +输出前请确认:
  155 +- ✅ 输出是否为纯JSON,无 ```json 包裹?
  156 +- ✅ total_suggest_cnt 是否等于所有门店 suggest_cnt 之和?
  157 +- ✅ 呆滞件/低频件的 suggest_cnt 是否为 0?
  158 +- ✅ 低频件规则检查:`out_times < 3` 或 `out_duration >= 30` 的是否 suggest_cnt = 0?
  159 +- ✅ shop_suggestions 是否包含了所有输入门店(无论是否补货)?
  160 +- ✅ suggest_cnt = 0 的门店是否有详细的不补货理由?
  161 +- ✅ 每个 reason 是否包含具体数据(库存、销量、库销比、天数等)?
  162 +- ✅ reason 是否采用专业采购语言,不过于简化?
prompts/sql_agent.md 0 → 100644
  1 +++ a/prompts/sql_agent.md
  1 +# SQL Agent 系统提示词
  2 +
  3 +## 角色定义
  4 +
  5 +你是一位专业的数据库分析专家,精通 MySQL 查询优化和汽车配件库存管理数据模型。
  6 +
  7 +### 核心能力
  8 +
  9 +1. **SQL编写**: 生成高效、安全的MySQL查询语句
  10 +2. **数据建模**: 理解配件库销比数据结构
  11 +3. **性能优化**: 避免全表扫描,合理使用索引
  12 +
  13 +---
  14 +
  15 +## 任务说明
  16 +
  17 +根据用户需求生成正确的 SQL 查询语句,从 part_ratio 表获取配件库销比数据。
  18 +
  19 +---
  20 +
  21 +## 数据表结构
  22 +
  23 +```sql
  24 +CREATE TABLE part_ratio (
  25 + id BIGINT PRIMARY KEY,
  26 + group_id BIGINT NOT NULL COMMENT '集团ID',
  27 + brand_id BIGINT NOT NULL COMMENT '品牌ID',
  28 + brand_grouping_id BIGINT COMMENT '品牌组合ID',
  29 + dealer_grouping_id BIGINT COMMENT '商家组合ID',
  30 + supplier_id BIGINT COMMENT '供应商ID',
  31 + supplier_name VARCHAR(500) COMMENT '供应商名称',
  32 + area_id BIGINT NOT NULL COMMENT '区域ID',
  33 + area_name VARCHAR(500) NOT NULL COMMENT '区域名称',
  34 + shop_id BIGINT NOT NULL COMMENT '库房ID',
  35 + shop_name VARCHAR(500) NOT NULL COMMENT '库房名称',
  36 + part_id BIGINT NOT NULL COMMENT '配件ID',
  37 + part_code VARCHAR(500) NOT NULL COMMENT '配件编码',
  38 + part_name VARCHAR(500) COMMENT '配件名称',
  39 + unit VARCHAR(50) COMMENT '单位',
  40 + cost_price DECIMAL(14,2) COMMENT '成本价',
  41 + in_stock_unlocked_cnt DECIMAL(20,2) DEFAULT 0 COMMENT '在库未锁数量',
  42 + has_plan_cnt DECIMAL(20,2) DEFAULT 0 COMMENT '已计划数量',
  43 + on_the_way_cnt DECIMAL(20,2) DEFAULT 0 COMMENT '在途数量',
  44 + out_stock_cnt DECIMAL(20,2) DEFAULT 0 COMMENT '出库数量(90天)',
  45 + buy_cnt INT DEFAULT 0 COMMENT '客户订件数',
  46 + transfer_cnt INT DEFAULT 0 COMMENT '主动调拨在途数量',
  47 + gen_transfer_cnt INT DEFAULT 0 COMMENT '自动调拨在途数量',
  48 + storage_locked_cnt DECIMAL(20,2) DEFAULT 0 COMMENT '库存锁定数量',
  49 + out_stock_ongoing_cnt DECIMAL(20,2) DEFAULT 0 COMMENT '出库中数量',
  50 + stock_age INT DEFAULT 0 COMMENT '库龄(天)',
  51 + out_times INT COMMENT '出库次数',
  52 + part_biz_type TINYINT COMMENT '配件业务类型: 1=配件 2=装饰',
  53 + statistics_date VARCHAR(50) NOT NULL COMMENT '统计日期(yyyy-MM-dd)'
  54 +);
  55 +```
  56 +
  57 +---
  58 +
  59 +## 核心计算公式
  60 +
  61 +| 指标 | 公式 | 说明 |
  62 +|------|------|------|
  63 +| 有效库存 | `in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt + transfer_cnt + gen_transfer_cnt` | 可用库存总量 |
  64 +| 月均销量 | `(out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3` | 基于90天数据计算 |
  65 +| 库销比 | `有效库存 / 月均销量` | 当月均销量 > 0 时有效 |
  66 +
  67 +---
  68 +
  69 +## 输出格式
  70 +
  71 +仅输出JSON对象,**不要**包含其他文字或代码块标记:
  72 +
  73 +{
  74 + "sql": "SELECT ...",
  75 + "explanation": "SQL说明"
  76 +}
  77 +
  78 +---
  79 +
  80 +## 约束条件
  81 +
  82 +1. **MySQL 5.x 兼容**: 不使用窗口函数(MySQL 5.x 不支持)
  83 +2. **必须过滤条件**:
  84 + - `statistics_date = 'xxxx-xx-xx'`
  85 + - `part_biz_type = 1`(仅配件)
  86 +3. **排序规则**: 按库销比升序(优先处理库销比低的)
  87 +4. **只允许 SELECT**: 禁止 INSERT/UPDATE/DELETE
prompts/suggestion.md 0 → 100644
  1 +++ a/prompts/suggestion.md
  1 +# 补货建议生成提示词
  2 +
  3 +基于以下配件库销比数据,分析并生成补货建议。
  4 +
  5 +---
  6 +
  7 +## 商家组合信息
  8 +
  9 +| 项目 | 数值 |
  10 +|------|------|
  11 +| 商家组合ID | {dealer_grouping_id} |
  12 +| 商家组合名称 | {dealer_grouping_name} |
  13 +| 统计日期 | {statistics_date} |
  14 +
  15 +---
  16 +
  17 +## 配件数据
  18 +
  19 +{part_data}
  20 +
  21 +---
  22 +
  23 +## 字段说明
  24 +
  25 +| 字段名 | 含义 | 计算公式/说明 |
  26 +|--------|------|---------------|
  27 +| valid_storage_cnt | 有效库存数量 | 在库未锁 + 在途 + 计划数 + 主动调拨在途 + 自动调拨在途 |
  28 +| avg_sales_cnt | 月均销量 | (90天出库数 + 未关单已锁 + 未关单出库 + 订件) / 3 |
  29 +| cost_price | 成本价 | 单件采购成本 |
  30 +| current_ratio | 当前库销比 | 有效库存 / 月均销量 |
  31 +| out_stock_cnt | 90天出库数量 | 用于判断呆滞件 |
  32 +
  33 +---
  34 +
  35 +## 业务术语定义
  36 +
  37 +### 🚫 呆滞件(不补货)
  38 +
  39 +- **判定条件**: `valid_storage_cnt > 0` 且 `out_stock_cnt = 0`
  40 +- **特征**: 有库存但90天无任何出库
  41 +- **处理**: 不做计划,应考虑清理
  42 +
  43 +### 🚫 低频件(不补货)
  44 +
  45 +- **判定条件**: `valid_storage_cnt = 0` 且 `avg_sales_cnt < 1`
  46 +- **特征**: 无库存且月均销量不足1件
  47 +- **处理**: 不做计划,需求过低
  48 +
  49 +### ✅ 缺货件(优先补货)
  50 +
  51 +- **判定条件**: `valid_storage_cnt = 0` 且 `avg_sales_cnt >= 1`
  52 +- **特征**: 无库存但有稳定需求
  53 +- **处理**: 高优先级补货
  54 +
  55 +---
  56 +
  57 +## 分析任务
  58 +
  59 +请按以下步骤执行:
  60 +
  61 +1. **识别并排除** 呆滞件和低频件(这些配件不应出现在输出中)
  62 +2. **逐个分析** 剩余配件的库存状况
  63 +3. **计算补货量** 使用公式: `建议数量 = ceil(目标库销比 × 月均销量 - 当前库存)`
  64 +4. **确定优先级** 基于库销比和销量水平
  65 +5. **撰写理由** 为每个建议提供数据支撑的决策依据
  66 +
  67 +---
  68 +
  69 +## 输出格式
  70 +
  71 +直接输出JSON数组,**不要**包含 ```json 标记:
  72 +
  73 +[
  74 + {{
  75 + "shop_id": 库房ID(整数),
  76 + "shop_name": "库房名称",
  77 + "part_code": "配件编码",
  78 + "part_name": "配件名称",
  79 + "unit": "单位",
  80 + "cost_price": 成本价(数字),
  81 + "current_storage_cnt": 当前库存(数字),
  82 + "avg_sales_cnt": 月均销量(数字),
  83 + "current_ratio": 当前库销比(数字),
  84 + "suggest_cnt": 建议采购数量(整数),
  85 + "suggest_amount": 建议采购金额(数字),
  86 + "suggestion_reason": "详细的补货依据和决策理由",
  87 + "priority": 优先级(1=高/2=中/3=低),
  88 + "confidence": 置信度(0.0-1.0)
  89 + }}
  90 +]
  91 +
  92 +---
  93 +
  94 +## 重要约束
  95 +
  96 +1. **仅输出需要补货的配件**(suggest_cnt > 0)
  97 +2. **呆滞件和低频件不应出现在输出中**
  98 +3. **suggest_amount = suggest_cnt × cost_price**
  99 +4. **输出必须是合法的JSON数组**
prompts/suggestion_system.md 0 → 100644
  1 +++ a/prompts/suggestion_system.md
  1 +# 补货建议分析系统提示词
  2 +
  3 +## 角色定义
  4 +
  5 +你是一位资深的汽车配件库存管理专家,专注于库销比分析和补货决策。
  6 +
  7 +### 核心能力
  8 +
  9 +1. **数据分析**: 精准解读库销比、销量、库存等指标
  10 +2. **风险识别**: 识别缺货风险、积压风险、呆滞风险
  11 +3. **决策优化**: 在资金有限时合理分配补货优先级
  12 +4. **成本意识**: 平衡库存周转与服务水平
  13 +
  14 +---
  15 +
  16 +## 决策标准
  17 +
  18 +### 需要补货的情况
  19 +
  20 +| 优先级 | 条件 | 说明 |
  21 +|--------|------|------|
  22 +| 高 | 库销比 < 0.5 且月均销量 ≥ 1 | 缺货风险高,急需补货 |
  23 +| 中 | 库销比 0.5-1.0 且月均销量 ≥ 1 | 库存偏低,建议补货 |
  24 +| 低 | 库销比 1.0-1.5 且月均销量 ≥ 1 | 安全边界,可选补货 |
  25 +
  26 +### 不补货的情况
  27 +
  28 +- **呆滞件**: 有库存但90天无销量(out_stock_cnt = 0)
  29 +- **低频件**: 无库存且月均销量 < 1
  30 +- **库存充足**: 库销比 > 1.5
  31 +
  32 +---
  33 +
  34 +## 输出要求
  35 +
  36 +1. **仅输出JSON格式**,不包含任何解释性文字
  37 +2. **基于数据决策**,不做假设或推测
  38 +3. **理由具体明确**,引用数据支撑结论
  39 +4. **计算准确无误**,建议金额 = 建议数量 × 成本价
pyproject.toml 0 → 100644
  1 +++ a/pyproject.toml
  1 +[build-system]
  2 +requires = ["hatchling"]
  3 +build-backend = "hatchling.build"
  4 +
  5 +[project]
  6 +name = "fw-pms-ai"
  7 +version = "0.1.0"
  8 +description = "AI 补货建议系统 - 基于 LangChain + LangGraph"
  9 +readme = "README.md"
  10 +requires-python = ">=3.11"
  11 +license = "MIT"
  12 +authors = [
  13 + { name = "FeeWee", email = "dev@feewee.cn" }
  14 +]
  15 +dependencies = [
  16 + # LangChain 生态
  17 + "langchain>=0.3.0",
  18 + "langgraph>=0.2.0",
  19 + "langchain-core>=0.3.0",
  20 +
  21 + # LLM 集成
  22 + "zhipuai>=2.0.0",
  23 +
  24 + # 定时任务
  25 + "apscheduler>=3.10.0",
  26 +
  27 + # 数据库
  28 + "mysql-connector-python>=8.0.0",
  29 + "sqlalchemy>=2.0.0",
  30 +
  31 + # 配置管理
  32 + "pydantic>=2.0.0",
  33 + "pydantic-settings>=2.0.0",
  34 + "python-dotenv>=1.0.0",
  35 +
  36 + # 工具库
  37 + "httpx>=0.25.0",
  38 + "tenacity>=8.0.0",
  39 +
  40 + # Web API
  41 + "fastapi>=0.109.0",
  42 + "uvicorn[standard]>=0.27.0",
  43 +]
  44 +
  45 +[project.optional-dependencies]
  46 +dev = [
  47 + "pytest>=7.0.0",
  48 + "pytest-asyncio>=0.21.0",
  49 + "black>=23.0.0",
  50 + "ruff>=0.1.0",
  51 +]
  52 +
  53 +[project.scripts]
  54 +fw-pms-ai = "fw_pms_ai.main:main"
  55 +
  56 +[tool.hatch.build.targets.wheel]
  57 +packages = ["src/fw_pms_ai"]
  58 +
  59 +[tool.black]
  60 +line-length = 100
  61 +target-version = ["py311"]
  62 +
  63 +[tool.ruff]
  64 +line-length = 100
  65 +target-version = "py311"
sql/final_schema.sql 0 → 100644
  1 +++ a/sql/final_schema.sql
  1 +-- ============================================================================
  2 +-- AI 补货建议系统 - 表结构
  3 +-- ============================================================================
  4 +-- 版本: 1.0.0
  5 +-- 更新日期: 2026-01-31
  6 +-- ============================================================================
  7 +
  8 +-- 1. AI补货任务表
  9 +DROP TABLE IF EXISTS ai_replenishment_task;
  10 +CREATE TABLE ai_replenishment_task (
  11 + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
  12 + task_no VARCHAR(32) NOT NULL UNIQUE COMMENT '任务编号(AI-开头)',
  13 + group_id BIGINT NOT NULL COMMENT '集团ID',
  14 + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
  15 + dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称',
  16 + brand_grouping_id BIGINT COMMENT '品牌组合ID',
  17 + plan_amount DECIMAL(14,2) DEFAULT 0 COMMENT '计划采购金额(预算)',
  18 + actual_amount DECIMAL(14,2) DEFAULT 0 COMMENT '实际分配金额',
  19 + part_count INT DEFAULT 0 COMMENT '分配配件数量',
  20 + base_ratio DECIMAL(10,4) COMMENT '基准库销比',
  21 + status TINYINT DEFAULT 0 COMMENT '状态: 0-运行中 1-成功 2-失败',
  22 + error_message TEXT COMMENT '错误信息',
  23 + llm_provider VARCHAR(32) COMMENT 'LLM提供商',
  24 + llm_model VARCHAR(64) COMMENT 'LLM模型名称',
  25 + llm_total_tokens INT DEFAULT 0 COMMENT 'LLM总Token数',
  26 + statistics_date VARCHAR(16) COMMENT '统计日期',
  27 + start_time DATETIME COMMENT '任务开始时间',
  28 + end_time DATETIME COMMENT '任务结束时间',
  29 + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  30 + INDEX idx_group_date (group_id, statistics_date),
  31 + INDEX idx_dealer_grouping (dealer_grouping_id)
  32 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货任务表-记录每次补货建议任务执行';
  33 +
  34 +-- 2. AI补货建议明细表
  35 +DROP TABLE IF EXISTS ai_replenishment_detail;
  36 +CREATE TABLE ai_replenishment_detail (
  37 + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
  38 + task_no VARCHAR(32) NOT NULL COMMENT '任务编号',
  39 + group_id BIGINT NOT NULL COMMENT '集团ID',
  40 + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
  41 + brand_grouping_id BIGINT COMMENT '品牌组合ID',
  42 + shop_id BIGINT NOT NULL COMMENT '库房ID',
  43 + shop_name VARCHAR(128) COMMENT '库房名称',
  44 + part_code VARCHAR(64) NOT NULL COMMENT '配件编码',
  45 + part_name VARCHAR(256) COMMENT '配件名称',
  46 + unit VARCHAR(32) COMMENT '单位',
  47 + cost_price DECIMAL(14,2) DEFAULT 0 COMMENT '成本价',
  48 + current_ratio DECIMAL(10,4) COMMENT '当前库销比',
  49 + base_ratio DECIMAL(10,4) COMMENT '基准库销比',
  50 + post_plan_ratio DECIMAL(10,4) COMMENT '计划后预计库销比',
  51 + valid_storage_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '有效库存数量',
  52 + avg_sales_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '平均销量(月)',
  53 + suggest_cnt INT DEFAULT 0 COMMENT '建议采购数量',
  54 + suggest_amount DECIMAL(14,2) DEFAULT 0 COMMENT '建议采购金额',
  55 + suggestion_reason TEXT COMMENT '补货建议理由',
  56 + priority INT NOT NULL DEFAULT 2 COMMENT '优先级: 1-高, 2-中, 3-低',
  57 + llm_confidence FLOAT DEFAULT 0.8 COMMENT 'LLM置信度',
  58 + statistics_date VARCHAR(16) COMMENT '统计日期',
  59 + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  60 + INDEX idx_task_no (task_no),
  61 + INDEX idx_shop_part (shop_id, part_code)
  62 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议明细表-存储每次任务的配件分配结果';
  63 +
  64 +-- 3. AI补货分析报告表
  65 +DROP TABLE IF EXISTS ai_replenishment_report;
  66 +CREATE TABLE ai_replenishment_report (
  67 + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
  68 + task_no VARCHAR(32) NOT NULL COMMENT '任务编号',
  69 + group_id BIGINT NOT NULL COMMENT '集团ID',
  70 + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
  71 + dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称',
  72 + total_storage_amount DECIMAL(16,2) COMMENT '总库存金额',
  73 + total_sales_amount DECIMAL(16,2) COMMENT '总销售金额(90天)',
  74 + overall_ratio DECIMAL(10,4) COMMENT '整体库销比',
  75 + target_ratio DECIMAL(10,4) COMMENT '目标库销比',
  76 + total_part_count INT DEFAULT 0 COMMENT '配件总数',
  77 + shortage_part_count INT DEFAULT 0 COMMENT '缺货配件数',
  78 + overstock_part_count INT DEFAULT 0 COMMENT '超标配件数',
  79 + normal_part_count INT DEFAULT 0 COMMENT '正常配件数',
  80 + stagnant_part_count INT DEFAULT 0 COMMENT '呆滞配件数',
  81 + suggest_total_amount DECIMAL(14,2) COMMENT '建议采购总金额',
  82 + suggest_part_count INT DEFAULT 0 COMMENT '建议采购配件数',
  83 + top_priority_parts TEXT COMMENT '重点关注配件(JSON数组)',
  84 + report_content JSON COMMENT '结构化报告内容(JSON)',
  85 + llm_provider VARCHAR(32) COMMENT 'LLM提供商',
  86 + llm_model VARCHAR(64) COMMENT 'LLM模型',
  87 + generation_tokens INT DEFAULT 0 COMMENT '生成Token数',
  88 + statistics_date VARCHAR(16) COMMENT '统计日期',
  89 + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  90 + INDEX idx_task_no (task_no),
  91 + INDEX idx_dealer_grouping (dealer_grouping_id)
  92 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货分析报告表-存储LLM生成的分析报告';
  93 +
  94 +-- 4. AI任务执行日志表
  95 +DROP TABLE IF EXISTS ai_task_execution_log;
  96 +CREATE TABLE ai_task_execution_log (
  97 + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
  98 + task_no VARCHAR(32) NOT NULL COMMENT '任务编号',
  99 + group_id BIGINT NOT NULL COMMENT '集团ID',
  100 + brand_grouping_id BIGINT COMMENT '品牌组合ID',
  101 + brand_grouping_name VARCHAR(128) COMMENT '品牌组合名称',
  102 + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
  103 + dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称',
  104 + step_name VARCHAR(64) NOT NULL COMMENT '步骤名称',
  105 + step_order INT DEFAULT 0 COMMENT '步骤顺序',
  106 + status TINYINT DEFAULT 0 COMMENT '状态: 0-进行中 1-成功 2-失败 3-跳过',
  107 + input_data TEXT COMMENT '输入数据(JSON)',
  108 + output_data TEXT COMMENT '输出数据(JSON)',
  109 + error_message TEXT COMMENT '错误信息',
  110 + retry_count INT DEFAULT 0 COMMENT '重试次数',
  111 + sql_query TEXT COMMENT 'SQL查询语句(如有)',
  112 + llm_prompt TEXT COMMENT 'LLM提示词(如有)',
  113 + llm_response TEXT COMMENT 'LLM响应(如有)',
  114 + llm_tokens INT DEFAULT 0 COMMENT 'LLM Token消耗',
  115 + execution_time_ms INT DEFAULT 0 COMMENT '执行耗时(毫秒)',
  116 + start_time DATETIME COMMENT '开始时间',
  117 + end_time DATETIME COMMENT '结束时间',
  118 + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  119 + INDEX idx_task_no (task_no),
  120 + INDEX idx_group_date (group_id, create_time),
  121 + INDEX idx_dealer_grouping (dealer_grouping_id)
  122 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI任务执行日志表-记录每个步骤的执行详情';
  123 +
  124 +-- 5. AI补货配件汇总表
  125 +DROP TABLE IF EXISTS ai_replenishment_part_summary;
  126 +CREATE TABLE ai_replenishment_part_summary (
  127 + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
  128 + task_no VARCHAR(32) NOT NULL COMMENT '任务编号',
  129 + group_id BIGINT NOT NULL COMMENT '集团ID',
  130 + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
  131 + part_code VARCHAR(64) NOT NULL COMMENT '配件编码',
  132 + part_name VARCHAR(256) COMMENT '配件名称',
  133 + unit VARCHAR(32) COMMENT '单位',
  134 + cost_price DECIMAL(14,2) DEFAULT 0.00 COMMENT '成本价',
  135 + total_storage_cnt DECIMAL(14,2) DEFAULT 0.00 COMMENT '商家组合内总库存数量',
  136 + total_avg_sales_cnt DECIMAL(14,2) DEFAULT 0.00 COMMENT '商家组合内总月均销量',
  137 + group_current_ratio DECIMAL(10,4) COMMENT '商家组合级库销比',
  138 + total_suggest_cnt INT DEFAULT 0 COMMENT '总建议数量',
  139 + total_suggest_amount DECIMAL(14,2) DEFAULT 0.00 COMMENT '总建议金额',
  140 + shop_count INT DEFAULT 0 COMMENT '涉及门店数',
  141 + need_replenishment_shop_count INT DEFAULT 0 COMMENT '需要补货的门店数',
  142 + part_decision_reason TEXT COMMENT '配件级补货决策理由',
  143 + priority INT NOT NULL DEFAULT 2 COMMENT '优先级: 1=高, 2=中, 3=低',
  144 + llm_confidence FLOAT DEFAULT 0.8 COMMENT 'LLM置信度',
  145 + statistics_date VARCHAR(16) COMMENT '统计日期',
  146 + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  147 + INDEX idx_task_no (task_no),
  148 + INDEX idx_part_code (part_code),
  149 + INDEX idx_dealer_grouping (dealer_grouping_id)
  150 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议-配件汇总表';
  151 +
sql/init.sql 0 → 100644
  1 +++ a/sql/init.sql
  1 +-- AI 补货建议系统表结构
  2 +-- 版本: 2.0
  3 +-- 更新: 2026-01-27
  4 +
  5 +-- 1. AI预计划明细表
  6 +DROP TABLE IF EXISTS ai_pre_plan_detail;
  7 +CREATE TABLE ai_pre_plan_detail (
  8 + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
  9 + group_id BIGINT NOT NULL COMMENT '集团ID',
  10 + brand_grouping_id BIGINT COMMENT '品牌组合ID',
  11 + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
  12 + dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称',
  13 + dealer_id BIGINT COMMENT '商家ID',
  14 + dealer_name VARCHAR(128) COMMENT '商家名称',
  15 + area_id BIGINT COMMENT '区域ID',
  16 + area_name VARCHAR(128) COMMENT '区域名称',
  17 + shop_id BIGINT NOT NULL COMMENT '库房ID',
  18 + shop_name VARCHAR(128) COMMENT '库房名称',
  19 + part_id BIGINT COMMENT '配件ID',
  20 + part_code VARCHAR(64) NOT NULL COMMENT '配件编码',
  21 + part_name VARCHAR(256) COMMENT '配件名称',
  22 + unit VARCHAR(32) COMMENT '单位',
  23 + cost_price DECIMAL(14,2) DEFAULT 0 COMMENT '成本价',
  24 + base_ratio DECIMAL(10,4) COMMENT '基准库销比',
  25 + current_ratio DECIMAL(10,4) COMMENT '当前库销比',
  26 + valid_storage_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '有效库存数量(在库未锁+在途+计划数+调拨在途)',
  27 + avg_sales_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '平均销量(月)',
  28 + target_storage_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '目标库存数量',
  29 + plan_cnt INT DEFAULT 0 COMMENT '建议计划数量',
  30 + plan_amount DECIMAL(14,2) DEFAULT 0 COMMENT '建议计划金额',
  31 + part_biz_type TINYINT DEFAULT 1 COMMENT '配件业务类型: 1-配件 2-装饰',
  32 + statistics_date VARCHAR(16) NOT NULL COMMENT '统计日期(yyyy-MM-dd)',
  33 + yn TINYINT DEFAULT 1 COMMENT '是否有效: 1-是 0-否',
  34 + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  35 + INDEX idx_group_date (group_id, statistics_date),
  36 + INDEX idx_dealer_grouping (dealer_grouping_id),
  37 + INDEX idx_shop (shop_id)
  38 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI预计划明细表-存储每日计算的配件补货建议';
  39 +
  40 +-- 2. AI补货任务表
  41 +DROP TABLE IF EXISTS ai_replenishment_task;
  42 +CREATE TABLE ai_replenishment_task (
  43 + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
  44 + task_no VARCHAR(32) NOT NULL UNIQUE COMMENT '任务编号(AI-开头)',
  45 + group_id BIGINT NOT NULL COMMENT '集团ID',
  46 + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
  47 + dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称',
  48 + brand_grouping_id BIGINT COMMENT '品牌组合ID',
  49 + plan_amount DECIMAL(14,2) DEFAULT 0 COMMENT '计划采购金额(预算)',
  50 + actual_amount DECIMAL(14,2) DEFAULT 0 COMMENT '实际分配金额',
  51 + part_count INT DEFAULT 0 COMMENT '分配配件数量',
  52 + base_ratio DECIMAL(10,4) COMMENT '基准库销比',
  53 + status TINYINT DEFAULT 0 COMMENT '状态: 0-运行中 1-成功 2-失败',
  54 + error_message TEXT COMMENT '错误信息',
  55 + llm_provider VARCHAR(32) COMMENT 'LLM提供商',
  56 + llm_model VARCHAR(64) COMMENT 'LLM模型名称',
  57 + llm_total_tokens INT DEFAULT 0 COMMENT 'LLM总Token数',
  58 + statistics_date VARCHAR(16) COMMENT '统计日期',
  59 + start_time DATETIME COMMENT '任务开始时间',
  60 + end_time DATETIME COMMENT '任务结束时间',
  61 + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  62 + INDEX idx_group_date (group_id, statistics_date),
  63 + INDEX idx_dealer_grouping (dealer_grouping_id)
  64 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货任务表-记录每次补货建议任务执行';
  65 +
  66 +-- 3. AI补货建议明细表
  67 +DROP TABLE IF EXISTS ai_replenishment_detail;
  68 +CREATE TABLE ai_replenishment_detail (
  69 + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
  70 + task_no VARCHAR(32) NOT NULL COMMENT '任务编号',
  71 + group_id BIGINT NOT NULL COMMENT '集团ID',
  72 + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
  73 + brand_grouping_id BIGINT COMMENT '品牌组合ID',
  74 + shop_id BIGINT NOT NULL COMMENT '库房ID',
  75 + shop_name VARCHAR(128) COMMENT '库房名称',
  76 + part_code VARCHAR(64) NOT NULL COMMENT '配件编码',
  77 + part_name VARCHAR(256) COMMENT '配件名称',
  78 + unit VARCHAR(32) COMMENT '单位',
  79 + cost_price DECIMAL(14,2) DEFAULT 0 COMMENT '成本价',
  80 + current_ratio DECIMAL(10,4) COMMENT '当前库销比',
  81 + base_ratio DECIMAL(10,4) COMMENT '基准库销比',
  82 + post_plan_ratio DECIMAL(10,4) COMMENT '计划后预计库销比',
  83 + valid_storage_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '有效库存数量',
  84 + avg_sales_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '平均销量(月)',
  85 + suggest_cnt INT DEFAULT 0 COMMENT '建议采购数量',
  86 + suggest_amount DECIMAL(14,2) DEFAULT 0 COMMENT '建议采购金额',
  87 + suggestion_reason TEXT COMMENT '补货建议理由',
  88 + priority INT NOT NULL DEFAULT 2 COMMENT '优先级: 1-高, 2-中, 3-低',
  89 + llm_confidence FLOAT DEFAULT 0.8 COMMENT 'LLM置信度',
  90 + statistics_date VARCHAR(16) COMMENT '统计日期',
  91 + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  92 + INDEX idx_task_no (task_no),
  93 + INDEX idx_shop_part (shop_id, part_code)
  94 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议明细表-存储每次任务的配件分配结果';
  95 +
  96 +-- 4. AI补货报告表
  97 +DROP TABLE IF EXISTS ai_replenishment_report;
  98 +CREATE TABLE ai_replenishment_report (
  99 + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
  100 + task_no VARCHAR(32) NOT NULL COMMENT '任务编号',
  101 + group_id BIGINT NOT NULL COMMENT '集团ID',
  102 + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
  103 + dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称',
  104 + total_storage_amount DECIMAL(16,2) COMMENT '总库存金额',
  105 + total_sales_amount DECIMAL(16,2) COMMENT '总销售金额(90天)',
  106 + overall_ratio DECIMAL(10,4) COMMENT '整体库销比',
  107 + target_ratio DECIMAL(10,4) COMMENT '目标库销比',
  108 + total_part_count INT DEFAULT 0 COMMENT '配件总数',
  109 + shortage_part_count INT DEFAULT 0 COMMENT '缺货配件数',
  110 + overstock_part_count INT DEFAULT 0 COMMENT '超标配件数',
  111 + normal_part_count INT DEFAULT 0 COMMENT '正常配件数',
  112 + stagnant_part_count INT DEFAULT 0 COMMENT '呆滞配件数',
  113 + suggest_total_amount DECIMAL(14,2) COMMENT '建议采购总金额',
  114 + suggest_part_count INT DEFAULT 0 COMMENT '建议采购配件数',
  115 + top_priority_parts TEXT COMMENT '重点关注配件(JSON数组)',
  116 + report_title VARCHAR(256) COMMENT '报告标题',
  117 + executive_summary TEXT COMMENT '执行摘要',
  118 + inventory_analysis TEXT COMMENT '库存分析',
  119 + risk_assessment TEXT COMMENT '风险评估',
  120 + purchase_recommendations TEXT COMMENT '采购建议',
  121 + optimization_suggestions TEXT COMMENT '优化建议',
  122 + full_report_markdown LONGTEXT COMMENT '完整报告(Markdown)',
  123 + llm_provider VARCHAR(32) COMMENT 'LLM提供商',
  124 + llm_model VARCHAR(64) COMMENT 'LLM模型',
  125 + generation_tokens INT DEFAULT 0 COMMENT '生成Token数',
  126 + statistics_date VARCHAR(16) COMMENT '统计日期',
  127 + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  128 + INDEX idx_task_no (task_no),
  129 + INDEX idx_dealer_grouping (dealer_grouping_id)
  130 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货分析报告表-存储LLM生成的分析报告';
sql/v2_add_log_tables.sql 0 → 100644
  1 +++ a/sql/v2_add_log_tables.sql
  1 +-- AI 任务执行日志表
  2 +-- 版本: 1.0
  3 +-- 创建日期: 2026-01-28
  4 +
  5 +-- 任务执行日志表
  6 +CREATE TABLE IF NOT EXISTS ai_task_execution_log (
  7 + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
  8 + task_no VARCHAR(32) NOT NULL COMMENT '任务编号',
  9 + group_id BIGINT NOT NULL COMMENT '集团ID',
  10 + brand_grouping_id BIGINT COMMENT '品牌组合ID',
  11 + brand_grouping_name VARCHAR(128) COMMENT '品牌组合名称',
  12 + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
  13 + dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称',
  14 + step_name VARCHAR(64) NOT NULL COMMENT '步骤名称',
  15 + step_order INT DEFAULT 0 COMMENT '步骤顺序',
  16 + status TINYINT DEFAULT 0 COMMENT '状态: 0-进行中 1-成功 2-失败 3-跳过',
  17 + input_data TEXT COMMENT '输入数据(JSON)',
  18 + output_data TEXT COMMENT '输出数据(JSON)',
  19 + error_message TEXT COMMENT '错误信息',
  20 + retry_count INT DEFAULT 0 COMMENT '重试次数',
  21 + sql_query TEXT COMMENT 'SQL查询语句(如有)',
  22 + llm_prompt TEXT COMMENT 'LLM提示词(如有)',
  23 + llm_response TEXT COMMENT 'LLM响应(如有)',
  24 + llm_tokens INT DEFAULT 0 COMMENT 'LLM Token消耗',
  25 + execution_time_ms INT DEFAULT 0 COMMENT '执行耗时(毫秒)',
  26 + start_time DATETIME COMMENT '开始时间',
  27 + end_time DATETIME COMMENT '结束时间',
  28 + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  29 + INDEX idx_task_no (task_no),
  30 + INDEX idx_group_date (group_id, create_time),
  31 + INDEX idx_dealer_grouping (dealer_grouping_id)
  32 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI任务执行日志表-记录每个步骤的执行详情';
  33 +
  34 +-- LLM补货建议明细表(商家组合维度)
  35 +CREATE TABLE IF NOT EXISTS ai_llm_suggestion_detail (
  36 + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
  37 + task_no VARCHAR(32) NOT NULL COMMENT '任务编号',
  38 + group_id BIGINT NOT NULL COMMENT '集团ID',
  39 + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
  40 + dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称',
  41 + shop_id BIGINT NOT NULL COMMENT '库房ID',
  42 + shop_name VARCHAR(128) COMMENT '库房名称',
  43 + part_code VARCHAR(64) NOT NULL COMMENT '配件编码',
  44 + part_name VARCHAR(256) COMMENT '配件名称',
  45 + unit VARCHAR(32) COMMENT '单位',
  46 + cost_price DECIMAL(14,2) DEFAULT 0 COMMENT '成本价',
  47 + current_storage_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '当前库存数量',
  48 + avg_sales_cnt DECIMAL(14,2) DEFAULT 0 COMMENT '平均销量(月)',
  49 + current_ratio DECIMAL(10,4) COMMENT '当前库销比',
  50 + suggest_cnt INT DEFAULT 0 COMMENT 'LLM建议采购数量',
  51 + suggest_amount DECIMAL(14,2) DEFAULT 0 COMMENT 'LLM建议采购金额',
  52 + suggestion_reason TEXT COMMENT 'LLM建议依据/理由',
  53 + priority INT DEFAULT 0 COMMENT '优先级(1-高 2-中 3-低)',
  54 + llm_confidence DECIMAL(5,2) COMMENT 'LLM置信度(0-1)',
  55 + statistics_date VARCHAR(16) COMMENT '统计日期',
  56 + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  57 + INDEX idx_task_no (task_no),
  58 + INDEX idx_dealer_grouping (dealer_grouping_id),
  59 + INDEX idx_shop_part (shop_id, part_code)
  60 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='LLM补货建议明细表-存储LLM生成的商家组合维度配件补货建议及依据';
sql/v3_add_suggestion_reason.sql 0 → 100644
  1 +++ a/sql/v3_add_suggestion_reason.sql
  1 +-- AI补货建议明细表 - 添加建议理由字段
  2 +-- 版本: 3.0
  3 +-- 创建日期: 2026-01-29
  4 +
  5 +ALTER TABLE ai_replenishment_detail
  6 +ADD COLUMN suggestion_reason TEXT COMMENT 'LLM建议理由' AFTER suggest_amount;
sql/v4_cleanup_unused_tables.sql 0 → 100644
  1 +++ a/sql/v4_cleanup_unused_tables.sql
  1 +-- 清理无效的 AI 业务表
  2 +-- 版本: 4.0
  3 +-- 创建日期: 2026-01-30
  4 +-- 描述: 删除不再使用的 ai_llm_suggestion_detail 和 ai_pre_plan_detail 表
  5 +
  6 +DROP TABLE IF EXISTS ai_llm_suggestion_detail;
  7 +DROP TABLE IF EXISTS ai_pre_plan_detail;
sql/v4_report_json_structure.sql 0 → 100644
  1 +++ a/sql/v4_report_json_structure.sql
  1 +-- 报告结构化重构 - 将 Markdown 字段改为 JSON
  2 +-- 版本: 4.0
  3 +-- 日期: 2026-01-30
  4 +
  5 +-- 1. 新增 JSON 字段
  6 +ALTER TABLE ai_replenishment_report
  7 +ADD COLUMN report_content JSON COMMENT '结构化报告内容(JSON)' AFTER top_priority_parts;
  8 +
  9 +-- 2. 删除旧的 Markdown 字段
  10 +ALTER TABLE ai_replenishment_report
  11 +DROP COLUMN report_title,
  12 +DROP COLUMN executive_summary,
  13 +DROP COLUMN inventory_analysis,
  14 +DROP COLUMN risk_assessment,
  15 +DROP COLUMN purchase_recommendations,
  16 +DROP COLUMN optimization_suggestions,
  17 +DROP COLUMN full_report_markdown;
sql/v5_add_priority_and_confidence.sql 0 → 100644
  1 +++ a/sql/v5_add_priority_and_confidence.sql
  1 +-- 5. 添加优先级和置信度字段
  2 +-- 时间: 2026-01-30
  3 +
  4 +ALTER TABLE ai_replenishment_detail
  5 +ADD COLUMN priority INT NOT NULL DEFAULT 2 COMMENT '优先级: 1-高, 2-中, 3-低' AFTER suggestion_reason,
  6 +ADD COLUMN llm_confidence FLOAT DEFAULT 0.8 COMMENT 'LLM置信度' AFTER priority;
sql/v6_add_part_summary.sql 0 → 100644
  1 +++ a/sql/v6_add_part_summary.sql
  1 +-- v6: 新增配件汇总表
  2 +-- 用于存储配件在商家组合维度的汇总补货建议
  3 +
  4 +CREATE TABLE IF NOT EXISTS ai_replenishment_part_summary (
  5 + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
  6 + task_no VARCHAR(32) NOT NULL COMMENT '任务编号',
  7 + group_id BIGINT NOT NULL COMMENT '集团ID',
  8 + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
  9 + part_code VARCHAR(64) NOT NULL COMMENT '配件编码',
  10 + part_name VARCHAR(256) COMMENT '配件名称',
  11 + unit VARCHAR(32) COMMENT '单位',
  12 + cost_price DECIMAL(14,2) DEFAULT 0.00 COMMENT '成本价',
  13 +
  14 + -- 商家组合级别汇总数据
  15 + total_storage_cnt DECIMAL(14,2) DEFAULT 0.00 COMMENT '商家组合内总库存数量',
  16 + total_avg_sales_cnt DECIMAL(14,2) DEFAULT 0.00 COMMENT '商家组合内总月均销量',
  17 + group_current_ratio DECIMAL(10,4) COMMENT '商家组合级库销比',
  18 +
  19 + -- 补货建议汇总
  20 + total_suggest_cnt INT DEFAULT 0 COMMENT '总建议数量',
  21 + total_suggest_amount DECIMAL(14,2) DEFAULT 0.00 COMMENT '总建议金额',
  22 + shop_count INT DEFAULT 0 COMMENT '涉及门店数',
  23 + need_replenishment_shop_count INT DEFAULT 0 COMMENT '需要补货的门店数',
  24 +
  25 + -- LLM分析结果
  26 + part_decision_reason TEXT COMMENT '配件级补货决策理由',
  27 + priority INT NOT NULL DEFAULT 2 COMMENT '优先级: 1=高, 2=中, 3=低',
  28 + llm_confidence FLOAT DEFAULT 0.8 COMMENT 'LLM置信度',
  29 +
  30 + -- 元数据
  31 + statistics_date VARCHAR(16) COMMENT '统计日期',
  32 + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  33 +
  34 + INDEX idx_task_no (task_no),
  35 + INDEX idx_part_code (part_code),
  36 + INDEX idx_dealer_grouping (dealer_grouping_id)
  37 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议-配件汇总表';
sql/v7_add_low_frequency_count.sql 0 → 100644
  1 +++ a/sql/v7_add_low_frequency_count.sql
  1 +-- v7: 添加低频件统计字段
  2 +-- 用于记录出库次数<3 或 平均出库间隔>=30天的配件数量
  3 +
  4 +ALTER TABLE ai_replenishment_report
  5 +ADD COLUMN low_frequency_part_count INT DEFAULT 0 COMMENT '低频件数量'
  6 +AFTER stagnant_part_count;
sql/v8_analysis_report.sql 0 → 100644
  1 +++ a/sql/v8_analysis_report.sql
  1 +-- v8_analysis_report.sql
  2 +-- 分析报告表,支持按品牌组合分组的分析数据持久化
  3 +-- 分析内容由 LLM 生成
  4 +
  5 +CREATE TABLE IF NOT EXISTS ai_analysis_report (
  6 + id BIGINT AUTO_INCREMENT PRIMARY KEY,
  7 + task_no VARCHAR(32) NOT NULL COMMENT '任务编号',
  8 + group_id BIGINT NOT NULL COMMENT '集团ID',
  9 + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
  10 + brand_grouping_id BIGINT DEFAULT NULL COMMENT '品牌组合ID,NULL表示全部品牌',
  11 + brand_grouping_name VARCHAR(256) DEFAULT NULL COMMENT '品牌组合名称',
  12 +
  13 + -- 风险管控统计(数量)
  14 + total_count INT DEFAULT 0 COMMENT '配件总数',
  15 + shortage_count INT DEFAULT 0 COMMENT '缺货数量',
  16 + overstock_count INT DEFAULT 0 COMMENT '超标数量',
  17 + normal_count INT DEFAULT 0 COMMENT '正常数量',
  18 + low_frequency_count INT DEFAULT 0 COMMENT '低频数量',
  19 + stagnant_count INT DEFAULT 0 COMMENT '呆滞数量',
  20 +
  21 + -- 风险管控统计(金额)
  22 + total_amount DECIMAL(16,2) DEFAULT 0 COMMENT '总金额',
  23 + shortage_amount DECIMAL(16,2) DEFAULT 0 COMMENT '缺货金额',
  24 + overstock_amount DECIMAL(16,2) DEFAULT 0 COMMENT '超标金额',
  25 + normal_amount DECIMAL(16,2) DEFAULT 0 COMMENT '正常金额',
  26 + low_frequency_amount DECIMAL(16,2) DEFAULT 0 COMMENT '低频金额',
  27 + stagnant_amount DECIMAL(16,2) DEFAULT 0 COMMENT '呆滞金额',
  28 +
  29 + -- LLM 生成的分析内容
  30 + overview_analysis TEXT COMMENT '核心经营综述(LLM生成)',
  31 + risk_analysis TEXT COMMENT '风险管控分析(LLM生成)',
  32 + action_plan TEXT COMMENT '行动计划分析(LLM生成)',
  33 + report_content JSON COMMENT '结构化报告内容',
  34 +
  35 + llm_provider VARCHAR(32) COMMENT 'LLM提供商',
  36 + llm_model VARCHAR(64) COMMENT 'LLM模型名称',
  37 + generation_tokens INT DEFAULT 0 COMMENT '生成token数',
  38 + statistics_date VARCHAR(16) COMMENT '统计日期',
  39 + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  40 +
  41 + INDEX idx_task_no (task_no),
  42 + INDEX idx_task_brand (task_no, brand_grouping_id),
  43 + INDEX idx_dealer_grouping (dealer_grouping_id)
  44 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI分析报告表';
src/fw_pms_ai/__init__.py 0 → 100644
  1 +++ a/src/fw_pms_ai/__init__.py
  1 +"""
  2 +fw-pms-ai: AI 补货建议系统
  3 +"""
  4 +
  5 +__version__ = "0.1.0"
src/fw_pms_ai/agent/__init__.py 0 → 100644
  1 +++ a/src/fw_pms_ai/agent/__init__.py
  1 +"""Agent 模块"""
  2 +
  3 +from .state import AgentState
  4 +from .replenishment import ReplenishmentAgent
  5 +from .sql_agent import SQLAgent
  6 +from ..models import SQLExecutionResult, ReplenishmentSuggestion, PartAnalysisResult
  7 +
  8 +__all__ = [
  9 + "AgentState",
  10 + "ReplenishmentAgent",
  11 + "SQLAgent",
  12 + "SQLExecutionResult",
  13 + "ReplenishmentSuggestion",
  14 + "PartAnalysisResult",
  15 +]
  16 +
  17 +
src/fw_pms_ai/agent/nodes.py 0 → 100644
  1 +++ a/src/fw_pms_ai/agent/nodes.py
  1 +"""
  2 +LangGraph Agent 节点实现
  3 +
  4 +重构版本:直接使用 part_ratio 数据 + SQL Agent
  5 +"""
  6 +
  7 +import logging
  8 +import time
  9 +import json
  10 +from typing import Dict, List
  11 +from decimal import Decimal
  12 +from datetime import datetime
  13 +
  14 +from langchain_core.messages import SystemMessage, HumanMessage
  15 +
  16 +from .state import AgentState
  17 +from .sql_agent import SQLAgent
  18 +from ..models import ReplenishmentSuggestion, PartAnalysisResult
  19 +from ..llm import get_llm_client
  20 +from ..services import DataService
  21 +from ..services.result_writer import ResultWriter
  22 +from ..models import ReplenishmentDetail, TaskExecutionLog, LogStatus, ReplenishmentPartSummary
  23 +
  24 +logger = logging.getLogger(__name__)
  25 +
  26 +
  27 +def _load_prompt(filename: str) -> str:
  28 + """从prompts目录加载提示词文件"""
  29 + import os
  30 + # 从 src/fw_pms_ai/agent/nodes.py 向上4层到达项目根目录
  31 + prompt_path = os.path.join(
  32 + os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))),
  33 + "prompts", filename
  34 + )
  35 + try:
  36 + with open(prompt_path, "r", encoding="utf-8") as f:
  37 + return f.read()
  38 + except FileNotFoundError:
  39 + logger.warning(f"Prompt文件未找到: {prompt_path}")
  40 + return ""
  41 +
  42 +
  43 +
  44 +def fetch_part_ratio_node(state: AgentState) -> AgentState:
  45 + """
  46 + 节点1: 获取 part_ratio 数据
  47 + 直接通过 dealer_grouping_id 从 part_ratio 表获取配件库销比数据
  48 + """
  49 + logger.info(f"[FetchPartRatio] ========== 开始获取数据 ==========")
  50 + logger.info(
  51 + f"[FetchPartRatio] group_id={state['group_id']}, "
  52 + f"dealer_grouping_id={state['dealer_grouping_id']}, "
  53 + f"date={state['statistics_date']}"
  54 + )
  55 +
  56 + start_time = time.time()
  57 +
  58 + sql_agent = SQLAgent()
  59 +
  60 + try:
  61 + # 直接使用 dealer_grouping_id 获取 part_ratio 数据
  62 + part_ratios = sql_agent.fetch_part_ratios(
  63 + group_id=state["group_id"],
  64 + dealer_grouping_id=state["dealer_grouping_id"],
  65 + statistics_date=state["statistics_date"],
  66 + )
  67 +
  68 + execution_time = int((time.time() - start_time) * 1000)
  69 +
  70 + # 记录执行日志
  71 + log_entry = {
  72 + "step_name": "fetch_part_ratio",
  73 + "step_order": 1,
  74 + "status": LogStatus.SUCCESS if part_ratios else LogStatus.SKIPPED,
  75 + "input_data": json.dumps({
  76 + "dealer_grouping_id": state["dealer_grouping_id"],
  77 + "statistics_date": state["statistics_date"],
  78 + }),
  79 + "output_data": json.dumps({"part_ratios_count": len(part_ratios)}),
  80 + "execution_time_ms": execution_time,
  81 + "start_time": datetime.now().isoformat(),
  82 + }
  83 +
  84 + logger.info(
  85 + f"[FetchPartRatio] 数据获取完成: part_ratios={len(part_ratios)}, "
  86 + f"耗时={execution_time}ms"
  87 + )
  88 +
  89 + return {
  90 + **state,
  91 + "part_ratios": part_ratios,
  92 + "sql_execution_logs": [log_entry],
  93 + "current_node": "fetch_part_ratio",
  94 + "next_node": "sql_agent",
  95 + }
  96 +
  97 + finally:
  98 + sql_agent.close()
  99 +
  100 +
  101 +
  102 +def sql_agent_node(state: AgentState) -> AgentState:
  103 + """
  104 + 节点2: SQL Agent 分析和生成建议
  105 + 按 part_code 分组,逐个配件分析各门店的补货需求
  106 + """
  107 + part_ratios = state.get("part_ratios", [])
  108 +
  109 +
  110 + logger.info(f"[SQLAgent] 开始分析: part_ratios={len(part_ratios)}")
  111 +
  112 + start_time = time.time()
  113 + retry_count = state.get("sql_retry_count", 0)
  114 +
  115 + if not part_ratios:
  116 + logger.warning("[SQLAgent] 无配件数据可分析")
  117 +
  118 + log_entry = {
  119 + "step_name": "sql_agent",
  120 + "step_order": 2,
  121 + "status": LogStatus.SKIPPED,
  122 + "error_message": "无配件数据",
  123 + "execution_time_ms": int((time.time() - start_time) * 1000),
  124 + }
  125 +
  126 + return {
  127 + **state,
  128 + "llm_suggestions": [],
  129 + "llm_analysis_summary": "无配件数据可分析",
  130 + "sql_execution_logs": [log_entry],
  131 + "current_node": "sql_agent",
  132 + "next_node": "allocate_budget",
  133 + }
  134 +
  135 + sql_agent = SQLAgent()
  136 +
  137 + try:
  138 + # 计算基准库销比(仅用于记录,不影响LLM建议)
  139 + total_valid_storage = sum(
  140 + Decimal(str(p.get("valid_storage_cnt", 0) or 0))
  141 + for p in part_ratios
  142 + )
  143 + total_avg_sales = sum(
  144 + Decimal(str(p.get("avg_sales_cnt", 0) or 0))
  145 + for p in part_ratios
  146 + )
  147 +
  148 + if total_avg_sales > 0:
  149 + base_ratio = total_valid_storage / total_avg_sales
  150 + else:
  151 + base_ratio = Decimal("0")
  152 +
  153 + logger.info(
  154 + f"[SQLAgent] 当前库销比: 总库存={total_valid_storage}, "
  155 + f"总销量={total_avg_sales}, 库销比={base_ratio}"
  156 + )
  157 +
  158 + # 定义批处理回调
  159 + # 由于 models 中没有 ResultWriter 的引用,这里尝试直接从 services 导入或实例化
  160 + # 为避免循环导入,我们在函数内导入
  161 + from ..services import ResultWriter as WriterService
  162 + writer = WriterService()
  163 +
  164 + # 1. 任务开始时清理旧数据(确保重试时不会产生重复数据)
  165 + # logger.info(f"[SQLAgent] 清理旧建议数据: task_no={state['task_no']}")
  166 + # writer.clear_llm_suggestions(state["task_no"])
  167 +
  168 + # 2. 移除批处理回调(不再过程写入,改为最后统一写入)
  169 + save_batch_callback = None
  170 +
  171 + # 使用分组分析生成补货建议(按 part_code 分组,逐个配件分析各门店需求)
  172 + suggestions, part_results, llm_stats = sql_agent.analyze_parts_by_group(
  173 + part_ratios=part_ratios,
  174 + dealer_grouping_id=state["dealer_grouping_id"],
  175 + dealer_grouping_name=state["dealer_grouping_name"],
  176 + statistics_date=state["statistics_date"],
  177 + target_ratio=base_ratio if base_ratio > 0 else Decimal("1.3"),
  178 + limit=1,
  179 + callback=save_batch_callback,
  180 + )
  181 +
  182 + execution_time = int((time.time() - start_time) * 1000)
  183 +
  184 + # 记录执行日志
  185 + log_entry = {
  186 + "step_name": "sql_agent",
  187 + "step_order": 2,
  188 + "status": LogStatus.SUCCESS,
  189 + "input_data": json.dumps({
  190 + "part_ratios_count": len(part_ratios),
  191 + }),
  192 + "output_data": json.dumps({
  193 + "suggestions_count": len(suggestions),
  194 + "part_results_count": len(part_results),
  195 + "base_ratio": float(base_ratio),
  196 + }),
  197 + "llm_tokens": llm_stats.get("prompt_tokens", 0) + llm_stats.get("completion_tokens", 0),
  198 + "execution_time_ms": execution_time,
  199 + "retry_count": retry_count,
  200 + }
  201 +
  202 + logger.info(
  203 + f"[SQLAgent] 分析完成: 建议数={len(suggestions)}, "
  204 + f"配件汇总数={len(part_results)}, tokens={llm_stats}, 耗时={execution_time}ms"
  205 + )
  206 +
  207 + return {
  208 + **state,
  209 + "base_ratio": base_ratio,
  210 + "llm_suggestions": suggestions,
  211 + "part_results": part_results,
  212 + "llm_prompt_tokens": state.get("llm_prompt_tokens", 0) + llm_stats.get("prompt_tokens", 0),
  213 + "llm_completion_tokens": state.get("llm_completion_tokens", 0) + llm_stats.get("completion_tokens", 0),
  214 + "sql_execution_logs": [log_entry],
  215 + "current_node": "sql_agent",
  216 + "next_node": "allocate_budget",
  217 + }
  218 +
  219 + except Exception as e:
  220 + logger.error(f"[SQLAgent] 执行失败: {e}")
  221 +
  222 + log_entry = {
  223 + "step_name": "sql_agent",
  224 + "step_order": 2,
  225 + "status": LogStatus.FAILED,
  226 + "error_message": str(e),
  227 + "retry_count": retry_count,
  228 + "execution_time_ms": int((time.time() - start_time) * 1000),
  229 + }
  230 +
  231 + # 检查是否需要重试
  232 + if retry_count < 3:
  233 + return {
  234 + **state,
  235 + "sql_retry_count": retry_count + 1,
  236 + "sql_execution_logs": [log_entry],
  237 + "current_node": "sql_agent",
  238 + "next_node": "sql_agent", # 重试
  239 + "error_message": str(e),
  240 + }
  241 +
  242 + return {
  243 + **state,
  244 + "llm_suggestions": [],
  245 + "sql_execution_logs": [log_entry],
  246 + "current_node": "sql_agent",
  247 + "next_node": "allocate_budget",
  248 + "error_message": str(e),
  249 + }
  250 +
  251 + finally:
  252 + sql_agent.close()
  253 +
  254 +
  255 +def allocate_budget_node(state: AgentState) -> AgentState:
  256 + """
  257 + 节点3: 转换LLM建议为补货明细
  258 + 注意:不做预算截断,所有建议直接输出
  259 + """
  260 + logger.info(f"[AllocateBudget] 开始处理LLM建议")
  261 +
  262 + start_time = time.time()
  263 +
  264 + llm_suggestions = state.get("llm_suggestions", [])
  265 +
  266 + if not llm_suggestions:
  267 + logger.warning("[AllocateBudget] 无LLM建议可处理")
  268 +
  269 + log_entry = {
  270 + "step_name": "allocate_budget",
  271 + "step_order": 3,
  272 + "status": LogStatus.SKIPPED,
  273 + "error_message": "无LLM建议",
  274 + "execution_time_ms": int((time.time() - start_time) * 1000),
  275 + }
  276 +
  277 + return {
  278 + **state,
  279 + "details": [],
  280 + "sql_execution_logs": [log_entry],
  281 + "current_node": "allocate_budget",
  282 + "next_node": "end",
  283 + }
  284 +
  285 + # 按优先级和库销比排序(优先级升序,库销比升序)
  286 + sorted_suggestions = sorted(
  287 + llm_suggestions,
  288 + key=lambda x: (x.priority, float(x.current_ratio))
  289 + )
  290 +
  291 + # 建立 part_code -> brand_grouping_id 映射,确保明细归属正确的品牌组合
  292 + part_ratios = state.get("part_ratios", [])
  293 + part_brand_map = {p.get("part_code"): p.get("brand_grouping_id") for p in part_ratios if p.get("part_code")}
  294 +
  295 + allocated_details = []
  296 + total_amount = Decimal("0")
  297 +
  298 + # 转换所有建议为明细(包括不需要补货的配件,以便记录完整分析结果)
  299 + for suggestion in sorted_suggestions:
  300 + # 获取该配件对应的 brand_grouping_id
  301 + bg_id = part_brand_map.get(suggestion.part_code)
  302 + if bg_id is None:
  303 + bg_id = state.get("brand_grouping_id")
  304 +
  305 + detail = ReplenishmentDetail(
  306 + task_no=state["task_no"],
  307 + group_id=state["group_id"],
  308 + dealer_grouping_id=state["dealer_grouping_id"],
  309 + brand_grouping_id=bg_id,
  310 + shop_id=suggestion.shop_id,
  311 + shop_name=suggestion.shop_name,
  312 + part_code=suggestion.part_code,
  313 + part_name=suggestion.part_name,
  314 + unit=suggestion.unit,
  315 + cost_price=suggestion.cost_price,
  316 + base_ratio=state.get("base_ratio", Decimal("1.1")),
  317 + current_ratio=suggestion.current_ratio,
  318 + valid_storage_cnt=suggestion.current_storage_cnt,
  319 + avg_sales_cnt=suggestion.avg_sales_cnt,
  320 + suggest_cnt=suggestion.suggest_cnt,
  321 + suggest_amount=suggestion.suggest_amount,
  322 + suggestion_reason=suggestion.suggestion_reason,
  323 + priority=suggestion.priority,
  324 + llm_confidence=suggestion.confidence,
  325 + statistics_date=state["statistics_date"],
  326 + )
  327 + # 计算预计库销比
  328 + post_storage = detail.valid_storage_cnt + detail.suggest_cnt
  329 + if post_storage <= 0 or detail.avg_sales_cnt <= 0:
  330 + # 库存为0或销量为0时,库销比设为0
  331 + detail.post_plan_ratio = Decimal("0")
  332 + else:
  333 + detail.post_plan_ratio = post_storage / detail.avg_sales_cnt
  334 +
  335 + allocated_details.append(detail)
  336 + total_amount += suggestion.suggest_amount
  337 +
  338 + execution_time = int((time.time() - start_time) * 1000)
  339 +
  340 + # 记录执行日志
  341 + log_entry = {
  342 + "step_name": "allocate_budget",
  343 + "step_order": 3,
  344 + "status": LogStatus.SUCCESS,
  345 + "input_data": json.dumps({
  346 + "suggestions_count": len(llm_suggestions),
  347 + }),
  348 + "output_data": json.dumps({
  349 + "details_count": len(allocated_details),
  350 + "total_amount": float(total_amount),
  351 + }),
  352 + "execution_time_ms": execution_time,
  353 + }
  354 +
  355 + logger.info(
  356 + f"[AllocateBudget] 分配完成: 配件数={len(allocated_details)}, "
  357 + f"金额={total_amount}"
  358 + )
  359 +
  360 + # 保存结果到数据库
  361 + try:
  362 + writer = ResultWriter()
  363 +
  364 + # 0. 先清理旧数据(防止重试或重复执行时产生重复记录)
  365 + writer.delete_details_by_task(state["task_no"])
  366 + writer.delete_part_summaries_by_task(state["task_no"])
  367 + logger.info(f"[AllocateBudget] 已清理旧数据: task_no={state['task_no']}")
  368 +
  369 + # 1. 保存补货明细
  370 + if allocated_details:
  371 + writer.save_details(allocated_details)
  372 + logger.info(f"[AllocateBudget] 已保存 {len(allocated_details)} 条补货明细")
  373 +
  374 + # 2. 保存配件汇总
  375 + part_results = state.get("part_results", [])
  376 + if part_results:
  377 + part_summaries = []
  378 + for pr in part_results:
  379 + summary = ReplenishmentPartSummary(
  380 + task_no=state["task_no"],
  381 + group_id=state["group_id"],
  382 + dealer_grouping_id=state["dealer_grouping_id"],
  383 + part_code=pr.part_code,
  384 + part_name=pr.part_name,
  385 + unit=pr.unit,
  386 + cost_price=pr.cost_price,
  387 + total_storage_cnt=pr.total_storage_cnt,
  388 + total_avg_sales_cnt=pr.total_avg_sales_cnt,
  389 + group_current_ratio=pr.group_current_ratio,
  390 + total_suggest_cnt=pr.total_suggest_cnt,
  391 + total_suggest_amount=pr.total_suggest_amount,
  392 + shop_count=pr.shop_count,
  393 + need_replenishment_shop_count=pr.need_replenishment_shop_count,
  394 + part_decision_reason=pr.part_decision_reason,
  395 + priority=pr.priority,
  396 + llm_confidence=pr.confidence,
  397 + statistics_date=state["statistics_date"],
  398 + )
  399 + part_summaries.append(summary)
  400 +
  401 + writer.save_part_summaries(part_summaries)
  402 + logger.info(f"[AllocateBudget] 已保存 {len(part_summaries)} 条配件分析汇总")
  403 +
  404 + writer.close()
  405 + except Exception as e:
  406 + logger.error(f"[AllocateBudget] 保存结果失败: {e}")
  407 + # 记录错误但不中断流程
  408 + error_log = {
  409 + "step_name": "allocate_budget",
  410 + "step_order": 3,
  411 + "status": LogStatus.FAILED,
  412 + "error_message": f"保存结果失败: {str(e)}",
  413 + "execution_time_ms": 0,
  414 + }
  415 +
  416 + return {
  417 + **state,
  418 + "details": allocated_details,
  419 + "sql_execution_logs": [log_entry, error_log],
  420 + "current_node": "allocate_budget",
  421 + "next_node": "end",
  422 + "status": "success",
  423 + "end_time": time.time(),
  424 + }
  425 +
  426 + return {
  427 + **state,
  428 + "details": allocated_details,
  429 + "sql_execution_logs": [log_entry],
  430 + "current_node": "allocate_budget",
  431 + "next_node": "end",
  432 + "status": "success",
  433 + "end_time": time.time(),
  434 + }
  435 +
  436 +
  437 +def should_retry_sql(state: AgentState) -> str:
  438 + """条件边: 判断是否需要重试SQL Agent"""
  439 + next_node = state.get("next_node", "allocate_budget")
  440 + retry_count = state.get("sql_retry_count", 0)
  441 +
  442 + if next_node == "sql_agent" and retry_count < 3:
  443 + logger.info(f"[Routing] SQL Agent需要重试: retry_count={retry_count}")
  444 + return "retry"
  445 +
  446 + return "continue"
  447 +
  448 +
  449 +def should_continue(state: AgentState) -> str:
  450 + """条件边: 判断是否继续"""
  451 + return state.get("next_node", "end")
  452 +
src/fw_pms_ai/agent/replenishment.py 0 → 100644
  1 +++ a/src/fw_pms_ai/agent/replenishment.py
  1 +"""
  2 +补货建议 Agent
  3 +
  4 +重构版本:使用 part_ratio + SQL Agent + LangGraph
  5 +"""
  6 +
  7 +import logging
  8 +import time
  9 +import uuid
  10 +from typing import Optional, List
  11 +from datetime import date, datetime
  12 +from decimal import Decimal
  13 +
  14 +from langgraph.graph import StateGraph, END
  15 +
  16 +from .state import AgentState
  17 +from .nodes import (
  18 + fetch_part_ratio_node,
  19 + sql_agent_node,
  20 + allocate_budget_node,
  21 + should_retry_sql,
  22 +)
  23 +from ..models import ReplenishmentTask, TaskStatus, TaskExecutionLog, LogStatus, ReplenishmentPartSummary
  24 +from ..services import ResultWriter
  25 +
  26 +logger = logging.getLogger(__name__)
  27 +
  28 +
  29 +class ReplenishmentAgent:
  30 + """补货建议 Agent"""
  31 +
  32 + def __init__(self):
  33 + self._graph = None
  34 + self._result_writer = ResultWriter()
  35 +
  36 + @property
  37 + def graph(self) -> StateGraph:
  38 + """获取工作流图"""
  39 + if self._graph is None:
  40 + self._graph = self._build_graph()
  41 + return self._graph
  42 +
  43 + def _build_graph(self) -> StateGraph:
  44 + """
  45 + 构建 LangGraph 工作流
  46 +
  47 + 工作流结构:
  48 + fetch_part_ratio → sql_agent → allocate_budget → END
  49 + """
  50 + workflow = StateGraph(AgentState)
  51 +
  52 + # 添加核心节点
  53 + workflow.add_node("fetch_part_ratio", fetch_part_ratio_node)
  54 + workflow.add_node("sql_agent", sql_agent_node)
  55 + workflow.add_node("allocate_budget", allocate_budget_node)
  56 +
  57 + # 设置入口
  58 + workflow.set_entry_point("fetch_part_ratio")
  59 +
  60 + # 添加边
  61 + workflow.add_edge("fetch_part_ratio", "sql_agent")
  62 +
  63 + # SQL Agent 条件边(支持重试)
  64 + workflow.add_conditional_edges(
  65 + "sql_agent",
  66 + should_retry_sql,
  67 + {
  68 + "retry": "sql_agent",
  69 + "continue": "allocate_budget",
  70 + }
  71 + )
  72 +
  73 + # allocate_budget → END
  74 + workflow.add_edge("allocate_budget", END)
  75 +
  76 + return workflow.compile()
  77 +
  78 + def run(
  79 + self,
  80 + group_id: int,
  81 + dealer_grouping_id: int,
  82 + dealer_grouping_name: str,
  83 + brand_grouping_id: Optional[int] = None,
  84 + brand_grouping_name: str = "",
  85 + statistics_date: Optional[str] = None,
  86 + ) -> AgentState:
  87 + """
  88 + 执行补货建议生成
  89 +
  90 + Args:
  91 + group_id: 集团ID
  92 + dealer_grouping_id: 商家组合ID
  93 + dealer_grouping_name: 商家组合名称
  94 + brand_grouping_id: 品牌组合ID
  95 + brand_grouping_name: 品牌组合名称
  96 + statistics_date: 统计日期
  97 + """
  98 + task_no = f"AI-{uuid.uuid4().hex[:12].upper()}"
  99 + if statistics_date is None:
  100 + statistics_date = date.today().strftime("%Y-%m-%d")
  101 +
  102 + logger.info(
  103 + f"开始执行补货建议: task_no={task_no}, "
  104 + f"dealer_grouping={dealer_grouping_name}"
  105 + )
  106 +
  107 + # 初始化状态
  108 + initial_state: AgentState = {
  109 + "task_no": task_no,
  110 + "group_id": group_id,
  111 + "brand_grouping_id": brand_grouping_id,
  112 + "brand_grouping_name": brand_grouping_name,
  113 + "dealer_grouping_id": dealer_grouping_id,
  114 + "dealer_grouping_name": dealer_grouping_name,
  115 + "statistics_date": statistics_date,
  116 + "part_ratios": [],
  117 + "sql_queries": [],
  118 + "sql_results": [],
  119 + "sql_retry_count": 0,
  120 + "sql_execution_logs": [],
  121 + "base_ratio": Decimal("1.1"),
  122 + "allocated_details": [],
  123 + "details": [],
  124 + "llm_suggestions": [],
  125 + "part_results": [],
  126 + "report": None,
  127 + "llm_provider": "",
  128 + "llm_model": "",
  129 + "llm_prompt_tokens": 0,
  130 + "llm_completion_tokens": 0,
  131 +
  132 + "status": "running",
  133 + "error_message": "",
  134 + "start_time": time.time(),
  135 + "end_time": 0,
  136 + "current_node": "",
  137 + "next_node": "fetch_part_ratio",
  138 + }
  139 +
  140 + # 创建任务记录
  141 + task = ReplenishmentTask(
  142 + task_no=task_no,
  143 + group_id=group_id,
  144 + dealer_grouping_id=dealer_grouping_id,
  145 + dealer_grouping_name=dealer_grouping_name,
  146 + brand_grouping_id=brand_grouping_id,
  147 + statistics_date=statistics_date,
  148 + status=TaskStatus.RUNNING,
  149 + )
  150 + self._result_writer.save_task(task)
  151 +
  152 + try:
  153 + # 执行工作流
  154 + final_state = self.graph.invoke(initial_state)
  155 +
  156 + # 更新任务状态
  157 + execution_time = int((final_state.get("end_time", time.time()) - final_state["start_time"]) * 1000)
  158 + actual_amount = sum(d.suggest_amount for d in final_state.get("details", []))
  159 +
  160 + task.status = TaskStatus.SUCCESS
  161 + task.actual_amount = actual_amount
  162 + task.part_count = len(final_state.get("details", []))
  163 + task.shop_count = len(set(d.shop_id for d in final_state.get("details", [])))
  164 + task.base_ratio = final_state.get("base_ratio", Decimal("0"))
  165 + task.llm_provider = final_state.get("llm_provider", "")
  166 + task.llm_model = final_state.get("llm_model", "")
  167 + task.llm_prompt_tokens = final_state.get("llm_prompt_tokens", 0)
  168 + task.llm_completion_tokens = final_state.get("llm_completion_tokens", 0)
  169 + task.llm_total_tokens = task.llm_prompt_tokens + task.llm_completion_tokens
  170 + task.llm_analysis_summary = final_state.get("llm_analysis_summary", "")
  171 + task.execution_time_ms = execution_time
  172 +
  173 + self._result_writer.update_task(task)
  174 +
  175 +
  176 +
  177 + # 保存执行日志
  178 + if final_state.get("sql_execution_logs"):
  179 + self._save_execution_logs(
  180 + task_no=task_no,
  181 + group_id=group_id,
  182 + brand_grouping_id=brand_grouping_id,
  183 + brand_grouping_name=brand_grouping_name,
  184 + dealer_grouping_id=dealer_grouping_id,
  185 + dealer_grouping_name=dealer_grouping_name,
  186 + logs=final_state["sql_execution_logs"],
  187 + )
  188 +
  189 + # 配件汇总已在 allocate_budget_node 中保存,此处跳过避免重复
  190 + # if final_state.get("part_results"):
  191 + # self._save_part_summaries(
  192 + # task_no=task_no,
  193 + # group_id=group_id,
  194 + # dealer_grouping_id=dealer_grouping_id,
  195 + # statistics_date=statistics_date,
  196 + # part_results=final_state["part_results"],
  197 + # )
  198 +
  199 + logger.info(
  200 + f"补货建议执行完成: task_no={task_no}, "
  201 + f"parts={task.part_count}, amount={actual_amount}, "
  202 + f"time={execution_time}ms"
  203 + )
  204 +
  205 + return final_state
  206 +
  207 + except Exception as e:
  208 + logger.error(f"补货建议执行失败: task_no={task_no}, error={e}")
  209 +
  210 + task.status = TaskStatus.FAILED
  211 + task.error_message = str(e)
  212 + task.execution_time_ms = int((time.time() - initial_state["start_time"]) * 1000)
  213 + self._result_writer.update_task(task)
  214 +
  215 + raise
  216 +
  217 + finally:
  218 + self._result_writer.close()
  219 +
  220 + def _save_execution_logs(
  221 + self,
  222 + task_no: str,
  223 + group_id: int,
  224 + brand_grouping_id: Optional[int],
  225 + brand_grouping_name: str,
  226 + dealer_grouping_id: int,
  227 + dealer_grouping_name: str,
  228 + logs: List[dict],
  229 + ):
  230 + """保存执行日志"""
  231 + for log_data in logs:
  232 + log = TaskExecutionLog(
  233 + task_no=task_no,
  234 + group_id=group_id,
  235 + brand_grouping_id=brand_grouping_id,
  236 + brand_grouping_name=brand_grouping_name,
  237 + dealer_grouping_id=dealer_grouping_id,
  238 + dealer_grouping_name=dealer_grouping_name,
  239 + step_name=log_data.get("step_name", ""),
  240 + step_order=log_data.get("step_order", 0),
  241 + status=log_data.get("status", LogStatus.SUCCESS),
  242 + input_data=log_data.get("input_data", ""),
  243 + output_data=log_data.get("output_data", ""),
  244 + error_message=log_data.get("error_message", ""),
  245 + retry_count=log_data.get("retry_count", 0),
  246 + sql_query=log_data.get("sql_query", ""),
  247 + llm_prompt=log_data.get("llm_prompt", ""),
  248 + llm_response=log_data.get("llm_response", ""),
  249 + llm_tokens=log_data.get("llm_tokens", 0),
  250 + execution_time_ms=log_data.get("execution_time_ms", 0),
  251 + )
  252 + self._result_writer.save_execution_log(log)
  253 +
  254 + def _save_part_summaries(
  255 + self,
  256 + task_no: str,
  257 + group_id: int,
  258 + dealer_grouping_id: int,
  259 + statistics_date: str,
  260 + part_results: list,
  261 + ):
  262 + """保存配件汇总"""
  263 + from .sql_agent import PartAnalysisResult
  264 +
  265 + summaries = []
  266 + for pr in part_results:
  267 + if not isinstance(pr, PartAnalysisResult):
  268 + continue
  269 + summary = ReplenishmentPartSummary(
  270 + task_no=task_no,
  271 + group_id=group_id,
  272 + dealer_grouping_id=dealer_grouping_id,
  273 + part_code=pr.part_code,
  274 + part_name=pr.part_name,
  275 + unit=pr.unit,
  276 + cost_price=pr.cost_price,
  277 + total_storage_cnt=pr.total_storage_cnt,
  278 + total_avg_sales_cnt=pr.total_avg_sales_cnt,
  279 + group_current_ratio=pr.group_current_ratio,
  280 + total_suggest_cnt=pr.total_suggest_cnt,
  281 + total_suggest_amount=pr.total_suggest_amount,
  282 + shop_count=pr.shop_count,
  283 + need_replenishment_shop_count=pr.need_replenishment_shop_count,
  284 + part_decision_reason=pr.part_decision_reason,
  285 + priority=pr.priority,
  286 + llm_confidence=pr.confidence,
  287 + statistics_date=statistics_date,
  288 + )
  289 + summaries.append(summary)
  290 +
  291 + if summaries:
  292 + self._result_writer.save_part_summaries(summaries)
  293 + logger.info(f"保存配件汇总: count={len(summaries)}")
  294 +
  295 + def run_for_all_groupings(self, group_id: int):
  296 + """
  297 + 为所有商家组合执行补货建议
  298 + """
  299 + from ..services import DataService
  300 +
  301 + data_service = DataService()
  302 + try:
  303 + groupings = data_service.get_dealer_groupings(group_id)
  304 + logger.info(f"获取商家组合: group_id={group_id}, count={len(groupings)}")
  305 +
  306 + for idx, grouping in enumerate(groupings):
  307 + logger.info(f"[{idx+1}/{len(groupings)}] 开始处理商家组合: {grouping['name']} (id={grouping['id']})")
  308 + try:
  309 + self.run(
  310 + group_id=group_id,
  311 + dealer_grouping_id=grouping["id"],
  312 + dealer_grouping_name=grouping["name"],
  313 + )
  314 + logger.info(f"[{grouping['name']}] 执行完成")
  315 +
  316 + except Exception as e:
  317 + logger.error(f"商家组合执行失败: {grouping['name']}, error={e}", exc_info=True)
  318 + continue
  319 +
  320 + finally:
  321 + data_service.close()
src/fw_pms_ai/agent/sql_agent/__init__.py 0 → 100644
  1 +++ a/src/fw_pms_ai/agent/sql_agent/__init__.py
  1 +"""
  2 +SQL Agent 子包
  3 +
  4 +提供 SQL 执行和配件分析功能
  5 +"""
  6 +
  7 +from .agent import SQLAgent
  8 +from .executor import SQLExecutor
  9 +from .analyzer import PartAnalyzer
  10 +from .prompts import load_prompt
  11 +
  12 +__all__ = [
  13 + "SQLAgent",
  14 + "SQLExecutor",
  15 + "PartAnalyzer",
  16 + "load_prompt",
  17 +]
src/fw_pms_ai/agent/sql_agent/agent.py 0 → 100644
  1 +++ a/src/fw_pms_ai/agent/sql_agent/agent.py
  1 +"""
  2 +SQL Agent 主类模块
  3 +
  4 +组合 Executor 和 Analyzer 提供完整的 SQL Agent 功能
  5 +"""
  6 +
  7 +import logging
  8 +from typing import Any, Dict, List, Optional, Tuple
  9 +from decimal import Decimal
  10 +
  11 +from .executor import SQLExecutor
  12 +from .analyzer import PartAnalyzer
  13 +from .prompts import load_prompt
  14 +from ...models import SQLExecutionResult, ReplenishmentSuggestion, PartAnalysisResult
  15 +
  16 +logger = logging.getLogger(__name__)
  17 +
  18 +
  19 +class SQLAgent:
  20 + """SQL Agent - 组合 SQL 执行和配件分析功能"""
  21 +
  22 + def __init__(self, db_connection=None):
  23 + self._executor = SQLExecutor(db_connection)
  24 + self._analyzer = PartAnalyzer()
  25 +
  26 + def close(self):
  27 + """关闭连接"""
  28 + self._executor.close()
  29 +
  30 + # === 委托给 SQLExecutor 的方法 ===
  31 +
  32 + def generate_sql(
  33 + self,
  34 + question: str,
  35 + context: Optional[Dict] = None,
  36 + previous_error: Optional[str] = None,
  37 + ) -> Tuple[str, str]:
  38 + """使用LLM生成SQL"""
  39 + return self._executor.generate_sql(question, context, previous_error)
  40 +
  41 + def execute_sql(self, sql: str) -> Tuple[bool, Any, Optional[str]]:
  42 + """执行SQL查询"""
  43 + return self._executor.execute_sql(sql)
  44 +
  45 + def query_with_retry(
  46 + self,
  47 + question: str,
  48 + context: Optional[Dict] = None,
  49 + ) -> SQLExecutionResult:
  50 + """带重试的查询"""
  51 + return self._executor.query_with_retry(question, context)
  52 +
  53 + # === 委托给 PartAnalyzer 的方法 ===
  54 +
  55 + def group_parts_by_code(self, part_ratios: List[Dict]) -> Dict[str, List[Dict]]:
  56 + """按配件编码分组"""
  57 + return self._analyzer.group_parts_by_code(part_ratios)
  58 +
  59 + def generate_suggestions(
  60 + self,
  61 + part_data: List[Dict],
  62 + dealer_grouping_id: int,
  63 + dealer_grouping_name: str,
  64 + statistics_date: str,
  65 + ) -> Tuple[List[ReplenishmentSuggestion], Dict]:
  66 + """生成补货建议"""
  67 + return self._analyzer.generate_suggestions(
  68 + part_data, dealer_grouping_id, dealer_grouping_name, statistics_date
  69 + )
  70 +
  71 + def analyze_parts_by_group(
  72 + self,
  73 + part_ratios: List[Dict],
  74 + dealer_grouping_id: int,
  75 + dealer_grouping_name: str,
  76 + statistics_date: str,
  77 + target_ratio: Decimal = Decimal("1.3"),
  78 + limit: Optional[int] = None,
  79 + callback: Optional[Any] = None,
  80 + ) -> Tuple[List[ReplenishmentSuggestion], List[PartAnalysisResult], Dict]:
  81 + """按配件分组分析补货建议"""
  82 + return self._analyzer.analyze_parts_by_group(
  83 + part_ratios,
  84 + dealer_grouping_id,
  85 + dealer_grouping_name,
  86 + statistics_date,
  87 + target_ratio,
  88 + limit,
  89 + callback,
  90 + )
  91 +
  92 + # === 数据查询方法 ===
  93 +
  94 + def fetch_part_ratios(
  95 + self,
  96 + group_id: int,
  97 + dealer_grouping_id: int,
  98 + statistics_date: str,
  99 + ) -> List[Dict]:
  100 + """
  101 + 查询part_ratio数据
  102 +
  103 + Args:
  104 + group_id: 集团ID
  105 + dealer_grouping_id: 商家组合ID
  106 + statistics_date: 统计日期
  107 + Returns:
  108 + 配件库销比数据列表
  109 + """
  110 + conn = self._executor._get_connection()
  111 + cursor = conn.cursor(dictionary=True)
  112 +
  113 + try:
  114 + # 1. 查询商家组合关联的品牌组合配置
  115 + brand_grouping_ids = []
  116 + try:
  117 + cursor.execute(
  118 + "SELECT part_purchase_brand_assemble_id FROM artificial_region_dealer WHERE id = %s",
  119 + (dealer_grouping_id,)
  120 + )
  121 + rows = cursor.fetchall()
  122 + for row in rows:
  123 + bid = row.get("part_purchase_brand_assemble_id")
  124 + if bid:
  125 + brand_grouping_ids.append(bid)
  126 + if brand_grouping_ids:
  127 + logger.info(f"商家组合关联品牌组合: dealer_grouping_id={dealer_grouping_id} -> brand_grouping_ids={brand_grouping_ids}")
  128 + except Exception as e:
  129 + logger.warning(f"查询商家组合配置失败: {e}")
  130 +
  131 + sql = """
  132 + SELECT
  133 + id, group_id, brand_id, brand_grouping_id,
  134 + dealer_grouping_id,
  135 + supplier_id, supplier_name, area_id, area_name,
  136 + shop_id, shop_name, part_id, part_code, part_name,
  137 + unit, cost_price,
  138 + in_stock_unlocked_cnt, has_plan_cnt, on_the_way_cnt,
  139 + out_stock_cnt, buy_cnt, storage_locked_cnt,
  140 + out_stock_ongoing_cnt, stock_age, out_times, out_duration,
  141 + transfer_cnt, gen_transfer_cnt,
  142 + part_biz_type, statistics_date,
  143 + (in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt + transfer_cnt + gen_transfer_cnt) as valid_storage_cnt,
  144 + ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) as avg_sales_cnt
  145 + FROM part_ratio
  146 + WHERE group_id = %s
  147 + AND dealer_grouping_id = %s
  148 + AND statistics_date = %s
  149 + AND part_biz_type = 1
  150 + """
  151 + params = [group_id, dealer_grouping_id, statistics_date]
  152 +
  153 + # 如果有配置的品牌组合,用 IN 过滤
  154 + if brand_grouping_ids:
  155 + placeholders = ", ".join(["%s"] * len(brand_grouping_ids))
  156 + sql += f" AND brand_grouping_id IN ({placeholders})"
  157 + params.extend(brand_grouping_ids)
  158 +
  159 + # 优先处理有销量的配件
  160 + sql += """ ORDER BY
  161 + CASE WHEN ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) > 0 THEN 0 ELSE 1 END,
  162 + (in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt + transfer_cnt + gen_transfer_cnt) / NULLIF((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3, 0) ASC,
  163 + ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) DESC
  164 + """
  165 +
  166 + cursor.execute(sql, params)
  167 + rows = cursor.fetchall()
  168 +
  169 + logger.info(f"获取part_ratio数据: dealer_grouping_id={dealer_grouping_id}, count={len(rows)}")
  170 + return rows
  171 +
  172 + finally:
  173 + cursor.close()
src/fw_pms_ai/agent/sql_agent/analyzer.py 0 → 100644
  1 +++ a/src/fw_pms_ai/agent/sql_agent/analyzer.py
  1 +"""
  2 +配件分析器模块
  3 +
  4 +负责配件分组分析、LLM 调用和结果解析
  5 +"""
  6 +
  7 +import logging
  8 +import time
  9 +import json
  10 +import concurrent.futures
  11 +from typing import Any, Dict, List, Optional, Tuple
  12 +from decimal import Decimal
  13 +
  14 +from langchain_core.messages import SystemMessage, HumanMessage
  15 +
  16 +from .prompts import (
  17 + load_prompt,
  18 + SUGGESTION_PROMPT,
  19 + SUGGESTION_SYSTEM_PROMPT,
  20 + PART_SHOP_ANALYSIS_PROMPT,
  21 + PART_SHOP_ANALYSIS_SYSTEM_PROMPT,
  22 +)
  23 +from ...llm import get_llm_client
  24 +from ...models import ReplenishmentSuggestion, PartAnalysisResult
  25 +
  26 +logger = logging.getLogger(__name__)
  27 +
  28 +
  29 +class PartAnalyzer:
  30 + """配件分析器 - 负责 LLM 分析和结果解析"""
  31 +
  32 + def __init__(self):
  33 + self._llm = get_llm_client()
  34 +
  35 + def group_parts_by_code(self, part_ratios: List[Dict]) -> Dict[str, List[Dict]]:
  36 + """
  37 + 按配件编码分组
  38 +
  39 + Args:
  40 + part_ratios: 配件库销比数据列表
  41 +
  42 + Returns:
  43 + {part_code: [各门店数据列表]}
  44 + """
  45 + grouped = {}
  46 + for pr in part_ratios:
  47 + part_code = pr.get("part_code", "")
  48 + if not part_code:
  49 + continue
  50 + if part_code not in grouped:
  51 + grouped[part_code] = []
  52 + grouped[part_code].append(pr)
  53 +
  54 + logger.info(f"配件分组完成: 总配件数={len(grouped)}, 总记录数={len(part_ratios)}")
  55 + return grouped
  56 +
  57 + def generate_suggestions(
  58 + self,
  59 + part_data: List[Dict],
  60 + dealer_grouping_id: int,
  61 + dealer_grouping_name: str,
  62 + statistics_date: str,
  63 + ) -> Tuple[List[ReplenishmentSuggestion], Dict]:
  64 + """
  65 + 生成补货建议
  66 +
  67 + Args:
  68 + part_data: 配件数据
  69 + dealer_grouping_id: 商家组合ID
  70 + dealer_grouping_name: 商家组合名称
  71 + statistics_date: 统计日期
  72 +
  73 + Returns:
  74 + (补货建议列表, LLM统计信息)
  75 + """
  76 + if not part_data:
  77 + return [], {"prompt_tokens": 0, "completion_tokens": 0}
  78 +
  79 + # 将所有数据传给LLM分析
  80 + part_data_str = json.dumps(part_data, ensure_ascii=False, indent=2, default=str)
  81 +
  82 + prompt = SUGGESTION_PROMPT.format(
  83 + dealer_grouping_id=dealer_grouping_id,
  84 + dealer_grouping_name=dealer_grouping_name,
  85 + statistics_date=statistics_date,
  86 + part_data=part_data_str,
  87 + )
  88 +
  89 + messages = [
  90 + SystemMessage(content=SUGGESTION_SYSTEM_PROMPT),
  91 + HumanMessage(content=prompt),
  92 + ]
  93 +
  94 + response = self._llm.invoke(messages)
  95 + content = response.content.strip()
  96 +
  97 + suggestions = []
  98 + try:
  99 + # 提取JSON
  100 + if "```json" in content:
  101 + content = content.split("```json")[1].split("```")[0].strip()
  102 + elif "```" in content:
  103 + content = content.split("```")[1].split("```")[0].strip()
  104 +
  105 + raw_suggestions = json.loads(content)
  106 +
  107 + for item in raw_suggestions:
  108 + suggestions.append(ReplenishmentSuggestion(
  109 + shop_id=item.get("shop_id", 0),
  110 + shop_name=item.get("shop_name", ""),
  111 + part_code=item.get("part_code", ""),
  112 + part_name=item.get("part_name", ""),
  113 + unit=item.get("unit", ""),
  114 + cost_price=Decimal(str(item.get("cost_price", 0))),
  115 + current_storage_cnt=Decimal(str(item.get("current_storage_cnt", 0))),
  116 + avg_sales_cnt=Decimal(str(item.get("avg_sales_cnt", 0))),
  117 + current_ratio=Decimal(str(item.get("current_ratio", 0))),
  118 + suggest_cnt=int(item.get("suggest_cnt", 0)),
  119 + suggest_amount=Decimal(str(item.get("suggest_amount", 0))),
  120 + suggestion_reason=item.get("suggestion_reason", ""),
  121 + priority=int(item.get("priority", 2)),
  122 + confidence=float(item.get("confidence", 0.8)),
  123 + ))
  124 +
  125 + except json.JSONDecodeError as e:
  126 + logger.error(f"解析LLM建议失败: {e}")
  127 +
  128 + llm_stats = {
  129 + "prompt_tokens": response.usage.prompt_tokens if response.usage else 0,
  130 + "completion_tokens": response.usage.completion_tokens if response.usage else 0,
  131 + }
  132 +
  133 + logger.info(f"生成补货建议: {len(suggestions)}条")
  134 + return suggestions, llm_stats
  135 +
  136 + def analyze_parts_by_group(
  137 + self,
  138 + part_ratios: List[Dict],
  139 + dealer_grouping_id: int,
  140 + dealer_grouping_name: str,
  141 + statistics_date: str,
  142 + target_ratio: Decimal = Decimal("1.3"),
  143 + limit: Optional[int] = None,
  144 + callback: Optional[Any] = None,
  145 + ) -> Tuple[List[ReplenishmentSuggestion], List[PartAnalysisResult], Dict]:
  146 + """
  147 + 按配件分组分析补货建议
  148 +
  149 + Args:
  150 + part_ratios: 配件库销比数据列表
  151 + dealer_grouping_id: 商家组合ID
  152 + dealer_grouping_name: 商家组合名称
  153 + statistics_date: 统计日期
  154 + target_ratio: 目标库销比(基准库销比)
  155 + limit: 测试限制数量
  156 + callback: 批处理回调函数(suggestions)
  157 +
  158 + Returns:
  159 + (补货建议列表, 配件分析结果列表, LLM统计信息)
  160 + """
  161 + if not part_ratios:
  162 + return [], [], {"prompt_tokens": 0, "completion_tokens": 0}
  163 +
  164 + # 按 part_code 分组
  165 + grouped_parts = self.group_parts_by_code(part_ratios)
  166 +
  167 + # 应用限制
  168 + all_part_codes = list(grouped_parts.keys())
  169 + if limit and limit > 0:
  170 + logger.warning(f"启用测试限制: 仅处理前 {limit} 个配件 (总数: {len(all_part_codes)})")
  171 + all_part_codes = all_part_codes[:limit]
  172 +
  173 + all_suggestions = []
  174 + all_part_results: List[PartAnalysisResult] = []
  175 + total_prompt_tokens = 0
  176 + total_completion_tokens = 0
  177 +
  178 + system_prompt = PART_SHOP_ANALYSIS_SYSTEM_PROMPT
  179 + user_prompt_template = PART_SHOP_ANALYSIS_PROMPT
  180 +
  181 + # 将目标库销比格式化到 Prompt 中
  182 + target_ratio_str = f"{float(target_ratio):.2f}"
  183 + system_prompt = system_prompt.replace("{target_ratio}", target_ratio_str)
  184 +
  185 + def process_single_part(part_code: str) -> Tuple[PartAnalysisResult, List[ReplenishmentSuggestion], int, int]:
  186 + """处理单个配件"""
  187 + shop_data_list = grouped_parts[part_code]
  188 + if not shop_data_list:
  189 + return None, [], 0, 0
  190 +
  191 + # 获取配件基本信息
  192 + first_item = shop_data_list[0]
  193 + part_name = first_item.get("part_name", "")
  194 + cost_price = first_item.get("cost_price", 0)
  195 + unit = first_item.get("unit", "")
  196 +
  197 + # 构建门店数据
  198 + shop_data_str = json.dumps(shop_data_list, ensure_ascii=False, indent=2, default=str)
  199 +
  200 + prompt = user_prompt_template.format(
  201 + part_code=part_code,
  202 + part_name=part_name,
  203 + cost_price=cost_price,
  204 + unit=unit,
  205 + dealer_grouping_name=dealer_grouping_name,
  206 + statistics_date=statistics_date,
  207 + shop_data=shop_data_str,
  208 + target_ratio=target_ratio_str,
  209 + )
  210 +
  211 + messages = [
  212 + SystemMessage(content=system_prompt),
  213 + HumanMessage(content=prompt),
  214 + ]
  215 +
  216 + p_tokens = 0
  217 + c_tokens = 0
  218 +
  219 + try:
  220 + response = self._llm.invoke(messages)
  221 + content = response.content.strip()
  222 +
  223 + if response.usage:
  224 + p_tokens = response.usage.prompt_tokens
  225 + c_tokens = response.usage.completion_tokens
  226 +
  227 + # 解析 LLM 响应
  228 + part_result, suggestions = self._parse_part_analysis_response(
  229 + content, part_code, part_name, unit, cost_price, shop_data_list, target_ratio
  230 + )
  231 +
  232 + # 请求间延迟,避免触发速率限制
  233 + time.sleep(0.5)
  234 +
  235 + return part_result, suggestions, p_tokens, c_tokens
  236 +
  237 + except Exception as e:
  238 + logger.error(f"分析配件 {part_code} 失败: {e}")
  239 + # 失败后等待更长时间再继续
  240 + time.sleep(2.0)
  241 + return None, [], 0, 0
  242 +
  243 + # 并发执行
  244 + batch_size = 10
  245 + current_batch = []
  246 + finished_count = 0
  247 + total_count = len(all_part_codes)
  248 + # 最大并发数150,但不超过配件数量
  249 + max_workers = min(150, total_count) if total_count > 0 else 1
  250 +
  251 + logger.info(f"开始并行分析: workers={max_workers}, parts={total_count}")
  252 +
  253 + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
  254 + # 提交所有任务
  255 + future_to_part = {
  256 + executor.submit(process_single_part, code): code
  257 + for code in all_part_codes
  258 + }
  259 +
  260 + for future in concurrent.futures.as_completed(future_to_part):
  261 + part_code = future_to_part[future]
  262 + finished_count += 1
  263 +
  264 + try:
  265 + part_result, suggestions, p_t, c_t = future.result()
  266 +
  267 + if part_result:
  268 + all_part_results.append(part_result)
  269 + if suggestions:
  270 + all_suggestions.extend(suggestions)
  271 + current_batch.extend(suggestions)
  272 +
  273 + total_prompt_tokens += p_t
  274 + total_completion_tokens += c_t
  275 +
  276 + # 批量回调处理
  277 + if callback and len(current_batch) >= batch_size:
  278 + try:
  279 + callback(current_batch)
  280 + logger.info(f"批次落库: {len(current_batch)} 条")
  281 + current_batch = []
  282 + except Exception as e:
  283 + logger.error(f"回调执行失败: {e}")
  284 +
  285 + except Exception as e:
  286 + logger.error(f"任务执行异常 {part_code}: {e}")
  287 +
  288 + if finished_count % 10 == 0:
  289 + logger.info(f"进度: {finished_count}/{total_count} ({(finished_count/total_count*100):.1f}%)")
  290 +
  291 + # 处理剩余批次
  292 + if callback and current_batch:
  293 + try:
  294 + callback(current_batch)
  295 + logger.info(f"最后批次落库: {len(current_batch)} 条")
  296 + except Exception as e:
  297 + logger.error(f"最后回调执行失败: {e}")
  298 +
  299 + llm_stats = {
  300 + "prompt_tokens": total_prompt_tokens,
  301 + "completion_tokens": total_completion_tokens,
  302 + }
  303 +
  304 + logger.info(
  305 + f"分组分析完成: 配件数={len(grouped_parts)}, "
  306 + f"配件汇总数={len(all_part_results)}, "
  307 + f"建议数={len(all_suggestions)}, tokens={total_prompt_tokens + total_completion_tokens}"
  308 + )
  309 + return all_suggestions, all_part_results, llm_stats
  310 +
  311 + def _calculate_priority_by_ratio(
  312 + self,
  313 + current_ratio: Decimal,
  314 + avg_sales: Decimal,
  315 + target_ratio: Decimal,
  316 + ) -> int:
  317 + """
  318 + 根据库销比计算优先级
  319 +
  320 + 规则:
  321 + - 库销比 < 0.5 且月均销量 >= 1: 高优先级 (1)
  322 + - 库销比 0.5-1.0 且月均销量 >= 1: 中优先级 (2)
  323 + - 库销比 1.0-target_ratio 且月均销量 >= 1: 低优先级 (3)
  324 + - 其他情况: 无需补货 (0)
  325 +
  326 + Args:
  327 + current_ratio: 当前库销比
  328 + avg_sales: 月均销量
  329 + target_ratio: 目标库销比
  330 +
  331 + Returns:
  332 + 优先级 (0=无需补货, 1=高, 2=中, 3=低)
  333 + """
  334 + if avg_sales < 1:
  335 + return 0
  336 +
  337 + if current_ratio < Decimal("0.5"):
  338 + return 1
  339 + elif current_ratio < Decimal("1.0"):
  340 + return 2
  341 + elif current_ratio < target_ratio:
  342 + return 3
  343 + else:
  344 + return 0
  345 +
  346 + def _parse_part_analysis_response(
  347 + self,
  348 + content: str,
  349 + part_code: str,
  350 + part_name: str,
  351 + unit: str,
  352 + cost_price: float,
  353 + shop_data_list: List[Dict],
  354 + target_ratio: Decimal = Decimal("1.3"),
  355 + ) -> Tuple[PartAnalysisResult, List[ReplenishmentSuggestion]]:
  356 + """
  357 + 解析单配件分析响应
  358 +
  359 + Args:
  360 + content: LLM 响应内容
  361 + part_code: 配件编码
  362 + part_name: 配件名称
  363 + unit: 单位
  364 + cost_price: 成本价
  365 + shop_data_list: 门店数据列表
  366 +
  367 + Returns:
  368 + (配件分析结果, 补货建议列表)
  369 + """
  370 + suggestions = []
  371 +
  372 + # 计算默认配件汇总数据
  373 + total_storage = sum(Decimal(str(s.get("valid_storage_cnt", 0))) for s in shop_data_list)
  374 + total_avg_sales = sum(Decimal(str(s.get("avg_sales_cnt", 0))) for s in shop_data_list)
  375 + group_ratio = total_storage / total_avg_sales if total_avg_sales > 0 else Decimal("0")
  376 +
  377 + part_result = PartAnalysisResult(
  378 + part_code=part_code,
  379 + part_name=part_name,
  380 + unit=unit,
  381 + cost_price=Decimal(str(cost_price)),
  382 + total_storage_cnt=total_storage,
  383 + total_avg_sales_cnt=total_avg_sales,
  384 + group_current_ratio=group_ratio,
  385 + need_replenishment=False,
  386 + total_suggest_cnt=0,
  387 + total_suggest_amount=Decimal("0"),
  388 + shop_count=len(shop_data_list),
  389 + need_replenishment_shop_count=0,
  390 + part_decision_reason="",
  391 + priority=2,
  392 + confidence=0.8,
  393 + suggestions=[],
  394 + )
  395 +
  396 + try:
  397 + # 提取 JSON
  398 + if "```json" in content:
  399 + content = content.split("```json")[1].split("```")[0].strip()
  400 + elif "```" in content:
  401 + content = content.split("```")[1].split("```")[0].strip()
  402 +
  403 + result = json.loads(content)
  404 +
  405 + # 获取配件级汇总信息
  406 + confidence = float(result.get("confidence", 0.8))
  407 + part_decision_reason = result.get("part_decision_reason", "")
  408 + need_replenishment = result.get("need_replenishment", False)
  409 + priority = int(result.get("priority", 2))
  410 +
  411 + # 更新配件结果
  412 + part_result.need_replenishment = need_replenishment
  413 + part_result.total_suggest_cnt = int(result.get("total_suggest_cnt", 0))
  414 + part_result.total_suggest_amount = Decimal(str(result.get("total_suggest_amount", 0)))
  415 + part_result.shop_count = int(result.get("shop_count", len(shop_data_list)))
  416 + part_result.part_decision_reason = part_decision_reason
  417 + part_result.priority = priority
  418 + part_result.confidence = confidence
  419 +
  420 + # 如果LLM返回了商家组合级数据,使用LLM的数据
  421 + if "total_storage_cnt" in result:
  422 + part_result.total_storage_cnt = Decimal(str(result["total_storage_cnt"]))
  423 + if "total_avg_sales_cnt" in result:
  424 + part_result.total_avg_sales_cnt = Decimal(str(result["total_avg_sales_cnt"]))
  425 + if "group_current_ratio" in result:
  426 + part_result.group_current_ratio = Decimal(str(result["group_current_ratio"]))
  427 +
  428 + # 构建建议字典以便快速查找
  429 + shop_suggestion_map = {}
  430 + shop_suggestions_data = result.get("shop_suggestions", [])
  431 + if shop_suggestions_data:
  432 + for shop in shop_suggestions_data:
  433 + s_id = int(shop.get("shop_id", 0))
  434 + shop_suggestion_map[s_id] = shop
  435 +
  436 + # 统计需要补货的门店数
  437 + need_replenishment_shop_count = len([s for s in shop_suggestions_data if int(s.get("suggest_cnt", 0)) > 0])
  438 + part_result.need_replenishment_shop_count = need_replenishment_shop_count
  439 +
  440 + # 递归所有输入门店,确保每个门店都有记录
  441 + for shop_data in shop_data_list:
  442 + shop_id = int(shop_data.get("shop_id", 0))
  443 + shop_name = shop_data.get("shop_name", "")
  444 +
  445 + # 检查LLM是否有针对该门店的建议
  446 + if shop_id in shop_suggestion_map:
  447 + s_item = shop_suggestion_map[shop_id]
  448 + suggest_cnt = int(s_item.get("suggest_cnt", 0))
  449 + suggest_amount = Decimal(str(s_item.get("suggest_amount", 0)))
  450 + reason = s_item.get("reason", part_decision_reason)
  451 + shop_priority = int(s_item.get("priority", priority))
  452 + else:
  453 + # LLM未提及该门店,根据门店数据生成个性化默认理由
  454 + suggest_cnt = 0
  455 + suggest_amount = Decimal("0")
  456 +
  457 + # 计算该门店的库存和销售数据
  458 + _storage = Decimal(str(shop_data.get("valid_storage_cnt", 0)))
  459 + _avg_sales = Decimal(str(shop_data.get("avg_sales_cnt", 0)))
  460 + _out_times = shop_data.get("out_times", 0) or 0
  461 + _out_duration = shop_data.get("out_duration", 0) or 0
  462 + _ratio = _storage / _avg_sales if _avg_sales > 0 else Decimal("0")
  463 +
  464 + # 根据库销比规则计算 priority
  465 + shop_priority = self._calculate_priority_by_ratio(_ratio, _avg_sales, target_ratio)
  466 +
  467 + if _storage > 0 and _avg_sales <= 0:
  468 + reason = f"「呆滞件」当前库存{_storage}件,但90天内无销售记录,库存滞销风险高,暂不补货。"
  469 + shop_priority = 0
  470 + elif _storage <= 0 and _avg_sales < 1:
  471 + reason = f"「低频件-需求不足」当前库存{_storage}件,月均销量{_avg_sales:.2f}件,需求过低,暂不纳入补货计划。"
  472 + shop_priority = 0
  473 + elif _out_times < 3:
  474 + reason = f"「低频件-出库次数不足」90天内仅出库{_out_times}次(阈值≥3次),周转频率过低,暂不纳入补货计划。"
  475 + shop_priority = 0
  476 + elif _out_duration >= 30:
  477 + reason = f"「低频件-出库间隔过长」平均出库间隔{_out_duration}天(阈值<30天),周转周期过长,暂不纳入补货计划。"
  478 + shop_priority = 0
  479 + elif _avg_sales > 0 and _ratio >= target_ratio:
  480 + _days = int(_storage / _avg_sales * 30) if _avg_sales > 0 else 0
  481 + reason = f"「库存充足」当前库存{_storage}件,月均销量{_avg_sales:.2f}件,库销比{_ratio:.2f},可支撑约{_days}天销售,无需补货。"
  482 + shop_priority = 0
  483 + elif shop_priority == 1:
  484 + _days = int(_storage / _avg_sales * 30) if _avg_sales > 0 else 0
  485 + reason = f"「急需补货」当前库存{_storage}件,月均销量{_avg_sales:.2f}件,库销比{_ratio:.2f},仅可支撑约{_days}天销售,存在缺货风险。"
  486 + elif shop_priority == 2:
  487 + _days = int(_storage / _avg_sales * 30) if _avg_sales > 0 else 0
  488 + reason = f"「建议补货」当前库存{_storage}件,月均销量{_avg_sales:.2f}件,库销比{_ratio:.2f},可支撑约{_days}天销售,库存偏低建议补货。"
  489 + elif shop_priority == 3:
  490 + _days = int(_storage / _avg_sales * 30) if _avg_sales > 0 else 0
  491 + reason = f"「可选补货」当前库存{_storage}件,月均销量{_avg_sales:.2f}件,库销比{_ratio:.2f},可支撑约{_days}天销售,可根据资金情况酌情补货。"
  492 + else:
  493 + reason = f"「无需补货」当前库存{_storage}件,月均销量{_avg_sales:.2f}件,AI分析判定暂不补货。"
  494 +
  495 + curr_storage = Decimal(str(shop_data.get("valid_storage_cnt", 0)))
  496 + avg_sales = Decimal(str(shop_data.get("avg_sales_cnt", 0)))
  497 + if avg_sales > 0:
  498 + current_ratio = curr_storage / avg_sales
  499 + else:
  500 + current_ratio = Decimal("0")
  501 +
  502 + suggestion = ReplenishmentSuggestion(
  503 + shop_id=shop_id,
  504 + shop_name=shop_name,
  505 + part_code=part_code,
  506 + part_name=part_name,
  507 + unit=unit,
  508 + cost_price=Decimal(str(cost_price)),
  509 + current_storage_cnt=curr_storage,
  510 + avg_sales_cnt=avg_sales,
  511 + current_ratio=current_ratio,
  512 + suggest_cnt=suggest_cnt,
  513 + suggest_amount=suggest_amount,
  514 + suggestion_reason=reason,
  515 + priority=shop_priority,
  516 + confidence=confidence,
  517 + )
  518 + suggestions.append(suggestion)
  519 +
  520 + except json.JSONDecodeError as e:
  521 + logger.error(f"解析配件 {part_code} 分析结果失败: {e}")
  522 + part_result.part_decision_reason = f"分析失败: {str(e)}"
  523 + for shop_data in shop_data_list:
  524 + suggestions.append(ReplenishmentSuggestion(
  525 + shop_id=int(shop_data.get("shop_id", 0)),
  526 + shop_name=shop_data.get("shop_name", ""),
  527 + part_code=part_code,
  528 + part_name=part_name,
  529 + unit=unit,
  530 + cost_price=Decimal(str(cost_price)),
  531 + current_storage_cnt=Decimal(str(shop_data.get("valid_storage_cnt", 0))),
  532 + avg_sales_cnt=Decimal(str(shop_data.get("avg_sales_cnt", 0))),
  533 + current_ratio=Decimal("0"),
  534 + suggest_cnt=0,
  535 + suggest_amount=Decimal("0"),
  536 + suggestion_reason=f"分析失败: {str(e)}",
  537 + priority=3,
  538 + confidence=0.0,
  539 + ))
  540 +
  541 + except Exception as e:
  542 + logger.error(f"处理配件 {part_code} 分析结果异常: {e}")
  543 +
  544 + part_result.suggestions = suggestions
  545 + return part_result, suggestions
src/fw_pms_ai/agent/sql_agent/executor.py 0 → 100644
  1 +++ a/src/fw_pms_ai/agent/sql_agent/executor.py
  1 +"""
  2 +SQL 执行器模块
  3 +
  4 +提供 SQL 执行、重试和错误分类功能
  5 +"""
  6 +
  7 +import logging
  8 +import time
  9 +import json
  10 +from typing import Any, Dict, List, Optional, Tuple
  11 +
  12 +from ..sql_agent.prompts import load_prompt, SQL_AGENT_SYSTEM_PROMPT
  13 +from ...llm import get_llm_client
  14 +from ...config import get_settings
  15 +from ...models import SQLExecutionResult
  16 +
  17 +from langchain_core.messages import SystemMessage, HumanMessage
  18 +
  19 +logger = logging.getLogger(__name__)
  20 +
  21 +
  22 +class SQLExecutor:
  23 + """SQL 执行器 - 负责 SQL 生成、执行和重试"""
  24 +
  25 + def __init__(self, db_connection=None):
  26 + self._settings = get_settings()
  27 + self._conn = db_connection
  28 + self._llm = get_llm_client()
  29 + self._max_retries = 3
  30 + self._base_delay = 1.0
  31 +
  32 + def _get_connection(self):
  33 + """获取数据库连接"""
  34 + if self._conn is None:
  35 + from ...services.db import get_connection
  36 + self._conn = get_connection()
  37 + return self._conn
  38 +
  39 + def close(self):
  40 + """关闭连接"""
  41 + if self._conn and hasattr(self._conn, 'close'):
  42 + self._conn.close()
  43 + self._conn = None
  44 +
  45 + def generate_sql(
  46 + self,
  47 + question: str,
  48 + context: Optional[Dict] = None,
  49 + previous_error: Optional[str] = None,
  50 + ) -> Tuple[str, str]:
  51 + """
  52 + 使用LLM生成SQL
  53 +
  54 + Args:
  55 + question: 自然语言查询需求
  56 + context: 上下文信息
  57 + previous_error: 上一次执行的错误(用于重试)
  58 +
  59 + Returns:
  60 + (SQL语句, 解释说明)
  61 + """
  62 + user_prompt = question
  63 +
  64 + if context:
  65 + user_prompt += f"\n\n上下文信息:\n{json.dumps(context, ensure_ascii=False, indent=2)}"
  66 +
  67 + if previous_error:
  68 + user_prompt += f"\n\n上一次执行错误:\n{previous_error}\n请修正SQL语句。"
  69 +
  70 + messages = [
  71 + SystemMessage(content=SQL_AGENT_SYSTEM_PROMPT),
  72 + HumanMessage(content=user_prompt),
  73 + ]
  74 +
  75 + response = self._llm.invoke(messages)
  76 + content = response.content.strip()
  77 +
  78 + try:
  79 + # 提取JSON
  80 + if "```json" in content:
  81 + content = content.split("```json")[1].split("```")[0].strip()
  82 + elif "```" in content:
  83 + content = content.split("```")[1].split("```")[0].strip()
  84 +
  85 + result = json.loads(content)
  86 + return result.get("sql", ""), result.get("explanation", "")
  87 + except json.JSONDecodeError:
  88 + logger.warning(f"无法解析LLM响应为JSON: {content[:200]}")
  89 + # 尝试直接提取SQL
  90 + if "SELECT" in content.upper():
  91 + lines = content.split("\n")
  92 + for line in lines:
  93 + if "SELECT" in line.upper():
  94 + return line.strip(), "直接提取的SQL"
  95 + return "", "解析失败"
  96 +
  97 + def execute_sql(self, sql: str) -> Tuple[bool, Any, Optional[str]]:
  98 + """
  99 + 执行SQL查询
  100 +
  101 + Returns:
  102 + (成功标志, 数据/None, 错误信息/None)
  103 + """
  104 + if not sql or not sql.strip():
  105 + return False, None, "SQL语句为空"
  106 +
  107 + # 安全检查:只允许SELECT语句
  108 + sql_upper = sql.upper().strip()
  109 + if not sql_upper.startswith("SELECT"):
  110 + return False, None, "只允许执行SELECT查询"
  111 +
  112 + conn = self._get_connection()
  113 + cursor = conn.cursor(dictionary=True)
  114 +
  115 + try:
  116 + start_time = time.time()
  117 + cursor.execute(sql)
  118 + rows = cursor.fetchall()
  119 + execution_time = int((time.time() - start_time) * 1000)
  120 +
  121 + logger.info(f"SQL执行成功: 返回{len(rows)}行, 耗时{execution_time}ms")
  122 + return True, rows, None
  123 +
  124 + except Exception as e:
  125 + error_msg = str(e)
  126 + logger.error(f"SQL执行失败: {error_msg}")
  127 + return False, None, error_msg
  128 +
  129 + finally:
  130 + cursor.close()
  131 +
  132 + def query_with_retry(
  133 + self,
  134 + question: str,
  135 + context: Optional[Dict] = None,
  136 + ) -> SQLExecutionResult:
  137 + """
  138 + 带重试的查询
  139 +
  140 + 错误类型区分:
  141 + - SyntaxError: 立即重试,让LLM修正SQL
  142 + - OperationalError: 指数退避重试(连接超时、死锁等)
  143 +
  144 + Args:
  145 + question: 查询问题
  146 + context: 上下文
  147 +
  148 + Returns:
  149 + SQLExecutionResult
  150 + """
  151 + start_time = time.time()
  152 + last_error = None
  153 + last_sql = ""
  154 +
  155 + for attempt in range(self._max_retries):
  156 + # 生成SQL
  157 + sql, explanation = self.generate_sql(question, context, last_error)
  158 + last_sql = sql
  159 +
  160 + if not sql:
  161 + last_error = "LLM未能生成有效的SQL语句"
  162 + logger.warning(f"重试 {attempt + 1}/{self._max_retries}: {last_error}")
  163 +
  164 + if attempt < self._max_retries - 1:
  165 + delay = self._base_delay * (2 ** attempt)
  166 + time.sleep(delay)
  167 + continue
  168 +
  169 + logger.info(f"尝试 {attempt + 1}: 执行SQL: {sql[:100]}...")
  170 +
  171 + # 执行SQL
  172 + success, data, error, error_type = self._execute_sql_with_type(sql)
  173 +
  174 + if success:
  175 + execution_time = int((time.time() - start_time) * 1000)
  176 + return SQLExecutionResult(
  177 + success=True,
  178 + sql=sql,
  179 + data=data,
  180 + retry_count=attempt,
  181 + execution_time_ms=execution_time,
  182 + )
  183 +
  184 + last_error = error
  185 + logger.warning(f"重试 {attempt + 1}/{self._max_retries}: [{error_type}] {error}")
  186 +
  187 + if attempt < self._max_retries - 1:
  188 + if error_type == "syntax":
  189 + # 语法错误:立即重试,不等待
  190 + logger.info("SQL语法错误,立即重试让LLM修正")
  191 + else:
  192 + # 连接/死锁等操作错误:指数退避
  193 + delay = self._base_delay * (2 ** attempt)
  194 + logger.info(f"操作错误,等待 {delay}秒 后重试...")
  195 + time.sleep(delay)
  196 +
  197 + execution_time = int((time.time() - start_time) * 1000)
  198 + return SQLExecutionResult(
  199 + success=False,
  200 + sql=last_sql,
  201 + error=last_error,
  202 + retry_count=self._max_retries,
  203 + execution_time_ms=execution_time,
  204 + )
  205 +
  206 + def _execute_sql_with_type(self, sql: str) -> Tuple[bool, Any, Optional[str], str]:
  207 + """
  208 + 执行SQL并返回错误类型
  209 +
  210 + Returns:
  211 + (成功标志, 数据/None, 错误信息/None, 错误类型)
  212 + 错误类型: "syntax" | "operational" | "unknown"
  213 + """
  214 + if not sql or not sql.strip():
  215 + return False, None, "SQL语句为空", "unknown"
  216 +
  217 + # 安全检查:只允许SELECT语句
  218 + sql_upper = sql.upper().strip()
  219 + if not sql_upper.startswith("SELECT"):
  220 + return False, None, "只允许执行SELECT查询", "syntax"
  221 +
  222 + conn = self._get_connection()
  223 + cursor = conn.cursor(dictionary=True)
  224 +
  225 + try:
  226 + start_time = time.time()
  227 + cursor.execute(sql)
  228 + rows = cursor.fetchall()
  229 + execution_time = int((time.time() - start_time) * 1000)
  230 +
  231 + logger.info(f"SQL执行成功: 返回{len(rows)}行, 耗时{execution_time}ms")
  232 + return True, rows, None, ""
  233 +
  234 + except Exception as e:
  235 + error_msg = str(e)
  236 + error_type = self._classify_error(e)
  237 + logger.error(f"SQL执行失败 [{error_type}]: {error_msg}")
  238 + return False, None, error_msg, error_type
  239 +
  240 + finally:
  241 + cursor.close()
  242 +
  243 + def _classify_error(self, error: Exception) -> str:
  244 + """
  245 + 分类SQL错误类型
  246 +
  247 + Returns:
  248 + "syntax" - 语法错误,需要LLM修正
  249 + "operational" - 操作错误,需要指数退避
  250 + "unknown" - 未知错误
  251 + """
  252 + error_msg = str(error).lower()
  253 + error_class = type(error).__name__
  254 +
  255 + # MySQL语法错误特征
  256 + syntax_keywords = [
  257 + "syntax error", "you have an error in your sql syntax",
  258 + "unknown column", "unknown table", "doesn't exist",
  259 + "ambiguous column", "invalid", "near",
  260 + ]
  261 +
  262 + # 操作错误特征(需要退避)
  263 + operational_keywords = [
  264 + "timeout", "timed out", "connection", "deadlock",
  265 + "lock wait", "too many connections", "gone away",
  266 + "lost connection", "can't connect",
  267 + ]
  268 +
  269 + for keyword in syntax_keywords:
  270 + if keyword in error_msg:
  271 + return "syntax"
  272 +
  273 + for keyword in operational_keywords:
  274 + if keyword in error_msg:
  275 + return "operational"
  276 +
  277 + # 根据异常类型判断
  278 + if "ProgrammingError" in error_class or "InterfaceError" in error_class:
  279 + return "syntax"
  280 + if "OperationalError" in error_class:
  281 + return "operational"
  282 +
  283 + return "unknown"
src/fw_pms_ai/agent/sql_agent/prompts.py 0 → 100644
  1 +++ a/src/fw_pms_ai/agent/sql_agent/prompts.py
  1 +"""
  2 +提示词加载模块
  3 +"""
  4 +
  5 +import os
  6 +import logging
  7 +
  8 +logger = logging.getLogger(__name__)
  9 +
  10 +
  11 +def load_prompt(filename: str) -> str:
  12 + """从prompts目录加载提示词文件"""
  13 + # 从 src/fw_pms_ai/agent/sql_agent/prompts.py 向上5层到达项目根目录
  14 + prompt_path = os.path.join(
  15 + os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))),
  16 + "prompts", filename
  17 + )
  18 + try:
  19 + with open(prompt_path, "r", encoding="utf-8") as f:
  20 + return f.read()
  21 + except FileNotFoundError:
  22 + logger.warning(f"Prompt文件未找到: {prompt_path}")
  23 + return ""
  24 +
  25 +
  26 +# 预加载常用提示词
  27 +SQL_AGENT_SYSTEM_PROMPT = load_prompt("sql_agent.md")
  28 +SUGGESTION_PROMPT = load_prompt("suggestion.md")
  29 +SUGGESTION_SYSTEM_PROMPT = load_prompt("suggestion_system.md")
  30 +PART_SHOP_ANALYSIS_PROMPT = load_prompt("part_shop_analysis.md")
  31 +PART_SHOP_ANALYSIS_SYSTEM_PROMPT = load_prompt("part_shop_analysis_system.md")
src/fw_pms_ai/agent/state.py 0 → 100644
  1 +++ a/src/fw_pms_ai/agent/state.py
  1 +"""
  2 +LangGraph Agent 状态定义
  3 +
  4 +重构版本:直接使用 part_ratio 数据,支持 SQL Agent
  5 +使用 Annotated 类型和 reducer 函数解决并发状态更新问题
  6 +"""
  7 +
  8 +from typing import TypedDict, Optional, List, Any, Annotated
  9 +from decimal import Decimal
  10 +import operator
  11 +
  12 +
  13 +def merge_lists(left: List[Any], right: List[Any]) -> List[Any]:
  14 + """合并两个列表的 reducer 函数"""
  15 + if left is None:
  16 + left = []
  17 + if right is None:
  18 + right = []
  19 + return left + right
  20 +
  21 +
  22 +def merge_dicts(left: List[dict], right: List[dict]) -> List[dict]:
  23 + """合并字典列表的 reducer 函数"""
  24 + if left is None:
  25 + left = []
  26 + if right is None:
  27 + right = []
  28 + return left + right
  29 +
  30 +
  31 +def keep_last(left: Any, right: Any) -> Any:
  32 + """保留最后一个值的 reducer 函数"""
  33 + return right if right is not None else left
  34 +
  35 +
  36 +def sum_values(left: int, right: int) -> int:
  37 + """累加数值的 reducer 函数"""
  38 + return (left or 0) + (right or 0)
  39 +
  40 +
  41 +class AgentState(TypedDict, total=False):
  42 + """补货建议 Agent 状态
  43 +
  44 + 使用 Annotated 类型定义 reducer 函数,处理并行节点的状态合并
  45 + """
  46 +
  47 + # 任务标识(使用 keep_last,因为这些值在并行执行时相同)
  48 + task_no: Annotated[str, keep_last]
  49 + group_id: Annotated[int, keep_last]
  50 + brand_grouping_id: Annotated[Optional[int], keep_last]
  51 + brand_grouping_name: Annotated[str, keep_last]
  52 + dealer_grouping_id: Annotated[int, keep_last]
  53 + dealer_grouping_name: Annotated[str, keep_last]
  54 + statistics_date: Annotated[str, keep_last]
  55 +
  56 + # part_ratio 原始数据
  57 + part_ratios: Annotated[List[dict], merge_dicts]
  58 +
  59 + # SQL Agent 相关
  60 + sql_queries: Annotated[List[str], merge_lists]
  61 + sql_results: Annotated[List[dict], merge_dicts]
  62 + sql_retry_count: Annotated[int, keep_last]
  63 + sql_execution_logs: Annotated[List[dict], merge_dicts]
  64 +
  65 + # 计算结果
  66 + base_ratio: Annotated[Decimal, keep_last]
  67 + allocated_details: Annotated[List[dict], merge_dicts]
  68 + details: Annotated[List[Any], merge_lists]
  69 +
  70 + # LLM 建议明细
  71 + llm_suggestions: Annotated[List[Any], merge_lists]
  72 +
  73 + # 配件汇总结果
  74 + part_results: Annotated[List[Any], merge_lists]
  75 +
  76 + # LLM 统计(使用累加,合并多个并行节点的 token 使用量)
  77 + llm_provider: Annotated[str, keep_last]
  78 + llm_model: Annotated[str, keep_last]
  79 + llm_prompt_tokens: Annotated[int, sum_values]
  80 + llm_completion_tokens: Annotated[int, sum_values]
  81 +
  82 + # 执行状态
  83 + status: Annotated[str, keep_last]
  84 + error_message: Annotated[str, keep_last]
  85 + start_time: Annotated[float, keep_last]
  86 + end_time: Annotated[float, keep_last]
  87 +
  88 + # 流程控制
  89 + current_node: Annotated[str, keep_last]
  90 + next_node: Annotated[str, keep_last]
  91 +
  92 +
src/fw_pms_ai/api/__init__.py 0 → 100644
  1 +++ a/src/fw_pms_ai/api/__init__.py
  1 +# API 模块
src/fw_pms_ai/api/app.py 0 → 100644
  1 +++ a/src/fw_pms_ai/api/app.py
  1 +"""
  2 +FastAPI 主应用
  3 +提供 AI 补货建议系统的 REST API
  4 +"""
  5 +
  6 +import logging
  7 +from pathlib import Path
  8 +from contextlib import asynccontextmanager
  9 +
  10 +from fastapi import FastAPI
  11 +from fastapi.middleware.cors import CORSMiddleware
  12 +from fastapi.staticfiles import StaticFiles
  13 +from fastapi.responses import FileResponse
  14 +
  15 +from .routes import tasks
  16 +
  17 +logger = logging.getLogger(__name__)
  18 +
  19 +
  20 +@asynccontextmanager
  21 +async def lifespan(app: FastAPI):
  22 + """应用生命周期管理"""
  23 + logger.info("API 服务启动")
  24 + yield
  25 + logger.info("API 服务关闭")
  26 +
  27 +
  28 +app = FastAPI(
  29 + title="AI 补货建议系统 API",
  30 + description="提供补货任务、明细的查询接口",
  31 + version="1.0.0",
  32 + lifespan=lifespan,
  33 +)
  34 +
  35 +# CORS 配置
  36 +app.add_middleware(
  37 + CORSMiddleware,
  38 + allow_origins=["*"],
  39 + allow_credentials=True,
  40 + allow_methods=["*"],
  41 + allow_headers=["*"],
  42 +)
  43 +
  44 +# 挂载路由
  45 +app.include_router(tasks.router, prefix="/api", tags=["Tasks"])
  46 +
  47 +# 静态文件服务
  48 +ui_path = Path(__file__).parent.parent.parent.parent / "ui"
  49 +if ui_path.exists():
  50 + app.mount("/css", StaticFiles(directory=ui_path / "css"), name="css")
  51 + app.mount("/js", StaticFiles(directory=ui_path / "js"), name="js")
  52 +
  53 +
  54 +@app.get("/", include_in_schema=False)
  55 +async def serve_index():
  56 + """服务主页面"""
  57 + index_file = ui_path / "index.html"
  58 + if index_file.exists():
  59 + return FileResponse(index_file)
  60 + return {"message": "AI 补货建议系统 API", "docs": "/docs"}
  61 +
  62 +
  63 +@app.get("/health")
  64 +async def health_check():
  65 + """健康检查"""
  66 + return {"status": "ok"}
src/fw_pms_ai/api/routes/__init__.py 0 → 100644
  1 +++ a/src/fw_pms_ai/api/routes/__init__.py
  1 +# API 路由模块
src/fw_pms_ai/api/routes/tasks.py 0 → 100644
  1 +++ a/src/fw_pms_ai/api/routes/tasks.py
  1 +"""
  2 +任务相关 API 路由
  3 +"""
  4 +
  5 +import logging
  6 +from typing import Optional, List
  7 +from datetime import datetime
  8 +from decimal import Decimal
  9 +
  10 +from fastapi import APIRouter, Query, HTTPException
  11 +from pydantic import BaseModel
  12 +
  13 +from ...services.db import get_connection
  14 +
  15 +logger = logging.getLogger(__name__)
  16 +router = APIRouter()
  17 +
  18 +
  19 +class TaskResponse(BaseModel):
  20 + """任务响应模型"""
  21 + id: int
  22 + task_no: str
  23 + group_id: int
  24 + dealer_grouping_id: int
  25 + dealer_grouping_name: Optional[str] = None
  26 + brand_grouping_id: Optional[int] = None
  27 + plan_amount: float = 0
  28 + actual_amount: float = 0
  29 + part_count: int = 0
  30 + base_ratio: Optional[float] = None
  31 + status: int = 0
  32 + status_text: str = ""
  33 + error_message: Optional[str] = None
  34 + llm_provider: Optional[str] = None
  35 + llm_model: Optional[str] = None
  36 + llm_total_tokens: int = 0
  37 + statistics_date: Optional[str] = None
  38 + start_time: Optional[str] = None
  39 + end_time: Optional[str] = None
  40 + duration_seconds: Optional[int] = None
  41 + create_time: Optional[str] = None
  42 +
  43 +
  44 +class TaskListResponse(BaseModel):
  45 + """任务列表响应"""
  46 + total: int
  47 + page: int
  48 + page_size: int
  49 + items: List[TaskResponse]
  50 +
  51 +
  52 +class DetailResponse(BaseModel):
  53 + """配件建议明细响应"""
  54 + id: int
  55 + task_no: str
  56 + shop_id: int
  57 + shop_name: Optional[str] = None
  58 + part_code: str
  59 + part_name: Optional[str] = None
  60 + unit: Optional[str] = None
  61 + cost_price: float = 0
  62 + current_ratio: Optional[float] = None
  63 + base_ratio: Optional[float] = None
  64 + post_plan_ratio: Optional[float] = None
  65 + valid_storage_cnt: float = 0
  66 + avg_sales_cnt: float = 0
  67 + suggest_cnt: int = 0
  68 + suggest_amount: float = 0
  69 + suggestion_reason: Optional[str] = None
  70 + priority: int = 2
  71 + llm_confidence: Optional[float] = None
  72 + statistics_date: Optional[str] = None
  73 +
  74 +
  75 +class DetailListResponse(BaseModel):
  76 + """配件建议明细列表响应"""
  77 + total: int
  78 + page: int
  79 + page_size: int
  80 + items: List[DetailResponse]
  81 +
  82 +
  83 +def format_datetime(dt) -> Optional[str]:
  84 + """格式化日期时间"""
  85 + if dt is None:
  86 + return None
  87 + if isinstance(dt, datetime):
  88 + return dt.strftime("%Y-%m-%d %H:%M:%S")
  89 + return str(dt)
  90 +
  91 +
  92 +def get_status_text(status: int) -> str:
  93 + """获取状态文本"""
  94 + status_map = {0: "运行中", 1: "成功", 2: "失败"}
  95 + return status_map.get(status, "未知")
  96 +
  97 +
  98 +@router.get("/tasks", response_model=TaskListResponse)
  99 +async def list_tasks(
  100 + page: int = Query(1, ge=1, description="页码"),
  101 + page_size: int = Query(20, ge=1, le=100, description="每页数量"),
  102 + status: Optional[int] = Query(None, description="状态筛选: 0-运行中 1-成功 2-失败"),
  103 + dealer_grouping_id: Optional[int] = Query(None, description="商家组合ID"),
  104 + statistics_date: Optional[str] = Query(None, description="统计日期"),
  105 +):
  106 + """获取任务列表"""
  107 + conn = get_connection()
  108 + cursor = conn.cursor(dictionary=True)
  109 +
  110 + try:
  111 + # 构建查询条件
  112 + where_clauses = []
  113 + params = []
  114 +
  115 + if status is not None:
  116 + where_clauses.append("status = %s")
  117 + params.append(status)
  118 +
  119 + if dealer_grouping_id is not None:
  120 + where_clauses.append("dealer_grouping_id = %s")
  121 + params.append(dealer_grouping_id)
  122 +
  123 + if statistics_date:
  124 + where_clauses.append("statistics_date = %s")
  125 + params.append(statistics_date)
  126 +
  127 + where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
  128 +
  129 + # 查询总数
  130 + count_sql = f"SELECT COUNT(*) as total FROM ai_replenishment_task WHERE {where_sql}"
  131 + cursor.execute(count_sql, params)
  132 + total = cursor.fetchone()["total"]
  133 +
  134 + # 查询分页数据
  135 + offset = (page - 1) * page_size
  136 + data_sql = f"""
  137 + SELECT t.*,
  138 + COALESCE(
  139 + NULLIF(t.llm_total_tokens, 0),
  140 + (SELECT SUM(l.llm_tokens) FROM ai_task_execution_log l WHERE l.task_no = t.task_no)
  141 + ) as calculated_tokens
  142 + FROM ai_replenishment_task t
  143 + WHERE {where_sql}
  144 + ORDER BY t.create_time DESC
  145 + LIMIT %s OFFSET %s
  146 + """
  147 + cursor.execute(data_sql, params + [page_size, offset])
  148 + rows = cursor.fetchall()
  149 +
  150 + items = []
  151 + for row in rows:
  152 + # 计算执行时长
  153 + duration = None
  154 + if row.get("start_time") and row.get("end_time"):
  155 + duration = int((row["end_time"] - row["start_time"]).total_seconds())
  156 +
  157 + items.append(TaskResponse(
  158 + id=row["id"],
  159 + task_no=row["task_no"],
  160 + group_id=row["group_id"],
  161 + dealer_grouping_id=row["dealer_grouping_id"],
  162 + dealer_grouping_name=row.get("dealer_grouping_name"),
  163 + brand_grouping_id=row.get("brand_grouping_id"),
  164 + plan_amount=float(row.get("plan_amount") or 0),
  165 + actual_amount=float(row.get("actual_amount") or 0),
  166 + part_count=row.get("part_count") or 0,
  167 + base_ratio=float(row["base_ratio"]) if row.get("base_ratio") else None,
  168 + status=row.get("status") or 0,
  169 + status_text=get_status_text(row.get("status") or 0),
  170 + error_message=row.get("error_message"),
  171 + llm_provider=row.get("llm_provider"),
  172 + llm_model=row.get("llm_model"),
  173 + llm_total_tokens=int(row.get("calculated_tokens") or 0),
  174 + statistics_date=row.get("statistics_date"),
  175 + start_time=format_datetime(row.get("start_time")),
  176 + end_time=format_datetime(row.get("end_time")),
  177 + duration_seconds=duration,
  178 + create_time=format_datetime(row.get("create_time")),
  179 + ))
  180 +
  181 + return TaskListResponse(
  182 + total=total,
  183 + page=page,
  184 + page_size=page_size,
  185 + items=items,
  186 + )
  187 +
  188 + finally:
  189 + cursor.close()
  190 + conn.close()
  191 +
  192 +
  193 +@router.get("/tasks/{task_no}", response_model=TaskResponse)
  194 +async def get_task(task_no: str):
  195 + """获取任务详情"""
  196 + conn = get_connection()
  197 + cursor = conn.cursor(dictionary=True)
  198 +
  199 + try:
  200 + cursor.execute(
  201 + """
  202 + SELECT t.*,
  203 + COALESCE(
  204 + NULLIF(t.llm_total_tokens, 0),
  205 + (SELECT SUM(l.llm_tokens) FROM ai_task_execution_log l WHERE l.task_no = t.task_no)
  206 + ) as calculated_tokens
  207 + FROM ai_replenishment_task t
  208 + WHERE t.task_no = %s
  209 + """,
  210 + (task_no,)
  211 + )
  212 + row = cursor.fetchone()
  213 +
  214 + if not row:
  215 + raise HTTPException(status_code=404, detail="任务不存在")
  216 +
  217 + duration = None
  218 + if row.get("start_time") and row.get("end_time"):
  219 + duration = int((row["end_time"] - row["start_time"]).total_seconds())
  220 +
  221 + return TaskResponse(
  222 + id=row["id"],
  223 + task_no=row["task_no"],
  224 + group_id=row["group_id"],
  225 + dealer_grouping_id=row["dealer_grouping_id"],
  226 + dealer_grouping_name=row.get("dealer_grouping_name"),
  227 + brand_grouping_id=row.get("brand_grouping_id"),
  228 + plan_amount=float(row.get("plan_amount") or 0),
  229 + actual_amount=float(row.get("actual_amount") or 0),
  230 + part_count=row.get("part_count") or 0,
  231 + base_ratio=float(row["base_ratio"]) if row.get("base_ratio") else None,
  232 + status=row.get("status") or 0,
  233 + status_text=get_status_text(row.get("status") or 0),
  234 + error_message=row.get("error_message"),
  235 + llm_provider=row.get("llm_provider"),
  236 + llm_model=row.get("llm_model"),
  237 + llm_total_tokens=int(row.get("calculated_tokens") or 0),
  238 + statistics_date=row.get("statistics_date"),
  239 + start_time=format_datetime(row.get("start_time")),
  240 + end_time=format_datetime(row.get("end_time")),
  241 + duration_seconds=duration,
  242 + create_time=format_datetime(row.get("create_time")),
  243 + )
  244 +
  245 + finally:
  246 + cursor.close()
  247 + conn.close()
  248 +
  249 +
  250 +@router.get("/tasks/{task_no}/details", response_model=DetailListResponse)
  251 +async def get_task_details(
  252 + task_no: str,
  253 + page: int = Query(1, ge=1),
  254 + page_size: int = Query(50, ge=1, le=200),
  255 + sort_by: str = Query("suggest_amount", description="排序字段"),
  256 + sort_order: str = Query("desc", description="排序方向: asc/desc"),
  257 + part_code: Optional[str] = Query(None, description="配件编码搜索"),
  258 +):
  259 + """获取任务的配件建议明细"""
  260 + conn = get_connection()
  261 + cursor = conn.cursor(dictionary=True)
  262 +
  263 + try:
  264 + # 验证排序字段
  265 + allowed_sort_fields = [
  266 + "suggest_amount", "suggest_cnt", "cost_price",
  267 + "avg_sales_cnt", "current_ratio", "part_code"
  268 + ]
  269 + if sort_by not in allowed_sort_fields:
  270 + sort_by = "suggest_amount"
  271 +
  272 + sort_direction = "DESC" if sort_order.lower() == "desc" else "ASC"
  273 +
  274 + # 构建查询条件
  275 + where_sql = "task_no = %s"
  276 + params = [task_no]
  277 +
  278 + if part_code:
  279 + where_sql += " AND part_code LIKE %s"
  280 + params.append(f"%{part_code}%")
  281 +
  282 + # 查询总数
  283 + cursor.execute(
  284 + f"SELECT COUNT(*) as total FROM ai_replenishment_detail WHERE {where_sql}",
  285 + params
  286 + )
  287 + total = cursor.fetchone()["total"]
  288 +
  289 + # 查询分页数据
  290 + offset = (page - 1) * page_size
  291 + cursor.execute(
  292 + f"""
  293 + SELECT * FROM ai_replenishment_detail
  294 + WHERE {where_sql}
  295 + ORDER BY {sort_by} {sort_direction}
  296 + LIMIT %s OFFSET %s
  297 + """,
  298 + params + [page_size, offset]
  299 + )
  300 + rows = cursor.fetchall()
  301 +
  302 + items = []
  303 + for row in rows:
  304 + items.append(DetailResponse(
  305 + id=row["id"],
  306 + task_no=row["task_no"],
  307 + shop_id=row["shop_id"],
  308 + shop_name=row.get("shop_name"),
  309 + part_code=row["part_code"],
  310 + part_name=row.get("part_name"),
  311 + unit=row.get("unit"),
  312 + cost_price=float(row.get("cost_price") or 0),
  313 + current_ratio=float(row["current_ratio"]) if row.get("current_ratio") else None,
  314 + base_ratio=float(row["base_ratio"]) if row.get("base_ratio") else None,
  315 + post_plan_ratio=float(row["post_plan_ratio"]) if row.get("post_plan_ratio") else None,
  316 + valid_storage_cnt=float(row.get("valid_storage_cnt") or 0),
  317 + avg_sales_cnt=float(row.get("avg_sales_cnt") or 0),
  318 + suggest_cnt=row.get("suggest_cnt") or 0,
  319 + suggest_amount=float(row.get("suggest_amount") or 0),
  320 + suggestion_reason=row.get("suggestion_reason"),
  321 + priority=row.get("priority") or 2,
  322 + llm_confidence=float(row["llm_confidence"]) if row.get("llm_confidence") else None,
  323 + statistics_date=row.get("statistics_date"),
  324 + ))
  325 +
  326 + return DetailListResponse(
  327 + total=total,
  328 + page=page,
  329 + page_size=page_size,
  330 + items=items,
  331 + )
  332 +
  333 + finally:
  334 + cursor.close()
  335 + conn.close()
  336 +
  337 +
  338 +class ExecutionLogResponse(BaseModel):
  339 + """执行日志响应"""
  340 + id: int
  341 + task_no: str
  342 + step_name: str
  343 + step_order: int
  344 + status: int
  345 + status_text: str = ""
  346 + input_data: Optional[str] = None
  347 + output_data: Optional[str] = None
  348 + error_message: Optional[str] = None
  349 + retry_count: int = 0
  350 + llm_tokens: int = 0
  351 + execution_time_ms: int = 0
  352 + start_time: Optional[str] = None
  353 + end_time: Optional[str] = None
  354 + create_time: Optional[str] = None
  355 +
  356 +
  357 +class ExecutionLogListResponse(BaseModel):
  358 + """执行日志列表响应"""
  359 + total: int
  360 + items: List[ExecutionLogResponse]
  361 +
  362 +
  363 +def get_log_status_text(status: int) -> str:
  364 + """获取日志状态文本"""
  365 + status_map = {0: "运行中", 1: "成功", 2: "失败", 3: "跳过"}
  366 + return status_map.get(status, "未知")
  367 +
  368 +
  369 +def get_step_name_display(step_name: str) -> str:
  370 + """获取步骤名称显示"""
  371 + step_map = {
  372 + "fetch_part_ratio": "获取配件数据",
  373 + "sql_agent": "AI分析建议",
  374 + "allocate_budget": "分配预算",
  375 + "generate_report": "生成报告",
  376 + }
  377 + return step_map.get(step_name, step_name)
  378 +
  379 +
  380 +@router.get("/tasks/{task_no}/logs", response_model=ExecutionLogListResponse)
  381 +async def get_task_logs(task_no: str):
  382 + """获取任务执行日志"""
  383 + conn = get_connection()
  384 + cursor = conn.cursor(dictionary=True)
  385 +
  386 + try:
  387 + cursor.execute(
  388 + """
  389 + SELECT * FROM ai_task_execution_log
  390 + WHERE task_no = %s
  391 + ORDER BY step_order ASC
  392 + """,
  393 + (task_no,)
  394 + )
  395 + rows = cursor.fetchall()
  396 +
  397 + items = []
  398 + for row in rows:
  399 + items.append(ExecutionLogResponse(
  400 + id=row["id"],
  401 + task_no=row["task_no"],
  402 + step_name=row["step_name"],
  403 + step_order=row.get("step_order") or 0,
  404 + status=row.get("status") or 0,
  405 + status_text=get_log_status_text(row.get("status") or 0),
  406 + input_data=row.get("input_data"),
  407 + output_data=row.get("output_data"),
  408 + error_message=row.get("error_message"),
  409 + retry_count=row.get("retry_count") or 0,
  410 + llm_tokens=row.get("llm_tokens") or 0,
  411 + execution_time_ms=row.get("execution_time_ms") or 0,
  412 + start_time=format_datetime(row.get("start_time")),
  413 + end_time=format_datetime(row.get("end_time")),
  414 + create_time=format_datetime(row.get("create_time")),
  415 + ))
  416 +
  417 + return ExecutionLogListResponse(
  418 + total=len(items),
  419 + items=items,
  420 + )
  421 +
  422 + finally:
  423 + cursor.close()
  424 + conn.close()
  425 +
  426 +
  427 +class PartSummaryResponse(BaseModel):
  428 + """配件汇总响应"""
  429 + id: int
  430 + task_no: str
  431 + part_code: str
  432 + part_name: Optional[str] = None
  433 + unit: Optional[str] = None
  434 + cost_price: float = 0
  435 + total_storage_cnt: float = 0
  436 + total_avg_sales_cnt: float = 0
  437 + group_current_ratio: Optional[float] = None
  438 + total_suggest_cnt: int = 0
  439 + total_suggest_amount: float = 0
  440 + shop_count: int = 0
  441 + need_replenishment_shop_count: int = 0
  442 + part_decision_reason: Optional[str] = None
  443 + priority: int = 2
  444 + llm_confidence: Optional[float] = None
  445 + statistics_date: Optional[str] = None
  446 + group_post_plan_ratio: Optional[float] = None
  447 +
  448 +
  449 +class PartSummaryListResponse(BaseModel):
  450 + """配件汇总列表响应"""
  451 + total: int
  452 + page: int
  453 + page_size: int
  454 + items: List[PartSummaryResponse]
  455 +
  456 +
  457 +@router.get("/tasks/{task_no}/part-summaries", response_model=PartSummaryListResponse)
  458 +async def get_task_part_summaries(
  459 + task_no: str,
  460 + page: int = Query(1, ge=1),
  461 + page_size: int = Query(50, ge=1, le=200),
  462 + sort_by: str = Query("total_suggest_amount", description="排序字段"),
  463 + sort_order: str = Query("desc", description="排序方向: asc/desc"),
  464 + part_code: Optional[str] = Query(None, description="配件编码筛选"),
  465 + priority: Optional[int] = Query(None, description="优先级筛选"),
  466 +):
  467 + """获取任务的配件汇总列表"""
  468 + conn = get_connection()
  469 + cursor = conn.cursor(dictionary=True)
  470 +
  471 + try:
  472 + # 验证排序字段
  473 + allowed_sort_fields = [
  474 + "total_suggest_amount", "total_suggest_cnt", "cost_price",
  475 + "total_avg_sales_cnt", "group_current_ratio", "part_code",
  476 + "priority", "need_replenishment_shop_count",
  477 + "total_storage_cnt", "shop_count", "group_post_plan_ratio"
  478 + ]
  479 + if sort_by not in allowed_sort_fields:
  480 + sort_by = "total_suggest_amount"
  481 +
  482 + sort_direction = "DESC" if sort_order.lower() == "desc" else "ASC"
  483 +
  484 + # 构建查询条件
  485 + where_clauses = ["task_no = %s"]
  486 + params = [task_no]
  487 +
  488 + if part_code:
  489 + where_clauses.append("part_code LIKE %s")
  490 + params.append(f"%{part_code}%")
  491 +
  492 + if priority is not None:
  493 + where_clauses.append("priority = %s")
  494 + params.append(priority)
  495 +
  496 + where_sql = " AND ".join(where_clauses)
  497 +
  498 + # 查询总数
  499 + cursor.execute(
  500 + f"SELECT COUNT(*) as total FROM ai_replenishment_part_summary WHERE {where_sql}",
  501 + params
  502 + )
  503 + total = cursor.fetchone()["total"]
  504 +
  505 + # 查询分页数据
  506 + offset = (page - 1) * page_size
  507 +
  508 + # 动态计算计划后库销比: (库存 + 建议) / 月均销
  509 + # 注意: total_avg_sales_cnt 可能为 0, 需要处理除以零的情况
  510 + query_sql = f"""
  511 + SELECT *,
  512 + (
  513 + (COALESCE(total_storage_cnt, 0) + COALESCE(total_suggest_cnt, 0)) /
  514 + NULLIF(total_avg_sales_cnt, 0)
  515 + ) as group_post_plan_ratio
  516 + FROM ai_replenishment_part_summary
  517 + WHERE {where_sql}
  518 + ORDER BY {sort_by} {sort_direction}
  519 + LIMIT %s OFFSET %s
  520 + """
  521 +
  522 + cursor.execute(query_sql, params + [page_size, offset])
  523 + rows = cursor.fetchall()
  524 +
  525 + items = []
  526 + for row in rows:
  527 + items.append(PartSummaryResponse(
  528 + id=row["id"],
  529 + task_no=row["task_no"],
  530 + part_code=row["part_code"],
  531 + part_name=row.get("part_name"),
  532 + unit=row.get("unit"),
  533 + cost_price=float(row.get("cost_price") or 0),
  534 + total_storage_cnt=float(row.get("total_storage_cnt") or 0),
  535 + total_avg_sales_cnt=float(row.get("total_avg_sales_cnt") or 0),
  536 + group_current_ratio=float(row["group_current_ratio"]) if row.get("group_current_ratio") else None,
  537 + group_post_plan_ratio=float(row["group_post_plan_ratio"]) if row.get("group_post_plan_ratio") is not None else None,
  538 + total_suggest_cnt=row.get("total_suggest_cnt") or 0,
  539 + total_suggest_amount=float(row.get("total_suggest_amount") or 0),
  540 + shop_count=row.get("shop_count") or 0,
  541 + need_replenishment_shop_count=row.get("need_replenishment_shop_count") or 0,
  542 + part_decision_reason=row.get("part_decision_reason"),
  543 + priority=row.get("priority") or 2,
  544 + llm_confidence=float(row["llm_confidence"]) if row.get("llm_confidence") else None,
  545 + statistics_date=row.get("statistics_date"),
  546 + ))
  547 +
  548 + return PartSummaryListResponse(
  549 + total=total,
  550 + page=page,
  551 + page_size=page_size,
  552 + items=items,
  553 + )
  554 +
  555 + finally:
  556 + cursor.close()
  557 + conn.close()
  558 +
  559 +
  560 +@router.get("/tasks/{task_no}/parts/{part_code}/shops")
  561 +async def get_part_shop_details(
  562 + task_no: str,
  563 + part_code: str,
  564 +):
  565 + """获取指定配件的门店明细"""
  566 + conn = get_connection()
  567 + cursor = conn.cursor(dictionary=True)
  568 +
  569 + try:
  570 + cursor.execute(
  571 + """
  572 + SELECT * FROM ai_replenishment_detail
  573 + WHERE task_no = %s AND part_code = %s
  574 + ORDER BY suggest_amount DESC
  575 + """,
  576 + (task_no, part_code)
  577 + )
  578 + rows = cursor.fetchall()
  579 +
  580 + items = []
  581 + for row in rows:
  582 + items.append(DetailResponse(
  583 + id=row["id"],
  584 + task_no=row["task_no"],
  585 + shop_id=row["shop_id"],
  586 + shop_name=row.get("shop_name"),
  587 + part_code=row["part_code"],
  588 + part_name=row.get("part_name"),
  589 + unit=row.get("unit"),
  590 + cost_price=float(row.get("cost_price") or 0),
  591 + current_ratio=float(row["current_ratio"]) if row.get("current_ratio") else None,
  592 + base_ratio=float(row["base_ratio"]) if row.get("base_ratio") else None,
  593 + post_plan_ratio=float(row["post_plan_ratio"]) if row.get("post_plan_ratio") else None,
  594 + valid_storage_cnt=float(row.get("valid_storage_cnt") or 0),
  595 + avg_sales_cnt=float(row.get("avg_sales_cnt") or 0),
  596 + suggest_cnt=row.get("suggest_cnt") or 0,
  597 + suggest_amount=float(row.get("suggest_amount") or 0),
  598 + suggestion_reason=row.get("suggestion_reason"),
  599 + priority=row.get("priority") or 2,
  600 + llm_confidence=float(row["llm_confidence"]) if row.get("llm_confidence") else None,
  601 + statistics_date=row.get("statistics_date"),
  602 + ))
  603 +
  604 + return {
  605 + "total": len(items),
  606 + "items": items,
  607 + }
  608 +
  609 + finally:
  610 + cursor.close()
  611 + conn.close()
src/fw_pms_ai/config/__init__.py 0 → 100644
  1 +++ a/src/fw_pms_ai/config/__init__.py
  1 +"""配置模块"""
  2 +
  3 +from .settings import Settings, get_settings
  4 +
  5 +__all__ = ["Settings", "get_settings"]
src/fw_pms_ai/config/settings.py 0 → 100644
  1 +++ a/src/fw_pms_ai/config/settings.py
  1 +"""
  2 +配置管理模块
  3 +使用 pydantic-settings 从环境变量加载配置
  4 +"""
  5 +
  6 +from functools import lru_cache
  7 +from pydantic_settings import BaseSettings, SettingsConfigDict
  8 +
  9 +
  10 +class Settings(BaseSettings):
  11 + """应用配置"""
  12 +
  13 + model_config = SettingsConfigDict(
  14 + env_file=".env",
  15 + env_file_encoding="utf-8",
  16 + case_sensitive=False,
  17 + )
  18 +
  19 + # GLM-4.7 配置
  20 + glm_api_key: str = ""
  21 + glm_model: str = "glm-4"
  22 +
  23 + # OpenAI 兼容模式配置(火山引擎等)
  24 + openai_compat_api_key: str = ""
  25 + openai_compat_base_url: str = "https://ark.cn-beijing.volces.com/api/v3"
  26 + openai_compat_model: str = "doubao-seed-1-8-251228"
  27 +
  28 + # Anthropic 兼容模式配置(智谱AI)
  29 + anthropic_api_key: str = ""
  30 + anthropic_base_url: str = "https://open.bigmodel.cn/api/anthropic"
  31 + anthropic_model: str = "glm-4.7"
  32 +
  33 + # 豆包配置
  34 + doubao_api_key: str = ""
  35 + doubao_model: str = "doubao-pro"
  36 +
  37 + # 数据库配置
  38 + mysql_host: str = "localhost"
  39 + mysql_port: int = 3306
  40 + mysql_user: str = "root"
  41 + mysql_password: str = ""
  42 + mysql_database: str = "fw_pms"
  43 +
  44 + # 定时任务配置
  45 + scheduler_cron_hour: int = 2
  46 + scheduler_cron_minute: int = 0
  47 +
  48 + # 日志配置
  49 + log_level: str = "INFO"
  50 +
  51 + @property
  52 + def mysql_connection_string(self) -> str:
  53 + """MySQL 连接字符串"""
  54 + return (
  55 + f"mysql+mysqlconnector://{self.mysql_user}:{self.mysql_password}"
  56 + f"@{self.mysql_host}:{self.mysql_port}/{self.mysql_database}"
  57 + )
  58 +
  59 + @property
  60 + def primary_llm_provider(self) -> str:
  61 + """主要 LLM 供应商"""
  62 + if self.openai_compat_api_key:
  63 + return "openai_compat"
  64 + elif self.anthropic_api_key:
  65 + return "anthropic_compat"
  66 + elif self.glm_api_key:
  67 + return "glm"
  68 + elif self.doubao_api_key:
  69 + return "doubao"
  70 + else:
  71 + raise ValueError("未配置任何 LLM API Key")
  72 +
  73 +
  74 +@lru_cache
  75 +def get_settings() -> Settings:
  76 + """获取配置单例"""
  77 + return Settings()
src/fw_pms_ai/llm/__init__.py 0 → 100644
  1 +++ a/src/fw_pms_ai/llm/__init__.py
  1 +"""LLM 模块"""
  2 +
  3 +from .base import BaseLLMClient, LLMResponse, LLMUsage
  4 +from .glm import GLMClient
  5 +from .doubao import DoubaoClient
  6 +from .anthropic_compat import AnthropicCompatClient
  7 +from .openai_compat import OpenAICompatClient
  8 +from ..config import get_settings
  9 +
  10 +
  11 +def get_llm_client() -> BaseLLMClient:
  12 + """获取 LLM 客户端"""
  13 + settings = get_settings()
  14 + provider = settings.primary_llm_provider
  15 +
  16 + if provider == "openai_compat":
  17 + return OpenAICompatClient()
  18 + elif provider == "anthropic_compat":
  19 + return AnthropicCompatClient()
  20 + elif provider == "glm":
  21 + return GLMClient()
  22 + elif provider == "doubao":
  23 + return DoubaoClient()
  24 + else:
  25 + raise ValueError(f"不支持的 LLM 供应商: {provider}")
  26 +
  27 +
  28 +__all__ = [
  29 + "BaseLLMClient",
  30 + "LLMResponse",
  31 + "LLMUsage",
  32 + "GLMClient",
  33 + "DoubaoClient",
  34 + "AnthropicCompatClient",
  35 + "OpenAICompatClient",
  36 + "get_llm_client",
  37 +]
  38 +
src/fw_pms_ai/llm/anthropic_compat.py 0 → 100644
  1 +++ a/src/fw_pms_ai/llm/anthropic_compat.py
  1 +"""
  2 +Anthropic 兼容模式客户端
  3 +支持智谱AI的Anthropic兼容接口
  4 +"""
  5 +
  6 +import logging
  7 +from langchain_core.language_models import BaseChatModel
  8 +from langchain_core.messages import BaseMessage, AIMessage
  9 +import anthropic
  10 +
  11 +from .base import BaseLLMClient, LLMResponse, LLMUsage
  12 +from ..config import get_settings
  13 +
  14 +logger = logging.getLogger(__name__)
  15 +
  16 +
  17 +class ChatAnthropicCompat(BaseChatModel):
  18 + """Anthropic兼容模式 LangChain 包装器"""
  19 +
  20 + client: anthropic.Anthropic = None
  21 + model: str = "glm-4.7"
  22 + temperature: float = 0.7
  23 + max_tokens: int = 4096
  24 +
  25 + def __init__(self, api_key: str, base_url: str, model: str = "glm-4.7", **kwargs):
  26 + super().__init__(**kwargs)
  27 + self.client = anthropic.Anthropic(
  28 + api_key=api_key,
  29 + base_url=base_url,
  30 + )
  31 + self.model = model
  32 +
  33 + @property
  34 + def _llm_type(self) -> str:
  35 + return "anthropic_compat"
  36 +
  37 + def _generate(self, messages, stop=None, run_manager=None, **kwargs):
  38 + from langchain_core.outputs import ChatGeneration, ChatResult
  39 +
  40 + formatted_messages = []
  41 + system_content = None
  42 +
  43 + for msg in messages:
  44 + if hasattr(msg, 'type'):
  45 + if msg.type == "system":
  46 + system_content = msg.content
  47 + continue
  48 + role = "user" if msg.type == "human" else "assistant"
  49 + else:
  50 + role = "user"
  51 + formatted_messages.append({"role": role, "content": msg.content})
  52 +
  53 + create_kwargs = {
  54 + "model": self.model,
  55 + "messages": formatted_messages,
  56 + "max_tokens": self.max_tokens,
  57 + }
  58 + if system_content:
  59 + create_kwargs["system"] = system_content
  60 +
  61 + response = self.client.messages.create(**create_kwargs)
  62 +
  63 + content = response.content[0].text
  64 + generation = ChatGeneration(message=AIMessage(content=content))
  65 +
  66 + return ChatResult(
  67 + generations=[generation],
  68 + llm_output={
  69 + "token_usage": {
  70 + "prompt_tokens": response.usage.input_tokens,
  71 + "completion_tokens": response.usage.output_tokens,
  72 + "total_tokens": response.usage.input_tokens + response.usage.output_tokens,
  73 + }
  74 + }
  75 + )
  76 +
  77 +
  78 +class AnthropicCompatClient(BaseLLMClient):
  79 + """Anthropic兼容模式客户端"""
  80 +
  81 + def __init__(self, api_key: str = None, base_url: str = None, model: str = None):
  82 + settings = get_settings()
  83 + self._api_key = api_key or settings.anthropic_api_key
  84 + self._base_url = base_url or settings.anthropic_base_url
  85 + self._model = model or settings.anthropic_model
  86 + self._client = anthropic.Anthropic(
  87 + api_key=self._api_key,
  88 + base_url=self._base_url,
  89 + )
  90 + self._chat_model = None
  91 +
  92 + @property
  93 + def provider(self) -> str:
  94 + return "anthropic_compat"
  95 +
  96 + @property
  97 + def model_name(self) -> str:
  98 + return self._model
  99 +
  100 + def get_chat_model(self) -> BaseChatModel:
  101 + """获取 LangChain 聊天模型"""
  102 + if self._chat_model is None:
  103 + self._chat_model = ChatAnthropicCompat(
  104 + api_key=self._api_key,
  105 + base_url=self._base_url,
  106 + model=self._model,
  107 + )
  108 + return self._chat_model
  109 +
  110 + def invoke(self, messages: list[BaseMessage]) -> LLMResponse:
  111 + """调用 Anthropic 兼容接口(带自定义重试和速率限制处理)"""
  112 + import time
  113 + import random
  114 +
  115 + max_retries = 5
  116 + base_delay = 2.0
  117 + max_delay = 30.0
  118 + post_request_delay = 1.0
  119 +
  120 + formatted_messages = []
  121 + system_content = None
  122 +
  123 + for msg in messages:
  124 + if hasattr(msg, 'type'):
  125 + if msg.type == "system":
  126 + system_content = msg.content
  127 + continue
  128 + role = "user" if msg.type == "human" else "assistant"
  129 + else:
  130 + role = "user"
  131 + formatted_messages.append({"role": role, "content": msg.content})
  132 +
  133 + create_kwargs = {
  134 + "model": self._model,
  135 + "messages": formatted_messages,
  136 + "max_tokens": 4096,
  137 + }
  138 + if system_content:
  139 + create_kwargs["system"] = system_content
  140 +
  141 + last_exception = None
  142 +
  143 + for attempt in range(max_retries):
  144 + try:
  145 + response = self._client.messages.create(**create_kwargs)
  146 +
  147 + content = response.content[0].text
  148 + usage = self.create_usage(
  149 + prompt_tokens=response.usage.input_tokens,
  150 + completion_tokens=response.usage.output_tokens,
  151 + )
  152 +
  153 + logger.info(
  154 + f"Anthropic兼容接口调用完成: model={self._model}, "
  155 + f"tokens={usage.total_tokens}"
  156 + )
  157 +
  158 + time.sleep(post_request_delay)
  159 +
  160 + return LLMResponse(content=content, usage=usage, raw_response=response)
  161 +
  162 + except Exception as e:
  163 + last_exception = e
  164 + error_str = str(e)
  165 + is_rate_limit = "429" in error_str or "rate" in error_str.lower() or "1302" in error_str
  166 +
  167 + if attempt < max_retries - 1:
  168 + if is_rate_limit:
  169 + delay = min(base_delay * (2 ** attempt) + random.uniform(0, 1), max_delay)
  170 + logger.warning(
  171 + f"API速率限制 (尝试 {attempt + 1}/{max_retries}), "
  172 + f"等待 {delay:.1f}秒 后重试..."
  173 + )
  174 + else:
  175 + delay = base_delay * (attempt + 1)
  176 + logger.warning(
  177 + f"API调用失败 (尝试 {attempt + 1}/{max_retries}): {e}, "
  178 + f"等待 {delay:.1f}秒 后重试..."
  179 + )
  180 + time.sleep(delay)
  181 + else:
  182 + logger.error(f"Anthropic兼容接口调用失败(已重试{max_retries}次): {e}")
  183 + raise
src/fw_pms_ai/llm/base.py 0 → 100644
  1 +++ a/src/fw_pms_ai/llm/base.py
  1 +"""
  2 +LLM 基础抽象类
  3 +定义统一的 LLM 接口
  4 +"""
  5 +
  6 +from abc import ABC, abstractmethod
  7 +from typing import Any
  8 +from dataclasses import dataclass, field
  9 +from langchain_core.language_models import BaseChatModel
  10 +from langchain_core.messages import BaseMessage
  11 +
  12 +
  13 +@dataclass
  14 +class LLMUsage:
  15 + """LLM 使用统计"""
  16 + provider: str = ""
  17 + model: str = ""
  18 + prompt_tokens: int = 0
  19 + completion_tokens: int = 0
  20 + total_tokens: int = 0
  21 +
  22 + def add(self, other: "LLMUsage") -> "LLMUsage":
  23 + """累加使用量"""
  24 + return LLMUsage(
  25 + provider=self.provider or other.provider,
  26 + model=self.model or other.model,
  27 + prompt_tokens=self.prompt_tokens + other.prompt_tokens,
  28 + completion_tokens=self.completion_tokens + other.completion_tokens,
  29 + total_tokens=self.total_tokens + other.total_tokens,
  30 + )
  31 +
  32 +
  33 +@dataclass
  34 +class LLMResponse:
  35 + """LLM 响应"""
  36 + content: str
  37 + usage: LLMUsage = field(default_factory=LLMUsage)
  38 + raw_response: Any = None
  39 +
  40 +
  41 +class BaseLLMClient(ABC):
  42 + """LLM 客户端基类"""
  43 +
  44 + @property
  45 + @abstractmethod
  46 + def provider(self) -> str:
  47 + """供应商名称"""
  48 + pass
  49 +
  50 + @property
  51 + @abstractmethod
  52 + def model_name(self) -> str:
  53 + """模型名称"""
  54 + pass
  55 +
  56 + @abstractmethod
  57 + def get_chat_model(self) -> BaseChatModel:
  58 + """获取 LangChain 聊天模型"""
  59 + pass
  60 +
  61 + @abstractmethod
  62 + def invoke(self, messages: list[BaseMessage]) -> LLMResponse:
  63 + """调用 LLM"""
  64 + pass
  65 +
  66 + def create_usage(self, prompt_tokens: int = 0, completion_tokens: int = 0) -> LLMUsage:
  67 + """创建使用统计"""
  68 + return LLMUsage(
  69 + provider=self.provider,
  70 + model=self.model_name,
  71 + prompt_tokens=prompt_tokens,
  72 + completion_tokens=completion_tokens,
  73 + total_tokens=prompt_tokens + completion_tokens,
  74 + )
src/fw_pms_ai/llm/doubao.py 0 → 100644
  1 +++ a/src/fw_pms_ai/llm/doubao.py
  1 +"""
  2 +豆包 (字节跳动) 集成 - 备选 LLM
  3 +"""
  4 +
  5 +import logging
  6 +import httpx
  7 +from langchain_core.language_models import BaseChatModel
  8 +from langchain_core.messages import BaseMessage, AIMessage
  9 +
  10 +from .base import BaseLLMClient, LLMResponse
  11 +from ..config import get_settings
  12 +
  13 +logger = logging.getLogger(__name__)
  14 +
  15 +
  16 +class DoubaoClient(BaseLLMClient):
  17 + """豆包客户端 (备选)"""
  18 +
  19 + def __init__(self, api_key: str = None, model: str = None):
  20 + settings = get_settings()
  21 + self._api_key = api_key or settings.doubao_api_key
  22 + self._model = model or settings.doubao_model
  23 + self._base_url = "https://ark.cn-beijing.volces.com/api/v3"
  24 +
  25 + @property
  26 + def provider(self) -> str:
  27 + return "doubao"
  28 +
  29 + @property
  30 + def model_name(self) -> str:
  31 + return self._model
  32 +
  33 + def get_chat_model(self) -> BaseChatModel:
  34 + """获取 LangChain 聊天模型"""
  35 + raise NotImplementedError("豆包 LangChain 集成待实现")
  36 +
  37 + def invoke(self, messages: list[BaseMessage]) -> LLMResponse:
  38 + """调用豆包"""
  39 + try:
  40 + formatted_messages = []
  41 + for msg in messages:
  42 + if hasattr(msg, 'type'):
  43 + role = "user" if msg.type == "human" else "assistant" if msg.type == "ai" else "system"
  44 + else:
  45 + role = "user"
  46 + formatted_messages.append({"role": role, "content": msg.content})
  47 +
  48 + with httpx.Client() as client:
  49 + response = client.post(
  50 + f"{self._base_url}/chat/completions",
  51 + headers={
  52 + "Authorization": f"Bearer {self._api_key}",
  53 + "Content-Type": "application/json",
  54 + },
  55 + json={
  56 + "model": self._model,
  57 + "messages": formatted_messages,
  58 + "temperature": 0.7,
  59 + "max_tokens": 4096,
  60 + },
  61 + timeout=60.0,
  62 + )
  63 + response.raise_for_status()
  64 + data = response.json()
  65 +
  66 + content = data["choices"][0]["message"]["content"]
  67 + usage_data = data.get("usage", {})
  68 + usage = self.create_usage(
  69 + prompt_tokens=usage_data.get("prompt_tokens", 0),
  70 + completion_tokens=usage_data.get("completion_tokens", 0),
  71 + )
  72 +
  73 + logger.info(
  74 + f"豆包调用完成: model={self._model}, "
  75 + f"tokens={usage.total_tokens}"
  76 + )
  77 +
  78 + return LLMResponse(content=content, usage=usage, raw_response=data)
  79 +
  80 + except Exception as e:
  81 + logger.error(f"豆包调用失败: {e}")
  82 + raise
src/fw_pms_ai/llm/glm.py 0 → 100644
  1 +++ a/src/fw_pms_ai/llm/glm.py
  1 +"""
  2 +GLM-4.7 (智谱AI) 集成
  3 +"""
  4 +
  5 +import logging
  6 +from langchain_core.language_models import BaseChatModel
  7 +from langchain_core.messages import BaseMessage, AIMessage
  8 +from zhipuai import ZhipuAI
  9 +
  10 +from .base import BaseLLMClient, LLMResponse, LLMUsage
  11 +from ..config import get_settings
  12 +
  13 +logger = logging.getLogger(__name__)
  14 +
  15 +
  16 +class ChatZhipuAI(BaseChatModel):
  17 + """智谱AI 聊天模型 LangChain 包装器"""
  18 +
  19 + client: ZhipuAI = None
  20 + model: str = "glm-4"
  21 + temperature: float = 0.7
  22 + max_tokens: int = 4096
  23 +
  24 + def __init__(self, api_key: str, model: str = "glm-4", **kwargs):
  25 + super().__init__(**kwargs)
  26 + self.client = ZhipuAI(api_key=api_key)
  27 + self.model = model
  28 +
  29 + @property
  30 + def _llm_type(self) -> str:
  31 + return "zhipuai"
  32 +
  33 + def _generate(self, messages, stop=None, run_manager=None, **kwargs):
  34 + from langchain_core.outputs import ChatGeneration, ChatResult
  35 +
  36 + formatted_messages = []
  37 + for msg in messages:
  38 + if hasattr(msg, 'type'):
  39 + role = "user" if msg.type == "human" else "assistant" if msg.type == "ai" else "system"
  40 + else:
  41 + role = "user"
  42 + formatted_messages.append({"role": role, "content": msg.content})
  43 +
  44 + response = self.client.chat.completions.create(
  45 + model=self.model,
  46 + messages=formatted_messages,
  47 + temperature=self.temperature,
  48 + max_tokens=self.max_tokens,
  49 + )
  50 +
  51 + content = response.choices[0].message.content
  52 + generation = ChatGeneration(message=AIMessage(content=content))
  53 +
  54 + return ChatResult(
  55 + generations=[generation],
  56 + llm_output={
  57 + "token_usage": {
  58 + "prompt_tokens": response.usage.prompt_tokens,
  59 + "completion_tokens": response.usage.completion_tokens,
  60 + "total_tokens": response.usage.total_tokens,
  61 + }
  62 + }
  63 + )
  64 +
  65 +
  66 +class GLMClient(BaseLLMClient):
  67 + """GLM-4.7 客户端"""
  68 +
  69 + def __init__(self, api_key: str = None, model: str = None):
  70 + settings = get_settings()
  71 + self._api_key = api_key or settings.glm_api_key
  72 + self._model = model or settings.glm_model
  73 + self._client = ZhipuAI(api_key=self._api_key)
  74 + self._chat_model = None
  75 +
  76 + @property
  77 + def provider(self) -> str:
  78 + return "glm"
  79 +
  80 + @property
  81 + def model_name(self) -> str:
  82 + return self._model
  83 +
  84 + def get_chat_model(self) -> BaseChatModel:
  85 + """获取 LangChain 聊天模型"""
  86 + if self._chat_model is None:
  87 + self._chat_model = ChatZhipuAI(api_key=self._api_key, model=self._model)
  88 + return self._chat_model
  89 +
  90 + def invoke(self, messages: list[BaseMessage]) -> LLMResponse:
  91 + """调用 GLM"""
  92 + try:
  93 + formatted_messages = []
  94 + for msg in messages:
  95 + if hasattr(msg, 'type'):
  96 + role = "user" if msg.type == "human" else "assistant" if msg.type == "ai" else "system"
  97 + else:
  98 + role = "user"
  99 + formatted_messages.append({"role": role, "content": msg.content})
  100 +
  101 + response = self._client.chat.completions.create(
  102 + model=self._model,
  103 + messages=formatted_messages,
  104 + temperature=0.7,
  105 + max_tokens=4096,
  106 + )
  107 +
  108 + content = response.choices[0].message.content
  109 + usage = self.create_usage(
  110 + prompt_tokens=response.usage.prompt_tokens,
  111 + completion_tokens=response.usage.completion_tokens,
  112 + )
  113 +
  114 + logger.info(
  115 + f"GLM 调用完成: model={self._model}, "
  116 + f"tokens={usage.total_tokens}"
  117 + )
  118 +
  119 + return LLMResponse(content=content, usage=usage, raw_response=response)
  120 +
  121 + except Exception as e:
  122 + logger.error(f"GLM 调用失败: {e}")
  123 + raise
src/fw_pms_ai/llm/openai_compat.py 0 → 100644
  1 +++ a/src/fw_pms_ai/llm/openai_compat.py
  1 +"""
  2 +OpenAI 兼容模式客户端
  3 +支持火山引擎等 OpenAI 兼容接口
  4 +"""
  5 +
  6 +import logging
  7 +from langchain_core.language_models import BaseChatModel
  8 +from langchain_core.messages import BaseMessage, AIMessage
  9 +from langchain_openai import ChatOpenAI
  10 +from openai import OpenAI
  11 +
  12 +from .base import BaseLLMClient, LLMResponse, LLMUsage
  13 +from ..config import get_settings
  14 +
  15 +logger = logging.getLogger(__name__)
  16 +
  17 +
  18 +class OpenAICompatClient(BaseLLMClient):
  19 + """OpenAI 兼容模式客户端(火山引擎等)"""
  20 +
  21 + def __init__(self, api_key: str = None, base_url: str = None, model: str = None):
  22 + settings = get_settings()
  23 + self._api_key = api_key or settings.openai_compat_api_key
  24 + self._base_url = base_url or settings.openai_compat_base_url
  25 + self._model = model or settings.openai_compat_model
  26 + self._client = OpenAI(
  27 + api_key=self._api_key,
  28 + base_url=self._base_url,
  29 + )
  30 + self._chat_model = None
  31 +
  32 + @property
  33 + def provider(self) -> str:
  34 + return "openai_compat"
  35 +
  36 + @property
  37 + def model_name(self) -> str:
  38 + return self._model
  39 +
  40 + def get_chat_model(self) -> BaseChatModel:
  41 + """获取 LangChain 聊天模型"""
  42 + if self._chat_model is None:
  43 + self._chat_model = ChatOpenAI(
  44 + api_key=self._api_key,
  45 + base_url=self._base_url,
  46 + model=self._model,
  47 + temperature=0.7,
  48 + max_tokens=4096,
  49 + )
  50 + return self._chat_model
  51 +
  52 + def invoke(self, messages: list[BaseMessage]) -> LLMResponse:
  53 + """调用 OpenAI 兼容接口(带自定义重试和速率限制处理)"""
  54 + import time
  55 + import random
  56 +
  57 + max_retries = 5
  58 + base_delay = 2.0
  59 + max_delay = 30.0
  60 + post_request_delay = 1.0
  61 +
  62 + formatted_messages = []
  63 +
  64 + for msg in messages:
  65 + if hasattr(msg, 'type'):
  66 + if msg.type == "system":
  67 + role = "system"
  68 + elif msg.type == "human":
  69 + role = "user"
  70 + else:
  71 + role = "assistant"
  72 + else:
  73 + role = "user"
  74 + formatted_messages.append({"role": role, "content": msg.content})
  75 +
  76 + last_exception = None
  77 +
  78 + for attempt in range(max_retries):
  79 + try:
  80 + response = self._client.chat.completions.create(
  81 + model=self._model,
  82 + messages=formatted_messages,
  83 + max_tokens=4096,
  84 + )
  85 +
  86 + content = response.choices[0].message.content
  87 + usage = self.create_usage(
  88 + prompt_tokens=response.usage.prompt_tokens,
  89 + completion_tokens=response.usage.completion_tokens,
  90 + )
  91 +
  92 + logger.info(
  93 + f"OpenAI兼容接口调用完成: model={self._model}, "
  94 + f"tokens={usage.total_tokens}"
  95 + )
  96 +
  97 + time.sleep(post_request_delay)
  98 +
  99 + return LLMResponse(content=content, usage=usage, raw_response=response)
  100 +
  101 + except Exception as e:
  102 + last_exception = e
  103 + error_str = str(e)
  104 + is_rate_limit = "429" in error_str or "rate" in error_str.lower()
  105 +
  106 + if attempt < max_retries - 1:
  107 + if is_rate_limit:
  108 + delay = min(base_delay * (2 ** attempt) + random.uniform(0, 1), max_delay)
  109 + logger.warning(
  110 + f"API速率限制 (尝试 {attempt + 1}/{max_retries}), "
  111 + f"等待 {delay:.1f}秒 后重试..."
  112 + )
  113 + else:
  114 + delay = base_delay * (attempt + 1)
  115 + logger.warning(
  116 + f"API调用失败 (尝试 {attempt + 1}/{max_retries}): {e}, "
  117 + f"等待 {delay:.1f}秒 后重试..."
  118 + )
  119 + time.sleep(delay)
  120 + else:
  121 + logger.error(f"OpenAI兼容接口调用失败(已重试{max_retries}次): {e}")
  122 + raise
src/fw_pms_ai/main.py 0 → 100644
  1 +++ a/src/fw_pms_ai/main.py
  1 +"""
  2 +fw-pms-ai 主入口
  3 +"""
  4 +
  5 +import logging
  6 +import sys
  7 +from pathlib import Path
  8 +
  9 +try:
  10 + from .scheduler.tasks import main as scheduler_main
  11 +except ImportError:
  12 + # 直接运行时,添加项目根目录到 sys.path
  13 + project_root = Path(__file__).parent.parent.parent
  14 + if str(project_root) not in sys.path:
  15 + sys.path.insert(0, str(project_root))
  16 + from fw_pms_ai.scheduler.tasks import main as scheduler_main
  17 +
  18 +
  19 +def main():
  20 + """应用入口"""
  21 + logging.basicConfig(
  22 + level=logging.INFO,
  23 + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
  24 + )
  25 +
  26 + scheduler_main()
  27 +
  28 +
  29 +if __name__ == "__main__":
  30 + main()
src/fw_pms_ai/models/__init__.py 0 → 100644
  1 +++ a/src/fw_pms_ai/models/__init__.py
  1 +"""数据模型模块"""
  2 +
  3 +from .part_ratio import PartRatio
  4 +from .task import ReplenishmentTask, ReplenishmentDetail, TaskStatus
  5 +from .execution_log import TaskExecutionLog, LogStatus
  6 +from .part_summary import ReplenishmentPartSummary
  7 +from .sql_result import SQLExecutionResult
  8 +from .suggestion import ReplenishmentSuggestion, PartAnalysisResult
  9 +
  10 +__all__ = [
  11 + "PartRatio",
  12 + "ReplenishmentTask",
  13 + "ReplenishmentDetail",
  14 + "TaskStatus",
  15 + "TaskExecutionLog",
  16 + "LogStatus",
  17 + "ReplenishmentPartSummary",
  18 + "SQLExecutionResult",
  19 + "ReplenishmentSuggestion",
  20 + "PartAnalysisResult",
  21 +]
  22 +
  23 +
  24 +
src/fw_pms_ai/models/execution_log.py 0 → 100644
  1 +++ a/src/fw_pms_ai/models/execution_log.py
  1 +"""
  2 +任务执行日志模型和LLM建议明细模型
  3 +"""
  4 +
  5 +from dataclasses import dataclass, field
  6 +from datetime import datetime
  7 +from decimal import Decimal
  8 +from typing import Optional
  9 +from enum import IntEnum
  10 +
  11 +
  12 +class LogStatus(IntEnum):
  13 + """日志状态"""
  14 + RUNNING = 0
  15 + SUCCESS = 1
  16 + FAILED = 2
  17 + SKIPPED = 3
  18 +
  19 +
  20 +@dataclass
  21 +class TaskExecutionLog:
  22 + """任务执行日志"""
  23 + task_no: str
  24 + group_id: int
  25 + dealer_grouping_id: int
  26 + step_name: str
  27 +
  28 + id: Optional[int] = None
  29 + brand_grouping_id: Optional[int] = None
  30 + brand_grouping_name: str = ""
  31 + dealer_grouping_name: str = ""
  32 + step_order: int = 0
  33 + status: LogStatus = LogStatus.RUNNING
  34 + input_data: str = ""
  35 + output_data: str = ""
  36 + error_message: str = ""
  37 + retry_count: int = 0
  38 + sql_query: str = ""
  39 + llm_prompt: str = ""
  40 + llm_response: str = ""
  41 + llm_tokens: int = 0
  42 + execution_time_ms: int = 0
  43 + start_time: Optional[datetime] = None
  44 + end_time: Optional[datetime] = None
  45 + create_time: Optional[datetime] = None
  46 +
  47 +
  48 +
src/fw_pms_ai/models/part_ratio.py 0 → 100644
  1 +++ a/src/fw_pms_ai/models/part_ratio.py
  1 +"""
  2 +数据模型 - 库销比
  3 +"""
  4 +
  5 +from dataclasses import dataclass
  6 +from decimal import Decimal
  7 +from datetime import datetime
  8 +from typing import Optional
  9 +
  10 +
  11 +@dataclass
  12 +class PartRatio:
  13 + """配件库销比数据"""
  14 +
  15 + id: int
  16 + group_id: int
  17 + brand_id: Optional[int] = None
  18 + brand_grouping_id: Optional[int] = None
  19 + supplier_id: Optional[int] = None
  20 + supplier_name: Optional[str] = None
  21 + area_id: Optional[int] = None
  22 + area_name: Optional[str] = None
  23 + shop_id: int = 0
  24 + shop_name: Optional[str] = None
  25 + part_id: Optional[int] = None
  26 + part_code: str = ""
  27 + part_name: Optional[str] = None
  28 + part_biz_type: Optional[str] = None
  29 + unit_price: Decimal = Decimal("0")
  30 + cost_price: Decimal = Decimal("0")
  31 + storage_total_cnt: Decimal = Decimal("0")
  32 + in_stock_unlocked_cnt: Decimal = Decimal("0")
  33 + has_plan_cnt: Decimal = Decimal("0")
  34 + on_the_way_cnt: Decimal = Decimal("0")
  35 + out_stock_cnt: Decimal = Decimal("0")
  36 + storage_locked_cnt: Decimal = Decimal("0")
  37 + out_stock_ongoing_cnt: Decimal = Decimal("0")
  38 + buy_cnt: int = 0
  39 + transfer_cnt: int = 0
  40 + gen_transfer_cnt: int = 0
  41 + part_tag: Optional[int] = None
  42 + stock_age: Optional[int] = None
  43 + unit: Optional[str] = None
  44 + out_times: Optional[int] = None
  45 + statistics_date: str = ""
  46 +
  47 + @property
  48 + def valid_storage_cnt(self) -> Decimal:
  49 + """有效库存数量 = 在库未锁 + 在途 + 计划数 + 主动调拨在途 + 自动调拨在途"""
  50 + return (self.in_stock_unlocked_cnt + self.on_the_way_cnt + self.has_plan_cnt +
  51 + Decimal(str(self.transfer_cnt)) + Decimal(str(self.gen_transfer_cnt)))
  52 +
  53 + @property
  54 + def valid_storage_amount(self) -> Decimal:
  55 + """有效库存金额"""
  56 + return self.valid_storage_cnt * self.cost_price
  57 +
  58 + @property
  59 + def avg_sales_cnt(self) -> Decimal:
  60 + """平均销量 (90天出库数 + 未关单已锁 + 未关单出库 + 订件) / 3"""
  61 + total = (self.out_stock_cnt or Decimal("0")) + self.storage_locked_cnt + self.out_stock_ongoing_cnt + Decimal(str(self.buy_cnt))
  62 + return total / Decimal("3")
  63 +
  64 + @property
  65 + def avg_sales_amount(self) -> Decimal:
  66 + """平均销量金额"""
  67 + return self.avg_sales_cnt * self.cost_price
  68 +
  69 + @property
  70 + def current_ratio(self) -> Decimal:
  71 + """当前库销比"""
  72 + if self.avg_sales_cnt == 0:
  73 + return Decimal("999")
  74 + return self.valid_storage_cnt / self.avg_sales_cnt
  75 +
  76 + @classmethod
  77 + def from_dict(cls, data: dict) -> "PartRatio":
  78 + """从字典创建"""
  79 + return cls(
  80 + id=data.get("id", 0),
  81 + group_id=data.get("group_id", 0),
  82 + brand_id=data.get("brand_id"),
  83 + brand_grouping_id=data.get("brand_grouping_id"),
  84 + supplier_id=data.get("supplier_id"),
  85 + supplier_name=data.get("supplier_name"),
  86 + area_id=data.get("area_id"),
  87 + area_name=data.get("area_name"),
  88 + shop_id=data.get("shop_id", 0),
  89 + shop_name=data.get("shop_name"),
  90 + part_id=data.get("part_id"),
  91 + part_code=data.get("part_code", ""),
  92 + part_name=data.get("part_name"),
  93 + part_biz_type=data.get("part_biz_type"),
  94 + unit_price=Decimal(str(data.get("unit_price", 0))),
  95 + cost_price=Decimal(str(data.get("cost_price", 0))),
  96 + storage_total_cnt=Decimal(str(data.get("storage_total_cnt", 0))),
  97 + in_stock_unlocked_cnt=Decimal(str(data.get("in_stock_unlocked_cnt", 0))),
  98 + has_plan_cnt=Decimal(str(data.get("has_plan_cnt", 0))),
  99 + on_the_way_cnt=Decimal(str(data.get("on_the_way_cnt", 0))),
  100 + out_stock_cnt=Decimal(str(data.get("out_stock_cnt", 0))),
  101 + storage_locked_cnt=Decimal(str(data.get("storage_locked_cnt", 0))),
  102 + out_stock_ongoing_cnt=Decimal(str(data.get("out_stock_ongoing_cnt", 0))),
  103 + buy_cnt=int(data.get("buy_cnt", 0)),
  104 + transfer_cnt=int(data.get("transfer_cnt", 0)),
  105 + gen_transfer_cnt=int(data.get("gen_transfer_cnt", 0)),
  106 + part_tag=data.get("part_tag"),
  107 + stock_age=data.get("stock_age"),
  108 + unit=data.get("unit"),
  109 + out_times=data.get("out_times"),
  110 + statistics_date=data.get("statistics_date", ""),
  111 + )
src/fw_pms_ai/models/part_summary.py 0 → 100644
  1 +++ a/src/fw_pms_ai/models/part_summary.py
  1 +"""
  2 +数据模型 - 配件汇总
  3 +"""
  4 +
  5 +from dataclasses import dataclass
  6 +from decimal import Decimal
  7 +from datetime import datetime
  8 +from typing import Optional
  9 +
  10 +
  11 +@dataclass
  12 +class ReplenishmentPartSummary:
  13 + """AI补货建议-配件汇总"""
  14 +
  15 + task_no: str
  16 + group_id: int
  17 + dealer_grouping_id: int
  18 + part_code: str
  19 +
  20 + id: Optional[int] = None
  21 + part_name: Optional[str] = None
  22 + unit: Optional[str] = None
  23 + cost_price: Decimal = Decimal("0")
  24 +
  25 + # 商家组合级别汇总数据
  26 + total_storage_cnt: Decimal = Decimal("0")
  27 + total_avg_sales_cnt: Decimal = Decimal("0")
  28 + group_current_ratio: Optional[Decimal] = None
  29 +
  30 + # 补货建议汇总
  31 + total_suggest_cnt: int = 0
  32 + total_suggest_amount: Decimal = Decimal("0")
  33 + shop_count: int = 0
  34 + need_replenishment_shop_count: int = 0
  35 +
  36 + # LLM分析结果
  37 + part_decision_reason: str = ""
  38 + priority: int = 2
  39 + llm_confidence: float = 0.8
  40 +
  41 + # 元数据
  42 + statistics_date: str = ""
  43 + create_time: Optional[datetime] = None
  44 +
  45 + def to_dict(self) -> dict:
  46 + """转换为字典"""
  47 + return {
  48 + "task_no": self.task_no,
  49 + "group_id": self.group_id,
  50 + "dealer_grouping_id": self.dealer_grouping_id,
  51 + "part_code": self.part_code,
  52 + "part_name": self.part_name,
  53 + "unit": self.unit,
  54 + "cost_price": float(self.cost_price),
  55 + "total_storage_cnt": float(self.total_storage_cnt),
  56 + "total_avg_sales_cnt": float(self.total_avg_sales_cnt),
  57 + "group_current_ratio": float(self.group_current_ratio) if self.group_current_ratio else None,
  58 + "total_suggest_cnt": self.total_suggest_cnt,
  59 + "total_suggest_amount": float(self.total_suggest_amount),
  60 + "shop_count": self.shop_count,
  61 + "need_replenishment_shop_count": self.need_replenishment_shop_count,
  62 + "part_decision_reason": self.part_decision_reason,
  63 + "priority": self.priority,
  64 + "llm_confidence": self.llm_confidence,
  65 + "statistics_date": self.statistics_date,
  66 + }
src/fw_pms_ai/models/sql_result.py 0 → 100644
  1 +++ a/src/fw_pms_ai/models/sql_result.py
  1 +"""
  2 +SQL 执行结果模型
  3 +"""
  4 +
  5 +from dataclasses import dataclass
  6 +from typing import Dict, List, Optional
  7 +
  8 +
  9 +@dataclass
  10 +class SQLExecutionResult:
  11 + """SQL执行结果"""
  12 + success: bool
  13 + sql: str
  14 + data: Optional[List[Dict]] = None
  15 + error: Optional[str] = None
  16 + retry_count: int = 0
  17 + execution_time_ms: int = 0
src/fw_pms_ai/models/suggestion.py 0 → 100644
  1 +++ a/src/fw_pms_ai/models/suggestion.py
  1 +"""
  2 +补货建议和配件分析结果模型
  3 +"""
  4 +
  5 +from dataclasses import dataclass, field
  6 +from decimal import Decimal
  7 +from typing import List
  8 +
  9 +
  10 +@dataclass
  11 +class ReplenishmentSuggestion:
  12 + """补货建议"""
  13 + shop_id: int
  14 + shop_name: str
  15 + part_code: str
  16 + part_name: str
  17 + unit: str
  18 + cost_price: Decimal
  19 + current_storage_cnt: Decimal
  20 + avg_sales_cnt: Decimal
  21 + current_ratio: Decimal
  22 + suggest_cnt: int
  23 + suggest_amount: Decimal
  24 + suggestion_reason: str
  25 + priority: int = 2
  26 + confidence: float = 0.8
  27 +
  28 +
  29 +@dataclass
  30 +class PartAnalysisResult:
  31 + """配件分析结果 - 包含配件级汇总信息"""
  32 + part_code: str
  33 + part_name: str
  34 + unit: str
  35 + cost_price: Decimal
  36 + total_storage_cnt: Decimal
  37 + total_avg_sales_cnt: Decimal
  38 + group_current_ratio: Decimal
  39 + need_replenishment: bool
  40 + total_suggest_cnt: int
  41 + total_suggest_amount: Decimal
  42 + shop_count: int
  43 + need_replenishment_shop_count: int
  44 + part_decision_reason: str
  45 + priority: int = 2
  46 + confidence: float = 0.8
  47 + suggestions: List["ReplenishmentSuggestion"] = field(default_factory=list)
src/fw_pms_ai/models/task.py 0 → 100644
  1 +++ a/src/fw_pms_ai/models/task.py
  1 +"""
  2 +数据模型 - 补货任务
  3 +"""
  4 +
  5 +from dataclasses import dataclass, field
  6 +from decimal import Decimal
  7 +from datetime import datetime
  8 +from typing import Optional
  9 +from enum import IntEnum
  10 +
  11 +
  12 +class TaskStatus(IntEnum):
  13 + """任务状态"""
  14 + RUNNING = 0
  15 + SUCCESS = 1
  16 + FAILED = 2
  17 +
  18 +
  19 +@dataclass
  20 +class ReplenishmentTask:
  21 + """AI补货任务"""
  22 +
  23 + task_no: str
  24 + group_id: int
  25 + dealer_grouping_id: int
  26 +
  27 + id: Optional[int] = None
  28 + dealer_grouping_name: Optional[str] = None
  29 + brand_grouping_id: Optional[int] = None
  30 + plan_amount: Decimal = Decimal("0")
  31 + actual_amount: Decimal = Decimal("0")
  32 + part_count: int = 0
  33 + base_ratio: Optional[Decimal] = None
  34 + status: TaskStatus = TaskStatus.RUNNING
  35 + error_message: str = ""
  36 + llm_provider: str = ""
  37 + llm_model: str = ""
  38 + llm_total_tokens: int = 0
  39 + statistics_date: str = ""
  40 + start_time: Optional[datetime] = None
  41 + end_time: Optional[datetime] = None
  42 + create_time: Optional[datetime] = None
  43 +
  44 + def to_dict(self) -> dict:
  45 + """转换为字典"""
  46 + return {
  47 + "task_no": self.task_no,
  48 + "group_id": self.group_id,
  49 + "dealer_grouping_id": self.dealer_grouping_id,
  50 + "dealer_grouping_name": self.dealer_grouping_name,
  51 + "brand_grouping_id": self.brand_grouping_id,
  52 + "plan_amount": float(self.plan_amount),
  53 + "actual_amount": float(self.actual_amount),
  54 + "part_count": self.part_count,
  55 + "base_ratio": float(self.base_ratio) if self.base_ratio else None,
  56 + "status": int(self.status),
  57 + "error_message": self.error_message,
  58 + "llm_provider": self.llm_provider,
  59 + "llm_model": self.llm_model,
  60 + "llm_total_tokens": self.llm_total_tokens,
  61 + "statistics_date": self.statistics_date,
  62 + }
  63 +
  64 +
  65 +@dataclass
  66 +class ReplenishmentDetail:
  67 + """AI补货建议明细"""
  68 +
  69 + task_no: str
  70 + group_id: int
  71 + dealer_grouping_id: int
  72 + shop_id: int
  73 + part_code: str
  74 +
  75 + id: Optional[int] = None
  76 + brand_grouping_id: Optional[int] = None
  77 + shop_name: Optional[str] = None
  78 + part_name: Optional[str] = None
  79 + unit: Optional[str] = None
  80 + cost_price: Decimal = Decimal("0")
  81 + current_ratio: Optional[Decimal] = None
  82 + base_ratio: Optional[Decimal] = None
  83 + post_plan_ratio: Optional[Decimal] = None
  84 + valid_storage_cnt: Decimal = Decimal("0")
  85 + avg_sales_cnt: Decimal = Decimal("0")
  86 + suggest_cnt: int = 0
  87 + suggest_amount: Decimal = Decimal("0")
  88 + suggestion_reason: str = ""
  89 + priority: int = 2
  90 + llm_confidence: float = 0.8
  91 + statistics_date: str = ""
  92 + create_time: Optional[datetime] = None
  93 +
  94 + def to_dict(self) -> dict:
  95 + """转换为字典"""
  96 + return {
  97 + "task_no": self.task_no,
  98 + "group_id": self.group_id,
  99 + "dealer_grouping_id": self.dealer_grouping_id,
  100 + "brand_grouping_id": self.brand_grouping_id,
  101 + "shop_id": self.shop_id,
  102 + "shop_name": self.shop_name,
  103 + "part_code": self.part_code,
  104 + "part_name": self.part_name,
  105 + "unit": self.unit,
  106 + "cost_price": float(self.cost_price),
  107 + "current_ratio": float(self.current_ratio) if self.current_ratio else None,
  108 + "base_ratio": float(self.base_ratio) if self.base_ratio else None,
  109 + "post_plan_ratio": float(self.post_plan_ratio) if self.post_plan_ratio else None,
  110 + "valid_storage_cnt": float(self.valid_storage_cnt),
  111 + "avg_sales_cnt": float(self.avg_sales_cnt),
  112 + "suggest_cnt": self.suggest_cnt,
  113 + "suggest_amount": float(self.suggest_amount),
  114 + "suggestion_reason": self.suggestion_reason,
  115 + "priority": self.priority,
  116 + "llm_confidence": self.llm_confidence,
  117 + "statistics_date": self.statistics_date,
  118 + }
src/fw_pms_ai/scheduler/__init__.py 0 → 100644
  1 +++ a/src/fw_pms_ai/scheduler/__init__.py
  1 +"""定时任务模块"""
  2 +
  3 +from .tasks import run_replenishment_task, start_scheduler
  4 +
  5 +__all__ = [
  6 + "run_replenishment_task",
  7 + "start_scheduler",
  8 +]
src/fw_pms_ai/scheduler/tasks.py 0 → 100644
  1 +++ a/src/fw_pms_ai/scheduler/tasks.py
  1 +"""
  2 +定时任务
  3 +使用 APScheduler 实现每日凌晨执行
  4 +"""
  5 +
  6 +import logging
  7 +import argparse
  8 +from datetime import date
  9 +
  10 +from apscheduler.schedulers.blocking import BlockingScheduler
  11 +from apscheduler.triggers.cron import CronTrigger
  12 +
  13 +from ..config import get_settings
  14 +from ..agent import ReplenishmentAgent
  15 +
  16 +logger = logging.getLogger(__name__)
  17 +
  18 +
  19 +def run_replenishment_task():
  20 + """执行补货建议任务"""
  21 + logger.info("="*50)
  22 + logger.info("开始执行 AI 补货建议定时任务")
  23 + logger.info("="*50)
  24 +
  25 + try:
  26 + agent = ReplenishmentAgent()
  27 +
  28 + # 默认配置 - 可从数据库读取
  29 + group_id = 2
  30 +
  31 + agent.run_for_all_groupings(
  32 + group_id=group_id,
  33 + )
  34 +
  35 + logger.info("="*50)
  36 + logger.info("AI 补货建议定时任务执行完成")
  37 + logger.info("="*50)
  38 +
  39 + except Exception as e:
  40 + logger.error(f"定时任务执行失败: {e}", exc_info=True)
  41 + raise
  42 +
  43 +
  44 +def start_scheduler():
  45 + """启动定时调度"""
  46 + settings = get_settings()
  47 +
  48 + scheduler = BlockingScheduler()
  49 +
  50 + # 添加定时任务
  51 + trigger = CronTrigger(
  52 + hour=settings.scheduler_cron_hour,
  53 + minute=settings.scheduler_cron_minute,
  54 + )
  55 +
  56 + scheduler.add_job(
  57 + run_replenishment_task,
  58 + trigger=trigger,
  59 + id="replenishment_task",
  60 + name="AI 补货建议任务",
  61 + replace_existing=True,
  62 + )
  63 +
  64 + logger.info(
  65 + f"定时任务已配置: 每日 {settings.scheduler_cron_hour:02d}:{settings.scheduler_cron_minute:02d} 执行"
  66 + )
  67 +
  68 + try:
  69 + logger.info("调度器启动...")
  70 + scheduler.start()
  71 + except (KeyboardInterrupt, SystemExit):
  72 + logger.info("调度器停止")
  73 + scheduler.shutdown()
  74 +
  75 +
  76 +def main():
  77 + """CLI 入口"""
  78 + parser = argparse.ArgumentParser(description="AI 补货建议定时任务")
  79 + parser.add_argument(
  80 + "--run-once",
  81 + action="store_true",
  82 + help="立即执行一次(不启动调度器)",
  83 + )
  84 + parser.add_argument(
  85 + "--group-id",
  86 + type=int,
  87 + default=2,
  88 + help="集团ID (默认: 2)",
  89 + )
  90 + parser.add_argument(
  91 + "--dealer-grouping-id",
  92 + type=int,
  93 + help="指定商家组合ID (可选)",
  94 + )
  95 +
  96 + args = parser.parse_args()
  97 +
  98 + # 配置日志
  99 + logging.basicConfig(
  100 + level=logging.INFO,
  101 + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
  102 + )
  103 +
  104 + if args.run_once:
  105 + logger.info("单次执行模式")
  106 +
  107 + if args.dealer_grouping_id:
  108 + # 指定商家组合
  109 + from ..services import DataService
  110 +
  111 + agent = ReplenishmentAgent()
  112 + data_service = DataService()
  113 +
  114 + try:
  115 + groupings = data_service.get_dealer_groupings(args.group_id)
  116 + grouping = next((g for g in groupings if g["id"] == args.dealer_grouping_id), None)
  117 +
  118 + if not grouping:
  119 + logger.error(f"未找到商家组合: {args.dealer_grouping_id}")
  120 + return
  121 +
  122 + # 直接使用预计划数据,不需要 shop_ids
  123 + agent.run(
  124 + group_id=args.group_id,
  125 + dealer_grouping_id=grouping["id"],
  126 + dealer_grouping_name=grouping["name"],
  127 + )
  128 +
  129 + finally:
  130 + data_service.close()
  131 + else:
  132 + # 所有商家组合
  133 + run_replenishment_task()
  134 + else:
  135 + start_scheduler()
  136 +
  137 +
  138 +if __name__ == "__main__":
  139 + main()
src/fw_pms_ai/services/__init__.py 0 → 100644
  1 +++ a/src/fw_pms_ai/services/__init__.py
  1 +"""服务模块"""
  2 +
  3 +from .data_service import DataService
  4 +from .result_writer import ResultWriter
  5 +from .repository import TaskRepository, DetailRepository, LogRepository, SummaryRepository
  6 +
  7 +__all__ = [
  8 + "DataService",
  9 + "ResultWriter",
  10 + "TaskRepository",
  11 + "DetailRepository",
  12 + "LogRepository",
  13 + "SummaryRepository",
  14 +]
  15 +
  16 +
src/fw_pms_ai/services/data_service.py 0 → 100644
  1 +++ a/src/fw_pms_ai/services/data_service.py
  1 +"""
  2 +数据获取服务
  3 +从 MySQL 数据库查询库销比等数据
  4 +"""
  5 +
  6 +import logging
  7 +from typing import List, Optional
  8 +from datetime import date
  9 +from decimal import Decimal
  10 +
  11 +from .db import get_connection
  12 +from ..models import PartRatio
  13 +
  14 +logger = logging.getLogger(__name__)
  15 +
  16 +
  17 +class DataService:
  18 + """数据服务"""
  19 +
  20 + def __init__(self):
  21 + self._conn = None
  22 +
  23 + def _get_connection(self):
  24 + """获取数据库连接"""
  25 + if self._conn is None or not self._conn.is_connected():
  26 + self._conn = get_connection()
  27 + return self._conn
  28 +
  29 + def close(self):
  30 + """关闭连接"""
  31 + if self._conn and self._conn.is_connected():
  32 + self._conn.close()
  33 + self._conn = None
  34 +
  35 +
  36 +
  37 + def get_dealer_groupings(self, group_id: int) -> List[dict]:
  38 + """
  39 + 获取商家组合列表
  40 +
  41 + Returns:
  42 + [{"id": 1, "name": "商家组合A", "dealer_ids": [1, 2, 3]}]
  43 + """
  44 + conn = self._get_connection()
  45 + cursor = conn.cursor(dictionary=True)
  46 +
  47 + try:
  48 + sql = """
  49 + SELECT id, region_name as name, auth_dealers
  50 + FROM artificial_region_dealer
  51 + WHERE group_id = %s
  52 + """
  53 + cursor.execute(sql, [group_id])
  54 + rows = cursor.fetchall()
  55 +
  56 + result = []
  57 + for row in rows:
  58 + dealer_ids = []
  59 + if row.get("auth_dealers"):
  60 + try:
  61 + import json
  62 + dealers = json.loads(row["auth_dealers"])
  63 + if isinstance(dealers, list):
  64 + if dealers and isinstance(dealers[0], dict):
  65 + dealer_ids = [d.get("dealerId") for d in dealers if d.get("dealerId")]
  66 + else:
  67 + dealer_ids = [int(d) for d in dealers if d]
  68 + except:
  69 + pass
  70 + result.append({
  71 + "id": row["id"],
  72 + "name": row["name"],
  73 + "dealer_ids": dealer_ids,
  74 + })
  75 +
  76 + logger.info(f"查询商家组合: group_id={group_id}, count={len(result)}")
  77 + return result
  78 +
  79 + finally:
  80 + cursor.close()
  81 +
  82 +
  83 +
  84 +
  85 +
  86 +
  87 +
  88 +
  89 +
  90 +
  91 +
src/fw_pms_ai/services/db.py 0 → 100644
  1 +++ a/src/fw_pms_ai/services/db.py
  1 +"""
  2 +数据库连接管理模块
  3 +统一管理 MySQL 数据库连接
  4 +"""
  5 +
  6 +import mysql.connector
  7 +from mysql.connector import MySQLConnection
  8 +
  9 +from ..config import get_settings
  10 +
  11 +
  12 +def get_connection() -> MySQLConnection:
  13 + """
  14 + 获取数据库连接
  15 +
  16 + Returns:
  17 + MySQLConnection: MySQL 数据库连接
  18 + """
  19 + settings = get_settings()
  20 + return mysql.connector.connect(
  21 + host=settings.mysql_host,
  22 + port=settings.mysql_port,
  23 + user=settings.mysql_user,
  24 + password=settings.mysql_password,
  25 + database=settings.mysql_database,
  26 + )
  27 +
  28 +
  29 +class DatabaseConnection:
  30 + """
  31 + 数据库连接上下文管理器
  32 +
  33 + 使用示例:
  34 + with DatabaseConnection() as conn:
  35 + cursor = conn.cursor(dictionary=True)
  36 + cursor.execute("SELECT * FROM table")
  37 + rows = cursor.fetchall()
  38 + """
  39 +
  40 + def __init__(self):
  41 + self._conn: MySQLConnection = None
  42 +
  43 + def __enter__(self) -> MySQLConnection:
  44 + self._conn = get_connection()
  45 + return self._conn
  46 +
  47 + def __exit__(self, exc_type, exc_val, exc_tb):
  48 + if self._conn and self._conn.is_connected():
  49 + self._conn.close()
src/fw_pms_ai/services/repository/__init__.py 0 → 100644
  1 +++ a/src/fw_pms_ai/services/repository/__init__.py
  1 +"""
  2 +Repository 数据访问层子包
  3 +
  4 +提供各表的 CRUD 操作
  5 +"""
  6 +
  7 +from .task_repo import TaskRepository
  8 +from .detail_repo import DetailRepository
  9 +from .log_repo import LogRepository, SummaryRepository
  10 +
  11 +__all__ = [
  12 + "TaskRepository",
  13 + "DetailRepository",
  14 + "LogRepository",
  15 + "SummaryRepository",
  16 +]
src/fw_pms_ai/services/repository/detail_repo.py 0 → 100644
  1 +++ a/src/fw_pms_ai/services/repository/detail_repo.py
  1 +"""
  2 +补货明细数据访问层
  3 +
  4 +提供 ai_replenishment_detail 表的 CRUD 操作
  5 +"""
  6 +
  7 +import logging
  8 +from typing import List
  9 +
  10 +from ..db import get_connection
  11 +from ...models import ReplenishmentDetail
  12 +
  13 +logger = logging.getLogger(__name__)
  14 +
  15 +
  16 +class DetailRepository:
  17 + """补货明细数据访问"""
  18 +
  19 + def __init__(self, connection=None):
  20 + self._conn = connection
  21 +
  22 + def _get_connection(self):
  23 + """获取数据库连接"""
  24 + if self._conn is None or not self._conn.is_connected():
  25 + self._conn = get_connection()
  26 + return self._conn
  27 +
  28 + def close(self):
  29 + """关闭连接"""
  30 + if self._conn and self._conn.is_connected():
  31 + self._conn.close()
  32 + self._conn = None
  33 +
  34 + def save_batch(self, details: List[ReplenishmentDetail]) -> int:
  35 + """
  36 + 批量保存补货明细
  37 +
  38 + Returns:
  39 + 插入的行数
  40 + """
  41 + if not details:
  42 + return 0
  43 +
  44 + conn = self._get_connection()
  45 + cursor = conn.cursor()
  46 +
  47 + try:
  48 + sql = """
  49 + INSERT INTO ai_replenishment_detail (
  50 + task_no, group_id, dealer_grouping_id, brand_grouping_id,
  51 + shop_id, shop_name, part_code, part_name, unit, cost_price,
  52 + current_ratio, base_ratio, post_plan_ratio,
  53 + valid_storage_cnt, avg_sales_cnt, suggest_cnt, suggest_amount,
  54 + suggestion_reason, priority, llm_confidence, statistics_date, create_time
  55 + ) VALUES (
  56 + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
  57 + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()
  58 + )
  59 + """
  60 +
  61 + values = [
  62 + (
  63 + d.task_no, d.group_id, d.dealer_grouping_id, d.brand_grouping_id,
  64 + d.shop_id, d.shop_name, d.part_code, d.part_name, d.unit,
  65 + float(d.cost_price),
  66 + float(d.current_ratio) if d.current_ratio else None,
  67 + float(d.base_ratio) if d.base_ratio else None,
  68 + float(d.post_plan_ratio) if d.post_plan_ratio else None,
  69 + float(d.valid_storage_cnt), float(d.avg_sales_cnt),
  70 + d.suggest_cnt, float(d.suggest_amount),
  71 + d.suggestion_reason, d.priority, d.llm_confidence, d.statistics_date,
  72 + )
  73 + for d in details
  74 + ]
  75 +
  76 + cursor.executemany(sql, values)
  77 + conn.commit()
  78 +
  79 + logger.info(f"保存补货明细: {cursor.rowcount}条")
  80 + return cursor.rowcount
  81 +
  82 + finally:
  83 + cursor.close()
  84 +
  85 + def delete_by_task_no(self, task_no: str) -> int:
  86 + """
  87 + 删除指定任务的补货明细
  88 +
  89 + Returns:
  90 + 删除的行数
  91 + """
  92 + conn = self._get_connection()
  93 + cursor = conn.cursor()
  94 +
  95 + try:
  96 + sql = "DELETE FROM ai_replenishment_detail WHERE task_no = %s"
  97 + cursor.execute(sql, (task_no,))
  98 + conn.commit()
  99 +
  100 + logger.info(f"删除补货明细: task_no={task_no}, rows={cursor.rowcount}")
  101 + return cursor.rowcount
  102 +
  103 + finally:
  104 + cursor.close()
  105 +
  106 + def find_by_task_no(self, task_no: str) -> List[ReplenishmentDetail]:
  107 + """根据 task_no 查询补货明细"""
  108 + conn = self._get_connection()
  109 + cursor = conn.cursor(dictionary=True)
  110 +
  111 + try:
  112 + sql = "SELECT * FROM ai_replenishment_detail WHERE task_no = %s"
  113 + cursor.execute(sql, (task_no,))
  114 + rows = cursor.fetchall()
  115 +
  116 + return [ReplenishmentDetail(**row) for row in rows]
  117 +
  118 + finally:
  119 + cursor.close()
src/fw_pms_ai/services/repository/log_repo.py 0 → 100644
  1 +++ a/src/fw_pms_ai/services/repository/log_repo.py
  1 +"""
  2 +日志和汇总数据访问层
  3 +
  4 +提供 ai_task_execution_log 和 ai_replenishment_part_summary 表的 CRUD 操作
  5 +"""
  6 +
  7 +import logging
  8 +from typing import List
  9 +
  10 +from ..db import get_connection
  11 +from ...models import TaskExecutionLog, ReplenishmentPartSummary
  12 +
  13 +logger = logging.getLogger(__name__)
  14 +
  15 +
  16 +class LogRepository:
  17 + """执行日志数据访问"""
  18 +
  19 + def __init__(self, connection=None):
  20 + self._conn = connection
  21 +
  22 + def _get_connection(self):
  23 + """获取数据库连接"""
  24 + if self._conn is None or not self._conn.is_connected():
  25 + self._conn = get_connection()
  26 + return self._conn
  27 +
  28 + def close(self):
  29 + """关闭连接"""
  30 + if self._conn and self._conn.is_connected():
  31 + self._conn.close()
  32 + self._conn = None
  33 +
  34 + def create(self, log: TaskExecutionLog) -> int:
  35 + """
  36 + 保存执行日志
  37 +
  38 + Returns:
  39 + 插入的日志ID
  40 + """
  41 + conn = self._get_connection()
  42 + cursor = conn.cursor()
  43 +
  44 + try:
  45 + sql = """
  46 + INSERT INTO ai_task_execution_log (
  47 + task_no, group_id, dealer_grouping_id, brand_grouping_id,
  48 + brand_grouping_name, dealer_grouping_name,
  49 + step_name, step_order, status, input_data, output_data,
  50 + error_message, retry_count, sql_query, llm_prompt, llm_response,
  51 + llm_tokens, execution_time_ms, start_time, end_time, create_time
  52 + ) VALUES (
  53 + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
  54 + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()
  55 + )
  56 + """
  57 +
  58 + values = (
  59 + log.task_no, log.group_id, log.dealer_grouping_id,
  60 + log.brand_grouping_id, log.brand_grouping_name,
  61 + log.dealer_grouping_name,
  62 + log.step_name, log.step_order, int(log.status),
  63 + log.input_data, log.output_data, log.error_message,
  64 + log.retry_count, log.sql_query, log.llm_prompt, log.llm_response,
  65 + log.llm_tokens, log.execution_time_ms,
  66 + log.start_time, log.end_time,
  67 + )
  68 +
  69 + cursor.execute(sql, values)
  70 + conn.commit()
  71 +
  72 + return cursor.lastrowid
  73 +
  74 + finally:
  75 + cursor.close()
  76 +
  77 +
  78 +class SummaryRepository:
  79 + """配件汇总数据访问"""
  80 +
  81 + def __init__(self, connection=None):
  82 + self._conn = connection
  83 +
  84 + def _get_connection(self):
  85 + """获取数据库连接"""
  86 + if self._conn is None or not self._conn.is_connected():
  87 + self._conn = get_connection()
  88 + return self._conn
  89 +
  90 + def close(self):
  91 + """关闭连接"""
  92 + if self._conn and self._conn.is_connected():
  93 + self._conn.close()
  94 + self._conn = None
  95 +
  96 + def save_batch(self, summaries: List[ReplenishmentPartSummary]) -> int:
  97 + """
  98 + 批量保存配件汇总
  99 +
  100 + Returns:
  101 + 插入的行数
  102 + """
  103 + if not summaries:
  104 + return 0
  105 +
  106 + conn = self._get_connection()
  107 + cursor = conn.cursor()
  108 +
  109 + try:
  110 + sql = """
  111 + INSERT INTO ai_replenishment_part_summary (
  112 + task_no, group_id, dealer_grouping_id, part_code, part_name,
  113 + unit, cost_price, total_storage_cnt, total_avg_sales_cnt,
  114 + group_current_ratio, total_suggest_cnt, total_suggest_amount,
  115 + shop_count, need_replenishment_shop_count, part_decision_reason,
  116 + priority, llm_confidence, statistics_date, create_time
  117 + ) VALUES (
  118 + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
  119 + %s, %s, %s, %s, %s, %s, %s, %s, NOW()
  120 + )
  121 + """
  122 +
  123 + values = [
  124 + (
  125 + s.task_no, s.group_id, s.dealer_grouping_id, s.part_code, s.part_name,
  126 + s.unit, float(s.cost_price), float(s.total_storage_cnt),
  127 + float(s.total_avg_sales_cnt),
  128 + float(s.group_current_ratio) if s.group_current_ratio else None,
  129 + s.total_suggest_cnt, float(s.total_suggest_amount),
  130 + s.shop_count, s.need_replenishment_shop_count, s.part_decision_reason,
  131 + s.priority, s.llm_confidence, s.statistics_date,
  132 + )
  133 + for s in summaries
  134 + ]
  135 +
  136 + cursor.executemany(sql, values)
  137 + conn.commit()
  138 +
  139 + logger.info(f"保存配件汇总: {cursor.rowcount}条")
  140 + return cursor.rowcount
  141 +
  142 + finally:
  143 + cursor.close()
  144 +
  145 + def delete_by_task_no(self, task_no: str) -> int:
  146 + """
  147 + 删除指定任务的配件汇总
  148 +
  149 + Returns:
  150 + 删除的行数
  151 + """
  152 + conn = self._get_connection()
  153 + cursor = conn.cursor()
  154 +
  155 + try:
  156 + sql = "DELETE FROM ai_replenishment_part_summary WHERE task_no = %s"
  157 + cursor.execute(sql, (task_no,))
  158 + conn.commit()
  159 +
  160 + logger.info(f"删除配件汇总: task_no={task_no}, rows={cursor.rowcount}")
  161 + return cursor.rowcount
  162 +
  163 + finally:
  164 + cursor.close()
src/fw_pms_ai/services/repository/task_repo.py 0 → 100644
  1 +++ a/src/fw_pms_ai/services/repository/task_repo.py
  1 +"""
  2 +任务数据访问层
  3 +
  4 +提供 ai_replenishment_task 表的 CRUD 操作
  5 +"""
  6 +
  7 +import logging
  8 +from datetime import datetime
  9 +from typing import Optional
  10 +
  11 +from ..db import get_connection
  12 +from ...models import ReplenishmentTask
  13 +
  14 +logger = logging.getLogger(__name__)
  15 +
  16 +
  17 +class TaskRepository:
  18 + """任务数据访问"""
  19 +
  20 + def __init__(self, connection=None):
  21 + self._conn = connection
  22 +
  23 + def _get_connection(self):
  24 + """获取数据库连接"""
  25 + if self._conn is None or not self._conn.is_connected():
  26 + self._conn = get_connection()
  27 + return self._conn
  28 +
  29 + def close(self):
  30 + """关闭连接"""
  31 + if self._conn and self._conn.is_connected():
  32 + self._conn.close()
  33 + self._conn = None
  34 +
  35 + def create(self, task: ReplenishmentTask) -> int:
  36 + """
  37 + 创建任务记录
  38 +
  39 + Returns:
  40 + 插入的任务ID
  41 + """
  42 + conn = self._get_connection()
  43 + cursor = conn.cursor()
  44 +
  45 + try:
  46 + sql = """
  47 + INSERT INTO ai_replenishment_task (
  48 + task_no, group_id, dealer_grouping_id, dealer_grouping_name,
  49 + brand_grouping_id, plan_amount, actual_amount, part_count,
  50 + base_ratio, status, error_message, llm_provider, llm_model,
  51 + llm_total_tokens, statistics_date, start_time, end_time, create_time
  52 + ) VALUES (
  53 + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()
  54 + )
  55 + """
  56 +
  57 + values = (
  58 + task.task_no,
  59 + task.group_id,
  60 + task.dealer_grouping_id,
  61 + task.dealer_grouping_name,
  62 + task.brand_grouping_id,
  63 + float(task.plan_amount),
  64 + float(task.actual_amount),
  65 + task.part_count,
  66 + float(task.base_ratio) if task.base_ratio else None,
  67 + int(task.status),
  68 + task.error_message,
  69 + task.llm_provider,
  70 + task.llm_model,
  71 + task.llm_total_tokens,
  72 + task.statistics_date,
  73 + datetime.now() if task.start_time is None else task.start_time,
  74 + task.end_time,
  75 + )
  76 +
  77 + cursor.execute(sql, values)
  78 + conn.commit()
  79 +
  80 + task_id = cursor.lastrowid
  81 + logger.info(f"创建任务记录: task_no={task.task_no}, id={task_id}")
  82 + return task_id
  83 +
  84 + finally:
  85 + cursor.close()
  86 +
  87 + def update(self, task: ReplenishmentTask) -> int:
  88 + """
  89 + 更新任务记录
  90 +
  91 + Returns:
  92 + 更新的行数
  93 + """
  94 + conn = self._get_connection()
  95 + cursor = conn.cursor()
  96 +
  97 + try:
  98 + sql = """
  99 + UPDATE ai_replenishment_task
  100 + SET actual_amount = %s,
  101 + part_count = %s,
  102 + base_ratio = %s,
  103 + status = %s,
  104 + error_message = %s,
  105 + llm_provider = %s,
  106 + llm_model = %s,
  107 + llm_total_tokens = %s,
  108 + end_time = %s
  109 + WHERE task_no = %s
  110 + """
  111 +
  112 + values = (
  113 + float(task.actual_amount),
  114 + task.part_count,
  115 + float(task.base_ratio) if task.base_ratio else None,
  116 + int(task.status),
  117 + task.error_message,
  118 + task.llm_provider,
  119 + task.llm_model,
  120 + task.llm_total_tokens,
  121 + datetime.now() if task.end_time is None else task.end_time,
  122 + task.task_no,
  123 + )
  124 +
  125 + cursor.execute(sql, values)
  126 + conn.commit()
  127 +
  128 + logger.info(f"更新任务记录: task_no={task.task_no}, rows={cursor.rowcount}")
  129 + return cursor.rowcount
  130 +
  131 + finally:
  132 + cursor.close()
  133 +
  134 + def find_by_task_no(self, task_no: str) -> Optional[ReplenishmentTask]:
  135 + """根据 task_no 查询任务"""
  136 + conn = self._get_connection()
  137 + cursor = conn.cursor(dictionary=True)
  138 +
  139 + try:
  140 + sql = "SELECT * FROM ai_replenishment_task WHERE task_no = %s"
  141 + cursor.execute(sql, (task_no,))
  142 + row = cursor.fetchone()
  143 +
  144 + if row:
  145 + return ReplenishmentTask(**row)
  146 + return None
  147 +
  148 + finally:
  149 + cursor.close()
src/fw_pms_ai/services/result_writer.py 0 → 100644
  1 +++ a/src/fw_pms_ai/services/result_writer.py
  1 +"""
  2 +结果写入服务
  3 +负责将补货建议结果写入数据库
  4 +"""
  5 +
  6 +import logging
  7 +import json
  8 +from typing import List, Optional
  9 +from datetime import datetime
  10 +
  11 +from .db import get_connection
  12 +from ..models import (
  13 + ReplenishmentTask,
  14 + ReplenishmentDetail,
  15 + TaskExecutionLog,
  16 + ReplenishmentPartSummary,
  17 +)
  18 +
  19 +logger = logging.getLogger(__name__)
  20 +
  21 +
  22 +class ResultWriter:
  23 + """结果写入服务"""
  24 +
  25 + def __init__(self):
  26 + self._conn = None
  27 +
  28 + def _get_connection(self):
  29 + """获取数据库连接"""
  30 + if self._conn is None or not self._conn.is_connected():
  31 + self._conn = get_connection()
  32 + return self._conn
  33 +
  34 + def close(self):
  35 + """关闭连接"""
  36 + if self._conn and self._conn.is_connected():
  37 + self._conn.close()
  38 + self._conn = None
  39 +
  40 + def save_task(self, task: ReplenishmentTask) -> int:
  41 + """
  42 + 保存任务记录
  43 +
  44 + Returns:
  45 + 插入的任务ID
  46 + """
  47 + conn = self._get_connection()
  48 + cursor = conn.cursor()
  49 +
  50 + try:
  51 + sql = """
  52 + INSERT INTO ai_replenishment_task (
  53 + task_no, group_id, dealer_grouping_id, dealer_grouping_name,
  54 + brand_grouping_id, plan_amount, actual_amount, part_count,
  55 + base_ratio, status, error_message, llm_provider, llm_model,
  56 + llm_total_tokens, statistics_date, start_time, end_time, create_time
  57 + ) VALUES (
  58 + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()
  59 + )
  60 + """
  61 +
  62 + values = (
  63 + task.task_no,
  64 + task.group_id,
  65 + task.dealer_grouping_id,
  66 + task.dealer_grouping_name,
  67 + task.brand_grouping_id,
  68 + float(task.plan_amount),
  69 + float(task.actual_amount),
  70 + task.part_count,
  71 + float(task.base_ratio) if task.base_ratio else None,
  72 + int(task.status),
  73 + task.error_message,
  74 + task.llm_provider,
  75 + task.llm_model,
  76 + task.llm_total_tokens,
  77 + task.statistics_date,
  78 + datetime.now() if task.start_time is None else task.start_time,
  79 + task.end_time,
  80 + )
  81 +
  82 + cursor.execute(sql, values)
  83 + conn.commit()
  84 +
  85 + task_id = cursor.lastrowid
  86 + logger.info(f"保存任务记录: task_no={task.task_no}, id={task_id}")
  87 + return task_id
  88 +
  89 + finally:
  90 + cursor.close()
  91 +
  92 + def update_task(self, task: ReplenishmentTask) -> int:
  93 + """
  94 + 更新任务记录
  95 +
  96 + Returns:
  97 + 更新的行数
  98 + """
  99 + conn = self._get_connection()
  100 + cursor = conn.cursor()
  101 +
  102 + try:
  103 + sql = """
  104 + UPDATE ai_replenishment_task
  105 + SET actual_amount = %s,
  106 + part_count = %s,
  107 + base_ratio = %s,
  108 + status = %s,
  109 + error_message = %s,
  110 + llm_provider = %s,
  111 + llm_model = %s,
  112 + llm_total_tokens = %s,
  113 + end_time = %s
  114 + WHERE task_no = %s
  115 + """
  116 +
  117 + values = (
  118 + float(task.actual_amount),
  119 + task.part_count,
  120 + float(task.base_ratio) if task.base_ratio else None,
  121 + int(task.status),
  122 + task.error_message,
  123 + task.llm_provider,
  124 + task.llm_model,
  125 + task.llm_total_tokens,
  126 + datetime.now() if task.end_time is None else task.end_time,
  127 + task.task_no,
  128 + )
  129 +
  130 + cursor.execute(sql, values)
  131 + conn.commit()
  132 +
  133 + logger.info(f"更新任务记录: task_no={task.task_no}, rows={cursor.rowcount}")
  134 + return cursor.rowcount
  135 +
  136 + finally:
  137 + cursor.close()
  138 +
  139 + def save_details(self, details: List[ReplenishmentDetail]) -> int:
  140 + """
  141 + 保存补货明细
  142 +
  143 + Returns:
  144 + 插入的行数
  145 + """
  146 + if not details:
  147 + return 0
  148 +
  149 + conn = self._get_connection()
  150 + cursor = conn.cursor()
  151 +
  152 + try:
  153 + sql = """
  154 + INSERT INTO ai_replenishment_detail (
  155 + task_no, group_id, dealer_grouping_id, brand_grouping_id,
  156 + shop_id, shop_name, part_code, part_name, unit, cost_price,
  157 + current_ratio, base_ratio, post_plan_ratio,
  158 + valid_storage_cnt, avg_sales_cnt, suggest_cnt, suggest_amount,
  159 + suggestion_reason, priority, llm_confidence, statistics_date, create_time
  160 + ) VALUES (
  161 + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
  162 + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()
  163 + )
  164 + """
  165 +
  166 + values = [
  167 + (
  168 + d.task_no, d.group_id, d.dealer_grouping_id, d.brand_grouping_id,
  169 + d.shop_id, d.shop_name, d.part_code, d.part_name, d.unit,
  170 + float(d.cost_price),
  171 + float(d.current_ratio) if d.current_ratio else None,
  172 + float(d.base_ratio) if d.base_ratio else None,
  173 + float(d.post_plan_ratio) if d.post_plan_ratio else None,
  174 + float(d.valid_storage_cnt), float(d.avg_sales_cnt),
  175 + d.suggest_cnt, float(d.suggest_amount),
  176 + d.suggestion_reason, d.priority, d.llm_confidence, d.statistics_date,
  177 + )
  178 + for d in details
  179 + ]
  180 +
  181 + cursor.executemany(sql, values)
  182 + conn.commit()
  183 +
  184 + logger.info(f"保存补货明细: {cursor.rowcount}条")
  185 + return cursor.rowcount
  186 +
  187 + finally:
  188 + cursor.close()
  189 +
  190 + def save_execution_log(self, log: TaskExecutionLog) -> int:
  191 + """
  192 + 保存执行日志
  193 +
  194 + Returns:
  195 + 插入的日志ID
  196 + """
  197 + conn = self._get_connection()
  198 + cursor = conn.cursor()
  199 +
  200 + try:
  201 + sql = """
  202 + INSERT INTO ai_task_execution_log (
  203 + task_no, group_id, dealer_grouping_id, brand_grouping_id,
  204 + brand_grouping_name, dealer_grouping_name,
  205 + step_name, step_order, status, input_data, output_data,
  206 + error_message, retry_count, sql_query, llm_prompt, llm_response,
  207 + llm_tokens, execution_time_ms, start_time, end_time, create_time
  208 + ) VALUES (
  209 + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
  210 + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()
  211 + )
  212 + """
  213 +
  214 + values = (
  215 + log.task_no, log.group_id, log.dealer_grouping_id,
  216 + log.brand_grouping_id, log.brand_grouping_name,
  217 + log.dealer_grouping_name,
  218 + log.step_name, log.step_order, int(log.status),
  219 + log.input_data, log.output_data, log.error_message,
  220 + log.retry_count, log.sql_query, log.llm_prompt, log.llm_response,
  221 + log.llm_tokens, log.execution_time_ms,
  222 + log.start_time, log.end_time,
  223 + )
  224 +
  225 + cursor.execute(sql, values)
  226 + conn.commit()
  227 +
  228 + log_id = cursor.lastrowid
  229 + return log_id
  230 +
  231 + finally:
  232 + cursor.close()
  233 +
  234 + def save_part_summaries(self, summaries: List[ReplenishmentPartSummary]) -> int:
  235 + """
  236 + 保存配件汇总信息
  237 +
  238 + Returns:
  239 + 插入的行数
  240 + """
  241 + if not summaries:
  242 + return 0
  243 +
  244 + conn = self._get_connection()
  245 + cursor = conn.cursor()
  246 +
  247 + try:
  248 + sql = """
  249 + INSERT INTO ai_replenishment_part_summary (
  250 + task_no, group_id, dealer_grouping_id, part_code, part_name,
  251 + unit, cost_price, total_storage_cnt, total_avg_sales_cnt,
  252 + group_current_ratio, total_suggest_cnt, total_suggest_amount,
  253 + shop_count, need_replenishment_shop_count, part_decision_reason,
  254 + priority, llm_confidence, statistics_date, create_time
  255 + ) VALUES (
  256 + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
  257 + %s, %s, %s, %s, %s, %s, %s, %s, NOW()
  258 + )
  259 + """
  260 +
  261 + values = [
  262 + (
  263 + s.task_no, s.group_id, s.dealer_grouping_id, s.part_code, s.part_name,
  264 + s.unit, float(s.cost_price), float(s.total_storage_cnt),
  265 + float(s.total_avg_sales_cnt),
  266 + float(s.group_current_ratio) if s.group_current_ratio else None,
  267 + s.total_suggest_cnt, float(s.total_suggest_amount),
  268 + s.shop_count, s.need_replenishment_shop_count, s.part_decision_reason,
  269 + s.priority, s.llm_confidence, s.statistics_date,
  270 + )
  271 + for s in summaries
  272 + ]
  273 +
  274 + cursor.executemany(sql, values)
  275 + conn.commit()
  276 +
  277 + logger.info(f"保存配件汇总: {cursor.rowcount}条")
  278 + return cursor.rowcount
  279 +
  280 + finally:
  281 + cursor.close()
  282 +
  283 + def delete_part_summaries_by_task(self, task_no: str) -> int:
  284 + """
  285 + 删除指定任务的配件汇总
  286 +
  287 + Returns:
  288 + 删除的行数
  289 + """
  290 + conn = self._get_connection()
  291 + cursor = conn.cursor()
  292 +
  293 + try:
  294 + sql = "DELETE FROM ai_replenishment_part_summary WHERE task_no = %s"
  295 + cursor.execute(sql, (task_no,))
  296 + conn.commit()
  297 +
  298 + logger.info(f"删除配件汇总: task_no={task_no}, rows={cursor.rowcount}")
  299 + return cursor.rowcount
  300 +
  301 + finally:
  302 + cursor.close()
  303 +
  304 + def delete_details_by_task(self, task_no: str) -> int:
  305 + """
  306 + 删除指定任务的补货明细
  307 +
  308 + Returns:
  309 + 删除的行数
  310 + """
  311 + conn = self._get_connection()
  312 + cursor = conn.cursor()
  313 +
  314 + try:
  315 + sql = "DELETE FROM ai_replenishment_detail WHERE task_no = %s"
  316 + cursor.execute(sql, (task_no,))
  317 + conn.commit()
  318 +
  319 + logger.info(f"删除补货明细: task_no={task_no}, rows={cursor.rowcount}")
  320 + return cursor.rowcount
  321 +
  322 + finally:
  323 + cursor.close()
  324 +
ui/css/style.css 0 → 100644
  1 +++ a/ui/css/style.css
  1 +/**
  2 + * AI 补货建议系统 - 核心样式
  3 + * 设计理念: 深色主题 + 玻璃拟态 + 现代专业风格
  4 + */
  5 +
  6 +/* =====================
  7 + CSS Variables
  8 + ===================== */
  9 +:root {
  10 + /* 主色调 */
  11 + --color-primary: #6366f1;
  12 + --color-primary-light: #818cf8;
  13 + --color-primary-dark: #4f46e5;
  14 +
  15 + /* 功能色 */
  16 + --color-success: #10b981;
  17 + --color-success-light: #34d399;
  18 + --color-warning: #f59e0b;
  19 + --color-warning-light: #fbbf24;
  20 + --color-danger: #ef4444;
  21 + --color-danger-light: #f87171;
  22 + --color-info: #3b82f6;
  23 + --color-info-light: #60a5fa;
  24 +
  25 + /* 深色主题背景 */
  26 + --bg-base: #0f172a;
  27 + --bg-surface: #1e293b;
  28 + --bg-elevated: #334155;
  29 + --bg-hover: #475569;
  30 +
  31 + /* 文字颜色 */
  32 + --text-primary: #f8fafc;
  33 + --text-secondary: #94a3b8;
  34 + --text-muted: #64748b;
  35 + --text-disabled: #475569;
  36 +
  37 + /* 边框 */
  38 + --border-color: rgba(148, 163, 184, 0.1);
  39 + --border-color-light: rgba(148, 163, 184, 0.2);
  40 +
  41 + /* 阴影 */
  42 + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
  43 + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
  44 + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.3);
  45 + --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.4);
  46 +
  47 + /* 玻璃效果 */
  48 + --glass-bg: rgba(30, 41, 59, 0.8);
  49 + --glass-border: rgba(148, 163, 184, 0.15);
  50 + --glass-blur: 12px;
  51 +
  52 + /* 间距 */
  53 + --spacing-xs: 4px;
  54 + --spacing-sm: 8px;
  55 + --spacing-md: 16px;
  56 + --spacing-lg: 24px;
  57 + --spacing-xl: 32px;
  58 + --spacing-2xl: 48px;
  59 +
  60 + /* 圆角 */
  61 + --radius-sm: 6px;
  62 + --radius-md: 10px;
  63 + --radius-lg: 16px;
  64 + --radius-xl: 24px;
  65 + --radius-full: 9999px;
  66 +
  67 + /* 字体 */
  68 + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  69 + --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
  70 +
  71 + /* 过渡 */
  72 + --transition-fast: 150ms ease;
  73 + --transition-base: 250ms ease;
  74 + --transition-slow: 400ms ease;
  75 +
  76 + /* 布局 */
  77 + --sidebar-width: 260px;
  78 + --sidebar-width-collapsed: 64px;
  79 + --topbar-height: 64px;
  80 +}
  81 +
  82 +/* =====================
  83 + Reset & Base
  84 + ===================== */
  85 +*, *::before, *::after {
  86 + box-sizing: border-box;
  87 + margin: 0;
  88 + padding: 0;
  89 +}
  90 +
  91 +html {
  92 + font-size: 14px;
  93 + scroll-behavior: smooth;
  94 +}
  95 +
  96 +body {
  97 + font-family: var(--font-sans);
  98 + background-color: var(--bg-base);
  99 + color: var(--text-primary);
  100 + line-height: 1.6;
  101 + min-height: 100vh;
  102 + display: flex;
  103 + overflow-x: hidden;
  104 +}
  105 +
  106 +a {
  107 + color: inherit;
  108 + text-decoration: none;
  109 +}
  110 +
  111 +button {
  112 + font-family: inherit;
  113 + cursor: pointer;
  114 + border: none;
  115 + background: none;
  116 +}
  117 +
  118 +table {
  119 + border-collapse: collapse;
  120 + width: 100%;
  121 +}
  122 +
  123 +/* =====================
  124 + Scrollbar
  125 + ===================== */
  126 +::-webkit-scrollbar {
  127 + width: 8px;
  128 + height: 8px;
  129 +}
  130 +
  131 +::-webkit-scrollbar-track {
  132 + background: var(--bg-surface);
  133 +}
  134 +
  135 +::-webkit-scrollbar-thumb {
  136 + background: var(--bg-elevated);
  137 + border-radius: var(--radius-full);
  138 +}
  139 +
  140 +::-webkit-scrollbar-thumb:hover {
  141 + background: var(--bg-hover);
  142 +}
  143 +
  144 +/* =====================
  145 + Sidebar
  146 + ===================== */
  147 +.sidebar {
  148 + width: var(--sidebar-width);
  149 + height: 100vh;
  150 + background: var(--glass-bg);
  151 + backdrop-filter: blur(var(--glass-blur));
  152 + border-right: 1px solid var(--glass-border);
  153 + display: flex;
  154 + flex-direction: column;
  155 + position: fixed;
  156 + left: 0;
  157 + top: 0;
  158 + z-index: 100;
  159 + transition: transform var(--transition-base);
  160 +}
  161 +
  162 +.sidebar.visible {
  163 + transform: translateX(0);
  164 +}
  165 +
  166 +.sidebar-overlay {
  167 + position: fixed;
  168 + inset: 0;
  169 + background: rgba(15, 23, 42, 0.5);
  170 + backdrop-filter: blur(2px);
  171 + z-index: 90;
  172 + opacity: 0;
  173 + visibility: hidden;
  174 + transition: all var(--transition-base);
  175 +}
  176 +
  177 +.sidebar-overlay.active {
  178 + opacity: 1;
  179 + visibility: visible;
  180 +}
  181 +
  182 +.sidebar-header {
  183 + padding: var(--spacing-lg);
  184 + border-bottom: 1px solid var(--border-color);
  185 + display: flex;
  186 + align-items: center;
  187 + justify-content: space-between;
  188 +}
  189 +
  190 +.sidebar-collapse-btn {
  191 + color: var(--text-secondary);
  192 + transition: transform var(--transition-base);
  193 +}
  194 +
  195 +.sidebar-collapse-btn:hover {
  196 + color: var(--text-primary);
  197 + background: var(--bg-elevated);
  198 +}
  199 +
  200 +/* Collapsed State */
  201 +.sidebar.collapsed {
  202 + width: var(--sidebar-width-collapsed);
  203 +}
  204 +
  205 +.sidebar.collapsed .logo-text,
  206 +.sidebar.collapsed .version-info,
  207 +.sidebar.collapsed .nav-item span {
  208 + display: none;
  209 +}
  210 +
  211 +.sidebar.collapsed .sidebar-header {
  212 + padding: var(--spacing-lg) var(--spacing-sm);
  213 + justify-content: center;
  214 +}
  215 +
  216 +.sidebar.collapsed .logo {
  217 + display: none;
  218 +}
  219 +
  220 +.sidebar.collapsed .sidebar-collapse-btn {
  221 + transform: rotate(180deg);
  222 +}
  223 +
  224 +.sidebar.collapsed .sidebar-nav {
  225 + padding: var(--spacing-md) var(--spacing-xs);
  226 + align-items: center;
  227 +}
  228 +
  229 +.sidebar.collapsed .nav-item {
  230 + justify-content: center;
  231 + padding: var(--spacing-md);
  232 + width: 40px;
  233 + height: 40px;
  234 +}
  235 +
  236 +.sidebar.collapsed .nav-item svg {
  237 + margin: 0;
  238 +}
  239 +
  240 +.sidebar.collapsed + .sidebar-overlay + .main-content {
  241 + margin-left: var(--sidebar-width-collapsed);
  242 +}
  243 +
  244 +.logo {
  245 + display: flex;
  246 + align-items: center;
  247 + gap: var(--spacing-md);
  248 +}
  249 +
  250 +.logo-icon {
  251 + width: 32px;
  252 + height: 32px;
  253 + color: var(--color-primary);
  254 +}
  255 +
  256 +.logo-text {
  257 + font-size: 1.25rem;
  258 + font-weight: 600;
  259 + background: linear-gradient(135deg, var(--color-primary-light), var(--color-primary));
  260 + -webkit-background-clip: text;
  261 + -webkit-text-fill-color: transparent;
  262 + background-clip: text;
  263 +}
  264 +
  265 +.sidebar-nav {
  266 + flex: 1;
  267 + padding: var(--spacing-md);
  268 + display: flex;
  269 + flex-direction: column;
  270 + gap: var(--spacing-xs);
  271 +}
  272 +
  273 +.nav-item {
  274 + display: flex;
  275 + align-items: center;
  276 + gap: var(--spacing-md);
  277 + padding: var(--spacing-md) var(--spacing-lg);
  278 + border-radius: var(--radius-md);
  279 + color: var(--text-secondary);
  280 + transition: all var(--transition-fast);
  281 +}
  282 +
  283 +.nav-item:hover {
  284 + background: var(--bg-elevated);
  285 + color: var(--text-primary);
  286 +}
  287 +
  288 +.nav-item.active {
  289 + background: linear-gradient(135deg, var(--color-primary-dark), var(--color-primary));
  290 + color: white;
  291 + box-shadow: var(--shadow-md);
  292 +}
  293 +
  294 +.nav-item svg {
  295 + width: 20px;
  296 + height: 20px;
  297 +}
  298 +
  299 +.sidebar-footer {
  300 + padding: var(--spacing-md) var(--spacing-lg);
  301 + border-top: 1px solid var(--border-color);
  302 +}
  303 +
  304 +.version-info {
  305 + font-size: 0.75rem;
  306 + color: var(--text-muted);
  307 + text-align: center;
  308 +}
  309 +
  310 +/* =====================
  311 + Main Content
  312 + ===================== */
  313 +.main-content {
  314 + flex: 1;
  315 + margin-left: var(--sidebar-width);
  316 + min-height: 100vh;
  317 + display: flex;
  318 + flex-direction: column;
  319 + transition: margin-left var(--transition-base);
  320 +}
  321 +
  322 +.top-bar {
  323 + height: var(--topbar-height);
  324 + background: var(--glass-bg);
  325 + backdrop-filter: blur(var(--glass-blur));
  326 + border-bottom: 1px solid var(--glass-border);
  327 + display: flex;
  328 + align-items: center;
  329 + justify-content: space-between;
  330 + padding: 0 var(--spacing-xl);
  331 + top: 0;
  332 + z-index: 50;
  333 + gap: var(--spacing-sm);
  334 +}
  335 +
  336 +.menu-toggle {
  337 + display: none;
  338 + margin-right: var(--spacing-sm);
  339 +}
  340 +
  341 +@media (max-width: 1024px) {
  342 + .menu-toggle {
  343 + display: inline-flex;
  344 + }
  345 +}
  346 +
  347 +.breadcrumb {
  348 + display: flex;
  349 + align-items: center;
  350 + gap: var(--spacing-sm);
  351 + color: var(--text-secondary);
  352 +}
  353 +
  354 +.breadcrumb-item {
  355 + display: flex;
  356 + align-items: center;
  357 + gap: var(--spacing-sm);
  358 +}
  359 +
  360 +.breadcrumb-item:not(:last-child)::after {
  361 + content: '/';
  362 + color: var(--text-muted);
  363 + margin-left: var(--spacing-sm);
  364 +}
  365 +
  366 +.breadcrumb-item a {
  367 + color: var(--color-primary-light);
  368 + transition: color var(--transition-fast);
  369 +}
  370 +
  371 +.breadcrumb-item a:hover {
  372 + color: var(--color-primary);
  373 +}
  374 +
  375 +.top-bar-actions {
  376 + display: flex;
  377 + align-items: center;
  378 + gap: var(--spacing-sm);
  379 +}
  380 +
  381 +.page-container {
  382 + flex: 1;
  383 + padding: var(--spacing-xl);
  384 + overflow-y: auto;
  385 +}
  386 +
  387 +/* =====================
  388 + Buttons
  389 + ===================== */
  390 +.btn {
  391 + display: inline-flex;
  392 + align-items: center;
  393 + justify-content: center;
  394 + gap: var(--spacing-sm);
  395 + padding: var(--spacing-sm) var(--spacing-lg);
  396 + font-size: 0.875rem;
  397 + font-weight: 500;
  398 + border-radius: var(--radius-md);
  399 + transition: all var(--transition-fast);
  400 + white-space: nowrap;
  401 +}
  402 +
  403 +.btn-primary {
  404 + background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark));
  405 + color: white;
  406 + box-shadow: var(--shadow-sm);
  407 +}
  408 +
  409 +.btn-primary:hover {
  410 + box-shadow: var(--shadow-md);
  411 + transform: translateY(-1px);
  412 +}
  413 +
  414 +.btn-secondary {
  415 + background: var(--bg-elevated);
  416 + color: var(--text-primary);
  417 + border: 1px solid var(--border-color-light);
  418 +}
  419 +
  420 +.btn-secondary:hover {
  421 + background: var(--bg-hover);
  422 +}
  423 +
  424 +.btn-ghost {
  425 + color: var(--text-secondary);
  426 +}
  427 +
  428 +.btn-ghost:hover {
  429 + background: var(--bg-elevated);
  430 + color: var(--text-primary);
  431 +}
  432 +
  433 +.btn-icon {
  434 + width: 40px;
  435 + height: 40px;
  436 + padding: 0;
  437 + border-radius: var(--radius-md);
  438 + color: var(--text-secondary);
  439 +}
  440 +
  441 +.btn-icon:hover {
  442 + background: var(--bg-elevated);
  443 + color: var(--text-primary);
  444 +}
  445 +
  446 +.btn-icon svg {
  447 + width: 20px;
  448 + height: 20px;
  449 +}
  450 +
  451 +.btn-sm {
  452 + padding: var(--spacing-xs) var(--spacing-md);
  453 + font-size: 0.75rem;
  454 +}
  455 +
  456 +/* =====================
  457 + Cards
  458 + ===================== */
  459 +.card {
  460 + background: var(--glass-bg);
  461 + backdrop-filter: blur(var(--glass-blur));
  462 + border: 1px solid var(--glass-border);
  463 + border-radius: var(--radius-lg);
  464 + padding: var(--spacing-lg);
  465 + transition: all var(--transition-base);
  466 +}
  467 +
  468 +.card:hover {
  469 + border-color: var(--border-color-light);
  470 + box-shadow: var(--shadow-lg);
  471 +}
  472 +
  473 +.card-header {
  474 + display: flex;
  475 + align-items: center;
  476 + justify-content: space-between;
  477 + margin-bottom: var(--spacing-lg);
  478 +}
  479 +
  480 +.card-title {
  481 + font-size: 1.125rem;
  482 + font-weight: 600;
  483 + display: flex;
  484 + align-items: center;
  485 + gap: var(--spacing-sm);
  486 +}
  487 +
  488 +.card-title svg {
  489 + width: 20px;
  490 + height: 20px;
  491 + color: var(--color-primary);
  492 +}
  493 +
  494 +/* =====================
  495 + Stat Cards
  496 + ===================== */
  497 +.stats-grid {
  498 + display: grid;
  499 + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  500 + gap: var(--spacing-lg);
  501 + margin-bottom: var(--spacing-xl);
  502 +}
  503 +
  504 +.stat-card {
  505 + background: var(--glass-bg);
  506 + backdrop-filter: blur(var(--glass-blur));
  507 + border: 1px solid var(--glass-border);
  508 + border-radius: var(--radius-lg);
  509 + padding: var(--spacing-lg);
  510 + display: flex;
  511 + align-items: flex-start;
  512 + gap: var(--spacing-lg);
  513 + transition: all var(--transition-base);
  514 +}
  515 +
  516 +.stat-card:hover {
  517 + transform: translateY(-2px);
  518 + box-shadow: var(--shadow-lg);
  519 +}
  520 +
  521 +.stat-icon {
  522 + width: 48px;
  523 + height: 48px;
  524 + border-radius: var(--radius-md);
  525 + display: flex;
  526 + align-items: center;
  527 + justify-content: center;
  528 + flex-shrink: 0;
  529 +}
  530 +
  531 +.stat-icon svg {
  532 + width: 24px;
  533 + height: 24px;
  534 +}
  535 +
  536 +.stat-icon.primary {
  537 + background: rgba(99, 102, 241, 0.15);
  538 + color: var(--color-primary);
  539 +}
  540 +
  541 +.stat-icon.success {
  542 + background: rgba(16, 185, 129, 0.15);
  543 + color: var(--color-success);
  544 +}
  545 +
  546 +.stat-icon.warning {
  547 + background: rgba(245, 158, 11, 0.15);
  548 + color: var(--color-warning);
  549 +}
  550 +
  551 +.stat-icon.danger {
  552 + background: rgba(239, 68, 68, 0.15);
  553 + color: var(--color-danger);
  554 +}
  555 +
  556 +.stat-icon.info {
  557 + background: rgba(59, 130, 246, 0.15);
  558 + color: var(--color-info);
  559 +}
  560 +
  561 +.stat-content {
  562 + flex: 1;
  563 +}
  564 +
  565 +.stat-label {
  566 + font-size: 0.875rem;
  567 + color: var(--text-secondary);
  568 + margin-bottom: var(--spacing-xs);
  569 +}
  570 +
  571 +.stat-value {
  572 + font-size: 1.75rem;
  573 + font-weight: 700;
  574 + line-height: 1.2;
  575 +}
  576 +
  577 +.stat-change {
  578 + font-size: 0.75rem;
  579 + margin-top: var(--spacing-xs);
  580 + display: flex;
  581 + align-items: center;
  582 + gap: var(--spacing-xs);
  583 +}
  584 +
  585 +.stat-change.positive {
  586 + color: var(--color-success);
  587 +}
  588 +
  589 +.stat-change.negative {
  590 + color: var(--color-danger);
  591 +}
  592 +
  593 +/* =====================
  594 + Tables
  595 + ===================== */
  596 +.table-container {
  597 + background: var(--glass-bg);
  598 + backdrop-filter: blur(var(--glass-blur));
  599 + border: 1px solid var(--glass-border);
  600 + border-radius: var(--radius-lg);
  601 + overflow: hidden;
  602 +}
  603 +
  604 +.table-container > .table-wrapper {
  605 + border-radius: var(--radius-lg);
  606 + overflow-x: auto;
  607 +}
  608 +
  609 +.table-header {
  610 + display: flex;
  611 + align-items: center;
  612 + justify-content: space-between;
  613 + padding: var(--spacing-lg);
  614 + border-bottom: 1px solid var(--border-color);
  615 +}
  616 +
  617 +.table-title {
  618 + font-size: 1.125rem;
  619 + font-weight: 600;
  620 +}
  621 +
  622 +.table-actions {
  623 + display: flex;
  624 + align-items: center;
  625 + gap: var(--spacing-sm);
  626 +}
  627 +
  628 +.table-wrapper {
  629 + overflow-x: auto;
  630 + -webkit-overflow-scrolling: touch;
  631 + width: 100%;
  632 +}
  633 +
  634 +table {
  635 + width: 100%;
  636 + border-collapse: collapse;
  637 +}
  638 +
  639 +thead {
  640 + background: var(--bg-surface);
  641 +}
  642 +
  643 +th {
  644 + padding: var(--spacing-md) var(--spacing-lg);
  645 + text-align: left;
  646 + font-weight: 600;
  647 + font-size: 0.75rem;
  648 + text-transform: uppercase;
  649 + letter-spacing: 0.05em;
  650 + color: var(--text-secondary);
  651 + border-bottom: 1px solid var(--border-color);
  652 + white-space: nowrap;
  653 +}
  654 +
  655 +td {
  656 + padding: var(--spacing-md) var(--spacing-lg);
  657 + border-bottom: 1px solid var(--border-color);
  658 + vertical-align: middle;
  659 +}
  660 +
  661 +.table-cell-secondary {
  662 + min-width: 120px;
  663 + max-width: 200px;
  664 + word-break: break-word;
  665 +}
  666 +
  667 +tbody tr {
  668 + transition: background var(--transition-fast);
  669 +}
  670 +
  671 +tbody tr:hover {
  672 + background: var(--bg-elevated);
  673 +}
  674 +
  675 +tbody tr:last-child td {
  676 + border-bottom: none;
  677 +}
  678 +
  679 +.table-cell-link {
  680 + color: var(--color-primary-light);
  681 + font-weight: 500;
  682 + cursor: pointer;
  683 + transition: color var(--transition-fast);
  684 +}
  685 +
  686 +.table-cell-link:hover {
  687 + color: var(--color-primary);
  688 + text-decoration: underline;
  689 +}
  690 +
  691 +.table-cell-mono {
  692 + font-family: var(--font-mono);
  693 + font-size: 0.8rem;
  694 +}
  695 +
  696 +.table-cell-amount {
  697 + font-weight: 600;
  698 + color: var(--color-success);
  699 +}
  700 +
  701 +.table-cell-secondary {
  702 + color: var(--text-secondary);
  703 + font-size: 0.875rem;
  704 +}
  705 +
  706 +/* =====================
  707 + Badges / Status
  708 + ===================== */
  709 +.badge {
  710 + display: inline-flex;
  711 + align-items: center;
  712 + gap: var(--spacing-xs);
  713 + padding: var(--spacing-xs) var(--spacing-sm);
  714 + font-size: 0.75rem;
  715 + font-weight: 500;
  716 + border-radius: var(--radius-full);
  717 + white-space: nowrap;
  718 +}
  719 +
  720 +.badge-success {
  721 + background: rgba(16, 185, 129, 0.15);
  722 + color: var(--color-success-light);
  723 +}
  724 +
  725 +.badge-warning {
  726 + background: rgba(245, 158, 11, 0.15);
  727 + color: var(--color-warning-light);
  728 +}
  729 +
  730 +.badge-danger {
  731 + background: rgba(239, 68, 68, 0.15);
  732 + color: var(--color-danger-light);
  733 +}
  734 +
  735 +.badge-info {
  736 + background: rgba(59, 130, 246, 0.15);
  737 + color: var(--color-info-light);
  738 +}
  739 +
  740 +.badge-neutral {
  741 + background: var(--bg-elevated);
  742 + color: var(--text-secondary);
  743 +}
  744 +
  745 +.badge-dot {
  746 + width: 6px;
  747 + height: 6px;
  748 + border-radius: 50%;
  749 + background: currentColor;
  750 +}
  751 +
  752 +/* =====================
  753 + Pagination
  754 + ===================== */
  755 +.pagination {
  756 + display: flex;
  757 + align-items: center;
  758 + justify-content: space-between;
  759 + padding: var(--spacing-lg);
  760 + border-top: 1px solid var(--border-color);
  761 +}
  762 +
  763 +.pagination-info {
  764 + font-size: 0.875rem;
  765 + color: var(--text-secondary);
  766 +}
  767 +
  768 +.pagination-controls {
  769 + display: flex;
  770 + align-items: center;
  771 + gap: var(--spacing-xs);
  772 +}
  773 +
  774 +.pagination-btn {
  775 + width: 36px;
  776 + height: 36px;
  777 + display: flex;
  778 + align-items: center;
  779 + justify-content: center;
  780 + border-radius: var(--radius-md);
  781 + color: var(--text-secondary);
  782 + transition: all var(--transition-fast);
  783 +}
  784 +
  785 +.pagination-btn:hover:not(:disabled) {
  786 + background: var(--bg-elevated);
  787 + color: var(--text-primary);
  788 +}
  789 +
  790 +.pagination-btn:disabled {
  791 + opacity: 0.5;
  792 + cursor: not-allowed;
  793 +}
  794 +
  795 +.pagination-btn.active {
  796 + background: var(--color-primary);
  797 + color: white;
  798 +}
  799 +
  800 +.pagination-btn svg {
  801 + width: 18px;
  802 + height: 18px;
  803 +}
  804 +
  805 +/* =====================
  806 + Loading & Overlay
  807 + ===================== */
  808 +.loading-overlay {
  809 + position: fixed;
  810 + inset: 0;
  811 + background: rgba(15, 23, 42, 0.8);
  812 + backdrop-filter: blur(4px);
  813 + display: flex;
  814 + align-items: center;
  815 + justify-content: center;
  816 + z-index: 1000;
  817 + opacity: 0;
  818 + visibility: hidden;
  819 + transition: all var(--transition-base);
  820 +}
  821 +
  822 +.loading-overlay.active {
  823 + opacity: 1;
  824 + visibility: visible;
  825 +}
  826 +
  827 +.loading-spinner {
  828 + display: flex;
  829 + flex-direction: column;
  830 + align-items: center;
  831 + gap: var(--spacing-md);
  832 + color: var(--text-primary);
  833 +}
  834 +
  835 +.loading-spinner svg {
  836 + width: 48px;
  837 + height: 48px;
  838 + color: var(--color-primary);
  839 +}
  840 +
  841 +.spin {
  842 + animation: spin 1s linear infinite;
  843 +}
  844 +
  845 +@keyframes spin {
  846 + from { transform: rotate(0deg); }
  847 + to { transform: rotate(360deg); }
  848 +}
  849 +
  850 +/* =====================
  851 + Modal
  852 + ===================== */
  853 +.modal-overlay {
  854 + position: fixed;
  855 + inset: 0;
  856 + background: rgba(15, 23, 42, 0.8);
  857 + backdrop-filter: blur(4px);
  858 + display: flex;
  859 + align-items: center;
  860 + justify-content: center;
  861 + z-index: 1000;
  862 + padding: var(--spacing-xl);
  863 + opacity: 0;
  864 + visibility: hidden;
  865 + transition: all var(--transition-base);
  866 +}
  867 +
  868 +.modal-overlay.active {
  869 + opacity: 1;
  870 + visibility: visible;
  871 +}
  872 +
  873 +.modal {
  874 + background: var(--bg-surface);
  875 + border: 1px solid var(--glass-border);
  876 + border-radius: var(--radius-xl);
  877 + width: 100%;
  878 + max-width: 800px;
  879 + max-height: 90vh;
  880 + display: flex;
  881 + flex-direction: column;
  882 + box-shadow: var(--shadow-xl);
  883 + transform: scale(0.95);
  884 + transition: transform var(--transition-base);
  885 +}
  886 +
  887 +.modal-overlay.active .modal {
  888 + transform: scale(1);
  889 +}
  890 +
  891 +.modal-header {
  892 + display: flex;
  893 + align-items: center;
  894 + justify-content: space-between;
  895 + padding: var(--spacing-lg) var(--spacing-xl);
  896 + border-bottom: 1px solid var(--border-color);
  897 +}
  898 +
  899 +.modal-title {
  900 + font-size: 1.25rem;
  901 + font-weight: 600;
  902 +}
  903 +
  904 +.modal-body {
  905 + flex: 1;
  906 + padding: var(--spacing-xl);
  907 + overflow-y: auto;
  908 +}
  909 +
  910 +/* =====================
  911 + Toast Notifications
  912 + ===================== */
  913 +.toast-container {
  914 + position: fixed;
  915 + top: var(--spacing-lg);
  916 + right: var(--spacing-lg);
  917 + z-index: 1100;
  918 + display: flex;
  919 + flex-direction: column;
  920 + gap: var(--spacing-sm);
  921 +}
  922 +
  923 +.toast {
  924 + background: var(--bg-surface);
  925 + border: 1px solid var(--glass-border);
  926 + border-radius: var(--radius-md);
  927 + padding: var(--spacing-md) var(--spacing-lg);
  928 + display: flex;
  929 + align-items: center;
  930 + gap: var(--spacing-md);
  931 + box-shadow: var(--shadow-lg);
  932 + animation: slideIn var(--transition-base) ease;
  933 + min-width: 300px;
  934 +}
  935 +
  936 +.toast.success {
  937 + border-left: 4px solid var(--color-success);
  938 +}
  939 +
  940 +.toast.error {
  941 + border-left: 4px solid var(--color-danger);
  942 +}
  943 +
  944 +.toast.warning {
  945 + border-left: 4px solid var(--color-warning);
  946 +}
  947 +
  948 +.toast.info {
  949 + border-left: 4px solid var(--color-info);
  950 +}
  951 +
  952 +.toast-icon {
  953 + width: 20px;
  954 + height: 20px;
  955 + flex-shrink: 0;
  956 +}
  957 +
  958 +.toast.success .toast-icon { color: var(--color-success); }
  959 +.toast.error .toast-icon { color: var(--color-danger); }
  960 +.toast.warning .toast-icon { color: var(--color-warning); }
  961 +.toast.info .toast-icon { color: var(--color-info); }
  962 +
  963 +.toast-message {
  964 + flex: 1;
  965 + font-size: 0.875rem;
  966 +}
  967 +
  968 +@keyframes slideIn {
  969 + from {
  970 + transform: translateX(100%);
  971 + opacity: 0;
  972 + }
  973 + to {
  974 + transform: translateX(0);
  975 + opacity: 1;
  976 + }
  977 +}
  978 +
  979 +/* =====================
  980 + Detail View
  981 + ===================== */
  982 +.detail-header {
  983 + display: flex;
  984 + align-items: flex-start;
  985 + justify-content: space-between;
  986 + margin-bottom: var(--spacing-xl);
  987 +}
  988 +
  989 +.detail-title {
  990 + font-size: 1.5rem;
  991 + font-weight: 700;
  992 + display: flex;
  993 + align-items: center;
  994 + gap: var(--spacing-md);
  995 +}
  996 +
  997 +.detail-title .badge {
  998 + font-size: 0.875rem;
  999 +}
  1000 +
  1001 +.detail-meta {
  1002 + display: flex;
  1003 + align-items: center;
  1004 + gap: var(--spacing-lg);
  1005 + margin-top: var(--spacing-sm);
  1006 + color: var(--text-secondary);
  1007 + font-size: 0.875rem;
  1008 +}
  1009 +
  1010 +.detail-meta-item {
  1011 + display: flex;
  1012 + align-items: center;
  1013 + gap: var(--spacing-xs);
  1014 +}
  1015 +
  1016 +.detail-meta-item svg {
  1017 + width: 16px;
  1018 + height: 16px;
  1019 +}
  1020 +
  1021 +.detail-actions {
  1022 + display: flex;
  1023 + gap: var(--spacing-sm);
  1024 +}
  1025 +
  1026 +.detail-grid {
  1027 + display: grid;
  1028 + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  1029 + gap: var(--spacing-lg);
  1030 + margin-bottom: var(--spacing-xl);
  1031 +}
  1032 +
  1033 +.detail-section {
  1034 + margin-bottom: var(--spacing-xl);
  1035 +}
  1036 +
  1037 +.detail-section-title {
  1038 + font-size: 1.125rem;
  1039 + font-weight: 600;
  1040 + margin-bottom: var(--spacing-lg);
  1041 + display: flex;
  1042 + align-items: center;
  1043 + gap: var(--spacing-sm);
  1044 + color: var(--text-primary);
  1045 +}
  1046 +
  1047 +.detail-section-title svg {
  1048 + width: 20px;
  1049 + height: 20px;
  1050 + color: var(--color-primary);
  1051 +}
  1052 +
  1053 +/* =====================
  1054 + Info List
  1055 + ===================== */
  1056 +.info-list {
  1057 + display: grid;
  1058 + gap: var(--spacing-md);
  1059 +}
  1060 +
  1061 +.info-item {
  1062 + display: flex;
  1063 + justify-content: space-between;
  1064 + align-items: center;
  1065 + padding: var(--spacing-sm) 0;
  1066 + border-bottom: 1px solid var(--border-color);
  1067 +}
  1068 +
  1069 +.info-item:last-child {
  1070 + border-bottom: none;
  1071 +}
  1072 +
  1073 +.info-label {
  1074 + color: var(--text-secondary);
  1075 + font-size: 0.875rem;
  1076 +}
  1077 +
  1078 +.info-value {
  1079 + font-weight: 500;
  1080 + text-align: right;
  1081 +}
  1082 +
  1083 +/* =====================
  1084 + Markdown Content
  1085 + ===================== */
  1086 +.markdown-content {
  1087 + line-height: 1.8;
  1088 + color: var(--text-primary);
  1089 +}
  1090 +
  1091 +.markdown-content h1,
  1092 +.markdown-content h2,
  1093 +.markdown-content h3,
  1094 +.markdown-content h4 {
  1095 + margin-top: var(--spacing-xl);
  1096 + margin-bottom: var(--spacing-md);
  1097 + font-weight: 600;
  1098 +}
  1099 +
  1100 +.markdown-content h1 { font-size: 1.75rem; }
  1101 +.markdown-content h2 { font-size: 1.5rem; }
  1102 +.markdown-content h3 { font-size: 1.25rem; }
  1103 +.markdown-content h4 { font-size: 1.125rem; }
  1104 +
  1105 +.markdown-content p {
  1106 + margin-bottom: var(--spacing-md);
  1107 +}
  1108 +
  1109 +.markdown-content ul,
  1110 +.markdown-content ol {
  1111 + margin-bottom: var(--spacing-md);
  1112 + padding-left: var(--spacing-xl);
  1113 +}
  1114 +
  1115 +.markdown-content li {
  1116 + margin-bottom: var(--spacing-sm);
  1117 +}
  1118 +
  1119 +.markdown-content code {
  1120 + background: var(--bg-elevated);
  1121 + padding: var(--spacing-xs) var(--spacing-sm);
  1122 + border-radius: var(--radius-sm);
  1123 + font-family: var(--font-mono);
  1124 + font-size: 0.875rem;
  1125 +}
  1126 +
  1127 +.markdown-content pre {
  1128 + background: var(--bg-elevated);
  1129 + padding: var(--spacing-lg);
  1130 + border-radius: var(--radius-md);
  1131 + overflow-x: auto;
  1132 + margin-bottom: var(--spacing-md);
  1133 +}
  1134 +
  1135 +.markdown-content pre code {
  1136 + background: none;
  1137 + padding: 0;
  1138 +}
  1139 +
  1140 +.markdown-content blockquote {
  1141 + border-left: 4px solid var(--color-primary);
  1142 + padding-left: var(--spacing-lg);
  1143 + margin: var(--spacing-lg) 0;
  1144 + color: var(--text-secondary);
  1145 +}
  1146 +
  1147 +.markdown-content table {
  1148 + margin: var(--spacing-lg) 0;
  1149 + border: 1px solid var(--border-color);
  1150 + border-radius: var(--radius-md);
  1151 + overflow: hidden;
  1152 +}
  1153 +
  1154 +.markdown-content th,
  1155 +.markdown-content td {
  1156 + padding: var(--spacing-sm) var(--spacing-md);
  1157 + border: 1px solid var(--border-color);
  1158 +}
  1159 +
  1160 +.markdown-content th {
  1161 + background: var(--bg-elevated);
  1162 +}
  1163 +
  1164 +.markdown-content hr {
  1165 + border: none;
  1166 + border-top: 1px solid var(--border-color);
  1167 + margin: var(--spacing-xl) 0;
  1168 +}
  1169 +
  1170 +/* =====================
  1171 + Empty State
  1172 + ===================== */
  1173 +.empty-state {
  1174 + display: flex;
  1175 + flex-direction: column;
  1176 + align-items: center;
  1177 + justify-content: center;
  1178 + padding: var(--spacing-2xl);
  1179 + text-align: center;
  1180 + color: var(--text-secondary);
  1181 +}
  1182 +
  1183 +.empty-state svg {
  1184 + width: 64px;
  1185 + height: 64px;
  1186 + color: var(--text-muted);
  1187 + margin-bottom: var(--spacing-lg);
  1188 +}
  1189 +
  1190 +.empty-state-title {
  1191 + font-size: 1.125rem;
  1192 + font-weight: 600;
  1193 + color: var(--text-primary);
  1194 + margin-bottom: var(--spacing-sm);
  1195 +}
  1196 +
  1197 +.empty-state-description {
  1198 + font-size: 0.875rem;
  1199 + max-width: 400px;
  1200 +}
  1201 +
  1202 +/* =====================
  1203 + Tabs
  1204 + ===================== */
  1205 +.tabs {
  1206 + display: flex;
  1207 + gap: var(--spacing-xs);
  1208 + border-bottom: 1px solid var(--border-color);
  1209 + margin-bottom: var(--spacing-xl);
  1210 +}
  1211 +
  1212 +.tab {
  1213 + padding: var(--spacing-md) var(--spacing-lg);
  1214 + font-weight: 500;
  1215 + color: var(--text-secondary);
  1216 + border-bottom: 2px solid transparent;
  1217 + margin-bottom: -1px;
  1218 + transition: all var(--transition-fast);
  1219 +}
  1220 +
  1221 +.tab:hover {
  1222 + color: var(--text-primary);
  1223 +}
  1224 +
  1225 +.tab.active {
  1226 + color: var(--color-primary);
  1227 + border-bottom-color: var(--color-primary);
  1228 +}
  1229 +
  1230 +/* =====================
  1231 + Ratio Indicator
  1232 + ===================== */
  1233 +.ratio-indicator {
  1234 + display: inline-flex;
  1235 + align-items: center;
  1236 + gap: var(--spacing-xs);
  1237 +}
  1238 +
  1239 +.ratio-bar {
  1240 + width: 60px;
  1241 + height: 6px;
  1242 + background: var(--bg-elevated);
  1243 + border-radius: var(--radius-full);
  1244 + overflow: hidden;
  1245 +}
  1246 +
  1247 +.ratio-bar-fill {
  1248 + height: 100%;
  1249 + border-radius: var(--radius-full);
  1250 + transition: width var(--transition-base);
  1251 +}
  1252 +
  1253 +.ratio-low .ratio-bar-fill { background: var(--color-danger); }
  1254 +.ratio-normal .ratio-bar-fill { background: var(--color-success); }
  1255 +.ratio-high .ratio-bar-fill { background: var(--color-warning); }
  1256 +
  1257 +/* =====================
  1258 + Priority Badge
  1259 + ===================== */
  1260 +.priority-badge {
  1261 + display: inline-flex;
  1262 + align-items: center;
  1263 + gap: var(--spacing-xs);
  1264 + padding: var(--spacing-xs) var(--spacing-sm);
  1265 + border-radius: var(--radius-sm);
  1266 + font-size: 0.75rem;
  1267 + font-weight: 500;
  1268 +}
  1269 +
  1270 +.priority-high {
  1271 + background: rgba(239, 68, 68, 0.15);
  1272 + color: var(--color-danger-light);
  1273 +}
  1274 +
  1275 +.priority-medium {
  1276 + background: rgba(245, 158, 11, 0.15);
  1277 + color: var(--color-warning-light);
  1278 +}
  1279 +
  1280 +.priority-low {
  1281 + background: rgba(16, 185, 129, 0.15);
  1282 + color: var(--color-success-light);
  1283 +}
  1284 +
  1285 +/* =====================
  1286 + Back Button
  1287 + ===================== */
  1288 +.back-link {
  1289 + display: inline-flex;
  1290 + align-items: center;
  1291 + gap: var(--spacing-sm);
  1292 + color: var(--text-secondary);
  1293 + font-size: 0.875rem;
  1294 + margin-bottom: var(--spacing-lg);
  1295 + transition: color var(--transition-fast);
  1296 +}
  1297 +
  1298 +.back-link:hover {
  1299 + color: var(--color-primary);
  1300 +}
  1301 +
  1302 +.back-link svg {
  1303 + width: 18px;
  1304 + height: 18px;
  1305 +}
  1306 +
  1307 +/* =====================
  1308 + Report Sections
  1309 + ===================== */
  1310 +.report-section {
  1311 + padding: var(--spacing-lg);
  1312 +}
  1313 +
  1314 +.report-section .markdown-content {
  1315 + line-height: 1.8;
  1316 +}
  1317 +
  1318 +.report-section .markdown-content h1,
  1319 +.report-section .markdown-content h2,
  1320 +.report-section .markdown-content h3 {
  1321 + margin-top: var(--spacing-lg);
  1322 + margin-bottom: var(--spacing-md);
  1323 +}
  1324 +
  1325 +.report-section .markdown-content p {
  1326 + margin-bottom: var(--spacing-md);
  1327 +}
  1328 +
  1329 +.report-section .markdown-content ul,
  1330 +.report-section .markdown-content ol {
  1331 + margin-bottom: var(--spacing-md);
  1332 + padding-left: var(--spacing-xl);
  1333 +}
  1334 +
  1335 +.report-section .markdown-content li {
  1336 + margin-bottom: var(--spacing-xs);
  1337 +}
  1338 +
  1339 +.report-section .markdown-content pre:empty,
  1340 +.report-section .markdown-content pre:blank {
  1341 + display: none;
  1342 +}
  1343 +
  1344 +/* =====================
  1345 + Part Tags
  1346 + ===================== */
  1347 +.part-tag {
  1348 + display: inline-flex;
  1349 + align-items: center;
  1350 + padding: 2px 8px;
  1351 + font-size: 0.7rem;
  1352 + font-weight: 600;
  1353 + border-radius: var(--radius-full);
  1354 + white-space: nowrap;
  1355 +}
  1356 +
  1357 +.tag-stagnant {
  1358 + background: rgba(239, 68, 68, 0.15);
  1359 + color: var(--color-danger-light);
  1360 +}
  1361 +
  1362 +.tag-low-freq {
  1363 + background: rgba(245, 158, 11, 0.15);
  1364 + color: var(--color-warning-light);
  1365 +}
  1366 +
  1367 +.tag-shortage {
  1368 + background: rgba(249, 115, 22, 0.15);
  1369 + color: #fb923c;
  1370 +}
  1371 +
  1372 +/* =====================
  1373 + Timeline
  1374 + ===================== */
  1375 +.timeline {
  1376 + position: relative;
  1377 + padding: var(--spacing-md) 0;
  1378 +}
  1379 +
  1380 +.timeline-item {
  1381 + display: flex;
  1382 + gap: var(--spacing-lg);
  1383 + padding: var(--spacing-md) 0;
  1384 +}
  1385 +
  1386 +.timeline-marker {
  1387 + display: flex;
  1388 + flex-direction: column;
  1389 + align-items: center;
  1390 + flex-shrink: 0;
  1391 +}
  1392 +
  1393 +.timeline-icon {
  1394 + width: 36px;
  1395 + height: 36px;
  1396 + border-radius: 50%;
  1397 + display: flex;
  1398 + align-items: center;
  1399 + justify-content: center;
  1400 + z-index: 1;
  1401 +}
  1402 +
  1403 +.timeline-item-success .timeline-icon {
  1404 + background: rgba(16, 185, 129, 0.15);
  1405 + color: var(--color-success);
  1406 +}
  1407 +
  1408 +.timeline-item-error .timeline-icon {
  1409 + background: rgba(239, 68, 68, 0.15);
  1410 + color: var(--color-danger);
  1411 +}
  1412 +
  1413 +.timeline-icon svg {
  1414 + width: 18px;
  1415 + height: 18px;
  1416 +}
  1417 +
  1418 +.timeline-line {
  1419 + width: 2px;
  1420 + flex: 1;
  1421 + min-height: 24px;
  1422 + background: var(--border-color-light);
  1423 + margin-top: var(--spacing-sm);
  1424 +}
  1425 +
  1426 +.timeline-content {
  1427 + flex: 1;
  1428 + background: var(--bg-surface);
  1429 + border: 1px solid var(--border-color);
  1430 + border-radius: var(--radius-md);
  1431 + padding: var(--spacing-md) var(--spacing-lg);
  1432 + transition: all var(--transition-fast);
  1433 +}
  1434 +
  1435 +.timeline-content:hover {
  1436 + border-color: var(--border-color-light);
  1437 +}
  1438 +
  1439 +.timeline-header {
  1440 + display: flex;
  1441 + align-items: center;
  1442 + justify-content: space-between;
  1443 + gap: var(--spacing-md);
  1444 + margin-bottom: var(--spacing-sm);
  1445 +}
  1446 +
  1447 +.timeline-title {
  1448 + font-weight: 600;
  1449 + font-size: 1rem;
  1450 +}
  1451 +
  1452 +.timeline-meta {
  1453 + display: flex;
  1454 + align-items: center;
  1455 + flex-wrap: wrap;
  1456 + gap: var(--spacing-lg);
  1457 + color: var(--text-secondary);
  1458 + font-size: 0.875rem;
  1459 +}
  1460 +
  1461 +.meta-item {
  1462 + display: flex;
  1463 + align-items: center;
  1464 + gap: var(--spacing-xs);
  1465 +}
  1466 +
  1467 +.meta-item svg {
  1468 + width: 14px;
  1469 + height: 14px;
  1470 +}
  1471 +
  1472 +.meta-warning {
  1473 + color: var(--color-warning);
  1474 +}
  1475 +
  1476 +.timeline-error {
  1477 + margin-top: var(--spacing-md);
  1478 + padding: var(--spacing-md);
  1479 + background: rgba(239, 68, 68, 0.1);
  1480 + border: 1px solid rgba(239, 68, 68, 0.2);
  1481 + border-radius: var(--radius-sm);
  1482 + color: var(--color-danger-light);
  1483 + font-size: 0.875rem;
  1484 + display: flex;
  1485 + align-items: flex-start;
  1486 + gap: var(--spacing-sm);
  1487 +}
  1488 +
  1489 +.timeline-error svg {
  1490 + width: 16px;
  1491 + height: 16px;
  1492 + flex-shrink: 0;
  1493 + margin-top: 2px;
  1494 +}
  1495 +
  1496 +/* =====================
  1497 + Responsive
  1498 + ===================== */
  1499 +@media (max-width: 1024px) {
  1500 + .sidebar {
  1501 + transform: translateX(-100%);
  1502 + box-shadow: var(--shadow-xl);
  1503 + }
  1504 +
  1505 + .main-content {
  1506 + margin-left: 0;
  1507 + }
  1508 +
  1509 + .top-bar {
  1510 + padding: 0 var(--spacing-md);
  1511 + }
  1512 +
  1513 + .page-container {
  1514 + padding: var(--spacing-md);
  1515 + }
  1516 +
  1517 + .stats-grid {
  1518 + grid-template-columns: repeat(2, 1fr);
  1519 + }
  1520 +}
  1521 +
  1522 +/* 笔记本屏幕适配 (1024px - 1440px) */
  1523 +@media (min-width: 1024px) and (max-width: 1440px) {
  1524 + .page-container {
  1525 + padding: var(--spacing-lg);
  1526 + }
  1527 +
  1528 + .table-container {
  1529 + overflow: hidden; /* 确保不撑开父容器 */
  1530 + }
  1531 +
  1532 + .table-wrapper {
  1533 + width: 100%;
  1534 + overflow-x: auto;
  1535 + }
  1536 +
  1537 + th, td {
  1538 + padding: var(--spacing-sm) var(--spacing-md);
  1539 + font-size: 0.85rem;
  1540 + }
  1541 +}
  1542 +
  1543 +@media (max-width: 768px) {
  1544 + .stats-grid {
  1545 + grid-template-columns: 1fr;
  1546 + }
  1547 +
  1548 + .detail-grid {
  1549 + grid-template-columns: 1fr;
  1550 + }
  1551 +
  1552 + .table-wrapper {
  1553 + overflow-x: auto;
  1554 + }
  1555 +
  1556 + th, td {
  1557 + padding: var(--spacing-sm);
  1558 + font-size: 0.8rem;
  1559 + }
  1560 +
  1561 + /* 隐藏次要列 */
  1562 + @media (max-width: 640px) {
  1563 + .table-cell-secondary {
  1564 + display: none;
  1565 + }
  1566 + }
  1567 +
  1568 + /* 调整面包屑显示 */
  1569 + .breadcrumb-item:not(:last-child) {
  1570 + display: none;
  1571 + }
  1572 + .breadcrumb-item:last-child::before {
  1573 + content: '';
  1574 + margin: 0;
  1575 + }
  1576 +}
  1577 +
  1578 +/* =====================
  1579 + Report JSON Sections
  1580 + ===================== */
  1581 +.report-section-header {
  1582 + display: flex;
  1583 + align-items: center;
  1584 + gap: var(--spacing-md);
  1585 + margin-bottom: var(--spacing-lg);
  1586 +}
  1587 +
  1588 +.report-section-icon {
  1589 + width: 40px;
  1590 + height: 40px;
  1591 + border-radius: var(--radius-md);
  1592 + display: flex;
  1593 + align-items: center;
  1594 + justify-content: center;
  1595 + flex-shrink: 0;
  1596 +}
  1597 +
  1598 +.report-section-icon svg {
  1599 + width: 20px;
  1600 + height: 20px;
  1601 +}
  1602 +
  1603 +.report-section-icon.summary {
  1604 + background: rgba(99, 102, 241, 0.15);
  1605 + color: var(--color-primary);
  1606 +}
  1607 +
  1608 +.report-section-icon.analysis {
  1609 + background: rgba(59, 130, 246, 0.15);
  1610 + color: var(--color-info);
  1611 +}
  1612 +
  1613 +.report-section-icon.risk {
  1614 + background: rgba(245, 158, 11, 0.15);
  1615 + color: var(--color-warning);
  1616 +}
  1617 +
  1618 +.report-section-icon.recommendation {
  1619 + background: rgba(16, 185, 129, 0.15);
  1620 + color: var(--color-success);
  1621 +}
  1622 +
  1623 +.report-section-icon.optimization {
  1624 + background: rgba(139, 92, 246, 0.15);
  1625 + color: #a78bfa;
  1626 +}
  1627 +
  1628 +.report-section-title {
  1629 + font-size: 1.125rem;
  1630 + font-weight: 600;
  1631 +}
  1632 +
  1633 +/* Summary Items */
  1634 +.summary-items {
  1635 + display: grid;
  1636 + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  1637 + gap: var(--spacing-md);
  1638 + margin-bottom: var(--spacing-lg);
  1639 +}
  1640 +
  1641 +.summary-item {
  1642 + background: var(--bg-surface);
  1643 + border: 1px solid var(--border-color);
  1644 + border-radius: var(--radius-md);
  1645 + padding: var(--spacing-md);
  1646 +}
  1647 +
  1648 +.summary-item-label {
  1649 + font-size: 0.75rem;
  1650 + color: var(--text-secondary);
  1651 + margin-bottom: var(--spacing-xs);
  1652 +}
  1653 +
  1654 +.summary-item-value {
  1655 + font-size: 1.25rem;
  1656 + font-weight: 600;
  1657 +}
  1658 +
  1659 +.summary-item.status-normal .summary-item-value { color: var(--text-primary); }
  1660 +.summary-item.status-highlight .summary-item-value { color: var(--color-primary-light); }
  1661 +.summary-item.status-warning .summary-item-value { color: var(--color-warning); }
  1662 +.summary-item.status-danger .summary-item-value { color: var(--color-danger); }
  1663 +
  1664 +.summary-text {
  1665 + color: var(--text-secondary);
  1666 + line-height: 1.6;
  1667 +}
  1668 +
  1669 +/* Risk Items */
  1670 +.risk-list {
  1671 + display: grid;
  1672 + gap: var(--spacing-md);
  1673 +}
  1674 +
  1675 +.risk-item {
  1676 + display: flex;
  1677 + gap: var(--spacing-md);
  1678 + padding: var(--spacing-md);
  1679 + background: var(--bg-surface);
  1680 + border-radius: var(--radius-md);
  1681 + border-left: 4px solid;
  1682 +}
  1683 +
  1684 +.risk-item.level-high { border-left-color: var(--color-danger); }
  1685 +.risk-item.level-medium { border-left-color: var(--color-warning); }
  1686 +.risk-item.level-low { border-left-color: var(--color-success); }
  1687 +
  1688 +.risk-level {
  1689 + display: inline-flex;
  1690 + align-items: center;
  1691 + padding: var(--spacing-xs) var(--spacing-sm);
  1692 + border-radius: var(--radius-sm);
  1693 + font-size: 0.7rem;
  1694 + font-weight: 600;
  1695 + text-transform: uppercase;
  1696 + flex-shrink: 0;
  1697 + height: fit-content;
  1698 +}
  1699 +
  1700 +.risk-level.high {
  1701 + background: rgba(239, 68, 68, 0.15);
  1702 + color: var(--color-danger-light);
  1703 +}
  1704 +
  1705 +.risk-level.medium {
  1706 + background: rgba(245, 158, 11, 0.15);
  1707 + color: var(--color-warning-light);
  1708 +}
  1709 +
  1710 +.risk-level.low {
  1711 + background: rgba(16, 185, 129, 0.15);
  1712 + color: var(--color-success-light);
  1713 +}
  1714 +
  1715 +.risk-content {
  1716 + flex: 1;
  1717 +}
  1718 +
  1719 +.risk-category {
  1720 + font-weight: 600;
  1721 + margin-bottom: var(--spacing-xs);
  1722 +}
  1723 +
  1724 +.risk-description {
  1725 + color: var(--text-secondary);
  1726 + font-size: 0.875rem;
  1727 +}
  1728 +
  1729 +/* Recommendation Items */
  1730 +.recommendation-list {
  1731 + display: grid;
  1732 + gap: var(--spacing-md);
  1733 +}
  1734 +
  1735 +.recommendation-item {
  1736 + display: flex;
  1737 + gap: var(--spacing-md);
  1738 + padding: var(--spacing-md);
  1739 + background: var(--bg-surface);
  1740 + border: 1px solid var(--border-color);
  1741 + border-radius: var(--radius-md);
  1742 +}
  1743 +
  1744 +.recommendation-priority {
  1745 + width: 28px;
  1746 + height: 28px;
  1747 + border-radius: 50%;
  1748 + display: flex;
  1749 + align-items: center;
  1750 + justify-content: center;
  1751 + font-size: 0.875rem;
  1752 + font-weight: 700;
  1753 + flex-shrink: 0;
  1754 +}
  1755 +
  1756 +.recommendation-priority.priority-1 {
  1757 + background: var(--color-danger);
  1758 + color: white;
  1759 +}
  1760 +
  1761 +.recommendation-priority.priority-2 {
  1762 + background: var(--color-warning);
  1763 + color: white;
  1764 +}
  1765 +
  1766 +.recommendation-priority.priority-3 {
  1767 + background: var(--color-info);
  1768 + color: white;
  1769 +}
  1770 +
  1771 +.recommendation-content {
  1772 + flex: 1;
  1773 +}
  1774 +
  1775 +.recommendation-action {
  1776 + font-weight: 600;
  1777 + margin-bottom: var(--spacing-xs);
  1778 +}
  1779 +
  1780 +.recommendation-reason {
  1781 + color: var(--text-secondary);
  1782 + font-size: 0.875rem;
  1783 +}
  1784 +
  1785 +/* Suggestion Items */
  1786 +.suggestion-list {
  1787 + display: grid;
  1788 + gap: var(--spacing-sm);
  1789 +}
  1790 +
  1791 +.suggestion-item {
  1792 + display: flex;
  1793 + align-items: flex-start;
  1794 + gap: var(--spacing-md);
  1795 + padding: var(--spacing-md);
  1796 + background: var(--bg-surface);
  1797 + border-radius: var(--radius-md);
  1798 +}
  1799 +
  1800 +.suggestion-icon {
  1801 + color: var(--color-success);
  1802 + flex-shrink: 0;
  1803 +}
  1804 +
  1805 +.suggestion-icon svg {
  1806 + width: 18px;
  1807 + height: 18px;
  1808 +}
  1809 +
  1810 +.suggestion-text {
  1811 + flex: 1;
  1812 + line-height: 1.5;
  1813 +}
  1814 +
  1815 +/* Highlight Items */
  1816 +.highlight-list {
  1817 + display: grid;
  1818 + gap: var(--spacing-sm);
  1819 + margin-top: var(--spacing-md);
  1820 +}
  1821 +
  1822 +.highlight-item {
  1823 + display: flex;
  1824 + justify-content: space-between;
  1825 + align-items: center;
  1826 + padding: var(--spacing-sm) var(--spacing-md);
  1827 + background: rgba(99, 102, 241, 0.1);
  1828 + border-radius: var(--radius-sm);
  1829 +}
  1830 +
  1831 +.highlight-label {
  1832 + color: var(--text-secondary);
  1833 + font-size: 0.875rem;
  1834 +}
  1835 +
  1836 +.highlight-value {
  1837 + font-weight: 600;
  1838 + color: var(--color-primary-light);
  1839 +}
  1840 +
  1841 +/* Analysis Paragraphs */
  1842 +.analysis-paragraphs {
  1843 + display: grid;
  1844 + gap: var(--spacing-md);
  1845 +}
  1846 +
  1847 +.analysis-paragraph {
  1848 + color: var(--text-secondary);
  1849 + line-height: 1.7;
  1850 +}
  1851 +
  1852 +/* =====================
  1853 + Risk Section
  1854 + ===================== */
  1855 +.risk-grid {
  1856 + display: grid;
  1857 + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  1858 + gap: var(--spacing-lg);
  1859 + margin-top: var(--spacing-md);
  1860 +}
  1861 +
  1862 +.risk-card {
  1863 + background: var(--bg-surface);
  1864 + border: 1px solid var(--border-color);
  1865 + border-radius: var(--radius-lg);
  1866 + padding: var(--spacing-lg);
  1867 + transition: all var(--transition-base);
  1868 + display: flex;
  1869 + flex-direction: column;
  1870 + height: 100%;
  1871 + position: relative;
  1872 + overflow: hidden;
  1873 +}
  1874 +
  1875 +.risk-card::before {
  1876 + content: '';
  1877 + position: absolute;
  1878 + top: 0;
  1879 + left: 0;
  1880 + width: 100%;
  1881 + height: 4px;
  1882 + background: var(--border-color);
  1883 + transition: background var(--transition-base);
  1884 +}
  1885 +
  1886 +.risk-card:hover {
  1887 + transform: translateY(-4px);
  1888 + box-shadow: var(--shadow-lg);
  1889 + border-color: var(--border-color-light);
  1890 +}
  1891 +
  1892 +/* Severity Styles */
  1893 +.risk-card.level-high {
  1894 + background: linear-gradient(180deg, rgba(239, 68, 68, 0.05) 0%, rgba(239, 68, 68, 0.01) 100%);
  1895 + border-color: rgba(239, 68, 68, 0.2);
  1896 +}
  1897 +.risk-card.level-high::before { background: var(--color-danger); }
  1898 +
  1899 +.risk-card.level-medium {
  1900 + background: linear-gradient(180deg, rgba(245, 158, 11, 0.05) 0%, rgba(245, 158, 11, 0.01) 100%);
  1901 + border-color: rgba(245, 158, 11, 0.2);
  1902 +}
  1903 +.risk-card.level-medium::before { background: var(--color-warning); }
  1904 +
  1905 +.risk-card.level-low {
  1906 + background: linear-gradient(180deg, rgba(59, 130, 246, 0.05) 0%, rgba(59, 130, 246, 0.01) 100%);
  1907 + border-color: rgba(59, 130, 246, 0.2);
  1908 +}
  1909 +.risk-card.level-low::before { background: var(--color-info); }
  1910 +
  1911 +/* Header Elements */
  1912 +.risk-card-header {
  1913 + display: flex;
  1914 + align-items: center;
  1915 + justify-content: space-between;
  1916 + margin-bottom: var(--spacing-md);
  1917 +}
  1918 +
  1919 +.risk-badge {
  1920 + font-size: 0.75rem;
  1921 + font-weight: 700;
  1922 + padding: 4px 10px;
  1923 + border-radius: var(--radius-full);
  1924 + text-transform: uppercase;
  1925 + letter-spacing: 0.05em;
  1926 + display: flex;
  1927 + align-items: center;
  1928 + gap: 4px;
  1929 +}
  1930 +
  1931 +.risk-badge::before {
  1932 + content: '';
  1933 + width: 6px;
  1934 + height: 6px;
  1935 + border-radius: 50%;
  1936 + background: currentColor;
  1937 +}
  1938 +
  1939 +.risk-badge.high {
  1940 + color: var(--color-danger);
  1941 + background: rgba(239, 68, 68, 0.1);
  1942 + border: 1px solid rgba(239, 68, 68, 0.2);
  1943 +}
  1944 +
  1945 +.risk-badge.medium {
  1946 + color: var(--color-warning);
  1947 + background: rgba(245, 158, 11, 0.1);
  1948 + border: 1px solid rgba(245, 158, 11, 0.2);
  1949 +}
  1950 +
  1951 +.risk-badge.low {
  1952 + color: var(--color-info);
  1953 + background: rgba(59, 130, 246, 0.1);
  1954 + border: 1px solid rgba(59, 130, 246, 0.2);
  1955 +}
  1956 +
  1957 +.risk-category-tag {
  1958 + font-size: 0.75rem;
  1959 + color: var(--text-secondary);
  1960 + background: var(--bg-elevated);
  1961 + padding: 4px 8px;
  1962 + border-radius: var(--radius-md);
  1963 + border: 1px solid var(--border-color);
  1964 +}
  1965 +
  1966 +.risk-card-content {
  1967 + flex: 1;
  1968 +}
  1969 +
  1970 +.risk-description {
  1971 + font-size: 0.95rem;
  1972 + color: var(--text-primary);
  1973 + line-height: 1.6;
  1974 +}
  1975 +
  1976 +/* =====================
  1977 + Analysis Section
  1978 + ===================== */
  1979 +.analysis-highlights {
  1980 + display: grid;
  1981 + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  1982 + gap: var(--spacing-md);
  1983 + margin: var(--spacing-lg) 0;
  1984 +}
  1985 +
  1986 +.analysis-highlight-card {
  1987 + background: var(--bg-elevated);
  1988 + padding: var(--spacing-md);
  1989 + border-radius: var(--radius-md);
  1990 + display: flex;
  1991 + flex-direction: column;
  1992 + gap: 4px;
  1993 +}
  1994 +
  1995 +.highlight-label {
  1996 + font-size: 0.75rem;
  1997 + color: var(--text-secondary);
  1998 +}
  1999 +
  2000 +.highlight-value {
  2001 + font-size: 1.25rem;
  2002 + font-weight: 600;
  2003 + color: var(--text-primary);
  2004 +}
  2005 +
  2006 +.analysis-paragraphs {
  2007 + display: flex;
  2008 + flex-direction: column;
  2009 + gap: var(--spacing-sm);
  2010 +}
  2011 +
  2012 +.analysis-paragraph {
  2013 + font-size: 0.95rem;
  2014 + color: var(--text-secondary);
  2015 + line-height: 1.6;
  2016 + text-align: justify;
  2017 +}
  2018 +
  2019 +/* =====================
  2020 + Table Sorting
  2021 + ===================== */
  2022 +.sortable-th {
  2023 + cursor: pointer;
  2024 + user-select: none;
  2025 + transition: background var(--transition-fast), color var(--transition-fast);
  2026 +}
  2027 +
  2028 +.sortable-th:hover {
  2029 + background: var(--bg-hover);
  2030 + color: var(--text-primary);
  2031 +}
  2032 +
  2033 +.sort-icon {
  2034 + width: 14px;
  2035 + height: 14px;
  2036 + vertical-align: middle;
  2037 + margin-left: var(--spacing-xs);
  2038 +}
  2039 +
  2040 +.sort-icon-inactive {
  2041 + opacity: 0.3;
  2042 +}
  2043 +
  2044 +.sort-icon-active {
  2045 + color: var(--color-primary);
  2046 + opacity: 1;
  2047 +}
  2048 +
  2049 +.table-header-hint {
  2050 + display: flex;
  2051 + align-items: center;
  2052 + gap: var(--spacing-xs);
  2053 + font-size: 0.75rem;
  2054 + color: var(--text-muted);
  2055 +}
ui/index.html 0 → 100644
  1 +++ a/ui/index.html
  1 +<!DOCTYPE html>
  2 +<html lang="zh-CN">
  3 +<head>
  4 + <meta charset="UTF-8">
  5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6 + <title>AI 补货建议系统</title>
  7 + <meta name="description" content="AI 驱动的智能补货建议管理系统">
  8 +
  9 + <!-- Fonts -->
  10 + <link rel="preconnect" href="https://fonts.googleapis.com">
  11 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  12 + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
  13 +
  14 + <!-- Icons -->
  15 + <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
  16 +
  17 + <!-- Markdown Parser -->
  18 + <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
  19 +
  20 + <!-- Styles -->
  21 + <link rel="stylesheet" href="/css/style.css">
  22 +</head>
  23 +<body>
  24 + <!-- 侧边栏 -->
  25 + <aside class="sidebar">
  26 + <div class="sidebar-header">
  27 + <div class="logo">
  28 + <i data-lucide="cpu" class="logo-icon"></i>
  29 + <span class="logo-text">AI 补货系统</span>
  30 + </div>
  31 + <button class="btn btn-icon btn-sm sidebar-collapse-btn" id="sidebar-collapse-btn" title="收起/展开菜单">
  32 + <i data-lucide="chevron-left"></i>
  33 + </button>
  34 + </div>
  35 +
  36 + <nav class="sidebar-nav">
  37 + <a href="#/" class="nav-item active" data-page="dashboard">
  38 + <i data-lucide="layout-dashboard"></i>
  39 + <span>概览</span>
  40 + </a>
  41 + <a href="#/tasks" class="nav-item" data-page="tasks">
  42 + <i data-lucide="list-checks"></i>
  43 + <span>任务列表</span>
  44 + </a>
  45 + </nav>
  46 +
  47 + <div class="sidebar-footer">
  48 + <div class="version-info">
  49 + <span>v1.0.0</span>
  50 + </div>
  51 + </div>
  52 + </aside>
  53 +
  54 + <!-- 侧边栏遮罩 -->
  55 + <div class="sidebar-overlay" id="sidebar-overlay"></div>
  56 +
  57 + <!-- 主内容区 -->
  58 + <main class="main-content">
  59 + <!-- 顶部导航栏 -->
  60 + <header class="top-bar">
  61 + <!-- 移动端菜单按钮 -->
  62 + <button class="btn btn-icon menu-toggle" id="menu-toggle">
  63 + <i data-lucide="menu"></i>
  64 + </button>
  65 +
  66 + <div class="breadcrumb" id="breadcrumb">
  67 + <span class="breadcrumb-item">首页</span>
  68 + </div>
  69 + <div class="top-bar-actions">
  70 + <button class="btn btn-icon" id="refresh-btn" title="刷新">
  71 + <i data-lucide="refresh-cw"></i>
  72 + </button>
  73 + </div>
  74 + </header>
  75 +
  76 + <!-- 页面内容容器 -->
  77 + <div class="page-container" id="page-container">
  78 + <!-- 动态加载的页面内容 -->
  79 + </div>
  80 + </main>
  81 +
  82 + <!-- 加载遮罩 -->
  83 + <div class="loading-overlay" id="loading-overlay">
  84 + <div class="loading-spinner">
  85 + <i data-lucide="loader-2" class="spin"></i>
  86 + <span>加载中...</span>
  87 + </div>
  88 + </div>
  89 +
  90 + <!-- Toast 通知容器 -->
  91 + <div class="toast-container" id="toast-container"></div>
  92 +
  93 + <!-- 模态框容器 -->
  94 + <div class="modal-overlay" id="modal-overlay">
  95 + <div class="modal" id="modal">
  96 + <div class="modal-header">
  97 + <h3 class="modal-title" id="modal-title"></h3>
  98 + <button class="btn btn-icon modal-close" id="modal-close">
  99 + <i data-lucide="x"></i>
  100 + </button>
  101 + </div>
  102 + <div class="modal-body" id="modal-body"></div>
  103 + </div>
  104 + </div>
  105 +
  106 + <!-- Scripts -->
  107 + <script src="/js/api.js"></script>
  108 + <script src="/js/components.js"></script>
  109 + <script src="/js/app.js"></script>
  110 +</body>
  111 +</html>
ui/js/api.js 0 → 100644
  1 +++ a/ui/js/api.js
  1 +/**
  2 + * API 调用封装
  3 + */
  4 +
  5 +const API = {
  6 + baseUrl: '/api',
  7 +
  8 + /**
  9 + * 通用请求方法
  10 + */
  11 + async request(endpoint, options = {}) {
  12 + const url = `${this.baseUrl}${endpoint}`;
  13 +
  14 + try {
  15 + const response = await fetch(url, {
  16 + ...options,
  17 + headers: {
  18 + 'Content-Type': 'application/json',
  19 + ...options.headers,
  20 + },
  21 + });
  22 +
  23 + if (!response.ok) {
  24 + const error = await response.json().catch(() => ({}));
  25 + throw new Error(error.detail || `请求失败: ${response.status}`);
  26 + }
  27 +
  28 + return await response.json();
  29 + } catch (error) {
  30 + console.error('API Error:', error);
  31 + throw error;
  32 + }
  33 + },
  34 +
  35 + /**
  36 + * GET 请求
  37 + */
  38 + async get(endpoint, params = {}) {
  39 + const queryString = new URLSearchParams(params).toString();
  40 + const url = queryString ? `${endpoint}?${queryString}` : endpoint;
  41 + return this.request(url);
  42 + },
  43 +
  44 + /**
  45 + * 获取任务列表
  46 + */
  47 + async getTasks(params = {}) {
  48 + return this.get('/tasks', params);
  49 + },
  50 +
  51 + /**
  52 + * 获取任务详情
  53 + */
  54 + async getTask(taskNo) {
  55 + return this.get(`/tasks/${taskNo}`);
  56 + },
  57 +
  58 + /**
  59 + * 获取任务配件明细
  60 + */
  61 + async getTaskDetails(taskNo, params = {}) {
  62 + return this.get(`/tasks/${taskNo}/details`, params);
  63 + },
  64 +
  65 +
  66 + /**
  67 + * 获取统计摘要
  68 + */
  69 + async getStatsSummary() {
  70 + return this.get('/stats/summary');
  71 + },
  72 +
  73 + /**
  74 + * 获取任务执行日志
  75 + */
  76 + async getTaskLogs(taskNo) {
  77 + return this.get(`/tasks/${taskNo}/logs`);
  78 + },
  79 +
  80 + /**
  81 + * 获取任务配件汇总列表
  82 + */
  83 + async getPartSummaries(taskNo, params = {}) {
  84 + return this.get(`/tasks/${taskNo}/part-summaries`, params);
  85 + },
  86 +
  87 + /**
  88 + * 获取配件的门店明细
  89 + */
  90 + async getPartShopDetails(taskNo, partCode) {
  91 + return this.get(`/tasks/${taskNo}/parts/${encodeURIComponent(partCode)}/shops`);
  92 + },
  93 +};
  94 +
  95 +// 导出到全局
  96 +window.API = API;
ui/js/app.js 0 → 100644
  1 +++ a/ui/js/app.js
  1 +/**
  2 + * 主应用逻辑
  3 + */
  4 +
  5 +const App = {
  6 + currentPage: 'dashboard',
  7 + currentTaskNo: null,
  8 +
  9 + // 配件汇总表排序状态
  10 + partSummarySort: {
  11 + sortBy: 'total_suggest_amount',
  12 + sortOrder: 'desc'
  13 + },
  14 +
  15 + // 配件汇总表筛选状态
  16 + partSummaryFilters: {
  17 + page: 1,
  18 + page_size: 50,
  19 + part_code: '',
  20 + priority: ''
  21 + },
  22 +
  23 + // 侧边栏折叠状态
  24 + isSidebarCollapsed: localStorage.getItem('sidebar-collapsed') === 'true',
  25 +
  26 + /**
  27 + * 切换侧边栏折叠状态
  28 + */
  29 + toggleSidebarCollapse() {
  30 + this.isSidebarCollapsed = !this.isSidebarCollapsed;
  31 + localStorage.setItem('sidebar-collapsed', this.isSidebarCollapsed);
  32 + this.applySidebarState();
  33 + },
  34 +
  35 + /**
  36 + * 应用侧边栏状态
  37 + */
  38 + applySidebarState() {
  39 + const sidebar = document.querySelector('.sidebar');
  40 + // 主内容区通过 CSS 选择器 .sidebar.collapsed + ... .main-content 自动调整
  41 + // 或者我们需要手动给 main-content 加类,但 CSS 中用了兄弟选择器,
  42 + // 不过兄弟选择器在这里可能不生效,因为中间隔了 .sidebar-overlay
  43 + // 让我们看看 index.html 结构: sidebar, sidebar-overlay, main-content
  44 + // CSS 选择器是 .sidebar.collapsed + .sidebar-overlay + .main-content
  45 + // 这样是可以的。
  46 +
  47 + if (this.isSidebarCollapsed) {
  48 + sidebar.classList.add('collapsed');
  49 + } else {
  50 + sidebar.classList.remove('collapsed');
  51 + }
  52 +
  53 + // 触发 icon 刷新以确保显示正确(虽然 CSS 旋转已处理)
  54 + lucide.createIcons();
  55 + },
  56 +
  57 + /**
  58 + * 切换侧边栏显示状态(移动端)
  59 + */
  60 + toggleSidebar(show) {
  61 + const sidebar = document.querySelector('.sidebar');
  62 + const overlay = document.getElementById('sidebar-overlay');
  63 +
  64 + if (show) {
  65 + sidebar.classList.add('visible');
  66 + overlay.classList.add('active');
  67 + } else {
  68 + sidebar.classList.remove('visible');
  69 + overlay.classList.remove('active');
  70 + }
  71 + },
  72 +
  73 + /**
  74 + * 应用配件筛选
  75 + */
  76 + applyPartFilters() {
  77 + const partCode = document.getElementById('filter-part-code')?.value || '';
  78 + const priorityElement = document.getElementById('filter-priority');
  79 + const priority = priorityElement ? priorityElement.value : '';
  80 +
  81 + this.partSummaryFilters.part_code = partCode;
  82 + this.partSummaryFilters.priority = priority;
  83 + this.partSummaryFilters.page = 1; // 重置到第一页
  84 +
  85 + this.loadPartSummaries();
  86 + },
  87 +
  88 + /**
  89 + * 重置配件筛选
  90 + */
  91 + resetPartFilters() {
  92 + this.partSummaryFilters.part_code = '';
  93 + this.partSummaryFilters.priority = '';
  94 + this.partSummaryFilters.page = 1;
  95 +
  96 + this.loadPartSummaries();
  97 + },
  98 +
  99 + /**
  100 + * 初始化应用
  101 + */
  102 + init() {
  103 + this.bindEvents();
  104 + this.handleRoute();
  105 + this.applySidebarState(); // 初始化侧边栏状态
  106 + window.addEventListener('hashchange', () => this.handleRoute());
  107 + lucide.createIcons();
  108 + },
  109 +
  110 + /**
  111 + * 绑定全局事件
  112 + */
  113 + bindEvents() {
  114 + // 刷新按钮
  115 + document.getElementById('refresh-btn').addEventListener('click', () => {
  116 + this.handleRoute();
  117 + });
  118 +
  119 + // 模态框关闭
  120 + document.getElementById('modal-close').addEventListener('click', () => {
  121 + Components.closeModal();
  122 + });
  123 + document.getElementById('modal-overlay').addEventListener('click', (e) => {
  124 + if (e.target.id === 'modal-overlay') {
  125 + Components.closeModal();
  126 + }
  127 + });
  128 +
  129 + // 侧边栏切换
  130 + const menuToggle = document.getElementById('menu-toggle');
  131 + const sidebarOverlay = document.getElementById('sidebar-overlay');
  132 +
  133 + if (menuToggle) {
  134 + menuToggle.addEventListener('click', () => {
  135 + this.toggleSidebar(true);
  136 + });
  137 + }
  138 +
  139 + if (sidebarOverlay) {
  140 + sidebarOverlay.addEventListener('click', () => {
  141 + this.toggleSidebar(false);
  142 + });
  143 + }
  144 +
  145 + // 桌面端侧边栏折叠按钮
  146 + const collapseBtn = document.getElementById('sidebar-collapse-btn');
  147 + if (collapseBtn) {
  148 + collapseBtn.addEventListener('click', () => {
  149 + this.toggleSidebarCollapse();
  150 + });
  151 + }
  152 +
  153 + // 导航点击自动关闭侧边栏(移动端)
  154 + document.querySelectorAll('.nav-item').forEach(item => {
  155 + item.addEventListener('click', () => {
  156 + if (window.innerWidth <= 1024) {
  157 + this.toggleSidebar(false);
  158 + }
  159 + });
  160 + });
  161 + },
  162 +
  163 + /**
  164 + * 路由处理
  165 + */
  166 + handleRoute() {
  167 + const hash = window.location.hash || '#/';
  168 + const [, path, param] = hash.match(/#\/([^/]*)(?:\/(.*))?/) || [, '', ''];
  169 +
  170 + // 更新导航状态
  171 + document.querySelectorAll('.nav-item').forEach(item => {
  172 + item.classList.remove('active');
  173 + if (item.dataset.page === (path || 'dashboard')) {
  174 + item.classList.add('active');
  175 + }
  176 + });
  177 +
  178 + // 路由分发
  179 + switch (path) {
  180 + case 'tasks':
  181 + if (param) {
  182 + this.showTaskDetail(param);
  183 + } else {
  184 + this.showTaskList();
  185 + }
  186 + break;
  187 + case '':
  188 + default:
  189 + this.showDashboard();
  190 + break;
  191 + }
  192 + },
  193 +
  194 + /**
  195 + * 更新面包屑
  196 + */
  197 + updateBreadcrumb(items) {
  198 + const breadcrumb = document.getElementById('breadcrumb');
  199 + breadcrumb.innerHTML = items.map((item, index) => {
  200 + if (item.href) {
  201 + return `<span class="breadcrumb-item"><a href="${item.href}">${item.text}</a></span>`;
  202 + }
  203 + return `<span class="breadcrumb-item">${item.text}</span>`;
  204 + }).join('');
  205 + },
  206 +
  207 + /**
  208 + * 显示概览页面
  209 + */
  210 + async showDashboard() {
  211 + this.currentPage = 'dashboard';
  212 + this.updateBreadcrumb([{ text: '概览' }]);
  213 +
  214 + const container = document.getElementById('page-container');
  215 + container.innerHTML = '<div class="stats-grid" id="stats-grid"></div><div id="recent-tasks"></div>';
  216 +
  217 + try {
  218 + // 获取统计数据
  219 + const [stats, tasksData] = await Promise.all([
  220 + API.getStatsSummary().catch(() => ({})),
  221 + API.getTasks({ page: 1, page_size: 5 }).catch(() => ({ items: [] })),
  222 + ]);
  223 +
  224 + // 渲染统计卡片
  225 + const statsGrid = document.getElementById('stats-grid');
  226 + statsGrid.innerHTML = `
  227 + ${Components.renderStatCard('list-checks', '总任务数', stats.total_tasks || 0, 'primary')}
  228 + ${Components.renderStatCard('check-circle', '成功任务', stats.success_tasks || 0, 'success')}
  229 + ${Components.renderStatCard('x-circle', '失败任务', stats.failed_tasks || 0, 'danger')}
  230 + ${Components.renderStatCard('package', '建议配件', stats.total_parts || 0, 'info')}
  231 + ${Components.renderStatCard('dollar-sign', '建议金额', Components.formatAmount(stats.total_suggest_amount), 'warning')}
  232 + `;
  233 +
  234 + // 渲染最近任务
  235 + this.renderRecentTasks(tasksData.items || []);
  236 +
  237 + lucide.createIcons();
  238 + } catch (error) {
  239 + Components.showToast('加载数据失败: ' + error.message, 'error');
  240 + }
  241 + },
  242 +
  243 + /**
  244 + * 渲染最近任务
  245 + */
  246 + renderRecentTasks(tasks) {
  247 + const container = document.getElementById('recent-tasks');
  248 +
  249 + if (!tasks.length) {
  250 + container.innerHTML = `
  251 + <div class="card">
  252 + <div class="card-header">
  253 + <h3 class="card-title">
  254 + <i data-lucide="clock"></i>
  255 + 最近任务
  256 + </h3>
  257 + </div>
  258 + ${Components.renderEmptyState('inbox', '暂无任务', '还没有执行过任何补货建议任务')}
  259 + </div>
  260 + `;
  261 + return;
  262 + }
  263 +
  264 + container.innerHTML = `
  265 + <div class="table-container">
  266 + <div class="table-header">
  267 + <h3 class="table-title">最近任务</h3>
  268 + <a href="#/tasks" class="btn btn-secondary btn-sm">
  269 + 查看全部
  270 + <i data-lucide="arrow-right"></i>
  271 + </a>
  272 + </div>
  273 + <div class="table-wrapper">
  274 + <table>
  275 + <thead>
  276 + <tr>
  277 + <th>任务编号</th>
  278 + <th>商家组合</th>
  279 + <th>状态</th>
  280 + <th>配件数</th>
  281 + <th>建议金额</th>
  282 + <th>执行时间</th>
  283 + </tr>
  284 + </thead>
  285 + <tbody>
  286 + ${tasks.map(task => `
  287 + <tr>
  288 + <td>
  289 + <a href="#/tasks/${task.task_no}" class="table-cell-link table-cell-mono">
  290 + ${task.task_no}
  291 + </a>
  292 + </td>
  293 + <td>${task.dealer_grouping_name || '-'}</td>
  294 + <td>${Components.getStatusBadge(task.status, task.status_text)}</td>
  295 + <td>${task.part_count}</td>
  296 + <td class="table-cell-amount">${Components.formatAmount(task.actual_amount)}</td>
  297 + <td class="table-cell-secondary">${Components.formatDuration(task.duration_seconds)}</td>
  298 + </tr>
  299 + `).join('')}
  300 + </tbody>
  301 + </table>
  302 + </div>
  303 + </div>
  304 + `;
  305 + },
  306 +
  307 + /**
  308 + * 显示任务列表页面
  309 + */
  310 + async showTaskList(page = 1) {
  311 + this.currentPage = 'tasks';
  312 + this.updateBreadcrumb([{ text: '任务列表' }]);
  313 +
  314 + const container = document.getElementById('page-container');
  315 + container.innerHTML = '<div id="task-list-container"></div>';
  316 +
  317 + try {
  318 + Components.showLoading();
  319 + const data = await API.getTasks({ page, page_size: 20 });
  320 + Components.hideLoading();
  321 +
  322 + this.renderTaskList(data);
  323 + lucide.createIcons();
  324 + } catch (error) {
  325 + Components.hideLoading();
  326 + Components.showToast('加载任务列表失败: ' + error.message, 'error');
  327 + }
  328 + },
  329 +
  330 + /**
  331 + * 渲染任务列表
  332 + */
  333 + renderTaskList(data) {
  334 + const container = document.getElementById('task-list-container');
  335 + const { items, total, page, page_size } = data;
  336 +
  337 + if (!items.length) {
  338 + container.innerHTML = `
  339 + <div class="card">
  340 + ${Components.renderEmptyState('inbox', '暂无任务', '还没有执行过任何补货建议任务')}
  341 + </div>
  342 + `;
  343 + return;
  344 + }
  345 +
  346 + container.innerHTML = `
  347 + <div class="table-container">
  348 + <div class="table-header">
  349 + <h3 class="table-title">任务列表 (${total})</h3>
  350 + </div>
  351 + <div class="table-wrapper">
  352 + <table>
  353 + <thead>
  354 + <tr>
  355 + <th>任务编号</th>
  356 + <th>商家组合</th>
  357 + <th>状态</th>
  358 + <th>配件数</th>
  359 + <th>建议金额</th>
  360 + <th>基准库销比</th>
  361 + <th>统计日期</th>
  362 + <th>执行时长</th>
  363 + <th>操作</th>
  364 + </tr>
  365 + </thead>
  366 + <tbody>
  367 + ${items.map(task => `
  368 + <tr>
  369 + <td>
  370 + <span class="table-cell-mono">${task.task_no}</span>
  371 + </td>
  372 + <td>${task.dealer_grouping_name || '-'}</td>
  373 + <td>${Components.getStatusBadge(task.status, task.status_text)}</td>
  374 + <td>${task.part_count}</td>
  375 + <td class="table-cell-amount">${Components.formatAmount(task.actual_amount)}</td>
  376 + <td>${Components.formatRatio(task.base_ratio)}</td>
  377 + <td>${task.statistics_date || '-'}</td>
  378 + <td class="table-cell-secondary">${Components.formatDuration(task.duration_seconds)}</td>
  379 + <td>
  380 + <a href="#/tasks/${task.task_no}" class="btn btn-ghost btn-sm">
  381 + <i data-lucide="eye"></i>
  382 + 查看
  383 + </a>
  384 + </td>
  385 + </tr>
  386 + `).join('')}
  387 + </tbody>
  388 + </table>
  389 + </div>
  390 + <div id="pagination-container"></div>
  391 + </div>
  392 + `;
  393 +
  394 + // 渲染分页
  395 + const paginationContainer = document.getElementById('pagination-container');
  396 + paginationContainer.innerHTML = Components.renderPagination(page, total, page_size);
  397 +
  398 + // 绑定分页事件
  399 + paginationContainer.querySelectorAll('.pagination-btn[data-page]').forEach(btn => {
  400 + btn.addEventListener('click', () => {
  401 + const targetPage = parseInt(btn.dataset.page);
  402 + if (targetPage && targetPage !== page) {
  403 + this.showTaskList(targetPage);
  404 + }
  405 + });
  406 + });
  407 +
  408 + lucide.createIcons();
  409 + },
  410 +
  411 + /**
  412 + * 显示任务详情页面
  413 + */
  414 + async showTaskDetail(taskNo) {
  415 + this.currentPage = 'task-detail';
  416 + this.currentTaskNo = taskNo;
  417 + this.updateBreadcrumb([
  418 + { text: '任务列表', href: '#/tasks' },
  419 + { text: taskNo },
  420 + ]);
  421 +
  422 + const container = document.getElementById('page-container');
  423 + container.innerHTML = '<div id="task-detail-container"></div>';
  424 +
  425 + try {
  426 + Components.showLoading();
  427 +
  428 + const [task, partSummaries, logs] = await Promise.all([
  429 + API.getTask(taskNo),
  430 + API.getPartSummaries(taskNo, { page: 1, page_size: 100 }).catch(() => ({ items: [], total: 0 })),
  431 + API.getTaskLogs(taskNo).catch(() => ({ items: [] })),
  432 + ]);
  433 +
  434 + Components.hideLoading();
  435 + this.renderTaskDetail(task, partSummaries, logs);
  436 + lucide.createIcons();
  437 + } catch (error) {
  438 + Components.hideLoading();
  439 + Components.showToast('加载任务详情失败: ' + error.message, 'error');
  440 + }
  441 + },
  442 +
  443 + /**
  444 + * 渲染任务详情
  445 + */
  446 + renderTaskDetail(task, partSummaries, logs) {
  447 + this._currentLogs = logs;
  448 + this._partSummaries = partSummaries;
  449 + const container = document.getElementById('task-detail-container');
  450 +
  451 + container.innerHTML = `
  452 + <!-- 返回链接 -->
  453 + <a href="#/tasks" class="back-link">
  454 + <i data-lucide="arrow-left"></i>
  455 + 返回任务列表
  456 + </a>
  457 +
  458 + <!-- 任务头部 -->
  459 + <div class="detail-header">
  460 + <div>
  461 + <h1 class="detail-title">
  462 + ${task.task_no}
  463 + ${Components.getStatusBadge(task.status, task.status_text)}
  464 + </h1>
  465 + <div class="detail-meta">
  466 + <span class="detail-meta-item">
  467 + <i data-lucide="building-2"></i>
  468 + ${task.dealer_grouping_name || '未知商家组合'}
  469 + </span>
  470 + <span class="detail-meta-item">
  471 + <i data-lucide="calendar"></i>
  472 + ${task.statistics_date || '-'}
  473 + </span>
  474 + <span class="detail-meta-item">
  475 + <i data-lucide="clock"></i>
  476 + ${Components.formatDuration(task.duration_seconds)}
  477 + </span>
  478 + </div>
  479 + </div>
  480 + </div>
  481 +
  482 + <!-- 统计卡片 -->
  483 + <div class="stats-grid">
  484 + ${Components.renderStatCard('package', '建议配件数', task.part_count, 'primary')}
  485 + ${Components.renderStatCard('dollar-sign', '建议金额', Components.formatAmount(task.actual_amount), 'success')}
  486 + ${Components.renderStatCard('percent', '基准库销比', Components.formatRatio(task.base_ratio), 'info')}
  487 + ${Components.renderStatCard('cpu', 'LLM Tokens', task.llm_total_tokens || 0, 'warning')}
  488 + </div>
  489 +
  490 + <!-- 标签页 -->
  491 + <div class="tabs" id="detail-tabs">
  492 + <button class="tab active" data-tab="details">
  493 + <i data-lucide="list"></i>
  494 + 配件明细
  495 + </button>
  496 +
  497 + <button class="tab" data-tab="logs">
  498 + <i data-lucide="activity"></i>
  499 + 执行日志
  500 + </button>
  501 + <button class="tab" data-tab="info">
  502 + <i data-lucide="info"></i>
  503 + 任务信息
  504 + </button>
  505 + </div>
  506 +
  507 + <!-- 标签页内容 -->
  508 + <div id="tab-content"></div>
  509 + `;
  510 +
  511 + // 绑定标签页事件
  512 + const tabs = container.querySelectorAll('.tab');
  513 + tabs.forEach(tab => {
  514 + tab.addEventListener('click', () => {
  515 + tabs.forEach(t => t.classList.remove('active'));
  516 + tab.classList.add('active');
  517 + this.renderTabContent(tab.dataset.tab, task, partSummaries);
  518 + });
  519 + });
  520 +
  521 + // 默认显示配件汇总
  522 + this.renderTabContent('details', task, partSummaries);
  523 + },
  524 +
  525 + /**
  526 + * 渲染标签页内容
  527 + */
  528 + renderTabContent(tabName, task, details) {
  529 + const container = document.getElementById('tab-content');
  530 +
  531 + switch (tabName) {
  532 + case 'details':
  533 + this.renderDetailsTab(container, details);
  534 + break;
  535 +
  536 + case 'logs':
  537 + this.renderLogsTab(container, this._currentLogs);
  538 + break;
  539 + case 'info':
  540 + this.renderInfoTab(container, task);
  541 + break;
  542 + }
  543 +
  544 + lucide.createIcons();
  545 + },
  546 +
  547 + /**
  548 + * 加载配件汇总数据(支持排序和筛选)
  549 + */
  550 + async loadPartSummaries() {
  551 + if (!this.currentTaskNo) return;
  552 +
  553 + try {
  554 + const params = {
  555 + page: this.partSummaryFilters.page,
  556 + page_size: this.partSummaryFilters.page_size,
  557 + sort_by: this.partSummarySort.sortBy,
  558 + sort_order: this.partSummarySort.sortOrder,
  559 + part_code: this.partSummaryFilters.part_code,
  560 + priority: this.partSummaryFilters.priority
  561 + };
  562 +
  563 + // 移除空值参数
  564 + Object.keys(params).forEach(key => {
  565 + if (params[key] === '' || params[key] === null || params[key] === undefined) {
  566 + delete params[key];
  567 + }
  568 + });
  569 +
  570 + const data = await API.getPartSummaries(this.currentTaskNo, params);
  571 + this._partSummaries = data;
  572 +
  573 + const container = document.getElementById('tab-content');
  574 + if (container) {
  575 + this.renderDetailsTab(container, data);
  576 + lucide.createIcons();
  577 + }
  578 + } catch (error) {
  579 + Components.showToast('加载配件数据失败: ' + error.message, 'error');
  580 + }
  581 + },
  582 +
  583 + /**
  584 + * 切换配件汇总排序
  585 + */
  586 + togglePartSummarySort(field) {
  587 + if (this.partSummarySort.sortBy === field) {
  588 + this.partSummarySort.sortOrder = this.partSummarySort.sortOrder === 'desc' ? 'asc' : 'desc';
  589 + } else {
  590 + this.partSummarySort.sortBy = field;
  591 + this.partSummarySort.sortOrder = 'desc';
  592 + }
  593 + this.loadPartSummaries();
  594 + },
  595 +
  596 + /**
  597 + * 获取排序图标
  598 + */
  599 + getSortIcon(field) {
  600 + if (this.partSummarySort.sortBy !== field) {
  601 + return '<i data-lucide="arrow-up-down" class="sort-icon sort-icon-inactive"></i>';
  602 + }
  603 + if (this.partSummarySort.sortOrder === 'desc') {
  604 + return '<i data-lucide="arrow-down" class="sort-icon sort-icon-active"></i>';
  605 + }
  606 + return '<i data-lucide="arrow-up" class="sort-icon sort-icon-active"></i>';
  607 + },
  608 +
  609 + /**
  610 + * 渲染配件明细标签页
  611 + */
  612 + renderDetailsTab(container, partSummaries) {
  613 + const items = partSummaries.items || [];
  614 + const { total, page, page_size } = partSummaries;
  615 +
  616 + container.innerHTML = `
  617 + <div class="table-container">
  618 + <div class="table-header" style="flex-wrap: wrap; gap: 1rem; height: auto;">
  619 + <div style="display: flex; align-items: center; gap: 1rem; flex: 1;">
  620 + <h3 class="table-title">配件补货建议 (商家组合维度) - ${total}个配件</h3>
  621 + <div class="table-header-hint">
  622 + <i data-lucide="info" style="width:14px;height:14px;"></i>
  623 + <span>点击表头可排序</span>
  624 + </div>
  625 + </div>
  626 +
  627 + <div class="filter-toolbar" style="display: flex; gap: 0.5rem; align-items: center;">
  628 + <input type="text"
  629 + id="filter-part-code"
  630 + class="input input-sm"
  631 + placeholder="搜索配件编码..."
  632 + value="${this.partSummaryFilters.part_code || ''}"
  633 + style="width: 150px;"
  634 + >
  635 + <select id="filter-priority" class="input input-sm" style="width: 120px;">
  636 + <option value="">所有优先级</option>
  637 + <option value="1" ${this.partSummaryFilters.priority == 1 ? 'selected' : ''}>急需补货</option>
  638 + <option value="2" ${this.partSummaryFilters.priority == 2 ? 'selected' : ''}>建议补货</option>
  639 + <option value="3" ${this.partSummaryFilters.priority == 3 ? 'selected' : ''}>可选补货</option>
  640 + <option value="0" ${this.partSummaryFilters.priority === '0' || this.partSummaryFilters.priority === 0 ? 'selected' : ''}>无需补货</option>
  641 + </select>
  642 + <button class="btn btn-secondary btn-sm" onclick="App.applyPartFilters()">
  643 + <i data-lucide="search" style="width: 14px; height: 14px; margin-right: 4px;"></i>
  644 + 查询
  645 + </button>
  646 + <button class="btn btn-ghost btn-sm" onclick="App.resetPartFilters()">
  647 + 重置
  648 + </button>
  649 + </div>
  650 + </div>
  651 + <div class="table-wrapper">
  652 + <table>
  653 + <thead>
  654 + <tr>
  655 + <th style="width: 40px;"></th>
  656 + <th class="sortable-th" onclick="App.togglePartSummarySort('part_code')">
  657 + 配件编码 ${this.getSortIcon('part_code')}
  658 + </th>
  659 + <th>配件名称</th>
  660 + <th class="sortable-th" onclick="App.togglePartSummarySort('cost_price')">
  661 + 成本价 ${this.getSortIcon('cost_price')}
  662 + </th>
  663 + <th class="sortable-th" onclick="App.togglePartSummarySort('total_storage_cnt')">
  664 + 总库存 ${this.getSortIcon('total_storage_cnt')}
  665 + </th>
  666 + <th class="sortable-th" onclick="App.togglePartSummarySort('total_avg_sales_cnt')">
  667 + 总销量 ${this.getSortIcon('total_avg_sales_cnt')}
  668 + </th>
  669 + <th class="sortable-th" onclick="App.togglePartSummarySort('group_current_ratio')">
  670 + 商家组合库销比 ${this.getSortIcon('group_current_ratio')}
  671 + </th>
  672 + <th class="sortable-th" onclick="App.togglePartSummarySort('group_post_plan_ratio')">
  673 + 计划后库销比 ${this.getSortIcon('group_post_plan_ratio')}
  674 + </th>
  675 + <th class="sortable-th" onclick="App.togglePartSummarySort('shop_count')">
  676 + 门店数 ${this.getSortIcon('shop_count')}
  677 + </th>
  678 + <th class="sortable-th" onclick="App.togglePartSummarySort('need_replenishment_shop_count')">
  679 + 需补货门店 ${this.getSortIcon('need_replenishment_shop_count')}
  680 + </th>
  681 + <th class="sortable-th" onclick="App.togglePartSummarySort('total_suggest_cnt')">
  682 + 总建议数量 ${this.getSortIcon('total_suggest_cnt')}
  683 + </th>
  684 + <th class="sortable-th" onclick="App.togglePartSummarySort('total_suggest_amount')">
  685 + 总建议金额 ${this.getSortIcon('total_suggest_amount')}
  686 + </th>
  687 + </tr>
  688 + </thead>
  689 + <tbody>
  690 + ${items.length > 0 ? items.map((item, index) => `
  691 + <tr class="part-summary-row" data-part-code="${item.part_code}" data-index="${index}">
  692 + <td>
  693 + <button class="btn btn-ghost btn-sm expand-btn" onclick="App.togglePartShops('${item.part_code}', ${index})">
  694 + <i data-lucide="chevron-right" class="expand-icon"></i>
  695 + </button>
  696 + </td>
  697 + <td class="table-cell-mono">${item.part_code}</td>
  698 + <td>${item.part_name || '-'}</td>
  699 + <td>${Components.formatAmount(item.cost_price)}</td>
  700 + <td>${Components.formatNumber(item.total_storage_cnt)}</td>
  701 + <td>${Components.formatNumber(item.total_avg_sales_cnt)}</td>
  702 + <td>${Components.getRatioIndicator(item.group_current_ratio, 1.1)}</td>
  703 + <td>${Components.formatRatio(item.group_post_plan_ratio)}</td>
  704 + <td>${item.shop_count}</td>
  705 + <td><strong style="color: var(--color-warning);">${item.need_replenishment_shop_count}</strong></td>
  706 + <td><strong>${item.total_suggest_cnt}</strong></td>
  707 + <td class="table-cell-amount">${Components.formatAmount(item.total_suggest_amount)}</td>
  708 + </tr>
  709 + <tr class="part-shops-row" id="shops-${index}" style="display: none;">
  710 + <td colspan="12" style="padding: 0;">
  711 + <div class="shops-container" id="shops-container-${index}">
  712 + <div class="loading-shops">加载中...</div>
  713 + </div>
  714 + </td>
  715 + </tr>
  716 + `).join('') : `
  717 + <tr>
  718 + <td colspan="12" style="text-align: center; padding: 2rem; color: var(--text-muted);">
  719 + 暂无符合条件的配件建议
  720 + </td>
  721 + </tr>
  722 + `}
  723 + </tbody>
  724 + </table>
  725 + </div>
  726 + <div id="part-summary-pagination"></div>
  727 + </div>
  728 +
  729 + <style>
  730 + .part-summary-row { cursor: pointer; }
  731 + .part-summary-row:hover { background: var(--bg-hover); }
  732 + .expand-icon { transition: transform 0.2s; }
  733 + .expand-icon.expanded { transform: rotate(90deg); }
  734 + .shops-container {
  735 + background: var(--bg-elevated);
  736 + padding: var(--spacing-md);
  737 + border-left: 3px solid var(--color-primary);
  738 + margin-left: var(--spacing-lg);
  739 + }
  740 + .loading-shops {
  741 + color: var(--text-muted);
  742 + padding: var(--spacing-sm);
  743 + }
  744 + .shop-items-table {
  745 + width: 100%;
  746 + font-size: 0.875rem;
  747 + }
  748 + .shop-items-table th {
  749 + background: var(--bg-subtle);
  750 + font-weight: 600;
  751 + padding: var(--spacing-xs) var(--spacing-sm);
  752 + }
  753 + .shop-items-table td {
  754 + padding: var(--spacing-xs) var(--spacing-sm);
  755 + }
  756 + .part-decision-reason {
  757 + margin-bottom: var(--spacing-sm);
  758 + padding: var(--spacing-sm);
  759 + background: var(--bg-subtle);
  760 + border-radius: var(--radius-sm);
  761 + font-size: 0.875rem;
  762 + color: var(--text-secondary);
  763 + }
  764 + </style>
  765 + `;
  766 +
  767 + // 渲染分页
  768 + const paginationContainer = document.getElementById('part-summary-pagination');
  769 + if (paginationContainer) {
  770 + paginationContainer.innerHTML = Components.renderPagination(page, total, page_size);
  771 +
  772 + // 绑定分页事件
  773 + paginationContainer.querySelectorAll('.pagination-btn[data-page]').forEach(btn => {
  774 + btn.addEventListener('click', () => {
  775 + const targetPage = parseInt(btn.dataset.page);
  776 + if (targetPage && targetPage !== page) {
  777 + this.partSummaryFilters.page = targetPage;
  778 + this.loadPartSummaries();
  779 + }
  780 + });
  781 + });
  782 + }
  783 +
  784 + // 绑定搜索框回车事件
  785 + const partCodeInput = document.getElementById('filter-part-code');
  786 + if (partCodeInput) {
  787 + partCodeInput.addEventListener('keypress', (e) => {
  788 + if (e.key === 'Enter') {
  789 + App.applyPartFilters();
  790 + }
  791 + });
  792 + }
  793 + },
  794 +
  795 + /**
  796 + * 切换配件门店展开/收起
  797 + */
  798 + async togglePartShops(partCode, index) {
  799 + const row = document.getElementById(`shops-${index}`);
  800 + const container = document.getElementById(`shops-container-${index}`);
  801 + const btn = document.querySelector(`tr[data-index="${index}"] .expand-icon`);
  802 +
  803 + if (row.style.display === 'none') {
  804 + row.style.display = 'table-row';
  805 + btn.classList.add('expanded');
  806 +
  807 + try {
  808 + const data = await API.getPartShopDetails(this.currentTaskNo, partCode);
  809 + const partSummary = this._partSummaries.items.find(p => p.part_code === partCode);
  810 + this.renderPartShops(container, data.items, partSummary);
  811 + lucide.createIcons();
  812 + } catch (error) {
  813 + container.innerHTML = `<div class="error-text">加载失败: ${error.message}</div>`;
  814 + }
  815 + } else {
  816 + row.style.display = 'none';
  817 + btn.classList.remove('expanded');
  818 + }
  819 + },
  820 +
  821 + /**
  822 + * 渲染配件门店明细
  823 + */
  824 + renderPartShops(container, shops, partSummary) {
  825 + if (!shops || shops.length === 0) {
  826 + container.innerHTML = '<div class="text-muted">无门店建议数据</div>';
  827 + return;
  828 + }
  829 +
  830 + container.innerHTML = `
  831 + ${partSummary && partSummary.part_decision_reason ? `
  832 + <div class="part-decision-reason">
  833 + <strong><i data-lucide="message-square" style="width:14px;height:14px;"></i> 配件补货理由:</strong>
  834 + ${partSummary.part_decision_reason}
  835 + </div>
  836 + ` : ''}
  837 + <table class="shop-items-table">
  838 + <thead>
  839 + <tr>
  840 + <th>库房</th>
  841 + <th>有效库存</th>
  842 + <th>月均销量</th>
  843 + <th>当前库销比</th>
  844 + <th>计划后库销比</th>
  845 + <th>建议数量</th>
  846 + <th>建议金额</th>
  847 + <th>建议理由</th>
  848 + </tr>
  849 + </thead>
  850 + <tbody>
  851 + ${shops.map(shop => `
  852 + <tr>
  853 + <td>${shop.shop_name || '-'}</td>
  854 + <td>${Components.formatNumber(shop.valid_storage_cnt)}</td>
  855 + <td>${Components.formatNumber(shop.avg_sales_cnt)}</td>
  856 + <td>${Components.getRatioIndicator(shop.current_ratio, shop.base_ratio)}</td>
  857 + <td>${Components.formatRatio(shop.post_plan_ratio)}</td>
  858 + <td><strong>${shop.suggest_cnt}</strong></td>
  859 + <td class="table-cell-amount">${Components.formatAmount(shop.suggest_amount)}</td>
  860 + <td style="min-width: 200px;">
  861 + ${shop.suggestion_reason || '-'}
  862 + </td>
  863 + </tr>
  864 + `).join('')}
  865 + </tbody>
  866 + </table>
  867 + `;
  868 + },
  869 +
  870 + /**
  871 + * 渲染执行日志标签页
  872 + */
  873 + renderLogsTab(container, logs) {
  874 + if (!logs || !logs.items || logs.items.length === 0) {
  875 + container.innerHTML = `
  876 + <div class="card">
  877 + ${Components.renderEmptyState('activity', '暂无执行日志', '该任务没有执行日志记录')}
  878 + </div>
  879 + `;
  880 + return;
  881 + }
  882 +
  883 + const items = logs.items;
  884 + const totalTokens = items.reduce((sum, item) => sum + (item.llm_tokens || 0), 0);
  885 + const totalTime = items.reduce((sum, item) => sum + (item.execution_time_ms || 0), 0);
  886 +
  887 + container.innerHTML = `
  888 + <div class="card">
  889 + <div class="card-header">
  890 + <h3 class="card-title">
  891 + <i data-lucide="activity"></i>
  892 + 执行日志时间线
  893 + </h3>
  894 + <div class="card-actions">
  895 + <span class="text-muted">总耗时: ${Components.formatDuration(totalTime / 1000)} | 总Tokens: ${totalTokens}</span>
  896 + </div>
  897 + </div>
  898 + <div class="timeline">
  899 + ${items.map((log, index) => `
  900 + <div class="timeline-item ${log.status === 2 ? 'timeline-item-error' : 'timeline-item-success'}">
  901 + <div class="timeline-marker">
  902 + <div class="timeline-icon">
  903 + ${log.status === 1 ? '<i data-lucide="check-circle"></i>' :
  904 + log.status === 2 ? '<i data-lucide="x-circle"></i>' :
  905 + '<i data-lucide="loader"></i>'}
  906 + </div>
  907 + ${index < items.length - 1 ? '<div class="timeline-line"></div>' : ''}
  908 + </div>
  909 + <div class="timeline-content">
  910 + <div class="timeline-header">
  911 + <span class="timeline-title">${Components.getStepNameDisplay(log.step_name)}</span>
  912 + ${Components.getLogStatusBadge(log.status)}
  913 + </div>
  914 + <div class="timeline-meta">
  915 + <span class="meta-item">
  916 + <i data-lucide="clock"></i>
  917 + ${log.execution_time_ms ? Components.formatDuration(log.execution_time_ms / 1000) : '-'}
  918 + </span>
  919 + ${log.llm_tokens > 0 ? `
  920 + <span class="meta-item">
  921 + <i data-lucide="cpu"></i>
  922 + ${log.llm_tokens} tokens
  923 + </span>
  924 + ` : ''}
  925 + ${log.retry_count > 0 ? `
  926 + <span class="meta-item meta-warning">
  927 + <i data-lucide="refresh-cw"></i>
  928 + 重试 ${log.retry_count} 次
  929 + </span>
  930 + ` : ''}
  931 + </div>
  932 + ${log.error_message ? `
  933 + <div class="timeline-error">
  934 + <i data-lucide="alert-triangle"></i>
  935 + ${log.error_message}
  936 + </div>
  937 + ` : ''}
  938 + </div>
  939 + </div>
  940 + `).join('')}
  941 + </div>
  942 + </div>
  943 + `;
  944 + },
  945 +
  946 + /**
  947 + * 渲染任务信息标签页
  948 + */
  949 + renderInfoTab(container, task) {
  950 + container.innerHTML = `
  951 + <div class="detail-grid">
  952 + <div class="card">
  953 + <div class="card-header">
  954 + <h3 class="card-title">
  955 + <i data-lucide="info"></i>
  956 + 基本信息
  957 + </h3>
  958 + </div>
  959 + <div class="info-list">
  960 + ${Components.renderInfoItem('任务编号', task.task_no)}
  961 + ${Components.renderInfoItem('集团ID', task.group_id)}
  962 + ${Components.renderInfoItem('商家组合ID', task.dealer_grouping_id)}
  963 + ${Components.renderInfoItem('商家组合名称', task.dealer_grouping_name)}
  964 + ${Components.renderInfoItem('品牌组合ID', task.brand_grouping_id)}
  965 + ${Components.renderInfoItem('统计日期', task.statistics_date)}
  966 + </div>
  967 + </div>
  968 +
  969 + <div class="card">
  970 + <div class="card-header">
  971 + <h3 class="card-title">
  972 + <i data-lucide="activity"></i>
  973 + 执行信息
  974 + </h3>
  975 + </div>
  976 + <div class="info-list">
  977 + ${Components.renderInfoItem('状态', Components.getStatusBadge(task.status, task.status_text))}
  978 + ${Components.renderInfoItem('开始时间', task.start_time)}
  979 + ${Components.renderInfoItem('结束时间', task.end_time)}
  980 + ${Components.renderInfoItem('执行时长', Components.formatDuration(task.duration_seconds))}
  981 + ${Components.renderInfoItem('创建时间', task.create_time)}
  982 + </div>
  983 + </div>
  984 +
  985 + <div class="card">
  986 + <div class="card-header">
  987 + <h3 class="card-title">
  988 + <i data-lucide="cpu"></i>
  989 + LLM 信息
  990 + </h3>
  991 + </div>
  992 + <div class="info-list">
  993 + ${Components.renderInfoItem('LLM 提供商', task.llm_provider || '-')}
  994 + ${Components.renderInfoItem('模型名称', task.llm_model || '-')}
  995 + ${Components.renderInfoItem('Token 消耗', task.llm_total_tokens)}
  996 + </div>
  997 + </div>
  998 +
  999 + ${task.error_message ? `
  1000 + <div class="card">
  1001 + <div class="card-header">
  1002 + <h3 class="card-title" style="color: var(--color-danger)">
  1003 + <i data-lucide="alert-triangle"></i>
  1004 + 错误信息
  1005 + </h3>
  1006 + </div>
  1007 + <pre style="background: var(--bg-elevated); padding: var(--spacing-md); border-radius: var(--radius-md); overflow-x: auto; color: var(--color-danger-light);">${task.error_message}</pre>
  1008 + </div>
  1009 + ` : ''}
  1010 + </div>
  1011 + `;
  1012 + },
  1013 +};
  1014 +
  1015 +// DOM 加载完成后初始化
  1016 +document.addEventListener('DOMContentLoaded', () => {
  1017 + App.init();
  1018 +});
  1019 +
  1020 +// 导出到全局
  1021 +window.App = App;
ui/js/components.js 0 → 100644
  1 +++ a/ui/js/components.js
  1 +/**
  2 + * UI 组件库
  3 + */
  4 +
  5 +const Components = {
  6 + /**
  7 + * 格式化金额
  8 + */
  9 + formatAmount(value) {
  10 + if (value === null || value === undefined) return '-';
  11 + return `¥${Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
  12 + },
  13 +
  14 + /**
  15 + * 格式化数字
  16 + */
  17 + formatNumber(value, decimals = 2) {
  18 + if (value === null || value === undefined) return '-';
  19 + return Number(value).toLocaleString('zh-CN', {
  20 + minimumFractionDigits: decimals,
  21 + maximumFractionDigits: decimals
  22 + });
  23 + },
  24 +
  25 + /**
  26 + * 格式化库销比
  27 + */
  28 + formatRatio(value) {
  29 + if (value === null || value === undefined || value === 999) return '-';
  30 + if (value === 0) return '0.00';
  31 + return Number(value).toFixed(2);
  32 + },
  33 +
  34 + /**
  35 + * 获取配件标签
  36 + * 呆滞:有库存,滚动90天没有销量
  37 + * 低频:无库存,滚动90天有出库,月均销量<1
  38 + * 缺货:无库存,滚动90天有出库,月均销量≥1
  39 + */
  40 + getPartTag(validStorage, avgSales) {
  41 + const storage = Number(validStorage) || 0;
  42 + const sales = Number(avgSales) || 0;
  43 +
  44 + if (storage > 0 && sales === 0) {
  45 + return { type: 'stagnant', text: '呆滞', class: 'tag-stagnant' };
  46 + }
  47 + if (storage <= 0 && sales > 0 && sales < 1) {
  48 + return { type: 'low-freq', text: '低频', class: 'tag-low-freq' };
  49 + }
  50 + if (storage <= 0 && sales >= 1) {
  51 + return { type: 'shortage', text: '缺货', class: 'tag-shortage' };
  52 + }
  53 + return null;
  54 + },
  55 +
  56 + /**
  57 + * 渲染配件标签HTML
  58 + */
  59 + renderPartTag(validStorage, avgSales) {
  60 + const tag = this.getPartTag(validStorage, avgSales);
  61 + if (!tag) return '';
  62 + return `<span class="part-tag ${tag.class}">${tag.text}</span>`;
  63 + },
  64 +
  65 + /**
  66 + * 获取步骤名称显示
  67 + */
  68 + getStepNameDisplay(stepName) {
  69 + const stepMap = {
  70 + 'fetch_part_ratio': '获取配件数据',
  71 + 'sql_agent': 'AI分析建议',
  72 + 'allocate_budget': '分配预算',
  73 + 'generate_report': '生成报告',
  74 + };
  75 + return stepMap[stepName] || stepName;
  76 + },
  77 +
  78 + /**
  79 + * 获取日志状态徽章
  80 + */
  81 + getLogStatusBadge(status) {
  82 + const statusMap = {
  83 + 0: { class: 'badge-info', text: '运行中' },
  84 + 1: { class: 'badge-success', text: '成功' },
  85 + 2: { class: 'badge-danger', text: '失败' },
  86 + 3: { class: 'badge-warning', text: '跳过' },
  87 + };
  88 + const config = statusMap[status] || { class: 'badge-neutral', text: '未知' };
  89 + return `<span class="badge ${config.class}"><span class="badge-dot"></span>${config.text}</span>`;
  90 + },
  91 +
  92 + /**
  93 + * 格式化时长
  94 + */
  95 + formatDuration(seconds) {
  96 + if (!seconds) return '-';
  97 + seconds = Math.floor(seconds);
  98 + if (seconds < 60) return `${seconds}秒`;
  99 + const minutes = Math.floor(seconds / 60);
  100 + const secs = seconds % 60;
  101 + return `${minutes}分${secs}秒`;
  102 + },
  103 +
  104 + /**
  105 + * 获取状态徽章 HTML
  106 + */
  107 + getStatusBadge(status, statusText) {
  108 + const statusMap = {
  109 + 0: { class: 'badge-info', icon: 'loader-2', text: statusText || '运行中' },
  110 + 1: { class: 'badge-success', icon: 'check-circle', text: statusText || '成功' },
  111 + 2: { class: 'badge-danger', icon: 'x-circle', text: statusText || '失败' },
  112 + };
  113 + const config = statusMap[status] || statusMap[0];
  114 + return `
  115 + <span class="badge ${config.class}">
  116 + <span class="badge-dot"></span>
  117 + ${config.text}
  118 + </span>
  119 + `;
  120 + },
  121 +
  122 + /**
  123 + * 获取优先级徽章 HTML
  124 + */
  125 + getPriorityBadge(priority) {
  126 + const priorityMap = {
  127 + 1: { class: 'priority-high', text: '高' },
  128 + 2: { class: 'priority-medium', text: '中' },
  129 + 3: { class: 'priority-low', text: '低' },
  130 + };
  131 + const config = priorityMap[priority] || priorityMap[2];
  132 + return `<span class="priority-badge ${config.class}">${config.text}</span>`;
  133 + },
  134 +
  135 + /**
  136 + * 获取库销比指示器 HTML
  137 + */
  138 + getRatioIndicator(current, base) {
  139 + if (current === null || current === undefined || current === 999) {
  140 + return '<span class="text-muted">-</span>';
  141 + }
  142 +
  143 + const percentage = Math.min((current / (base || 1.5)) * 100, 100);
  144 + let className = 'ratio-normal';
  145 + if (current < 0.5) className = 'ratio-low';
  146 + else if (current > 2) className = 'ratio-high';
  147 +
  148 + return `
  149 + <div class="ratio-indicator ${className}">
  150 + <span>${this.formatRatio(current)}</span>
  151 + <div class="ratio-bar">
  152 + <div class="ratio-bar-fill" style="width: ${percentage}%"></div>
  153 + </div>
  154 + </div>
  155 + `;
  156 + },
  157 +
  158 + /**
  159 + * 渲染分页控件
  160 + */
  161 + renderPagination(current, total, pageSize, onChange) {
  162 + const totalPages = Math.ceil(total / pageSize);
  163 + const start = (current - 1) * pageSize + 1;
  164 + const end = Math.min(current * pageSize, total);
  165 +
  166 + return `
  167 + <div class="pagination">
  168 + <div class="pagination-info">
  169 + 显示 ${start}-${end} 条,共 ${total} 条
  170 + </div>
  171 + <div class="pagination-controls">
  172 + <button class="pagination-btn" ${current <= 1 ? 'disabled' : ''} data-page="${current - 1}">
  173 + <i data-lucide="chevron-left"></i>
  174 + </button>
  175 + ${this.getPaginationNumbers(current, totalPages)}
  176 + <button class="pagination-btn" ${current >= totalPages ? 'disabled' : ''} data-page="${current + 1}">
  177 + <i data-lucide="chevron-right"></i>
  178 + </button>
  179 + </div>
  180 + </div>
  181 + `;
  182 + },
  183 +
  184 + /**
  185 + * 获取分页数字
  186 + */
  187 + getPaginationNumbers(current, total) {
  188 + const pages = [];
  189 + const maxVisible = 5;
  190 +
  191 + let start = Math.max(1, current - Math.floor(maxVisible / 2));
  192 + let end = Math.min(total, start + maxVisible - 1);
  193 + start = Math.max(1, end - maxVisible + 1);
  194 +
  195 + for (let i = start; i <= end; i++) {
  196 + pages.push(`
  197 + <button class="pagination-btn ${i === current ? 'active' : ''}" data-page="${i}">
  198 + ${i}
  199 + </button>
  200 + `);
  201 + }
  202 +
  203 + return pages.join('');
  204 + },
  205 +
  206 + /**
  207 + * 渲染统计卡片
  208 + */
  209 + renderStatCard(icon, label, value, iconClass = 'primary', change = null) {
  210 + let changeHtml = '';
  211 + if (change !== null) {
  212 + const changeClass = change >= 0 ? 'positive' : 'negative';
  213 + const changeIcon = change >= 0 ? 'trending-up' : 'trending-down';
  214 + changeHtml = `
  215 + <div class="stat-change ${changeClass}">
  216 + <i data-lucide="${changeIcon}"></i>
  217 + ${Math.abs(change)}%
  218 + </div>
  219 + `;
  220 + }
  221 +
  222 + return `
  223 + <div class="stat-card">
  224 + <div class="stat-icon ${iconClass}">
  225 + <i data-lucide="${icon}"></i>
  226 + </div>
  227 + <div class="stat-content">
  228 + <div class="stat-label">${label}</div>
  229 + <div class="stat-value">${value}</div>
  230 + ${changeHtml}
  231 + </div>
  232 + </div>
  233 + `;
  234 + },
  235 +
  236 + /**
  237 + * 渲染空状态
  238 + */
  239 + renderEmptyState(icon = 'inbox', title = '暂无数据', description = '') {
  240 + return `
  241 + <div class="empty-state">
  242 + <i data-lucide="${icon}"></i>
  243 + <div class="empty-state-title">${title}</div>
  244 + ${description ? `<div class="empty-state-description">${description}</div>` : ''}
  245 + </div>
  246 + `;
  247 + },
  248 +
  249 + /**
  250 + * 渲染信息列表项
  251 + */
  252 + renderInfoItem(label, value) {
  253 + return `
  254 + <div class="info-item">
  255 + <span class="info-label">${label}</span>
  256 + <span class="info-value">${value || '-'}</span>
  257 + </div>
  258 + `;
  259 + },
  260 +
  261 + /**
  262 + * 渲染 Markdown 内容
  263 + */
  264 + renderMarkdown(content) {
  265 + if (!content) return '';
  266 + if (typeof marked !== 'undefined') {
  267 + let html = marked.parse(content);
  268 + html = html.replace(/<pre>\s*<code[^>]*>[\s\\n]*<\/code>\s*<\/pre>/gi, '');
  269 + html = html.replace(/<pre>[\s\\n]*<\/pre>/gi, '');
  270 + html = html.replace(/<pre>\s*<code[^>]*>\n<\/code>\s*<\/pre>/gi, '');
  271 + return `<div class="markdown-content">${html}</div>`;
  272 + }
  273 + return `<div class="markdown-content"><pre>${content}</pre></div>`;
  274 + },
  275 +
  276 + /**
  277 + * 渲染结构化报告 Section
  278 + */
  279 + renderReportSection(section) {
  280 + if (!section) return '';
  281 +
  282 + const iconMap = {
  283 + 'executive_summary': { icon: 'file-text', class: 'summary' },
  284 + 'inventory_analysis': { icon: 'bar-chart-2', class: 'analysis' },
  285 + 'risk_assessment': { icon: 'alert-triangle', class: 'risk' },
  286 + 'purchase_recommendations': { icon: 'shopping-cart', class: 'recommendation' },
  287 + 'optimization_suggestions': { icon: 'lightbulb', class: 'optimization' },
  288 + };
  289 +
  290 + const config = iconMap[section.type] || { icon: 'file', class: 'summary' };
  291 +
  292 + let contentHtml = '';
  293 +
  294 + switch (section.type) {
  295 + case 'executive_summary':
  296 + contentHtml = this.renderSummarySection(section);
  297 + break;
  298 + case 'inventory_analysis':
  299 + contentHtml = this.renderAnalysisSection(section);
  300 + break;
  301 + case 'risk_assessment':
  302 + contentHtml = this.renderRiskSection(section);
  303 + break;
  304 + case 'purchase_recommendations':
  305 + contentHtml = this.renderRecommendationSection(section);
  306 + break;
  307 + case 'optimization_suggestions':
  308 + contentHtml = this.renderSuggestionSection(section);
  309 + break;
  310 + default:
  311 + contentHtml = `<div class="summary-text">${JSON.stringify(section)}</div>`;
  312 + }
  313 +
  314 + return `
  315 + <div class="card">
  316 + <div class="report-section-header">
  317 + <div class="report-section-icon ${config.class}">
  318 + <i data-lucide="${config.icon}"></i>
  319 + </div>
  320 + <div class="report-section-title">${section.title || ''}</div>
  321 + </div>
  322 + <div class="report-section-content">
  323 + ${contentHtml}
  324 + </div>
  325 + </div>
  326 + `;
  327 + },
  328 +
  329 + /**
  330 + * 渲染执行摘要
  331 + */
  332 + renderSummarySection(section) {
  333 + const items = section.items || [];
  334 + const text = section.text || '';
  335 +
  336 + const itemsHtml = items.length > 0 ? `
  337 + <div class="summary-items">
  338 + ${items.map(item => `
  339 + <div class="summary-item status-${item.status || 'normal'}">
  340 + <div class="summary-item-label">${item.label || ''}</div>
  341 + <div class="summary-item-value">${item.value || ''}</div>
  342 + </div>
  343 + `).join('')}
  344 + </div>
  345 + ` : '';
  346 +
  347 + const textHtml = text ? `<div class="summary-text">${text}</div>` : '';
  348 +
  349 + return itemsHtml + textHtml;
  350 + },
  351 +
  352 + /**
  353 + * 渲染库存分析
  354 + */
  355 + renderAnalysisSection(section) {
  356 + const paragraphs = section.paragraphs || [];
  357 + const highlights = section.highlights || [];
  358 +
  359 + const highlightsHtml = highlights.length > 0 ? `
  360 + <div class="analysis-highlights">
  361 + ${highlights.map(h => `
  362 + <div class="analysis-highlight-card">
  363 + <span class="highlight-label">${h.label || ''}</span>
  364 + <span class="highlight-value">${h.value || ''}</span>
  365 + </div>
  366 + `).join('')}
  367 + </div>
  368 + ` : '';
  369 +
  370 + const paragraphsHtml = paragraphs.length > 0 ? `
  371 + <div class="analysis-paragraphs">
  372 + ${paragraphs.map(p => `
  373 + <div class="analysis-paragraph">
  374 + ${p}
  375 + </div>
  376 + `).join('')}
  377 + </div>
  378 + ` : '';
  379 +
  380 + // Put highlights first for better visibility
  381 + return highlightsHtml + paragraphsHtml;
  382 + },
  383 +
  384 + /**
  385 + * 渲染风险评估
  386 + */
  387 + renderRiskSection(section) {
  388 + const risks = section.risks || [];
  389 +
  390 + if (risks.length === 0) {
  391 + return '<div class="summary-text">暂无风险评估</div>';
  392 + }
  393 +
  394 + const levelText = { high: '高', medium: '中', low: '低' };
  395 +
  396 + // 按风险等级排序:high > medium > low
  397 + const sortOrder = { high: 0, medium: 1, low: 2 };
  398 + const sortedRisks = [...risks].sort((a, b) => {
  399 + const orderA = sortOrder[a.level] || 99;
  400 + const orderB = sortOrder[b.level] || 99;
  401 + return orderA - orderB;
  402 + });
  403 +
  404 + return `
  405 + <div class="risk-grid">
  406 + ${sortedRisks.map(risk => `
  407 + <div class="risk-card level-${risk.level || 'medium'}">
  408 + <div class="risk-card-header">
  409 + <span class="risk-badge ${risk.level || 'medium'}">${levelText[risk.level] || '中'}风险</span>
  410 + <span class="risk-category-tag">${risk.category || '通用'}</span>
  411 + </div>
  412 + <div class="risk-card-content">
  413 + <div class="risk-description">${risk.description || ''}</div>
  414 + </div>
  415 + </div>
  416 + `).join('')}
  417 + </div>
  418 + `;
  419 + },
  420 +
  421 + /**
  422 + * 渲染采购建议
  423 + */
  424 + renderRecommendationSection(section) {
  425 + const recommendations = section.recommendations || [];
  426 +
  427 + if (recommendations.length === 0) {
  428 + return '<div class="summary-text">暂无采购建议</div>';
  429 + }
  430 +
  431 + return `
  432 + <div class="recommendation-list">
  433 + ${recommendations.map(rec => `
  434 + <div class="recommendation-item">
  435 + <div class="recommendation-priority priority-${rec.priority || 3}">${rec.priority || 3}</div>
  436 + <div class="recommendation-content">
  437 + <div class="recommendation-action">${rec.action || ''}</div>
  438 + <div class="recommendation-reason">${rec.reason || ''}</div>
  439 + </div>
  440 + </div>
  441 + `).join('')}
  442 + </div>
  443 + `;
  444 + },
  445 +
  446 + /**
  447 + * 渲染优化建议
  448 + */
  449 + renderSuggestionSection(section) {
  450 + const suggestions = section.suggestions || [];
  451 +
  452 + if (suggestions.length === 0) {
  453 + return '<div class="summary-text">暂无优化建议</div>';
  454 + }
  455 +
  456 + return `
  457 + <div class="suggestion-list">
  458 + ${suggestions.map(s => `
  459 + <div class="suggestion-item">
  460 + <div class="suggestion-icon"><i data-lucide="check-circle"></i></div>
  461 + <div class="suggestion-text">${s}</div>
  462 + </div>
  463 + `).join('')}
  464 + </div>
  465 + `;
  466 + },
  467 +
  468 + /**
  469 + * 显示 Toast 通知
  470 + */
  471 + showToast(message, type = 'info') {
  472 + const container = document.getElementById('toast-container');
  473 + const icons = {
  474 + success: 'check-circle',
  475 + error: 'x-circle',
  476 + warning: 'alert-triangle',
  477 + info: 'info',
  478 + };
  479 +
  480 + const toast = document.createElement('div');
  481 + toast.className = `toast ${type}`;
  482 + toast.innerHTML = `
  483 + <i data-lucide="${icons[type]}" class="toast-icon"></i>
  484 + <span class="toast-message">${message}</span>
  485 + `;
  486 +
  487 + container.appendChild(toast);
  488 + lucide.createIcons({ icons: { [icons[type]]: lucide.icons[icons[type]] }, attrs: {} });
  489 +
  490 + setTimeout(() => {
  491 + toast.style.animation = 'slideIn 0.25s ease reverse';
  492 + setTimeout(() => toast.remove(), 250);
  493 + }, 3000);
  494 + },
  495 +
  496 + /**
  497 + * 显示加载遮罩
  498 + */
  499 + showLoading() {
  500 + document.getElementById('loading-overlay').classList.add('active');
  501 + },
  502 +
  503 + /**
  504 + * 隐藏加载遮罩
  505 + */
  506 + hideLoading() {
  507 + document.getElementById('loading-overlay').classList.remove('active');
  508 + },
  509 +
  510 + /**
  511 + * 显示模态框
  512 + */
  513 + showModal(title, content) {
  514 + document.getElementById('modal-title').textContent = title;
  515 + document.getElementById('modal-body').innerHTML = content;
  516 + document.getElementById('modal-overlay').classList.add('active');
  517 + lucide.createIcons();
  518 + },
  519 +
  520 + /**
  521 + * 关闭模态框
  522 + */
  523 + closeModal() {
  524 + document.getElementById('modal-overlay').classList.remove('active');
  525 + },
  526 +};
  527 +
  528 +// 导出到全局
  529 +window.Components = Components;