Commit 25a8b07f6d8eb2c73251e4862df9503833215e8e

Authored by 朱焱飞
1 parent f52f1031

feat: 重构分析报告为库存总览、销售分析、库存健康和补货总结四个主要板块。

Too many changes to show.

To preserve performance only 20 of 23 files are displayed.

.kiro/specs/refactor-analysis-report/design.md 0 → 100644
  1 +# 设计文档:重构分析报告功能
  2 +
  3 +## 概述
  4 +
  5 +重构 AI 补货建议系统的分析报告功能,将现有的四模块宏观决策报告(整体态势研判/风险预警/采购策略/效果预期)替换为四大数据驱动板块(库存总体概览/销量分析/库存构成健康度/补货建议生成情况)。每个板块包含精确的统计数据和 LLM 生成的分析文本。
  6 +
  7 +核心设计变更:
  8 +- 从"LLM 主导分析"转变为"数据统计 + LLM 辅助分析"模式
  9 +- 使用 LangGraph 动态节点并发生成四个板块的 LLM 分析
  10 +- 前端新增 Chart.js 图表支持库存健康度可视化
  11 +- 数据库表结构完全重建,四个 JSON 字段分别存储各板块数据
  12 +
  13 +## 架构
  14 +
  15 +### 整体数据流
  16 +
  17 +```mermaid
  18 +graph TD
  19 + A[allocate_budget 节点完成] --> B[generate_analysis_report 节点]
  20 + B --> C[统计计算阶段]
  21 + C --> C1[库存概览统计]
  22 + C --> C2[销量分析统计]
  23 + C --> C3[健康度统计]
  24 + C --> C4[补货建议统计]
  25 + C1 & C2 & C3 & C4 --> D[LangGraph 并发 LLM 分析]
  26 + D --> D1[库存概览 LLM 节点]
  27 + D --> D2[销量分析 LLM 节点]
  28 + D --> D3[健康度 LLM 节点]
  29 + D --> D4[补货建议 LLM 节点]
  30 + D1 & D2 & D3 & D4 --> E[汇总报告]
  31 + E --> F[Result_Writer 写入数据库]
  32 + F --> G[API 返回前端]
  33 + G --> H[Report_UI 渲染]
  34 +```
  35 +
  36 +### LangGraph 并发子图设计
  37 +
  38 +在现有工作流 `allocate_budget → generate_analysis_report → END` 中,`generate_analysis_report` 节点内部使用一个 LangGraph 子图实现并发:
  39 +
  40 +```mermaid
  41 +graph TD
  42 + START[统计计算完成] --> FORK{并发分发}
  43 + FORK --> N1[inventory_overview_llm]
  44 + FORK --> N2[sales_analysis_llm]
  45 + FORK --> N3[inventory_health_llm]
  46 + FORK --> N4[replenishment_summary_llm]
  47 + N1 & N2 & N3 & N4 --> JOIN[汇总合并]
  48 + JOIN --> END_NODE[返回完整报告]
  49 +```
  50 +
  51 +实现方式:使用 `langgraph.graph.StateGraph` 构建子图,通过 `add_node` 添加四个并发 LLM 节点,使用 fan-out/fan-in 模式(从 START 分发到四个节点,四个节点汇聚到 END)。每个节点独立调用 LLM,失败不影响其他节点。
  52 +
  53 +## 组件与接口
  54 +
  55 +### 1. 统计计算模块 (analysis_report_node.py)
  56 +
  57 +#### `calculate_inventory_overview(part_ratios: list[dict]) -> dict`
  58 +计算库存总体概览统计数据。有效库存使用 `valid_storage_cnt`(三项公式:in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt)。
  59 +
  60 +输入:PartRatio 字典列表
  61 +输出:
  62 +```python
  63 +{
  64 + "total_valid_storage_cnt": Decimal, # 有效库存总数量(五项之和)
  65 + "total_valid_storage_amount": Decimal, # 有效库存总金额(资金占用)
  66 + "total_in_stock_unlocked_cnt": Decimal, # 在库未锁总数量
  67 + "total_in_stock_unlocked_amount": Decimal,
  68 + "total_on_the_way_cnt": Decimal, # 在途总数量
  69 + "total_on_the_way_amount": Decimal,
  70 + "total_has_plan_cnt": Decimal, # 计划数总数量
  71 + "total_has_plan_amount": Decimal,
  72 + "total_transfer_cnt": Decimal, # 主动调拨在途总数量
  73 + "total_transfer_amount": Decimal,
  74 + "total_gen_transfer_cnt": Decimal, # 自动调拨在途总数量
  75 + "total_gen_transfer_amount": Decimal,
  76 + "total_avg_sales_cnt": Decimal, # 月均销量总数量
  77 + "overall_ratio": Decimal, # 整体库销比
  78 + "part_count": int, # 配件总种类数
  79 +}
  80 +```
  81 +
  82 +#### `calculate_sales_analysis(part_ratios: list[dict]) -> dict`
  83 +计算销量分析统计数据。
  84 +
  85 +输入:PartRatio 字典列表
  86 +输出:
  87 +```python
  88 +{
  89 + "total_avg_sales_cnt": Decimal, # 月均销量总数量
  90 + "total_avg_sales_amount": Decimal, # 月均销量总金额
  91 + "total_out_stock_cnt": Decimal, # 90天出库数总量
  92 + "total_storage_locked_cnt": Decimal, # 未关单已锁总量
  93 + "total_out_stock_ongoing_cnt": Decimal, # 未关单出库总量
  94 + "total_buy_cnt": int, # 订件总量
  95 + "has_sales_part_count": int, # 有销量配件数
  96 + "no_sales_part_count": int, # 无销量配件数
  97 +}
  98 +```
  99 +
  100 +#### `calculate_inventory_health(part_ratios: list[dict]) -> dict`
  101 +计算库存构成健康度统计数据。
  102 +
  103 +输入:PartRatio 字典列表
  104 +输出:
  105 +```python
  106 +{
  107 + "shortage": {"count": int, "amount": Decimal, "count_pct": float, "amount_pct": float},
  108 + "stagnant": {"count": int, "amount": Decimal, "count_pct": float, "amount_pct": float},
  109 + "low_freq": {"count": int, "amount": Decimal, "count_pct": float, "amount_pct": float},
  110 + "normal": {"count": int, "amount": Decimal, "count_pct": float, "amount_pct": float},
  111 + "total_count": int,
  112 + "total_amount": Decimal,
  113 +}
  114 +```
  115 +
  116 +#### `calculate_replenishment_summary(part_results: list) -> dict`
  117 +计算补货建议生成情况统计数据。
  118 +
  119 +输入:part_results 列表(配件汇总结果)
  120 +输出:
  121 +```python
  122 +{
  123 + "urgent": {"count": int, "amount": Decimal}, # 急需补货 (priority=1)
  124 + "suggested": {"count": int, "amount": Decimal}, # 建议补货 (priority=2)
  125 + "optional": {"count": int, "amount": Decimal}, # 可选补货 (priority=3)
  126 + "total_count": int,
  127 + "total_amount": Decimal,
  128 +}
  129 +```
  130 +
  131 +### 2. LLM 分析节点
  132 +
  133 +四个独立的 LLM 分析函数,每个接收对应板块的统计数据,调用 LLM 生成分析文本:
  134 +
  135 +- `llm_analyze_inventory_overview(stats: dict) -> str` — 返回 JSON 字符串
  136 +- `llm_analyze_sales(stats: dict) -> str` — 返回 JSON 字符串
  137 +- `llm_analyze_inventory_health(stats: dict) -> str` — 返回 JSON 字符串
  138 +- `llm_analyze_replenishment_summary(stats: dict) -> str` — 返回 JSON 字符串
  139 +
  140 +每个函数加载对应的提示词模板(统一放在 `prompts/analysis_report.md` 中,按板块分段),填充统计数据,调用 LLM,解析 JSON 响应。
  141 +
  142 +### 3. 提示词文件
  143 +
  144 +拆分为四个独立提示词文件,每个板块一个,确保分析专业且有实际决策价值:
  145 +
  146 +#### prompts/report_inventory_overview.md — 库存概览分析
  147 +
  148 +角色:资深汽车配件库存管理专家。输入统计数据后,要求 LLM 分析:
  149 +- **资金占用评估**:总库存金额是否合理,各构成部分(在库未锁/在途/计划数/主动调拨在途/自动调拨在途)的资金分配比例是否健康
  150 +- **库销比诊断**:当前整体库销比处于什么水平(<1 库存不足,1-2 合理,2-3 偏高,>3 严重积压),对比行业经验给出判断
  151 +- **库存结构建议**:基于五项构成的比例,给出具体的库存结构优化方向
  152 +
  153 +输出 JSON 格式:
  154 +```json
  155 +{
  156 + "capital_assessment": {
  157 + "total_evaluation": "总资金占用评估",
  158 + "structure_ratio": "各构成部分的资金比例分析",
  159 + "risk_level": "high/medium/low"
  160 + },
  161 + "ratio_diagnosis": {
  162 + "level": "不足/合理/偏高/严重积压",
  163 + "analysis": "库销比具体分析",
  164 + "benchmark": "行业参考值对比"
  165 + },
  166 + "recommendations": ["具体建议1", "具体建议2"]
  167 +}
  168 +```
  169 +
  170 +#### prompts/report_sales_analysis.md — 销量分析
  171 +
  172 +角色:汽车配件销售数据分析师。输入统计数据后,要求 LLM 分析:
  173 +- **销量构成解读**:90天出库数占比最大说明正常销售为主,未关单已锁/出库占比高说明有大量待处理订单,订件占比高说明客户预订需求旺盛
  174 +- **销售活跃度**:有销量 vs 无销量配件的比例反映 SKU 活跃度,无销量占比过高说明 SKU 管理需要优化
  175 +- **需求趋势判断**:基于各组成部分的比例关系,判断当前需求是稳定、上升还是下降趋势
  176 +
  177 +输出 JSON 格式:
  178 +```json
  179 +{
  180 + "composition_analysis": {
  181 + "main_driver": "主要销量来源分析",
  182 + "pending_orders_impact": "未关单对销量的影响",
  183 + "booking_trend": "订件趋势分析"
  184 + },
  185 + "activity_assessment": {
  186 + "active_ratio": "活跃SKU占比评估",
  187 + "optimization_suggestion": "SKU优化建议"
  188 + },
  189 + "demand_trend": {
  190 + "direction": "上升/稳定/下降",
  191 + "evidence": "判断依据",
  192 + "forecast": "短期需求预测"
  193 + }
  194 +}
  195 +```
  196 +
  197 +#### prompts/report_inventory_health.md — 库存健康度分析
  198 +
  199 +角色:汽车配件库存健康度诊断专家。输入统计数据后,要求 LLM 分析:
  200 +- **健康度评分**:基于正常件占比给出整体健康度评分(正常件>70%为健康,50-70%为亚健康,<50%为不健康)
  201 +- **问题诊断**:呆滞件占比高说明采购决策需要优化,缺货件占比高说明补货不及时,低频件占比高说明 SKU 精简空间大
  202 +- **资金释放机会**:呆滞件和低频件占用的资金可以通过促销、退货等方式释放,给出具体金额估算
  203 +- **改善优先级**:按影响程度排序,给出最应优先处理的问题类型
  204 +
  205 +输出 JSON 格式:
  206 +```json
  207 +{
  208 + "health_score": {
  209 + "score": "健康/亚健康/不健康",
  210 + "normal_ratio_evaluation": "正常件占比评估"
  211 + },
  212 + "problem_diagnosis": {
  213 + "stagnant_analysis": "呆滞件问题分析及原因",
  214 + "shortage_analysis": "缺货件问题分析及影响",
  215 + "low_freq_analysis": "低频件问题分析及建议"
  216 + },
  217 + "capital_release": {
  218 + "stagnant_releasable": "呆滞件可释放资金估算",
  219 + "low_freq_releasable": "低频件可释放资金估算",
  220 + "action_plan": "资金释放行动方案"
  221 + },
  222 + "priority_actions": ["最优先处理事项1", "最优先处理事项2"]
  223 +}
  224 +```
  225 +
  226 +#### prompts/report_replenishment_summary.md — 补货建议分析
  227 +
  228 +角色:汽车配件采购策略顾问。输入统计数据后,要求 LLM 分析:
  229 +- **紧迫度评估**:急需补货占比反映当前缺货风险程度,急需占比>30%说明库存管理存在较大问题
  230 +- **资金分配建议**:基于各优先级的金额分布,给出资金分配的先后顺序和比例建议
  231 +- **执行节奏建议**:急需补货应立即执行,建议补货可在1-2周内完成,可选补货可根据资金情况灵活安排
  232 +- **风险提示**:如果可选补货金额占比过高,提示可能存在过度补货风险
  233 +
  234 +输出 JSON 格式:
  235 +```json
  236 +{
  237 + "urgency_assessment": {
  238 + "urgent_ratio_evaluation": "急需补货占比评估",
  239 + "risk_level": "high/medium/low",
  240 + "immediate_action_needed": true/false
  241 + },
  242 + "budget_allocation": {
  243 + "recommended_order": "建议资金分配顺序",
  244 + "urgent_budget": "急需补货建议预算",
  245 + "suggested_budget": "建议补货建议预算",
  246 + "optional_budget": "可选补货建议预算"
  247 + },
  248 + "execution_plan": {
  249 + "urgent_timeline": "急需补货执行时间建议",
  250 + "suggested_timeline": "建议补货执行时间建议",
  251 + "optional_timeline": "可选补货执行时间建议"
  252 + },
  253 + "risk_warnings": ["风险提示1", "风险提示2"]
  254 +}
  255 +```
  256 +
  257 +### 4. API 接口 (tasks.py)
  258 +
  259 +更新 `GET /api/tasks/{task_no}/analysis-report` 端点:
  260 +
  261 +响应模型 `AnalysisReportResponse`:
  262 +```python
  263 +class AnalysisReportResponse(BaseModel):
  264 + id: int
  265 + task_no: str
  266 + group_id: int
  267 + dealer_grouping_id: int
  268 + dealer_grouping_name: Optional[str]
  269 + report_type: str
  270 +
  271 + inventory_overview: Optional[Dict[str, Any]] # 库存概览(统计+分析)
  272 + sales_analysis: Optional[Dict[str, Any]] # 销量分析(统计+分析)
  273 + inventory_health: Optional[Dict[str, Any]] # 健康度(统计+分析+图表数据)
  274 + replenishment_summary: Optional[Dict[str, Any]] # 补货建议(统计+分析)
  275 +
  276 + llm_provider: Optional[str]
  277 + llm_model: Optional[str]
  278 + llm_tokens: int
  279 + execution_time_ms: int
  280 + statistics_date: Optional[str]
  281 + create_time: Optional[str]
  282 +```
  283 +
  284 +### 5. 前端渲染 (app.js)
  285 +
  286 +`renderReportTab()` 重写,渲染四个板块:
  287 +
  288 +1. **库存概览板块**: 统计卡片(总数量、总金额、库销比)+ 五项构成明细表(在库未锁/在途/计划数/主动调拨在途/自动调拨在途)+ LLM 分析文本
  289 +2. **销量分析板块**: 统计卡片(月均销量、总金额)+ 构成明细表 + LLM 分析文本
  290 +3. **健康度板块**: 统计卡片 + Chart.js 环形图(数量占比 + 金额占比)+ LLM 分析文本
  291 +4. **补货建议板块**: 优先级统计表 + LLM 分析文本
  292 +
  293 +### 6. Chart.js 集成
  294 +
  295 +在 `ui/index.html` 中通过 CDN 引入 Chart.js:
  296 +```html
  297 +<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
  298 +```
  299 +
  300 +健康度板块使用两个环形图(Doughnut Chart):
  301 +- 数量占比图:缺货/呆滞/低频/正常 四类的数量百分比
  302 +- 金额占比图:缺货/呆滞/低频/正常 四类的金额百分比
  303 +
  304 +## 数据模型
  305 +
  306 +### 数据库表 (ai_analysis_report)
  307 +
  308 +```sql
  309 +DROP TABLE IF EXISTS ai_analysis_report;
  310 +CREATE TABLE ai_analysis_report (
  311 + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
  312 + task_no VARCHAR(32) NOT NULL COMMENT '任务编号',
  313 + group_id BIGINT NOT NULL COMMENT '集团ID',
  314 + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
  315 + dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称',
  316 + brand_grouping_id BIGINT COMMENT '品牌组合ID',
  317 + report_type VARCHAR(32) DEFAULT 'replenishment' COMMENT '报告类型',
  318 +
  319 + -- 四大板块 (JSON 结构化存储,每个字段包含 stats + llm_analysis)
  320 + inventory_overview JSON COMMENT '库存总体概览(统计数据+LLM分析)',
  321 + sales_analysis JSON COMMENT '销量分析(统计数据+LLM分析)',
  322 + inventory_health JSON COMMENT '库存构成健康度(统计数据+图表数据+LLM分析)',
  323 + replenishment_summary JSON COMMENT '补货建议生成情况(统计数据+LLM分析)',
  324 +
  325 + -- LLM 元数据
  326 + llm_provider VARCHAR(32) COMMENT 'LLM提供商',
  327 + llm_model VARCHAR(64) COMMENT 'LLM模型名称',
  328 + llm_tokens INT DEFAULT 0 COMMENT 'LLM Token消耗',
  329 + execution_time_ms INT DEFAULT 0 COMMENT '执行耗时(毫秒)',
  330 +
  331 + statistics_date VARCHAR(16) COMMENT '统计日期',
  332 + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  333 +
  334 + INDEX idx_task_no (task_no),
  335 + INDEX idx_group_date (group_id, statistics_date),
  336 + INDEX idx_dealer_grouping (dealer_grouping_id)
  337 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议分析报告表-重构版';
  338 +```
  339 +
  340 +### Python 数据模型 (AnalysisReport)
  341 +
  342 +```python
  343 +@dataclass
  344 +class AnalysisReport:
  345 + task_no: str
  346 + group_id: int
  347 + dealer_grouping_id: int
  348 +
  349 + id: Optional[int] = None
  350 + dealer_grouping_name: Optional[str] = None
  351 + brand_grouping_id: Optional[int] = None
  352 + report_type: str = "replenishment"
  353 +
  354 + # 四大板块
  355 + inventory_overview: Optional[Dict[str, Any]] = None
  356 + sales_analysis: Optional[Dict[str, Any]] = None
  357 + inventory_health: Optional[Dict[str, Any]] = None
  358 + replenishment_summary: Optional[Dict[str, Any]] = None
  359 +
  360 + # LLM 元数据
  361 + llm_provider: str = ""
  362 + llm_model: str = ""
  363 + llm_tokens: int = 0
  364 + execution_time_ms: int = 0
  365 +
  366 + statistics_date: str = ""
  367 + create_time: Optional[datetime] = None
  368 +```
  369 +
  370 +### 每个板块的 JSON 数据结构
  371 +
  372 +每个板块的 JSON 包含 `stats`(统计数据)和 `llm_analysis`(LLM 分析文本)两部分:
  373 +
  374 +```python
  375 +# inventory_overview 示例
  376 +{
  377 + "stats": {
  378 + "total_valid_storage_cnt": 12500,
  379 + "total_valid_storage_amount": 3250000.00,
  380 + "total_in_stock_unlocked_cnt": 8000,
  381 + "total_in_stock_unlocked_amount": 2080000.00,
  382 + "total_on_the_way_cnt": 2500,
  383 + "total_on_the_way_amount": 650000.00,
  384 + "total_has_plan_cnt": 1000,
  385 + "total_has_plan_amount": 260000.00,
  386 + "total_transfer_cnt": 600,
  387 + "total_transfer_amount": 156000.00,
  388 + "total_gen_transfer_cnt": 400,
  389 + "total_gen_transfer_amount": 104000.00,
  390 + "total_avg_sales_cnt": 5000,
  391 + "overall_ratio": 2.5,
  392 + "part_count": 800
  393 + },
  394 + "llm_analysis": { ... } # LLM 返回的 JSON 分析对象
  395 +}
  396 +
  397 +# inventory_health 示例(额外包含 chart_data)
  398 +{
  399 + "stats": {
  400 + "shortage": {"count": 50, "amount": 125000, "count_pct": 6.25, "amount_pct": 3.85},
  401 + "stagnant": {"count": 120, "amount": 480000, "count_pct": 15.0, "amount_pct": 14.77},
  402 + "low_freq": {"count": 200, "amount": 300000, "count_pct": 25.0, "amount_pct": 9.23},
  403 + "normal": {"count": 430, "amount": 2345000, "count_pct": 53.75, "amount_pct": 72.15},
  404 + "total_count": 800,
  405 + "total_amount": 3250000
  406 + },
  407 + "chart_data": {
  408 + "labels": ["缺货件", "呆滞件", "低频件", "正常件"],
  409 + "count_values": [50, 120, 200, 430],
  410 + "amount_values": [125000, 480000, 300000, 2345000]
  411 + },
  412 + "llm_analysis": { ... }
  413 +}
  414 +```
  415 +
  416 +
  417 +## 正确性属性
  418 +
  419 +*正确性属性是系统在所有合法执行中都应保持为真的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规范与机器可验证正确性保证之间的桥梁。*
  420 +
  421 +### Property 1: 库存概览统计一致性
  422 +
  423 +*对于任意* PartRatio 字典列表,`calculate_inventory_overview` 的输出应满足:
  424 +- `total_valid_storage_cnt` = 所有配件 `(in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt)` 之和
  425 +- `total_valid_storage_amount` = 所有配件 `(in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt) × cost_price` 之和
  426 +- `total_in_stock_unlocked_cnt + total_on_the_way_cnt + total_has_plan_cnt` = `total_valid_storage_cnt`(构成不变量)
  427 +- 当 `total_avg_sales_cnt > 0` 时,`overall_ratio` = `total_valid_storage_cnt / total_avg_sales_cnt`
  428 +
  429 +**Validates: Requirements 2.1, 2.2, 2.3**
  430 +
  431 +### Property 2: 销量分析统计一致性
  432 +
  433 +*对于任意* PartRatio 字典列表,`calculate_sales_analysis` 的输出应满足:
  434 +- `total_avg_sales_cnt` = 所有配件 `(out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3` 之和
  435 +- `(total_out_stock_cnt + total_storage_locked_cnt + total_out_stock_ongoing_cnt + total_buy_cnt) / 3` = `total_avg_sales_cnt`(构成不变量)
  436 +- `has_sales_part_count + no_sales_part_count` = 总配件数
  437 +
  438 +**Validates: Requirements 3.1, 3.2, 3.4**
  439 +
  440 +### Property 3: 健康度分类完备性与一致性
  441 +
  442 +*对于任意* PartRatio 字典列表,`calculate_inventory_health` 的输出应满足:
  443 +- `shortage.count + stagnant.count + low_freq.count + normal.count` = `total_count`(分类完备)
  444 +- `shortage.amount + stagnant.amount + low_freq.amount + normal.amount` = `total_amount`(金额守恒)
  445 +- 每种类型的 `count_pct` = `count / total_count × 100`
  446 +- 所有 `count_pct` 之和 ≈ 100.0(浮点精度容差内)
  447 +
  448 +**Validates: Requirements 4.1, 4.2**
  449 +
  450 +### Property 4: 补货建议统计一致性
  451 +
  452 +*对于任意* part_results 列表,`calculate_replenishment_summary` 的输出应满足:
  453 +- `urgent.count + suggested.count + optional.count` = `total_count`
  454 +- `urgent.amount + suggested.amount + optional.amount` = `total_amount`
  455 +
  456 +**Validates: Requirements 5.1, 5.2**
  457 +
  458 +### Property 5: 报告数据模型序列化 round-trip
  459 +
  460 +*对于任意* 合法的 AnalysisReport 对象,调用 `to_dict()` 后再用返回的字典构造新的 AnalysisReport 对象,两个对象的核心字段应相等。
  461 +
  462 +**Validates: Requirements 10.2, 10.3**
  463 +
  464 +## 错误处理
  465 +
  466 +| 错误场景 | 处理方式 |
  467 +|---------|---------|
  468 +| LLM 单板块调用失败 | 该板块 `llm_analysis` 置为 `{"error": "错误信息"}`,其他板块正常生成 |
  469 +| LLM 返回非法 JSON | 记录原始响应到日志,`llm_analysis` 置为 `{"error": "JSON解析失败", "raw": "原始文本前200字符"}` |
  470 +| PartRatio 列表为空 | 所有统计值为 0,LLM 分析说明无数据 |
  471 +| part_results 列表为空 | 补货建议板块统计值为 0,LLM 分析说明无补货建议 |
  472 +| 数据库写入失败 | 记录错误日志,不中断主工作流,返回包含错误信息的报告 |
  473 +| 提示词文件缺失 | 抛出 FileNotFoundError,由上层节点捕获并记录 |
  474 +
  475 +## 测试策略
  476 +
  477 +### 属性测试 (Property-Based Testing)
  478 +
  479 +使用 `hypothesis` 库进行属性测试,每个属性至少运行 100 次迭代。
  480 +
  481 +- **Property 1**: 生成随机 PartRatio 字典列表(随机数量、随机字段值),验证库存概览统计的不变量
  482 + - Tag: **Feature: refactor-analysis-report, Property 1: 库存概览统计一致性**
  483 +- **Property 2**: 生成随机 PartRatio 字典列表,验证销量分析统计的不变量
  484 + - Tag: **Feature: refactor-analysis-report, Property 2: 销量分析统计一致性**
  485 +- **Property 3**: 生成随机 PartRatio 字典列表,验证健康度分类的完备性和一致性
  486 + - Tag: **Feature: refactor-analysis-report, Property 3: 健康度分类完备性与一致性**
  487 +- **Property 4**: 生成随机 part_results 列表(随机优先级和金额),验证补货建议统计的一致性
  488 + - Tag: **Feature: refactor-analysis-report, Property 4: 补货建议统计一致性**
  489 +- **Property 5**: 生成随机 AnalysisReport 对象,验证 to_dict round-trip
  490 + - Tag: **Feature: refactor-analysis-report, Property 5: 报告数据模型序列化 round-trip**
  491 +
  492 +### 单元测试
  493 +
  494 +- 边界情况:空列表输入、月均销量为零、所有配件同一类型
  495 +- LLM 响应解析:合法 JSON、非法 JSON、空响应
  496 +- API 端点:有报告数据、无报告数据
  497 +
  498 +### 集成测试
  499 +
  500 +- 完整报告生成流程(mock LLM)
  501 +- 数据库写入和读取一致性
  502 +- 前端渲染(手动验证)
.kiro/specs/refactor-analysis-report/requirements.md 0 → 100644
  1 +# 需求文档:重构分析报告功能
  2 +
  3 +## 简介
  4 +
  5 +重构 AI 补货建议系统的分析报告功能。先清理现有分析报告相关代码(仅限分析报告模块,不涉及其他模块),再基于新结构重建。新报告涵盖库存总体概览、销量分析、库存构成健康度、补货建议生成情况四大板块。每个板块包含统计数据和 LLM 生成的分析文本。考虑使用 LangGraph 动态节点并发生成各板块统计及分析,最后汇总。涉及全栈改动:数据库表结构、数据模型、Agent 节点、LLM 提示词、API 接口、前端页面。
  6 +
  7 +## 术语表
  8 +
  9 +- **Report_Generator**: 分析报告生成节点(analysis_report_node),负责统计计算和调用 LLM 生成报告
  10 +- **Report_API**: 分析报告 API 接口,负责从数据库读取报告并返回给前端
  11 +- **Report_UI**: 前端报告渲染模块,负责展示报告数据和图表
  12 +- **PartRatio**: 配件库销比数据,包含库存、销量、成本等原始数据
  13 +- **有效库存**: valid_storage_cnt = in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt(在库未锁 + 在途 + 计划数)
  14 +- **月均销量**: (90天出库数 + 未关单已锁 + 未关单出库 + 订件) / 3
  15 +- **库销比**: 有效库存(valid_storage_cnt) / 月均销量
  16 +- **呆滞件**: 有效库存(valid_storage_cnt) > 0 且 90天出库数 = 0 的配件
  17 +- **低频件**: 月均销量 < 1 或 出库次数 < 3 或 出库间隔 >= 30天 的配件
  18 +- **缺货件**: 有效库存(valid_storage_cnt) = 0 且 月均销量 >= 1 的配件
  19 +- **正常件**: 不属于呆滞件、低频件、缺货件的配件
  20 +- **急需补货**: 优先级 1,库销比 < 0.5 且月均销量 >= 1
  21 +- **建议补货**: 优先级 2,库销比 0.5-1.0 且月均销量 >= 1
  22 +- **可选补货**: 优先级 3,库销比 1.0-目标值 且月均销量 >= 1
  23 +- **Result_Writer**: 数据库写入服务,负责将报告持久化到 MySQL
  24 +
  25 +## 需求
  26 +
  27 +### 需求 1:清理现有分析报告代码
  28 +
  29 +**用户故事:** 作为开发者,我希望先清理现有分析报告相关代码,以便在干净的基础上重构新报告功能。
  30 +
  31 +#### 验收标准
  32 +
  33 +1. WHEN 开始重构, THE 开发者 SHALL 清理 analysis_report_node.py 中的现有报告生成逻辑
  34 +2. WHEN 开始重构, THE 开发者 SHALL 清理 analysis_report.py 中的现有数据模型
  35 +3. WHEN 开始重构, THE 开发者 SHALL 清理 prompts/analysis_report.md 中的现有提示词
  36 +4. WHEN 开始重构, THE 开发者 SHALL 清理前端 app.js 中的现有报告渲染代码(renderReportTab、renderOverallAssessment、renderRiskAlerts、renderStrategy、renderExpectedImpact)
  37 +5. WHEN 清理代码, THE 开发者 SHALL 仅清理分析报告相关代码,保持其他模块代码不变
  38 +
  39 +### 需求 2:库存总体概览统计
  40 +
  41 +**用户故事:** 作为采购决策者,我希望看到库存总体概览(总数量、总金额/资金占用、库销比及库存构成明细),以便快速了解当前库存状况和资金占用情况。
  42 +
  43 +#### 验收标准
  44 +
  45 +1. WHEN Report_Generator 接收到 PartRatio 列表, THE Report_Generator SHALL 使用 valid_storage_cnt(in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt)计算所有配件的有效库存总数量和总金额(有效库存 × 成本价之和)
  46 +2. WHEN Report_Generator 计算库存构成, THE Report_Generator SHALL 分别统计在库未锁(in_stock_unlocked_cnt)、在途(on_the_way_cnt)、计划数(has_plan_cnt)的总数量/总金额
  47 +3. WHEN Report_Generator 计算库销比, THE Report_Generator SHALL 使用有效库存总数量除以月均销量总数量得到整体库销比
  48 +4. WHEN 库存概览统计完成, THE Report_Generator SHALL 将统计数据作为上下文传递给 LLM 生成库存概览分析文本
  49 +5. IF 月均销量总数量为零, THEN THE Report_Generator SHALL 将库销比标记为特殊值并在分析中说明无销量数据
  50 +
  51 +### 需求 3:销量分析统计
  52 +
  53 +**用户故事:** 作为采购决策者,我希望看到销量分析(月均销量及各组成部分明细),以便了解销售趋势和需求分布。
  54 +
  55 +#### 验收标准
  56 +
  57 +1. WHEN Report_Generator 接收到 PartRatio 列表, THE Report_Generator SHALL 计算所有配件的月均销量总数量和总金额(月均销量 × 成本价之和)
  58 +2. WHEN Report_Generator 计算销量构成, THE Report_Generator SHALL 分别统计90天出库数(out_stock_cnt)总量、未关单已锁(storage_locked_cnt)总量、未关单出库(out_stock_ongoing_cnt)总量、订件(buy_cnt)总量
  59 +3. WHEN 销量统计完成, THE Report_Generator SHALL 将统计数据作为上下文传递给 LLM 生成销量分析文本
  60 +4. WHEN Report_Generator 计算销量分布, THE Report_Generator SHALL 统计有销量配件数(月均销量 > 0)和无销量配件数(月均销量 = 0)
  61 +
  62 +### 需求 4:库存构成健康度统计
  63 +
  64 +**用户故事:** 作为采购决策者,我希望看到库存健康度分析(缺货/呆滞/低频/正常各类型的数量和金额占比),以便识别库存结构问题。
  65 +
  66 +#### 验收标准
  67 +
  68 +1. WHEN Report_Generator 分类配件, THE Report_Generator SHALL 将每个配件归类为缺货件、呆滞件、低频件或正常件中的一种
  69 +2. WHEN Report_Generator 统计各类型, THE Report_Generator SHALL 计算每种类型的配件数量、占总数量的百分比、涉及金额(有效库存 × 成本价)、占总金额的百分比
  70 +3. WHEN 健康度统计完成, THE Report_Generator SHALL 将统计数据作为上下文传递给 LLM 生成健康度分析文本
  71 +4. WHEN Report_API 返回健康度数据, THE Report_API SHALL 返回包含各类型数量和金额的结构化数据,供前端生成图表
  72 +5. WHEN Report_UI 展示健康度数据, THE Report_UI SHALL 使用图表展示各类型配件的数量占比和金额占比
  73 +
  74 +### 需求 5:补货建议生成情况统计
  75 +
  76 +**用户故事:** 作为采购决策者,我希望看到本次补货建议的生成情况(急需/建议/可选各优先级的配件数和金额),以便了解补货的紧迫程度和资金需求。
  77 +
  78 +#### 验收标准
  79 +
  80 +1. WHEN Report_Generator 统计补货建议, THE Report_Generator SHALL 按优先级(急需补货/建议补货/可选补货)分别统计配件种类数和建议补货总金额
  81 +2. WHEN Report_Generator 统计补货总量, THE Report_Generator SHALL 计算所有优先级的配件总种类数和总金额
  82 +3. WHEN 补货统计完成, THE Report_Generator SHALL 将统计数据作为上下文传递给 LLM 生成补货建议分析文本
  83 +
  84 +### 需求 6:数据库表结构重构
  85 +
  86 +**用户故事:** 作为开发者,我希望数据库表结构能够存储新的报告模块数据,以便支持四大板块的数据持久化。
  87 +
  88 +#### 验收标准
  89 +
  90 +1. THE ai_analysis_report 表 SHALL 包含库存概览模块的 JSON 字段(inventory_overview)存储统计数据和 LLM 分析
  91 +2. THE ai_analysis_report 表 SHALL 包含销量分析模块的 JSON 字段(sales_analysis)存储统计数据和 LLM 分析
  92 +3. THE ai_analysis_report 表 SHALL 包含健康度模块的 JSON 字段(inventory_health)存储统计数据和 LLM 分析
  93 +4. THE ai_analysis_report 表 SHALL 包含补货建议模块的 JSON 字段(replenishment_summary)存储统计数据和 LLM 分析
  94 +5. THE ai_analysis_report 表 SHALL 保留 task_no、group_id、dealer_grouping_id 等基础字段和 LLM 元数据字段
  95 +
  96 +### 需求 7:LLM 提示词重构与并发生成
  97 +
  98 +**用户故事:** 作为开发者,我希望使用 LangGraph 动态节点并发生成各板块的 LLM 分析,提高报告生成效率。
  99 +
  100 +#### 验收标准
  101 +
  102 +1. WHEN Report_Generator 生成报告, THE Report_Generator SHALL 使用 LangGraph 动态节点并发调用 LLM 生成四个板块的分析文本
  103 +2. WHEN 每个板块节点调用 LLM, THE 节点 SHALL 在提示词中仅包含该板块相关的统计数据
  104 +3. WHEN LLM 生成分析, THE LLM 输出 SHALL 为合法的 JSON 对象
  105 +4. WHEN 所有板块分析完成, THE Report_Generator SHALL 汇总四个板块的统计数据和 LLM 分析结果为一个完整报告
  106 +5. IF 某个板块的 LLM 调用失败, THEN THE Report_Generator SHALL 记录错误日志,该板块分析文本置为错误提示,其他板块继续正常生成
  107 +
  108 +### 需求 8:API 接口重构
  109 +
  110 +**用户故事:** 作为前端开发者,我希望 API 返回新结构的报告数据,以便前端能够渲染四大板块。
  111 +
  112 +#### 验收标准
  113 +
  114 +1. WHEN 前端请求分析报告, THE Report_API SHALL 返回包含四个模块(inventory_overview、sales_analysis、inventory_health、replenishment_summary)的 JSON 响应
  115 +2. WHEN 报告中包含 JSON 字符串字段, THE Report_API SHALL 将其解析为 JSON 对象后返回
  116 +3. IF 指定 task_no 无报告数据, THEN THE Report_API SHALL 返回 null
  117 +
  118 +### 需求 9:前端报告页面重构
  119 +
  120 +**用户故事:** 作为采购决策者,我希望在前端看到结构清晰的四大板块报告,包含统计数据卡片、图表和 LLM 分析文本。
  121 +
  122 +#### 验收标准
  123 +
  124 +1. WHEN Report_UI 渲染报告, THE Report_UI SHALL 按库存概览、销量分析、健康度、补货建议的顺序展示四个板块
  125 +2. WHEN Report_UI 渲染库存概览板块, THE Report_UI SHALL 展示总库存数量、总金额(资金占用)、库销比的统计卡片,以及三项构成明细(在库未锁/在途/计划数)和 LLM 分析文本
  126 +3. WHEN Report_UI 渲染销量分析板块, THE Report_UI SHALL 展示月均销量总量、销量构成明细(90天出库/未关单已锁/未关单出库/订件)和 LLM 分析文本
  127 +4. WHEN Report_UI 渲染健康度板块, THE Report_UI SHALL 展示各类型配件的数量/金额占比图表和 LLM 分析文本
  128 +5. WHEN Report_UI 渲染补货建议板块, THE Report_UI SHALL 展示各优先级的配件数/金额统计和 LLM 分析文本
  129 +6. WHEN Report_UI 渲染健康度图表, THE Report_UI SHALL 使用 Chart.js 生成饼图或环形图展示数量占比和金额占比
  130 +
  131 +### 需求 10:数据模型与写入服务重构
  132 +
  133 +**用户故事:** 作为开发者,我希望 Python 数据模型和数据库写入服务能够适配新的报告结构。
  134 +
  135 +#### 验收标准
  136 +
  137 +1. THE AnalysisReport 数据模型 SHALL 包含 inventory_overview、sales_analysis、inventory_health、replenishment_summary 四个字典字段
  138 +2. WHEN Result_Writer 保存报告, THE Result_Writer SHALL 将四个模块的字典数据序列化为 JSON 字符串写入对应数据库字段
  139 +3. THE AnalysisReport 数据模型 SHALL 提供 to_dict 方法将报告转换为可序列化的字典
.kiro/specs/refactor-analysis-report/tasks.md 0 → 100644
  1 +# 实现计划:重构分析报告功能
  2 +
  3 +## 概述
  4 +
  5 +全栈重构分析报告功能,从清理现有代码开始,依次重建数据库表、数据模型、统计计算、LLM 提示词、并发节点、API 接口、前端页面。使用 Python + LangGraph + FastAPI + Chart.js 技术栈。
  6 +
  7 +## 任务
  8 +
  9 +- [x] 1. 清理现有分析报告专属文件
  10 + - [x] 1.1 清空 `src/fw_pms_ai/models/analysis_report.py`,仅保留文件头注释和必要 import,移除现有 AnalysisReport dataclass 的所有字段和方法
  11 + - [x] 1.2 清空 `src/fw_pms_ai/agent/analysis_report_node.py`,仅保留文件头注释和必要 import,移除 `_load_prompt`、`_calculate_suggestion_stats`、`_calculate_risk_stats`、`_build_suggestion_summary`、`generate_analysis_report_node` 等所有函数
  12 + - [x] 1.3 清空 `prompts/analysis_report.md` 的内容
  13 + - 注意:此步骤仅清理分析报告专属文件,不修改 `tasks.py`、`app.js`、`result_writer.py` 等共享文件(这些文件中的报告相关代码将在后续重建步骤中以替换方式更新)
  14 + - _Requirements: 1.1, 1.2, 1.3, 1.5_
  15 +
  16 +- [x] 2. 重建数据库表和数据模型
  17 + - [x] 2.1 重写 `sql/migrate_analysis_report.sql`,创建新表结构(inventory_overview、sales_analysis、inventory_health、replenishment_summary 四个 JSON 字段 + LLM 元数据字段)
  18 + - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5_
  19 + - [x] 2.2 在已清空的 `src/fw_pms_ai/models/analysis_report.py` 中编写新的 AnalysisReport dataclass(四个 Dict 字段 + to_dict 方法)
  20 + - _Requirements: 10.1, 10.3_
  21 + - [x] 2.3 替换 `src/fw_pms_ai/services/result_writer.py` 中的 `save_analysis_report` 方法为新版本,适配新表结构(四个 JSON 字段序列化写入),不修改该文件中的其他方法
  22 + - _Requirements: 10.2_
  23 + - [ ]* 2.4 编写属性测试:报告数据模型序列化 round-trip
  24 + - **Property 5: 报告数据模型序列化 round-trip**
  25 + - **Validates: Requirements 10.2, 10.3**
  26 +
  27 +- [x] 3. 实现四大板块统计计算函数
  28 + - [x] 3.1 在已清空的 `src/fw_pms_ai/agent/analysis_report_node.py` 中实现 `calculate_inventory_overview(part_ratios)` 函数
  29 + - 计算有效库存(valid_storage_cnt = in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt)总数量/总金额、三项构成明细、库销比
  30 + - _Requirements: 2.1, 2.2, 2.3, 2.5_
  31 + - [x] 3.2 实现 `calculate_sales_analysis(part_ratios)` 函数
  32 + - 计算月均销量总数量/总金额、各组成部分(out_stock_cnt/storage_locked_cnt/out_stock_ongoing_cnt/buy_cnt)总量、有销量/无销量配件数
  33 + - _Requirements: 3.1, 3.2, 3.4_
  34 + - [x] 3.3 实现 `calculate_inventory_health(part_ratios)` 函数
  35 + - 将配件分类为缺货/呆滞/低频/正常,计算各类型数量/金额/百分比,生成 chart_data
  36 + - _Requirements: 4.1, 4.2_
  37 + - [x] 3.4 实现 `calculate_replenishment_summary(part_results)` 函数
  38 + - 按优先级(1=急需/2=建议/3=可选)统计配件种类数和金额
  39 + - _Requirements: 5.1, 5.2_
  40 + - [ ]* 3.5 编写属性测试:库存概览统计一致性
  41 + - **Property 1: 库存概览统计一致性**
  42 + - **Validates: Requirements 2.1, 2.2, 2.3**
  43 + - [ ]* 3.6 编写属性测试:销量分析统计一致性
  44 + - **Property 2: 销量分析统计一致性**
  45 + - **Validates: Requirements 3.1, 3.2, 3.4**
  46 + - [ ]* 3.7 编写属性测试:健康度分类完备性与一致性
  47 + - **Property 3: 健康度分类完备性与一致性**
  48 + - **Validates: Requirements 4.1, 4.2**
  49 + - [ ]* 3.8 编写属性测试:补货建议统计一致性
  50 + - **Property 4: 补货建议统计一致性**
  51 + - **Validates: Requirements 5.1, 5.2**
  52 +
  53 +- [ ] 4. Checkpoint - 确保统计计算函数和属性测试通过
  54 + - 确保所有测试通过,如有问题请向用户确认。
  55 +
  56 +- [x] 5. 创建 LLM 提示词文件
  57 + - [x] 5.1 创建 `prompts/report_inventory_overview.md`,包含库存概览分析提示词(资金占用评估、库销比诊断、库存结构建议),确保分析专业且有实际决策价值
  58 + - _Requirements: 7.2_
  59 + - [x] 5.2 创建 `prompts/report_sales_analysis.md`,包含销量分析提示词(销量构成解读、销售活跃度、需求趋势判断),确保分析专业且有实际决策价值
  60 + - _Requirements: 7.2_
  61 + - [x] 5.3 创建 `prompts/report_inventory_health.md`,包含健康度分析提示词(健康度评分、问题诊断、资金释放机会、改善优先级),确保分析专业且有实际决策价值
  62 + - _Requirements: 7.2_
  63 + - [x] 5.4 创建 `prompts/report_replenishment_summary.md`,包含补货建议分析提示词(紧迫度评估、资金分配建议、执行节奏、风险提示),确保分析专业且有实际决策价值
  64 + - _Requirements: 7.2_
  65 + - [x] 5.5 删除旧的 `prompts/analysis_report.md` 文件(已在步骤1.3清空,此处正式删除)
  66 + - _Requirements: 1.3_
  67 +
  68 +- [x] 6. 实现 LangGraph 并发 LLM 分析节点
  69 + - [x] 6.1 在 `src/fw_pms_ai/agent/analysis_report_node.py` 中实现四个 LLM 分析函数(`llm_analyze_inventory_overview`、`llm_analyze_sales`、`llm_analyze_inventory_health`、`llm_analyze_replenishment_summary`),每个函数加载对应提示词、填充统计数据、调用 LLM、解析 JSON 响应
  70 + - _Requirements: 7.2, 7.3_
  71 + - [x] 6.2 使用 LangGraph StateGraph 构建并发子图,四个 LLM 节点从 START fan-out 并发执行,结果 fan-in 汇总
  72 + - _Requirements: 7.1, 7.4_
  73 + - [x] 6.3 实现新的 `generate_analysis_report_node(state)` 主函数,串联统计计算 → 并发 LLM 分析 → 汇总报告 → 写入数据库,单板块 LLM 失败不影响其他板块
  74 + - _Requirements: 7.4, 7.5_
  75 + - [x] 6.4 确认 `src/fw_pms_ai/agent/replenishment.py` 中的工作流引用无需修改(`generate_analysis_report_node` 函数签名保持不变)
  76 + - _Requirements: 7.1_
  77 +
  78 +- [ ] 7. Checkpoint - 确保后端报告生成流程完整
  79 + - 确保所有测试通过,如有问题请向用户确认。
  80 +
  81 +- [x] 8. 重建 API 接口
  82 + - [x] 8.1 替换 `src/fw_pms_ai/api/routes/tasks.py` 中的 `AnalysisReportResponse` 模型为新版本(inventory_overview、sales_analysis、inventory_health、replenishment_summary 四个 Dict 字段),不修改该文件中的其他模型和端点
  83 + - _Requirements: 8.1_
  84 + - [x] 8.2 替换 `get_analysis_report` 端点实现,从新表读取数据并解析 JSON 字段,不修改该文件中的其他端点
  85 + - _Requirements: 8.1, 8.2, 8.3_
  86 +
  87 +- [x] 9. 重建前端报告页面
  88 + - [x] 9.1 在 `ui/index.html` 中引入 Chart.js CDN
  89 + - _Requirements: 9.6_
  90 + - [x] 9.2 替换 `ui/js/app.js` 中的 `renderReportTab` 方法为新版本,渲染四大板块框架,同时移除旧的 `renderOverallAssessment`、`renderRiskAlerts`、`renderStrategy`、`renderExpectedImpact` 方法,不修改该文件中的其他方法
  91 + - _Requirements: 9.1, 1.4_
  92 + - [x] 9.3 实现 `renderInventoryOverview` 方法,渲染库存概览板块(统计卡片 + 五项构成明细 + LLM 分析文本)
  93 + - _Requirements: 9.2_
  94 + - [x] 9.4 实现 `renderSalesAnalysis` 方法,渲染销量分析板块(统计卡片 + 构成明细 + LLM 分析文本)
  95 + - _Requirements: 9.3_
  96 + - [x] 9.5 实现 `renderInventoryHealth` 方法,渲染健康度板块(统计卡片 + Chart.js 环形图 + LLM 分析文本)
  97 + - _Requirements: 9.4, 9.6_
  98 + - [x] 9.6 实现 `renderReplenishmentSummary` 方法,渲染补货建议板块(优先级统计表 + LLM 分析文本)
  99 + - _Requirements: 9.5_
  100 + - [x] 9.7 在 `ui/css/style.css` 中添加新报告板块的样式(统计卡片、图表容器、分析文本区域),不修改现有样式
  101 + - _Requirements: 9.1_
  102 +
  103 +- [ ] 10. Final Checkpoint - 全栈集成验证
  104 + - 确保所有测试通过,如有问题请向用户确认。
  105 +
  106 +## 安全约束:不影响补货建议功能
  107 +
  108 +本次重构严格限定在分析报告模块范围内,以下补货建议相关代码禁止修改:
  109 +
  110 +- `src/fw_pms_ai/agent/nodes.py` — 补货建议核心节点(fetch_part_ratio、sql_agent、allocate_budget)
  111 +- `src/fw_pms_ai/agent/replenishment.py` — 补货建议工作流(仅确认无需修改,不做任何改动)
  112 +- `src/fw_pms_ai/agent/sql_agent/` — SQL Agent 目录
  113 +- `src/fw_pms_ai/models/part_ratio.py` — 配件库销比模型
  114 +- `src/fw_pms_ai/models/replenishment_*.py` — 补货建议相关模型
  115 +- `result_writer.py` 中的 `save_task`、`update_task`、`save_details`、`save_part_summaries`、`save_execution_log` 等方法
  116 +- `tasks.py` 中的任务列表、任务详情、配件明细、配件汇总、执行日志等端点
  117 +- `app.js` 中的任务列表、任务详情、配件明细等渲染方法
  118 +- `prompts/` 中的 `part_shop_analysis*.md`、`suggestion*.md`、`sql_agent.md` 等提示词文件
  119 +
  120 +## 备注
  121 +
  122 +- 标记 `*` 的任务为可选任务,可跳过以加快 MVP 进度
  123 +- 每个任务引用了具体的需求编号以保证可追溯性
  124 +- 属性测试使用 `hypothesis` 库,每个属性至少 100 次迭代
  125 +- Checkpoint 用于阶段性验证,确保增量正确
  126 +- 共享文件(tasks.py、app.js、result_writer.py、style.css)中的修改均采用"替换特定函数/类"方式,明确不修改其他部分
README.md
1 # fw-pms-ai 1 # fw-pms-ai
2 2
3 -AI 配件系统 - 基于 Python + LangChain + LangGraph 3 +fw-pms 配件管理系统 AI 扩展平台 — 基于 Python + LangChain + LangGraph
4 4
5 ## 项目简介 5 ## 项目简介
6 6
7 -本项目是 `fw-pms` 的 AI 扩展模块,使用大语言模型 (LLM) 和 Agent 技术,为配件管理系统提供智能化的补货建议能力 7 +本项目是 `fw-pms` 配件管理系统的 **AI 能力扩展平台**,使用大语言模型 (LLM) 和 Agent 技术,为配件业务提供多种智能化功能
8 8
9 -## 核心技术 9 +### 功能模块
10 10
11 -### LangChain + LangGraph  
12 -  
13 -| 技术 | 作用 |  
14 -|------|------|  
15 -| **LangChain** | LLM 框架,提供模型抽象、Prompt 管理、消息格式化 |  
16 -| **LangGraph** | Agent 工作流编排,管理状态机、定义节点和边、支持条件分支 |  
17 -| **SQL Agent** | 自定义 Text-to-SQL 实现,支持错误重试和 LLM 数据分析 | 11 +| 状态 | 模块 | 说明 |
  12 +|------|------|------|
  13 +| ✅ 已实现 | **智能补货建议** | 分析库销比数据,LLM 逐配件分析各门店补货需求,生成结构化建议 |
  14 +| ✅ 已实现 | **分析报告** | 四大板块(库存概览/销量分析/健康度/补货建议)并发 LLM 分析 |
  15 +| 🚧 规划中 | **需求预测** | 基于历史销量预测未来配件需求 |
  16 +| 🚧 规划中 | **异常检测** | 识别库存和销量数据异常 |
  17 +| 🚧 规划中 | **智能定价** | AI 辅助配件定价建议 |
  18 +
  19 +## 技术栈
  20 +
  21 +| 组件 | 技术 | 版本要求 |
  22 +|------|------|---------|
  23 +| 编程语言 | Python | ≥ 3.11 |
  24 +| Agent 框架 | LangChain + LangGraph | LangChain ≥ 0.3, LangGraph ≥ 0.2 |
  25 +| LLM 集成 | 智谱 GLM / 豆包 / OpenAI 兼容 / Anthropic 兼容 | — |
  26 +| Web API | FastAPI + Uvicorn | FastAPI ≥ 0.109 |
  27 +| 数据库 | MySQL (mysql-connector-python + SQLAlchemy) | SQLAlchemy ≥ 2.0 |
  28 +| 任务调度 | APScheduler | ≥ 3.10 |
  29 +| 配置管理 | Pydantic Settings + python-dotenv | Pydantic ≥ 2.0 |
  30 +| HTTP 客户端 | httpx | ≥ 0.25 |
  31 +| 重试机制 | tenacity | ≥ 8.0 |
  32 +
  33 +## 系统架构
18 34
19 ```mermaid 35 ```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[AnalysisReport]  
26 - F --> G[SaveResult] 36 +flowchart TB
  37 + subgraph Scheduler ["定时调度 (APScheduler)"]
  38 + S[每日凌晨触发]
  39 + end
  40 +
  41 + subgraph API ["FastAPI API 层"]
  42 + A[/tasks, details, logs, reports/]
  43 + end
  44 +
  45 + subgraph Agent ["LangGraph 工作流"]
  46 + direction TB
  47 + B[1. fetch_part_ratio] --> C[2. sql_agent]
  48 + C --> D{需要重试?}
  49 + D -->|是| C
  50 + D -->|否| E[3. allocate_budget]
  51 + E --> F[4. generate_analysis_report]
  52 + F --> G[END]
  53 + end
  54 +
  55 + subgraph ReportSubgraph ["分析报告并发子图"]
  56 + direction LR
  57 + R1[库存概览 LLM] & R2[销量分析 LLM] & R3[健康度 LLM] & R4[补货建议 LLM]
  58 + end
  59 +
  60 + subgraph Services ["业务服务层"]
  61 + DS[DataService]
  62 + RW[ResultWriter]
  63 + RP[Repository]
  64 + end
  65 +
  66 + subgraph LLM ["LLM 适配层"]
  67 + L1[GLMClient]
  68 + L2[DoubaoClient]
  69 + L3[OpenAICompatClient]
  70 + L4[AnthropicCompatClient]
  71 + end
  72 +
  73 + subgraph DB ["数据存储"]
  74 + MySQL[(MySQL)]
  75 + end
  76 +
  77 + S --> Agent
  78 + A --> Services
  79 + B --> DS
  80 + C --> LLM
  81 + F --> ReportSubgraph
  82 + ReportSubgraph --> LLM
  83 + E --> RW
  84 + DS --> MySQL
  85 + RW --> MySQL
  86 + RP --> MySQL
27 ``` 87 ```
28 88
29 详细架构图见 [docs/architecture.md](docs/architecture.md) 89 详细架构图见 [docs/architecture.md](docs/architecture.md)
30 90
31 -## 功能模块 91 +> 平台采用模块化设计,新增 AI 功能模块只需添加对应的 Agent 工作流节点和提示词文件。
32 92
33 -### ✅ 已实现 93 +## 补货建议工作流
34 94
35 -| 模块 | 功能 | 说明 |  
36 -|------|------|------|  
37 -| **SQL Agent** | LLM 分析 | 直接分析 part_ratio 数据生成补货建议 |  
38 -| **补货分配** | Replenishment | 转换 LLM 建议为补货明细 | 95 +补货建议模块的核心是一个 **4 节点 LangGraph 工作流**,按顺序执行:
39 96
40 -### 🚧 计划中 97 +| 序号 | 节点 | 功能 | 说明 |
  98 +|------|------|------|------|
  99 +| 1 | `fetch_part_ratio` | 获取库销比数据 | 通过 dealer_grouping_id 从 part_ratio 表查询配件数据 |
  100 +| 2 | `sql_agent` | LLM 分析 + 建议生成 | 按 part_code 分组,逐配件分析各门店补货需求,支持错误重试 |
  101 +| 3 | `allocate_budget` | 转换补货明细 | 将 LLM 建议转换为结构化的补货明细记录 |
  102 +| 4 | `generate_analysis_report` | 生成分析报告 | 统计计算 + 4 路并发 LLM 分析生成结构化报告 |
41 103
42 -| 模块 | 功能 |  
43 -|------|------|  
44 -| 预测引擎 | 基于历史销量预测未来需求 |  
45 -| 异常检测 | 识别数据异常 | 104 +### 分析报告四大板块
  105 +
  106 +| 板块 | 统计计算 | LLM 分析 |
  107 +|------|---------|---------|
  108 +| **库存概览** | 有效库存、资金占用、配件总数 | 库存状况综合评价 |
  109 +| **销量分析** | 月均销量、出库频次、销售趋势 | 销售趋势洞察 |
  110 +| **库存健康度** | 缺货/呆滞/低频/正常分类统计 | 健康度风险提示 |
  111 +| **补货建议汇总** | 按优先级分类统计补货数量和金额 | 补货策略建议 |
  112 +
  113 +> 四个 LLM 分析节点使用 LangGraph 子图 **并发执行**,单板块失败不影响其他板块。
  114 +
  115 +## 业务术语
  116 +
  117 +| 术语 | 定义 | 处理方式 |
  118 +|------|------|---------|
  119 +| **呆滞件** | 有效库存 > 0,90天出库数 = 0 | 不做计划 |
  120 +| **低频件** | 月均销量 < 1 或 出库次数 < 3 或 出库间隔 ≥ 30天 | 不做计划 |
  121 +| **缺货件** | 有效库存 = 0,月均销量 ≥ 1 | 需要补货 |
  122 +| **正常件** | 不属于以上三类 | 按需补货 |
46 123
47 ## 项目结构 124 ## 项目结构
48 125
49 ``` 126 ```
50 fw-pms-ai/ 127 fw-pms-ai/
51 ├── src/fw_pms_ai/ 128 ├── src/fw_pms_ai/
52 -│ ├── agent/ # LangGraph Agent  
53 -│ │ ├── state.py # Agent 状态定义  
54 -│ │ ├── nodes.py # 工作流节点  
55 -│ │ ├── sql_agent.py # SQL Agent(Text-to-SQL + 建议生成)  
56 -│ │ └── replenishment.py  
57 -│ ├── api/ # FastAPI 接口  
58 -│ │ ├── app.py # 应用入口  
59 -│ │ └── routes/ # 路由模块  
60 -│ ├── config/ # 配置管理  
61 -│ ├── llm/ # LLM 集成  
62 -│ │ ├── base.py # 抽象基类  
63 -│ │ ├── glm.py # 智谱 GLM  
64 -│ │ ├── doubao.py # 豆包  
65 -│ │ ├── openai_compat.py  
66 -│ │ └── anthropic_compat.py  
67 -│ ├── models/ # 数据模型  
68 -│ │ ├── task.py # 任务和明细模型  
69 -│ │ ├── execution_log.py # 执行日志模型  
70 -│ │ ├── part_ratio.py # 库销比模型  
71 -│ │ ├── part_summary.py # 配件汇总模型  
72 -│ │ ├── sql_result.py # SQL执行结果模型  
73 -│ │ └── suggestion.py # 补货建议模型  
74 -│ ├── services/ # 业务服务  
75 -│ │ ├── db.py # 数据库连接  
76 -│ │ ├── data_service.py # 数据查询服务  
77 -│ │ └── result_writer.py # 结果写入服务  
78 -│ ├── scheduler/ # 定时任务  
79 -│ └── main.py  
80 -├── prompts/ # AI Prompt 文件  
81 -│ ├── sql_agent.md # SQL Agent 系统提示词  
82 -│ ├── suggestion.md # 补货建议提示词  
83 -│ ├── suggestion_system.md  
84 -│ ├── part_shop_analysis.md  
85 -│ └── part_shop_analysis_system.md  
86 -├── ui/ # 前端静态文件  
87 -├── sql/ # 数据库迁移脚本  
88 -├── pyproject.toml 129 +│ ├── main.py # 应用入口
  130 +│ ├── agent/ # LangGraph Agent 工作流
  131 +│ │ ├── state.py # Agent 状态定义 (TypedDict + Annotated reducer)
  132 +│ │ ├── nodes.py # 工作流节点 (fetch/sql_agent/allocate)
  133 +│ │ ├── analysis_report_node.py # 分析报告节点 (统计计算 + 并发 LLM 子图)
  134 +│ │ ├── replenishment.py # ReplenishmentAgent 主类 (构建图 + 运行)
  135 +│ │ └── sql_agent/ # SQL Agent 子包
  136 +│ │ ├── agent.py # SQLAgent 主类
  137 +│ │ ├── executor.py # SQL 执行器
  138 +│ │ ├── analyzer.py # 配件分析器 (逐配件 LLM 分析)
  139 +│ │ └── prompts.py # 提示词加载
  140 +│ ├── api/ # FastAPI REST API
  141 +│ │ ├── app.py # FastAPI 应用 (CORS + 静态文件 + 路由)
  142 +│ │ └── routes/
  143 +│ │ └── tasks.py # 任务/明细/日志/汇总/报告 API
  144 +│ ├── config/
  145 +│ │ └── settings.py # Pydantic Settings 配置管理
  146 +│ ├── llm/ # LLM 适配层
  147 +│ │ ├── base.py # BaseLLMClient 抽象基类
  148 +│ │ ├── glm.py # 智谱 GLM 客户端
  149 +│ │ ├── doubao.py # 豆包客户端
  150 +│ │ ├── openai_compat.py # OpenAI 兼容客户端 (火山引擎等)
  151 +│ │ └── anthropic_compat.py # Anthropic 兼容客户端
  152 +│ ├── models/ # 数据模型 (Pydantic)
  153 +│ │ ├── task.py # 任务模型 + 状态枚举
  154 +│ │ ├── suggestion.py # 补货建议模型
  155 +│ │ ├── part_ratio.py # 配件库销比模型
  156 +│ │ ├── part_summary.py # 配件汇总模型
  157 +│ │ ├── analysis_report.py # 分析报告模型
  158 +│ │ ├── execution_log.py # 执行日志模型
  159 +│ │ └── sql_result.py # SQL 执行结果模型
  160 +│ ├── services/ # 业务服务层
  161 +│ │ ├── db.py # 数据库连接管理
  162 +│ │ ├── data_service.py # 数据查询服务
  163 +│ │ ├── result_writer.py # 结果写入服务
  164 +│ │ └── repository/ # 仓库模式
  165 +│ │ ├── task_repo.py # 任务仓库
  166 +│ │ ├── detail_repo.py # 明细仓库
  167 +│ │ └── log_repo.py # 日志仓库
  168 +│ └── scheduler/
  169 +│ └── tasks.py # APScheduler 定时任务 + CLI 参数解析
  170 +├── prompts/ # AI 提示词文件
  171 +│ ├── sql_agent.md # SQL Agent 系统提示词
  172 +│ ├── suggestion.md # 补货建议生成提示词
  173 +│ ├── suggestion_system.md # 补货建议系统提示词
  174 +│ ├── part_shop_analysis.md # 配件门店分析提示词
  175 +│ ├── part_shop_analysis_system.md
  176 +│ ├── report_inventory_overview.md # 分析报告-库存概览提示词
  177 +│ ├── report_sales_analysis.md # 分析报告-销量分析提示词
  178 +│ ├── report_inventory_health.md # 分析报告-库存健康度提示词
  179 +│ └── report_replenishment_summary.md # 分析报告-补货建议提示词
  180 +├── ui/ # 前端静态文件
  181 +│ ├── index.html # 主页面
  182 +│ ├── css/ # 样式
  183 +│ ├── js/ # JavaScript
  184 +│ └── merchant-report/ # 商家组合报告页面
  185 +├── sql/ # 数据库脚本
  186 +│ ├── init.sql # 初始化建表 (4张表)
  187 +│ └── migrate_analysis_report.sql # 分析报告表迁移
  188 +├── docs/ # 文档
  189 +│ ├── architecture.md # 系统架构文档
  190 +│ └── 商家组合维度分析需求设计.md
  191 +├── pyproject.toml # 项目配置 (hatchling 构建)
  192 +├── .env # 环境变量
89 └── README.md 193 └── README.md
90 ``` 194 ```
91 195
  196 +## 数据表说明
92 197
93 -## 工作流程 198 +| 表名 | 说明 | SQL 文件 |
  199 +|------|------|---------|
  200 +| `part_ratio` | 配件库销比数据(来源表,只读) | — |
  201 +| `ai_replenishment_task` | 任务记录 | init.sql |
  202 +| `ai_replenishment_detail` | 配件级别补货建议明细 | init.sql |
  203 +| `ai_replenishment_part_summary` | 配件汇总表(按配件编码聚合) | init.sql |
  204 +| `ai_task_execution_log` | 任务执行日志(每步骤详情) | init.sql |
  205 +| `ai_analysis_report` | 结构化分析报告(四大板块 JSON) | migrate_analysis_report.sql |
94 206
95 -```  
96 -1. FetchPartRatio - 从 part_ratio 表获取库销比数据  
97 -2. SQLAgent - LLM 分析数据,生成补货建议  
98 -3. AllocateBudget - 转换建议为补货明细  
99 -4. AnalysisReport - 生成分析报告(风险评估、行动方案)  
100 -5. SaveResult - 写入数据库  
101 -``` 207 +## API 接口
102 208
103 -### 业务术语 209 +基于 FastAPI 提供 REST API,支持前端 UI 对接。
104 210
105 -| 术语 | 定义 | 处理 | 211 +| 方法 | 路径 | 说明 |
106 |------|------|------| 212 |------|------|------|
107 -| **呆滞件** | 有库存,90天无销量 | 不做计划 |  
108 -| **低频件** | 无库存,月均销量<1 | 不做计划 |  
109 -| **缺货件** | 无库存,月均销量≥1 | 需要补货 | 213 +| GET | `/api/tasks` | 任务列表(分页、状态/组合/日期筛选) |
  214 +| GET | `/api/tasks/{task_no}` | 任务详情 |
  215 +| GET | `/api/tasks/{task_no}/details` | 配件建议明细(分页、排序、搜索) |
  216 +| GET | `/api/tasks/{task_no}/logs` | 执行日志 |
  217 +| GET | `/api/tasks/{task_no}/part-summaries` | 配件汇总列表(分页、优先级筛选) |
  218 +| GET | `/api/tasks/{task_no}/parts/{part_code}/shops` | 指定配件的门店明细 |
  219 +| GET | `/api/tasks/{task_no}/analysis-report` | 分析报告 |
  220 +| GET | `/health` | 健康检查 |
  221 +| GET | `/` | 主页面(静态文件) |
110 222
111 -## 数据表说明 223 +运行后访问 `/docs` 查看 Swagger 文档。
112 224
113 -| 表名 | 说明 |  
114 -|------|------|  
115 -| `part_ratio` | 配件库销比数据(来源表) |  
116 -| `ai_replenishment_task` | 任务记录 |  
117 -| `ai_replenishment_detail` | 配件级别补货建议 |  
118 -| `ai_replenishment_part_summary` | 配件汇总表 |  
119 -| `ai_task_execution_log` | 任务执行日志 | 225 +## LLM 集成
  226 +
  227 +支持 4 种 LLM 客户端,通过环境变量自动选择:
  228 +
  229 +| 客户端 | 环境变量 | 说明 |
  230 +|--------|---------|------|
  231 +| `OpenAICompatClient` | `OPENAI_COMPAT_API_KEY` | OpenAI 兼容接口(火山引擎、智谱等) |
  232 +| `AnthropicCompatClient` | `ANTHROPIC_API_KEY` | Anthropic 兼容接口 |
  233 +| `GLMClient` | `GLM_API_KEY` | 智谱 GLM 原生 SDK |
  234 +| `DoubaoClient` | `DOUBAO_API_KEY` | 豆包 |
  235 +
  236 +优先级:`OpenAI Compat` > `Anthropic Compat` > `GLM` > `Doubao`
120 237
121 ## 快速开始 238 ## 快速开始
122 239
@@ -131,39 +248,53 @@ pip install -e . @@ -131,39 +248,53 @@ pip install -e .
131 248
132 ```bash 249 ```bash
133 cp .env.example .env 250 cp .env.example .env
  251 +# 编辑 .env 填写以下配置
134 ``` 252 ```
135 253
136 必填配置项: 254 必填配置项:
137 -- `GLM_API_KEY` / `ANTHROPIC_API_KEY` - LLM API Key  
138 -- `MYSQL_HOST`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE` 255 +
  256 +```env
  257 +# LLM (至少配置一种)
  258 +OPENAI_COMPAT_API_KEY=your-key
  259 +OPENAI_COMPAT_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
  260 +OPENAI_COMPAT_MODEL=glm-4-7-251222
  261 +
  262 +# 数据库
  263 +MYSQL_HOST=localhost
  264 +MYSQL_PORT=3306
  265 +MYSQL_USER=root
  266 +MYSQL_PASSWORD=your-password
  267 +MYSQL_DATABASE=fw_pms
  268 +```
139 269
140 ### 3. 初始化数据库 270 ### 3. 初始化数据库
141 271
142 ```bash 272 ```bash
  273 +# 基础表结构
143 mysql -u root -p fw_pms < sql/init.sql 274 mysql -u root -p fw_pms < sql/init.sql
  275 +
  276 +# 分析报告表
  277 +mysql -u root -p fw_pms < sql/migrate_analysis_report.sql
144 ``` 278 ```
145 279
146 ### 4. 运行 280 ### 4. 运行
147 281
148 ```bash 282 ```bash
149 -# 启动定时任务调度器 283 +# 启动定时任务调度器(默认每日 02:00 执行)
150 fw-pms-ai 284 fw-pms-ai
151 285
152 -# 立即执行一次 286 +# 立即执行一次(所有商家组合)
153 fw-pms-ai --run-once 287 fw-pms-ai --run-once
154 288
155 -# 指定参数  
156 -fw-pms-ai --run-once --group-id 2 289 +# 指定集团和商家组合
  290 +fw-pms-ai --run-once --group-id 2 --dealer-grouping-id 100
157 ``` 291 ```
158 292
159 -## AI Prompt 文件 293 +### 5. 启动 API 服务
160 294
161 -Prompt 文件存放在 `prompts/` 目录:  
162 -  
163 -| 文件 | 用途 |  
164 -|------|------|  
165 -| `suggestion.md` | 补货建议生成(含业务术语定义) |  
166 -| `analyze_inventory.md` | 库存分析 | 295 +```bash
  296 +uvicorn fw_pms_ai.api.app:app --host 0.0.0.0 --port 8000 --reload
  297 +```
167 298
168 ## 开发 299 ## 开发
169 300
@@ -171,6 +302,26 @@ Prompt 文件存放在 `prompts/` 目录: @@ -171,6 +302,26 @@ Prompt 文件存放在 `prompts/` 目录:
171 # 安装开发依赖 302 # 安装开发依赖
172 pip install -e ".[dev]" 303 pip install -e ".[dev]"
173 304
  305 +# 代码格式化
  306 +black src/
  307 +ruff check src/
  308 +
174 # 运行测试 309 # 运行测试
175 pytest tests/ -v 310 pytest tests/ -v
176 ``` 311 ```
  312 +
  313 +## 提示词文件
  314 +
  315 +提示词文件存放在 `prompts/` 目录,供 LLM 调用时使用:
  316 +
  317 +| 文件 | 用途 |
  318 +|------|------|
  319 +| `sql_agent.md` | SQL Agent 生成 SQL 查询的系统提示词 |
  320 +| `suggestion.md` | 配件补货建议生成 |
  321 +| `suggestion_system.md` | 补货建议系统角色提示词 |
  322 +| `part_shop_analysis.md` | 配件门店级别分析 |
  323 +| `part_shop_analysis_system.md` | 门店分析系统角色提示词 |
  324 +| `report_inventory_overview.md` | 分析报告 — 库存概览板块 |
  325 +| `report_sales_analysis.md` | 分析报告 — 销量分析板块 |
  326 +| `report_inventory_health.md` | 分析报告 — 库存健康度板块 |
  327 +| `report_replenishment_summary.md` | 分析报告 — 补货建议板块 |
docs/architecture.md deleted
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 --> E2[generate_analysis_report]  
31 - E2 --> F[END]  
32 - end  
33 -  
34 - subgraph Services ["业务服务层"]  
35 - G[DataService]  
36 - H[ResultWriter]  
37 - end  
38 -  
39 - subgraph LLM ["LLM 集成"]  
40 - I[GLM]  
41 - J[Doubao]  
42 - K[OpenAI Compat]  
43 - end  
44 -  
45 - subgraph DB ["数据存储"]  
46 - L[(MySQL)]  
47 - end  
48 -  
49 - A --> Agent  
50 - B --> G  
51 - C --> LLM  
52 - E --> H  
53 - G --> L  
54 - H --> L  
55 -```  
56 -  
57 ----  
58 -  
59 -## 工作流节点说明  
60 -  
61 -| 节点 | 职责 | 输入 | 输出 |  
62 -|------|------|------|------|  
63 -| `fetch_part_ratio` | 获取商家组合的配件库销比数据 | dealer_grouping_id | part_ratios[] |  
64 -| `sql_agent` | LLM 分析配件数据,生成补货建议 | part_ratios[] | llm_suggestions[], part_results[] |  
65 -| `allocate_budget` | 转换 LLM 建议为补货明细 | llm_suggestions[] | details[] |  
66 -| `generate_analysis_report` | 生成分析报告 | part_ratios[], details[] | analysis_report |  
67 -  
68 ----  
69 -  
70 -## 核心数据流  
71 -  
72 -```mermaid  
73 -sequenceDiagram  
74 - participant API  
75 - participant Agent  
76 - participant SQLAgent  
77 - participant LLM  
78 - participant DB  
79 -  
80 - API->>Agent: 创建任务  
81 - Agent->>DB: 保存任务记录  
82 - Agent->>DB: 查询 part_ratio  
83 - Agent->>SQLAgent: 分析配件数据  
84 -  
85 - loop 每个配件  
86 - SQLAgent->>LLM: 发送分析请求  
87 - LLM-->>SQLAgent: 返回补货建议  
88 - end  
89 -  
90 - SQLAgent-->>Agent: 汇总建议  
91 - Agent->>DB: 保存补货明细  
92 - Agent->>DB: 更新任务状态  
93 - Agent-->>API: 返回结果  
94 -```  
95 -  
96 ----  
97 -  
98 -## 目录结构  
99 -  
100 -```  
101 -src/fw_pms_ai/  
102 -├── agent/ # LangGraph 工作流  
103 -│ ├── state.py # 状态定义 (TypedDict)  
104 -│ ├── nodes.py # 工作流节点  
105 -│ ├── sql_agent.py # SQL Agent 实现  
106 -│ └── replenishment.py # 主入口  
107 -├── api/ # REST API  
108 -├── config/ # 配置管理  
109 -├── llm/ # LLM 适配器  
110 -├── models/ # 数据模型  
111 -├── services/ # 业务服务  
112 -└── scheduler/ # 定时任务  
113 -```  
114 -  
115 ----  
116 -  
117 -## 数据库表结构  
118 -  
119 -| 表名 | 用途 |  
120 -|------|------|  
121 -| `part_ratio` | 配件库销比数据(只读) |  
122 -| `ai_replenishment_task` | 任务记录 |  
123 -| `ai_replenishment_detail` | 补货明细 |  
124 -| `ai_replenishment_part_summary` | 配件级汇总 |  
125 -| `ai_task_execution_log` | 执行日志 |  
126 -| `ai_analysis_report` | 分析报告(JSON结构化) |  
docs/商家组合维度分析需求设计.md 0 → 100644
  1 +# 商家组合维度分析报告 - 需求分析与设计文档
  2 +
  3 +> **版本**: 2.0.0
  4 +> **日期**: 2026-02-12
  5 +> **项目**: fw-pms-ai(AI配件补货建议系统)
  6 +
  7 +---
  8 +
  9 +## 1. 业务背景与目标
  10 +
  11 +### 1.1 业务痛点
  12 +
  13 +汽车配件管理面临以下核心挑战:
  14 +
  15 +| 痛点 | 描述 | 影响 |
  16 +|------|------|------|
  17 +| **库存失衡** | 部分配件长期缺货,部分配件严重呆滞 | 缺货导致客户流失,呆滞占用资金 |
  18 +| **人工决策低效** | 传统补货依赖采购员经验判断 | 效率低、易出错、难以规模化 |
  19 +| **多门店协调困难** | 同一商家组合下的多门店库存无法统一调配 | 资源利用率低,部分门店过剩而另一部分缺货 |
  20 +| **数据利用不足** | 丰富的销售数据未能有效转化为决策依据 | 补货缺乏数据支撑,决策质量参差不齐 |
  21 +
  22 +### 1.2 项目目标
  23 +
  24 +```mermaid
  25 +mindmap
  26 + root((商家组合维度分析))
  27 + 智能补货建议
  28 + LLM驱动分析
  29 + 配件级决策
  30 + 门店级分配
  31 + 风险识别与预警
  32 + 呆滞件识别
  33 + 低频件过滤
  34 + 缺货预警
  35 + 数据驱动分析报告
  36 + 库存概览
  37 + 销量分析
  38 + 库存健康度
  39 + 补货建议汇总
  40 + 可视化展示
  41 + 分析报告
  42 + 配件明细
  43 + 执行日志
  44 +```
  45 +
  46 +**核心价值主张**:
  47 +1. **智能化**:通过 LLM 自动分析库销比数据,生成专业补货建议
  48 +2. **精细化**:从商家组合维度统一分析,再下钻到配件级、门店级
  49 +3. **专业化**:输出的分析理由贴合采购人员专业度,包含具体数据指标
  50 +
  51 +---
  52 +
  53 +## 2. 功能模块设计
  54 +
  55 +### 2.1 功能架构
  56 +
  57 +```mermaid
  58 +flowchart TB
  59 + subgraph 用户层["🖥️ 用户层"]
  60 + UI[Web管理界面]
  61 + end
  62 +
  63 + subgraph 应用层["⚙️ 应用层"]
  64 + API[FastAPI 接口层]
  65 +
  66 + subgraph Agent["🤖 LangGraph Agent"]
  67 + N1[获取配件库销比<br/>fetch_part_ratio]
  68 + N2[LLM分析生成建议<br/>sql_agent]
  69 + N3[转换补货明细<br/>allocate_budget]
  70 + N4[生成分析报告<br/>generate_analysis_report]
  71 + end
  72 +
  73 + subgraph ReportSubgraph["📊 分析报告并发子图"]
  74 + R1[库存概览 LLM]
  75 + R2[销量分析 LLM]
  76 + R3[健康度 LLM]
  77 + R4[补货建议 LLM]
  78 + end
  79 + end
  80 +
  81 + subgraph 基础设施["🔧 基础设施"]
  82 + LLM[LLM服务<br/>智谱GLM/豆包/OpenAI/Anthropic]
  83 + DB[(MySQL数据库)]
  84 + end
  85 +
  86 + UI --> API
  87 + API --> Agent
  88 + N1 --> N2 --> N3 --> N4
  89 + N4 --> ReportSubgraph
  90 + N1 -.-> DB
  91 + N2 -.-> LLM
  92 + N3 -.-> DB
  93 + R1 & R2 & R3 & R4 -.-> LLM
  94 +```
  95 +
  96 +### 2.2 功能模块清单
  97 +
  98 +| 模块 | 功能 | 输入 | 输出 |
  99 +|------|------|------|------|
  100 +| **数据获取** | 获取商家组合内所有配件的库销比数据 | dealer_grouping_id | part_ratios[] |
  101 +| **LLM分析** | 按配件分组分析,生成补货建议和决策理由 | part_ratios, base_ratio | llm_suggestions[] |
  102 +| **建议转换** | 将LLM建议转换为结构化的补货明细 | llm_suggestions[] | details[], part_summaries[] |
  103 +| **分析报告** | 四大板块统计计算 + 并发LLM分析 | part_ratios, part_results | analysis_report |
  104 +
  105 +---
  106 +
  107 +## 3. 系统架构设计
  108 +
  109 +### 3.1 整体架构
  110 +
  111 +```mermaid
  112 +C4Component
  113 + title 商家组合维度分析系统 - 组件架构
  114 +
  115 + Container_Boundary(web, "Web层") {
  116 + Component(ui, "前端UI", "HTML/CSS/JS", "任务管理、结果展示")
  117 + }
  118 +
  119 + Container_Boundary(api, "API层") {
  120 + Component(routes, "路由模块", "FastAPI", "REST API接口")
  121 + Component(scheduler, "定时调度", "APScheduler", "任务调度")
  122 + }
  123 +
  124 + Container_Boundary(agent, "Agent层") {
  125 + Component(workflow, "工作流引擎", "LangGraph", "状态机编排")
  126 + Component(nodes, "节点实现", "Python", "业务逻辑")
  127 + Component(report_subgraph, "报告子图", "LangGraph 并发子图", "4路并发LLM分析")
  128 + Component(prompts, "提示词", "Markdown", "LLM指令")
  129 + }
  130 +
  131 + Container_Boundary(service, "服务层") {
  132 + Component(data, "数据服务", "Python", "数据查询")
  133 + Component(writer, "写入服务", "Python", "结果持久化")
  134 + }
  135 +
  136 + Container_Boundary(infra, "基础设施") {
  137 + ComponentDb(mysql, "MySQL", "数据库", "业务数据存储")
  138 + Component(llm, "LLM", "GLM/Doubao/OpenAI/Anthropic", "大语言模型")
  139 + }
  140 +
  141 + Rel(ui, routes, "HTTP请求")
  142 + Rel(routes, workflow, "触发任务")
  143 + Rel(workflow, nodes, "执行")
  144 + Rel(nodes, report_subgraph, "fan-out/fan-in")
  145 + Rel(nodes, prompts, "加载")
  146 + Rel(nodes, llm, "调用")
  147 + Rel(nodes, data, "查询")
  148 + Rel(nodes, writer, "写入")
  149 + Rel(data, mysql, "SQL")
  150 + Rel(writer, mysql, "SQL")
  151 +```
  152 +
  153 +### 3.2 工作流状态机
  154 +
  155 +```mermaid
  156 +stateDiagram-v2
  157 + [*] --> FetchPartRatio: 启动任务
  158 +
  159 + FetchPartRatio --> SQLAgent: 获取库销比数据
  160 + FetchPartRatio --> [*]: 无数据
  161 +
  162 + SQLAgent --> SQLAgent: 重试(错误 & 次数<3)
  163 + SQLAgent --> AllocateBudget: 分析完成
  164 + SQLAgent --> [*]: 重试失败
  165 +
  166 + AllocateBudget --> GenerateReport: 转换完成
  167 +
  168 + GenerateReport --> [*]: 生成报告
  169 +
  170 + state GenerateReport {
  171 + [*] --> 统计计算
  172 + 统计计算 --> 并发LLM子图
  173 +
  174 + state 并发LLM子图 {
  175 + [*] --> 库存概览LLM
  176 + [*] --> 销量分析LLM
  177 + [*] --> 健康度LLM
  178 + [*] --> 补货建议LLM
  179 + 库存概览LLM --> [*]
  180 + 销量分析LLM --> [*]
  181 + 健康度LLM --> [*]
  182 + 补货建议LLM --> [*]
  183 + }
  184 +
  185 + 并发LLM子图 --> 汇总写入
  186 + 汇总写入 --> [*]
  187 + }
  188 +```
  189 +
  190 +---
  191 +
  192 +## 4. 核心算法说明
  193 +
  194 +### 4.1 三层决策逻辑
  195 +
  196 +```mermaid
  197 +flowchart LR
  198 + subgraph L1["第一层: 配件级判断"]
  199 + A1[汇总商家组合内<br/>所有门店数据]
  200 + A2[计算整体库销比]
  201 + A3{是否需要补货?}
  202 + A4[生成配件级理由]
  203 + end
  204 +
  205 + subgraph L2["第二层: 门店级分配"]
  206 + B1[按库销比从低到高排序]
  207 + B2[计算各门店缺口]
  208 + B3[分配补货数量]
  209 + end
  210 +
  211 + subgraph L3["第三层: 决策理由生成"]
  212 + C1[状态判定标签]
  213 + C2[关键指标数据]
  214 + C3[缺口分析]
  215 + C4[天数说明]
  216 + end
  217 +
  218 + A1 --> A2 --> A3
  219 + A3 -->|是| L2
  220 + A3 -->|否| A4
  221 + B1 --> B2 --> B3
  222 + B3 --> L3
  223 + C1 --> C2 --> C3 --> C4
  224 +```
  225 +
  226 +### 4.2 补货数量计算公式
  227 +
  228 +```
  229 +suggest_cnt = ceil(目标库销比 × 月均销量 - 当前库存)
  230 +```
  231 +
  232 +其中:
  233 +- **有效库存** = `in_stock_unlocked_cnt` + `on_the_way_cnt` + `has_plan_cnt`
  234 +- **月均销量** = (`out_stock_cnt` + `storage_locked_cnt` + `out_stock_ongoing_cnt` + `buy_cnt`) / 3
  235 +- **资金占用** = (`in_stock_unlocked_cnt` + `on_the_way_cnt`) × `cost_price`
  236 +
  237 +### 4.3 配件分类与处理规则
  238 +
  239 +| 分类 | 判定条件 | 处理策略 |
  240 +|------|----------|----------|
  241 +| **缺货件** | 有效库存 = 0 且 月均销量 ≥ 1 | 优先补货 |
  242 +| **呆滞件** | 有效库存 > 0 且 90天出库数 = 0 | 不补货,建议清理 |
  243 +| **低频件** | 月均销量 < 1 或 出库次数 < 3 或 出库间隔 ≥ 30天 | 不补货 |
  244 +| **正常件** | 不属于以上三类 | 按缺口补货 |
  245 +
  246 +> 分类优先级:缺货件 > 呆滞件 > 低频件 > 正常件(按顺序判断,命中即止)
  247 +
  248 +### 4.4 优先级判定标准
  249 +
  250 +```mermaid
  251 +flowchart TD
  252 + A{库存状态} -->|库存=0 且 销量活跃| H[高优先级<br/>急需补货]
  253 + A -->|库销比<0.5| M[中优先级<br/>建议补货]
  254 + A -->|0.5≤库销比<目标值| L[低优先级<br/>可选补货]
  255 + A -->|库销比≥目标值| N[无需补货<br/>库存充足]
  256 +
  257 + style H fill:#ff6b6b
  258 + style M fill:#feca57
  259 + style L fill:#48dbfb
  260 + style N fill:#2ecc71
  261 +```
  262 +
  263 +---
  264 +
  265 +## 5. 数据模型设计
  266 +
  267 +### 5.1 ER图
  268 +
  269 +```mermaid
  270 +erDiagram
  271 + AI_REPLENISHMENT_TASK ||--o{ AI_REPLENISHMENT_DETAIL : contains
  272 + AI_REPLENISHMENT_TASK ||--o{ AI_REPLENISHMENT_PART_SUMMARY : contains
  273 + AI_REPLENISHMENT_TASK ||--o{ AI_TASK_EXECUTION_LOG : logs
  274 + AI_REPLENISHMENT_TASK ||--o| AI_ANALYSIS_REPORT : generates
  275 + PART_RATIO }o--|| AI_REPLENISHMENT_DETAIL : references
  276 +
  277 + AI_REPLENISHMENT_TASK {
  278 + bigint id PK
  279 + varchar task_no UK "AI-开头"
  280 + bigint group_id "集团ID"
  281 + bigint dealer_grouping_id
  282 + varchar dealer_grouping_name
  283 + bigint brand_grouping_id "品牌组合ID"
  284 + decimal plan_amount "计划采购金额"
  285 + decimal actual_amount "实际分配金额"
  286 + int part_count "配件数量"
  287 + decimal base_ratio "基准库销比"
  288 + tinyint status "0运行/1成功/2失败"
  289 + varchar llm_provider
  290 + varchar llm_model
  291 + int llm_total_tokens
  292 + varchar statistics_date
  293 + datetime start_time
  294 + datetime end_time
  295 + }
  296 +
  297 + AI_REPLENISHMENT_DETAIL {
  298 + bigint id PK
  299 + varchar task_no FK
  300 + bigint group_id
  301 + bigint dealer_grouping_id
  302 + bigint brand_grouping_id
  303 + bigint shop_id "库房ID"
  304 + varchar shop_name
  305 + varchar part_code "配件编码"
  306 + varchar part_name
  307 + varchar unit
  308 + decimal cost_price
  309 + decimal current_ratio "当前库销比"
  310 + decimal base_ratio "基准库销比"
  311 + decimal post_plan_ratio "计划后库销比"
  312 + decimal valid_storage_cnt "有效库存"
  313 + decimal avg_sales_cnt "月均销量"
  314 + int suggest_cnt "建议数量"
  315 + decimal suggest_amount "建议金额"
  316 + text suggestion_reason "决策理由"
  317 + int priority "1高/2中/3低"
  318 + float llm_confidence "LLM置信度"
  319 + }
  320 +
  321 + AI_REPLENISHMENT_PART_SUMMARY {
  322 + bigint id PK
  323 + varchar task_no FK
  324 + bigint group_id
  325 + bigint dealer_grouping_id
  326 + varchar part_code "配件编码"
  327 + varchar part_name
  328 + varchar unit
  329 + decimal cost_price
  330 + decimal total_storage_cnt "总库存"
  331 + decimal total_avg_sales_cnt "总月均销量"
  332 + decimal group_current_ratio "商家组合库销比"
  333 + int total_suggest_cnt "总建议数量"
  334 + decimal total_suggest_amount "总建议金额"
  335 + int shop_count "涉及门店数"
  336 + int need_replenishment_shop_count "需补货门店数"
  337 + text part_decision_reason "配件级理由"
  338 + int priority "1高/2中/3低"
  339 + float llm_confidence
  340 + }
  341 +
  342 + AI_TASK_EXECUTION_LOG {
  343 + bigint id PK
  344 + varchar task_no FK
  345 + bigint group_id
  346 + bigint brand_grouping_id
  347 + varchar brand_grouping_name
  348 + bigint dealer_grouping_id
  349 + varchar dealer_grouping_name
  350 + varchar step_name "步骤名称"
  351 + int step_order "步骤顺序"
  352 + tinyint status "0进行/1成功/2失败/3跳过"
  353 + text input_data "输入JSON"
  354 + text output_data "输出JSON"
  355 + text error_message
  356 + int retry_count
  357 + text sql_query
  358 + text llm_prompt
  359 + text llm_response
  360 + int llm_tokens "Token消耗"
  361 + int execution_time_ms "耗时"
  362 + }
  363 +
  364 + AI_ANALYSIS_REPORT {
  365 + bigint id PK
  366 + varchar task_no FK
  367 + bigint group_id
  368 + bigint dealer_grouping_id
  369 + varchar dealer_grouping_name
  370 + bigint brand_grouping_id
  371 + varchar report_type "默认replenishment"
  372 + json inventory_overview "库存概览"
  373 + json sales_analysis "销量分析"
  374 + json inventory_health "健康度"
  375 + json replenishment_summary "补货建议"
  376 + varchar llm_provider
  377 + varchar llm_model
  378 + int llm_tokens
  379 + int execution_time_ms
  380 + }
  381 +
  382 + PART_RATIO {
  383 + bigint id PK
  384 + bigint shop_id
  385 + varchar part_code
  386 + decimal in_stock_unlocked_cnt "在库未锁定"
  387 + decimal on_the_way_cnt "在途"
  388 + decimal has_plan_cnt "已有计划"
  389 + decimal out_stock_cnt "出库数"
  390 + decimal storage_locked_cnt "库存锁定"
  391 + decimal out_stock_ongoing_cnt "出库在途"
  392 + decimal buy_cnt "采购数"
  393 + decimal cost_price "成本价"
  394 + int out_times "出库次数"
  395 + int out_duration "平均出库间隔"
  396 + }
  397 +```
  398 +
  399 +### 5.2 核心表结构
  400 +
  401 +#### ai_replenishment_task(任务主表)
  402 +| 字段 | 类型 | 说明 |
  403 +|------|------|------|
  404 +| task_no | VARCHAR(32) | 任务编号,AI-开头,唯一 |
  405 +| group_id | BIGINT | 集团ID |
  406 +| dealer_grouping_id | BIGINT | 商家组合ID |
  407 +| dealer_grouping_name | VARCHAR(128) | 商家组合名称 |
  408 +| brand_grouping_id | BIGINT | 品牌组合ID |
  409 +| plan_amount | DECIMAL(14,2) | 计划采购金额(预算) |
  410 +| actual_amount | DECIMAL(14,2) | 实际分配金额 |
  411 +| part_count | INT | 配件数量 |
  412 +| base_ratio | DECIMAL(10,4) | 基准库销比 |
  413 +| status | TINYINT | 状态: 0运行中/1成功/2失败 |
  414 +| llm_provider | VARCHAR(32) | LLM提供商 |
  415 +| llm_model | VARCHAR(64) | LLM模型名称 |
  416 +| statistics_date | VARCHAR(16) | 统计日期 |
  417 +| start_time / end_time | DATETIME | 任务执行起止时间 |
  418 +
  419 +#### ai_replenishment_part_summary(配件汇总表)
  420 +| 字段 | 类型 | 说明 |
  421 +|------|------|------|
  422 +| task_no | VARCHAR(32) | 任务编号 |
  423 +| group_id | BIGINT | 集团ID |
  424 +| dealer_grouping_id | BIGINT | 商家组合ID |
  425 +| part_code | VARCHAR(64) | 配件编码 |
  426 +| part_name | VARCHAR(256) | 配件名称 |
  427 +| cost_price | DECIMAL(14,2) | 成本价 |
  428 +| total_storage_cnt | DECIMAL(14,2) | 商家组合内总库存 |
  429 +| total_avg_sales_cnt | DECIMAL(14,2) | 总月均销量 |
  430 +| group_current_ratio | DECIMAL(10,4) | 商家组合级库销比 |
  431 +| total_suggest_cnt | INT | 总建议数量 |
  432 +| total_suggest_amount | DECIMAL(14,2) | 总建议金额 |
  433 +| shop_count | INT | 涉及门店数 |
  434 +| need_replenishment_shop_count | INT | 需补货门店数 |
  435 +| part_decision_reason | TEXT | 配件级补货理由 |
  436 +| priority | INT | 优先级: 1高/2中/3低 |
  437 +
  438 +#### ai_analysis_report(分析报告表)
  439 +| 字段 | 类型 | 说明 |
  440 +|------|------|------|
  441 +| task_no | VARCHAR(32) | 任务编号 |
  442 +| group_id | BIGINT | 集团ID |
  443 +| dealer_grouping_id | BIGINT | 商家组合ID |
  444 +| report_type | VARCHAR(32) | 报告类型(默认 replenishment) |
  445 +| inventory_overview | JSON | 库存总体概览(stats + llm_analysis) |
  446 +| sales_analysis | JSON | 销量分析(stats + llm_analysis) |
  447 +| inventory_health | JSON | 库存健康度(stats + chart_data + llm_analysis) |
  448 +| replenishment_summary | JSON | 补货建议(stats + llm_analysis) |
  449 +| llm_provider | VARCHAR(32) | LLM提供商 |
  450 +| llm_model | VARCHAR(64) | LLM模型名称 |
  451 +| llm_tokens | INT | LLM Token总消耗 |
  452 +| execution_time_ms | INT | 执行耗时(毫秒) |
  453 +
  454 +---
  455 +
  456 +## 6. API 接口设计
  457 +
  458 +### 6.1 接口总览
  459 +
  460 +```mermaid
  461 +flowchart LR
  462 + subgraph Tasks["任务管理"]
  463 + T1["GET /api/tasks"]
  464 + T2["GET /api/tasks/:task_no"]
  465 + end
  466 +
  467 + subgraph Details["明细查询"]
  468 + D1["GET /api/tasks/:task_no/details"]
  469 + D2["GET /api/tasks/:task_no/part-summaries"]
  470 + D3["GET /api/tasks/:task_no/parts/:part_code/shops"]
  471 + end
  472 +
  473 + subgraph Reports["报告模块"]
  474 + R1["GET /api/tasks/:task_no/analysis-report"]
  475 + R2["GET /api/tasks/:task_no/logs"]
  476 + end
  477 +
  478 + subgraph Health["健康检查"]
  479 + H1["GET /health"]
  480 + end
  481 +```
  482 +
  483 +### 6.2 核心接口定义
  484 +
  485 +#### 1. 获取任务列表
  486 +```
  487 +GET /api/tasks?page=1&page_size=20&status=1&dealer_grouping_id=100&statistics_date=20260212
  488 +```
  489 +
  490 +**响应示例**:
  491 +```json
  492 +{
  493 + "items": [
  494 + {
  495 + "id": 1,
  496 + "task_no": "AI-ABC12345",
  497 + "group_id": 2,
  498 + "dealer_grouping_id": 100,
  499 + "dealer_grouping_name": "华东区商家组合",
  500 + "brand_grouping_id": 50,
  501 + "plan_amount": 100000.00,
  502 + "actual_amount": 89520.50,
  503 + "part_count": 156,
  504 + "base_ratio": 1.5000,
  505 + "status": 1,
  506 + "status_text": "成功",
  507 + "llm_provider": "openai_compat",
  508 + "llm_model": "glm-4-7-251222",
  509 + "llm_total_tokens": 8500,
  510 + "statistics_date": "20260212",
  511 + "start_time": "2026-02-12 02:00:00",
  512 + "end_time": "2026-02-12 02:05:30",
  513 + "duration_seconds": 330,
  514 + "create_time": "2026-02-12 02:00:00"
  515 + }
  516 + ],
  517 + "total": 100,
  518 + "page": 1,
  519 + "page_size": 20
  520 +}
  521 +```
  522 +
  523 +#### 2. 获取配件汇总(商家组合维度)
  524 +```
  525 +GET /api/tasks/{task_no}/part-summaries?sort_by=total_suggest_amount&sort_order=desc&priority=1
  526 +```
  527 +
  528 +**响应示例**:
  529 +```json
  530 +{
  531 + "items": [
  532 + {
  533 + "id": 1,
  534 + "task_no": "AI-ABC12345",
  535 + "part_code": "C211F280503",
  536 + "part_name": "机油滤芯",
  537 + "unit": "个",
  538 + "cost_price": 140.00,
  539 + "total_storage_cnt": 25,
  540 + "total_avg_sales_cnt": 18.5,
  541 + "group_current_ratio": 1.35,
  542 + "group_post_plan_ratio": 2.0,
  543 + "total_suggest_cnt": 12,
  544 + "total_suggest_amount": 1680.00,
  545 + "shop_count": 5,
  546 + "need_replenishment_shop_count": 3,
  547 + "part_decision_reason": "【配件决策】该配件在商家组合内总库存25件...",
  548 + "priority": 1,
  549 + "llm_confidence": 0.85
  550 + }
  551 + ],
  552 + "total": 50,
  553 + "page": 1,
  554 + "page_size": 50
  555 +}
  556 +```
  557 +
  558 +#### 3. 获取门店级明细
  559 +```
  560 +GET /api/tasks/{task_no}/parts/{part_code}/shops
  561 +```
  562 +
  563 +**响应示例**:
  564 +```json
  565 +{
  566 + "total": 3,
  567 + "items": [
  568 + {
  569 + "id": 101,
  570 + "task_no": "AI-ABC12345",
  571 + "shop_id": 1001,
  572 + "shop_name": "杭州西湖店",
  573 + "part_code": "C211F280503",
  574 + "part_name": "机油滤芯",
  575 + "cost_price": 140.00,
  576 + "valid_storage_cnt": 5,
  577 + "avg_sales_cnt": 6.2,
  578 + "current_ratio": 0.81,
  579 + "post_plan_ratio": 1.61,
  580 + "suggest_cnt": 5,
  581 + "suggest_amount": 700.00,
  582 + "suggestion_reason": "「建议补货」当前库存5件,月均销量6.2件...",
  583 + "priority": 1
  584 + }
  585 + ]
  586 +}
  587 +```
  588 +
  589 +#### 4. 获取配件建议明细
  590 +```
  591 +GET /api/tasks/{task_no}/details?page=1&page_size=50&sort_by=suggest_amount&sort_order=desc&part_code=C211
  592 +```
  593 +
  594 +#### 5. 获取分析报告
  595 +```
  596 +GET /api/tasks/{task_no}/analysis-report
  597 +```
  598 +
  599 +**响应示例**:
  600 +```json
  601 +{
  602 + "id": 1,
  603 + "task_no": "AI-ABC12345",
  604 + "group_id": 2,
  605 + "dealer_grouping_id": 100,
  606 + "report_type": "replenishment",
  607 + "inventory_overview": {
  608 + "stats": {
  609 + "total_valid_storage_cnt": 2500,
  610 + "total_valid_storage_amount": 350000.0,
  611 + "total_capital_occupation": 280000.0,
  612 + "overall_ratio": 1.35,
  613 + "part_count": 156
  614 + },
  615 + "llm_analysis": { "..." : "LLM生成的分析结论" }
  616 + },
  617 + "sales_analysis": {
  618 + "stats": { "total_avg_sales_cnt": 1850, "..." : "..." },
  619 + "llm_analysis": { "..." : "..." }
  620 + },
  621 + "inventory_health": {
  622 + "stats": { "shortage": { "count": 12, "amount": 5000 }, "..." : "..." },
  623 + "chart_data": { "labels": ["缺货件","呆滞件","低频件","正常件"], "..." : "..." },
  624 + "llm_analysis": { "..." : "..." }
  625 + },
  626 + "replenishment_summary": {
  627 + "stats": { "urgent": { "count": 15, "amount": 25000 }, "..." : "..." },
  628 + "llm_analysis": { "..." : "..." }
  629 + },
  630 + "llm_tokens": 3200,
  631 + "execution_time_ms": 12000
  632 +}
  633 +```
  634 +
  635 +#### 6. 获取执行日志
  636 +```
  637 +GET /api/tasks/{task_no}/logs
  638 +```
  639 +
  640 +---
  641 +
  642 +## 7. 前端交互设计
  643 +
  644 +### 7.1 页面结构
  645 +
  646 +```mermaid
  647 +flowchart TB
  648 + subgraph Dashboard["仪表盘"]
  649 + S1[统计卡片]
  650 + S2[最近任务列表]
  651 + end
  652 +
  653 + subgraph TaskList["任务列表页"]
  654 + L1[筛选条件]
  655 + L2[任务表格]
  656 + L3[分页控件]
  657 + end
  658 +
  659 + subgraph TaskDetail["任务详情页"]
  660 + D1[任务头部信息]
  661 + D2[统计卡片]
  662 +
  663 + subgraph Tabs["标签页"]
  664 + T1[配件明细]
  665 + T2[分析报告]
  666 + T3[执行日志]
  667 + T4[任务信息]
  668 + end
  669 + end
  670 +
  671 + Dashboard --> TaskList --> TaskDetail
  672 +```
  673 +
  674 +### 7.2 配件明细交互
  675 +
  676 +```mermaid
  677 +sequenceDiagram
  678 + participant U as 用户
  679 + participant UI as 前端UI
  680 + participant API as 后端API
  681 +
  682 + U->>UI: 点击任务详情
  683 + UI->>API: GET /api/tasks/{task_no}/part-summaries
  684 + API-->>UI: 返回配件汇总列表
  685 + UI->>UI: 渲染配件表格(可排序/筛选/优先级)
  686 +
  687 + U->>UI: 点击展开某配件
  688 + UI->>API: GET /api/tasks/{task_no}/parts/{part_code}/shops
  689 + API-->>UI: 返回门店级明细
  690 + UI->>UI: 展开子表格显示门店数据
  691 +
  692 + Note over UI: 门店数据包含:<br/>库存、销量、库销比<br/>建议数量、建议理由<br/>计划后库销比
  693 +```
  694 +
  695 +### 7.3 关键UI组件
  696 +
  697 +| 组件 | 功能 | 交互方式 |
  698 +|------|------|----------|
  699 +| **配件汇总表格** | 展示商家组合维度的配件建议 | 支持排序、筛选、分页、优先级筛选 |
  700 +| **可展开行** | 展示配件下的门店明细 | 点击行展开/收起 |
  701 +| **配件决策卡片** | 显示LLM生成的配件级理由 | 展开配件时显示 |
  702 +| **库销比指示器** | 直观显示库销比健康度 | 颜色渐变(红/黄/绿) |
  703 +| **分析报告面板** | 四大板块数据驱动展示 | 统计数据 + LLM 分析 + 图表 |
  704 +
  705 +---
  706 +
  707 +## 8. 分析报告设计
  708 +
  709 +### 8.1 报告模块结构
  710 +
  711 +分析报告由 **统计计算** + **4路并发 LLM 分析** 的 LangGraph 子图生成。每个板块包含 `stats`(统计数据)和 `llm_analysis`(LLM 分析结论)。
  712 +
  713 +```mermaid
  714 +flowchart TB
  715 + subgraph Report["分析报告四大板块"]
  716 + M1["板块1: 库存总体概览<br/>inventory_overview"]
  717 + M2["板块2: 销量分析<br/>sales_analysis"]
  718 + M3["板块3: 库存构成健康度<br/>inventory_health"]
  719 + M4["板块4: 补货建议生成情况<br/>replenishment_summary"]
  720 + end
  721 +
  722 + M1 --> S1[有效库存/资金占用]
  723 + M1 --> S2[在库/在途/已有计划]
  724 + M1 --> S3[整体库销比]
  725 +
  726 + M2 --> R1[月均销量/销售金额]
  727 + M2 --> R2[有销量/无销量配件数]
  728 + M2 --> R3[出库/锁定/采购统计]
  729 +
  730 + M3 --> P1[缺货件统计]
  731 + M3 --> P2[呆滞件统计]
  732 + M3 --> P3[低频件统计]
  733 + M3 --> P4[正常件统计]
  734 + M3 --> P5[chart_data图表数据]
  735 +
  736 + M4 --> E1[急需补货统计]
  737 + M4 --> E2[建议补货统计]
  738 + M4 --> E3[可选补货统计]
  739 +```
  740 +
  741 +### 8.2 各板块统计计算与LLM分析
  742 +
  743 +| 板块 | 统计计算 | LLM 分析 | 提示词文件 |
  744 +|------|---------|---------|-----------|
  745 +| **库存概览** | 有效库存、资金占用、配件总数、整体库销比 | 库存状况综合评价 | `report_inventory_overview.md` |
  746 +| **销量分析** | 月均销量、出库频次、有/无销量配件数 | 销售趋势洞察 | `report_sales_analysis.md` |
  747 +| **库存健康度** | 缺货/呆滞/低频/正常分类统计(数量/金额/占比) | 健康度风险提示 | `report_inventory_health.md` |
  748 +| **补货建议汇总** | 按优先级(急需/建议/可选)分类统计 | 补货策略建议 | `report_replenishment_summary.md` |
  749 +
  750 +> 四个 LLM 分析节点使用 LangGraph 子图 **并发执行**(fan-out / fan-in),单板块失败不影响其他板块。
  751 +
  752 +### 8.3 并发子图实现
  753 +
  754 +```mermaid
  755 +flowchart LR
  756 + START --> A[库存概览LLM] --> END2[END]
  757 + START --> B[销量分析LLM] --> END2
  758 + START --> C[健康度LLM] --> END2
  759 + START --> D[补货建议LLM] --> END2
  760 +```
  761 +
  762 +子图采用 `ReportLLMState` TypedDict 定义状态,使用 `Annotated` reducer 合并并发结果:
  763 +- 分析结果:`_merge_dict`(保留非 None)
  764 +- Token 用量:`_sum_int`(累加)
  765 +
  766 +---
  767 +
  768 +## 9. 技术选型
  769 +
  770 +| 组件 | 技术 | 选型理由 |
  771 +|------|------|----------|
  772 +| **编程语言** | Python 3.11+ | 丰富的AI/ML生态 |
  773 +| **Agent框架** | LangChain + LangGraph | 成熟的LLM编排框架,支持并发子图 |
  774 +| **API框架** | FastAPI | 高性能、自动文档 |
  775 +| **数据库** | MySQL | 与主系统保持一致 |
  776 +| **LLM** | 智谱GLM / 豆包 / OpenAI兼容 / Anthropic兼容 | 多模型支持,优先级自动选择 |
  777 +| **前端** | 原生HTML+CSS+JS | 轻量级,无构建依赖 |
  778 +
  779 +### LLM 客户端优先级
  780 +
  781 +| 优先级 | 客户端 | 触发条件 |
  782 +|--------|--------|----------|
  783 +| 1 | `OpenAICompatClient` | `OPENAI_COMPAT_API_KEY` 已配置 |
  784 +| 2 | `AnthropicCompatClient` | `ANTHROPIC_API_KEY` 已配置 |
  785 +| 3 | `GLMClient` | `GLM_API_KEY` 已配置 |
  786 +| 4 | `DoubaoClient` | `DOUBAO_API_KEY` 已配置 |
  787 +
  788 +---
  789 +
  790 +## 10. 部署与运维
  791 +
  792 +### 10.1 部署架构
  793 +
  794 +```mermaid
  795 +flowchart LR
  796 + subgraph Client["客户端"]
  797 + Browser[浏览器]
  798 + end
  799 +
  800 + subgraph Server["服务器"]
  801 + Nginx[Nginx<br/>静态资源/反向代理]
  802 + API[FastAPI<br/>API服务]
  803 + Scheduler[APScheduler<br/>定时任务]
  804 + end
  805 +
  806 + subgraph External["外部服务"]
  807 + LLM[LLM API]
  808 + DB[(MySQL)]
  809 + end
  810 +
  811 + Browser --> Nginx
  812 + Nginx --> API
  813 + API --> LLM
  814 + API --> DB
  815 + Scheduler --> API
  816 +```
  817 +
  818 +### 10.2 关键监控指标
  819 +
  820 +| 指标 | 阈值 | 告警方式 |
  821 +|------|------|----------|
  822 +| 任务成功率 | < 95% | 邮件 |
  823 +| LLM响应时间 | > 30s | 日志 |
  824 +| Token消耗 | > 10000/任务 | 日志 |
  825 +| API响应时间 | > 2s | 监控 |
  826 +
  827 +---
  828 +
  829 +## 附录
  830 +
  831 +### A. 术语表
  832 +
  833 +| 术语 | 定义 |
  834 +|------|------|
  835 +| 商家组合 | 多个经销商/门店的逻辑分组 |
  836 +| 库销比 | 库存数量 / 月均销量,衡量库存健康度 |
  837 +| 呆滞件 | 有库存但90天无出库数的配件 |
  838 +| 低频件 | 月均销量<1 或 出库次数<3 或 出库间隔≥30天的配件 |
  839 +| 有效库存 | 在库未锁定 + 在途 + 已有计划 |
  840 +| 资金占用 | (在库未锁定 + 在途) × 成本价 |
  841 +
  842 +### B. 参考文档
  843 +
  844 +- [docs/architecture.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/docs/architecture.md) - 系统架构文档
  845 +- [prompts/part_shop_analysis.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/part_shop_analysis.md) - 配件分析提示词
  846 +- [prompts/report_inventory_overview.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/report_inventory_overview.md) - 库存概览提示词
  847 +- [prompts/report_sales_analysis.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/report_sales_analysis.md) - 销量分析提示词
  848 +- [prompts/report_inventory_health.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/report_inventory_health.md) - 库存健康度提示词
  849 +- [prompts/report_replenishment_summary.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/report_replenishment_summary.md) - 补货建议提示词
  850 +
  851 +### C. 版本变更记录
  852 +
  853 +| 版本 | 日期 | 变更说明 |
  854 +|------|------|----------|
  855 +| 1.0.0 | 2026-02-09 | 初始版本 |
  856 +| 2.0.0 | 2026-02-12 | 根据实际实现更新:分析报告重构为四大数据驱动板块、ER图更新、API路径和字段对齐、新增LLM客户端等 |
prompts/analysis_report.md deleted
1 -# 智能补货建议分析报告  
2 -  
3 -你是一位资深汽车配件采购顾问。AI系统已经基于全量数据生成了补货建议的**多维统计分布数据**,现在需要你站在更宏观的视角,为采购决策者提供**整体性分析**。  
4 -  
5 -> **核心定位**: 统计数据揭示了补货的宏观特征与分布情况,本报告聚焦于"整体策略、风险预警、资金规划"等**决策层面**的洞察。  
6 -  
7 ----  
8 -  
9 -## 商家组合信息  
10 -  
11 -| 项目 | 数值 |  
12 -|------|------|  
13 -| 商家组合ID | {dealer_grouping_id} |  
14 -| 商家组合名称 | {dealer_grouping_name} |  
15 -| 报告生成日期 | {statistics_date} |  
16 -  
17 ----  
18 -  
19 -## 本期补货建议概览 (统计摘要)  
20 -  
21 -{suggestion_summary}  
22 -  
23 ----  
24 -  
25 -## 库存健康度参考  
26 -  
27 -| 状态分类 | 配件数量 | 涉及金额 |  
28 -|----------|----------|----------|  
29 -| 缺货件 | {shortage_cnt} | {shortage_amount} |  
30 -| 呆滞件 | {stagnant_cnt} | {stagnant_amount} |  
31 -| 低频件 | {low_freq_cnt} | {low_freq_amount} |  
32 -  
33 ----  
34 -  
35 -## 分析任务  
36 -  
37 -请严格按以下4个模块输出 **JSON格式** 的整体分析报告。  
38 -  
39 -**注意**: 分析应基于提供的统计分布数据(如优先级、价格区间、周转频次等),按以下**宏观维度**展开。  
40 -  
41 -### 模块1: 整体态势研判 (overall_assessment)  
42 -  
43 -从全局视角评估本次补货建议:  
44 -  
45 -1. **补货规模评估**: 结合涉及配件数和总金额,评估本期补货的力度和资金需求规模。  
46 -2. **结构特征分析**: 基于价格区间和周转频次分布,分析补货建议的结构特征(如是否偏向高频低价件,或存在大量低频高价件)。  
47 -3. **时机判断**: 当前是否处于补货的有利时机?需要考虑哪些时间因素(如节假日、促销季、供应商备货周期)?  
48 -  
49 -### 模块2: 风险预警与应对 (risk_alerts)  
50 -  
51 -识别本次补货可能面临的风险并给出应对建议:  
52 -  
53 -1. **供应风险**: 高频或高优先级配件的供应保障是否关键?  
54 -2. **资金风险**: 大额补货配件(≥5000元)的占比是否过高?是否构成资金压力?  
55 -3. **库存结构风险**: 低频配件的补货比例是否合理?是否存在积压风险?  
56 -4. **执行重点**: 针对高优先级或大额补货的配件,建议采取什么复核策略?  
57 -  
58 -### 模块3: 采购策略建议 (procurement_strategy)  
59 -  
60 -提供整体性的采购执行策略:  
61 -  
62 -1. **优先级排序原则**: 结合优先级分布数据,给出资金分配和采购执行的先后顺序建议。  
63 -2. **批量采购机会**: 对于低价高频的配件,是否建议采用批量采购策略以优化成本?  
64 -3. **分批采购建议**: 对于大额或低频配件,是否建议分批次补货以控制风险?  
65 -4. **供应商协调要点**: 针对本期补货的结构特征(如大额占比高或高频占比高),与供应商沟通的侧重点是什么?  
66 -  
67 -### 模块4: 效果预期与建议 (expected_impact)  
68 -  
69 -预估按建议执行后的整体效果:  
70 -  
71 -1. **库存健康度改善**: 补货后整体库存结构预计如何变化?缺货率预计下降多少?  
72 -2. **资金效率预估**: 本期补货的预计投入产出如何?资金周转是否会改善?  
73 -3. **后续关注点**: 补货完成后需要持续关注哪些指标或配件?下一步建议行动是什么?  
74 -  
75 ----  
76 -  
77 -## 输出格式  
78 -  
79 -直接输出JSON对象,**不要**包含 ```json 标记:  
80 -  
81 -{{  
82 - "overall_assessment": {{  
83 - "scale_evaluation": {{  
84 - "current_vs_historical": "与历史同期对比结论",  
85 - "possible_reasons": "规模变化的可能原因"  
86 - }},  
87 - "structure_analysis": {{  
88 - "category_distribution": "品类分布特征",  
89 - "price_range_distribution": "价格区间分布特征",  
90 - "turnover_distribution": "周转频次分布特征",  
91 - "imbalance_warning": "是否存在失衡及说明"  
92 - }},  
93 - "timing_judgment": {{  
94 - "is_favorable": true或false,  
95 - "timing_factors": "需要考虑的时间因素",  
96 - "recommendation": "时机相关建议"  
97 - }}  
98 - }},  
99 - "risk_alerts": {{  
100 - "supply_risks": [  
101 - {{  
102 - "risk_type": "风险类型(缺货/涨价/交期延长等)",  
103 - "affected_scope": "影响范围描述",  
104 - "likelihood": "可能性评估(高/中/低)",  
105 - "mitigation": "应对建议"  
106 - }}  
107 - ],  
108 - "capital_risks": {{  
109 - "cash_flow_pressure": "资金压力评估",  
110 - "stagnation_warning": "呆滞风险提示",  
111 - "recommendation": "资金风险应对建议"  
112 - }},  
113 - "market_risks": [  
114 - {{  
115 - "risk_description": "市场风险描述",  
116 - "affected_parts": "影响配件范围",  
117 - "recommendation": "应对建议"  
118 - }}  
119 - ],  
120 - "execution_anomalies": [  
121 - {{  
122 - "anomaly_type": "异常类型",  
123 - "description": "异常描述",  
124 - "review_suggestion": "复核建议"  
125 - }}  
126 - ]  
127 - }},  
128 - "procurement_strategy": {{  
129 - "priority_principle": {{  
130 - "tier1_criteria": "第一优先级标准及说明",  
131 - "tier2_criteria": "第二优先级标准及说明",  
132 - "tier3_criteria": "可延后采购的标准及说明"  
133 - }},  
134 - "batch_opportunities": {{  
135 - "potential_savings": "潜在节省金额或比例",  
136 - "applicable_categories": "适用品类或供应商",  
137 - "execution_suggestion": "具体操作建议"  
138 - }},  
139 - "phased_procurement": {{  
140 - "recommended_parts": "建议分批采购的配件范围",  
141 - "suggested_rhythm": "建议的采购节奏"  
142 - }},  
143 - "supplier_coordination": {{  
144 - "key_communications": "关键沟通事项",  
145 - "timing_suggestions": "沟通时机建议"  
146 - }}  
147 - }},  
148 - "expected_impact": {{  
149 - "inventory_health": {{  
150 - "structure_improvement": "库存结构改善预期",  
151 - "shortage_reduction": "缺货率预计下降幅度"  
152 - }},  
153 - "capital_efficiency": {{  
154 - "investment_amount": 本期补货投入金额,  
155 - "expected_return": "预期收益描述",  
156 - "turnover_improvement": "周转改善预期"  
157 - }},  
158 - "follow_up_actions": {{  
159 - "key_metrics_to_watch": "需持续关注的指标",  
160 - "next_steps": "下一步建议行动"  
161 - }}  
162 - }}  
163 -}}  
164 -  
165 ----  
166 -  
167 -## 重要约束  
168 -  
169 -1. **输出必须是合法的JSON对象**  
170 -2. **所有金额单位为元,保留2位小数**  
171 -3. **聚焦宏观分析,不要重复明细中已有的配件级别信息**  
172 -4. **风险和效果预估尽量量化**  
173 -5. **策略建议要具体可执行,避免空泛描述**  
174 -6. **分析基于提供的汇总数据,保持客观理性**  
prompts/report_inventory_health.md 0 → 100644
  1 +# 库存健康度分析提示词
  2 +
  3 +你是一位汽车配件库存健康度诊断专家,擅长从库存结构数据中识别问题并提出改善方案。请基于以下健康度统计数据,进行专业的库存健康度诊断。
  4 +
  5 +---
  6 +
  7 +## 统计数据
  8 +
  9 +| 指标 | 数值 |
  10 +|------|------|
  11 +| 配件总种类数 | {total_count} |
  12 +| 库存总金额 | {total_amount} 元 |
  13 +
  14 +### 各类型配件统计
  15 +
  16 +| 类型 | 数量 | 数量占比 | 金额(元) | 金额占比 |
  17 +|------|------|----------|------------|----------|
  18 +| 缺货件 | {shortage_count} | {shortage_count_pct}% | {shortage_amount} | {shortage_amount_pct}% |
  19 +| 呆滞件 | {stagnant_count} | {stagnant_count_pct}% | {stagnant_amount} | {stagnant_amount_pct}% |
  20 +| 低频件 | {low_freq_count} | {low_freq_count_pct}% | {low_freq_amount} | {low_freq_amount_pct}% |
  21 +| 正常件 | {normal_count} | {normal_count_pct}% | {normal_amount} | {normal_amount_pct}% |
  22 +
  23 +---
  24 +
  25 +## 术语说明
  26 +
  27 +- **缺货件**: 有效库存 = 0 且月均销量 >= 1,有需求但无库存
  28 +- **呆滞件**: 有效库存 > 0 且90天出库数 = 0,有库存但无销售
  29 +- **低频件**: 月均销量 < 1 或出库次数 < 3 或出库间隔 >= 30天
  30 +- **正常件**: 不属于以上三类的配件
  31 +
  32 +---
  33 +
  34 +## 当前季节信息
  35 +
  36 +- **当前季节**: {current_season}
  37 +- **统计日期**: {statistics_date}
  38 +
  39 +---
  40 +
  41 +## 季节性因素参考
  42 +
  43 +| 季节 | 健康度评估调整 | 特别关注 |
  44 +|------|--------------|---------|
  45 +| 春季(3-5月) | 呆滞件中可能包含冬季配件,属正常现象 | 关注冬季配件是否及时清理 |
  46 +| 夏季(6-8月) | 制冷配件缺货风险高,需重点关注 | 空调、冷却系统配件缺货影响大 |
  47 +| 秋季(9-11月) | 夏季配件可能转为低频,需提前处理 | 关注夏季配件库存消化 |
  48 +| 冬季(12-2月) | 电瓶、暖风配件缺货影响大 | 春节前缺货损失更大,需提前备货 |
  49 +
  50 +---
  51 +
  52 +## 分析框架与判断标准
  53 +
  54 +### 健康度评分标准
  55 +| 正常件数量占比 | 健康度等级 | 说明 |
  56 +|---------------|-----------|------|
  57 +| > 70% | 健康 | 库存结构良好,继续保持 |
  58 +| 50% - 70% | 亚健康 | 存在优化空间,需关注问题件 |
  59 +| < 50% | 不健康 | 库存结构严重失衡,需立即改善 |
  60 +
  61 +### 各类型问题件风险评估标准
  62 +| 类型 | 数量占比阈值 | 金额占比阈值 | 风险等级 |
  63 +|------|-------------|-------------|---------|
  64 +| 缺货件 | > 10% | - | 高风险(影响销售) |
  65 +| 呆滞件 | > 15% | > 20% | 高风险(资金占用) |
  66 +| 低频件 | > 25% | > 30% | 中风险(周转效率) |
  67 +
  68 +### 资金释放潜力评估
  69 +| 类型 | 可释放比例 | 释放方式 |
  70 +|------|-----------|---------|
  71 +| 呆滞件 | 60%-80% | 促销清仓、退货供应商、调拨其他门店 |
  72 +| 低频件 | 30%-50% | 降价促销、减少补货、逐步淘汰 |
  73 +
  74 +---
  75 +
  76 +## 分析任务
  77 +
  78 +请严格按照以下步骤进行分析,每一步都要展示推理过程:
  79 +
  80 +### 步骤1:健康度评分
  81 +- 读取正常件数量占比
  82 +- 对照健康度评分标准,确定健康度等级
  83 +- 说明判断依据
  84 +
  85 +### 步骤2:问题件诊断
  86 +对每类问题件进行分析:
  87 +
  88 +**缺货件分析:**
  89 +- 对照风险阈值(数量占比>10%),判断风险等级
  90 +- 分析缺货对业务的影响(销售损失、客户流失)
  91 +- 推断可能原因(补货不及时、需求预测不准、供应链问题)
  92 +
  93 +**呆滞件分析:**
  94 +- 对照风险阈值(数量占比>15%或金额占比>20%),判断风险等级
  95 +- 分析呆滞对资金的影响
  96 +- 推断可能原因(采购决策失误、市场变化、产品更新换代)
  97 +
  98 +**低频件分析:**
  99 +- 对照风险阈值(数量占比>25%或金额占比>30%),判断风险等级
  100 +- 分析低频件对SKU效率的影响
  101 +- 推断可能原因(长尾需求、季节性产品、新品导入)
  102 +
  103 +### 步骤3:资金释放机会评估
  104 +- 计算呆滞件可释放资金 = 呆滞件金额 × 可释放比例(60%-80%)
  105 +- 计算低频件可释放资金 = 低频件金额 × 可释放比例(30%-50%)
  106 +- 给出具体的资金释放行动方案
  107 +
  108 +### 步骤4:改善优先级排序
  109 +- 根据风险等级和影响程度,排序问题类型
  110 +- 给出2-3条优先级最高的改善行动
  111 +
  112 +---
  113 +
  114 +## 输出格式
  115 +
  116 +直接输出JSON对象,**不要**包含 ```json 标记:
  117 +
  118 +{{
  119 + "analysis_process": {{
  120 + "health_score_diagnosis": {{
  121 + "normal_ratio": "正常件数量占比(直接读取:{normal_count_pct}%)",
  122 + "score": "健康/亚健康/不健康",
  123 + "reasoning": "判断依据:对照标准xxx,当前正常件占比为xxx%,因此判断为xxx"
  124 + }},
  125 + "problem_diagnosis": {{
  126 + "shortage": {{
  127 + "risk_level": "高/中/低",
  128 + "threshold_comparison": "对照阈值>10%,当前{shortage_count_pct}%,结论",
  129 + "business_impact": "对业务的具体影响分析",
  130 + "possible_causes": ["可能原因1", "可能原因2"]
  131 + }},
  132 + "stagnant": {{
  133 + "risk_level": "高/中/低",
  134 + "threshold_comparison": "对照阈值(数量>15%或金额>20%),当前数量{stagnant_count_pct}%/金额{stagnant_amount_pct}%,结论",
  135 + "capital_impact": "对资金的具体影响分析",
  136 + "possible_causes": ["可能原因1", "可能原因2"]
  137 + }},
  138 + "low_freq": {{
  139 + "risk_level": "高/中/低",
  140 + "threshold_comparison": "对照阈值(数量>25%或金额>30%),当前数量{low_freq_count_pct}%/金额{low_freq_amount_pct}%,结论",
  141 + "efficiency_impact": "对SKU效率的具体影响分析",
  142 + "possible_causes": ["可能原因1", "可能原因2"]
  143 + }}
  144 + }},
  145 + "capital_release_calculation": {{
  146 + "stagnant_calculation": "呆滞件可释放资金 = {stagnant_amount} × 70% = xxx元(取中间值70%)",
  147 + "low_freq_calculation": "低频件可释放资金 = {low_freq_amount} × 40% = xxx元(取中间值40%)",
  148 + "total_releasable": "总可释放资金 = xxx元"
  149 + }},
  150 + "seasonal_analysis": {{
  151 + "current_season": "当前季节",
  152 + "seasonal_stagnant_items": "呆滞件中是否包含季节性配件(如冬季的空调配件)",
  153 + "seasonal_shortage_risk": "当季高需求配件的缺货风险评估",
  154 + "upcoming_season_alert": "下一季节需要关注的配件类型"
  155 + }}
  156 + }},
  157 + "conclusion": {{
  158 + "health_score": {{
  159 + "score": "健康/亚健康/不健康",
  160 + "normal_ratio_evaluation": "正常件占比评估结论(基于分析得出)"
  161 + }},
  162 + "problem_diagnosis": {{
  163 + "stagnant_analysis": "呆滞件问题分析及原因(基于分析得出)",
  164 + "shortage_analysis": "缺货件问题分析及影响(基于分析得出)",
  165 + "low_freq_analysis": "低频件问题分析及建议(基于分析得出)"
  166 + }},
  167 + "capital_release": {{
  168 + "stagnant_releasable": "呆滞件可释放资金估算(基于计算得出)",
  169 + "low_freq_releasable": "低频件可释放资金估算(基于计算得出)",
  170 + "action_plan": "资金释放行动方案(具体步骤)"
  171 + }},
  172 + "priority_actions": [
  173 + {{
  174 + "priority": 1,
  175 + "action": "最优先处理事项",
  176 + "reason": "优先原因",
  177 + "expected_effect": "预期效果"
  178 + }},
  179 + {{
  180 + "priority": 2,
  181 + "action": "次优先处理事项",
  182 + "reason": "优先原因",
  183 + "expected_effect": "预期效果"
  184 + }}
  185 + ]
  186 + }}
  187 +}}
  188 +
  189 +---
  190 +
  191 +## 重要约束
  192 +
  193 +1. **输出必须是合法的JSON对象**
  194 +2. **分析必须基于提供的数据,不要编造数据**
  195 +3. **每个结论都必须有明确的推理依据和数据支撑**
  196 +4. **资金释放估算应基于实际数据和给定的释放比例范围**
  197 +5. **score 只能是"健康"、"亚健康"、"不健康"三个值之一**
  198 +6. **priority_actions 数组至少包含2条,最多3条**
  199 +7. **如果数据全为零,请在分析中说明无有效数据,并给出相应建议**
  200 +8. **所有金额计算结果保留两位小数**
prompts/report_inventory_overview.md 0 → 100644
  1 +# 库存概览分析提示词
  2 +
  3 +你是一位资深汽车配件库存管理专家,拥有20年以上的汽车后市场库存管理经验。请基于以下库存概览统计数据,进行专业的库存分析。
  4 +
  5 +---
  6 +
  7 +## 统计数据
  8 +
  9 +| 指标 | 数值 |
  10 +|------|------|
  11 +| 配件总种类数 | {part_count} |
  12 +| 有效库存总数量 | {total_valid_storage_cnt} |
  13 +| 有效库存总金额(资金占用) | {total_valid_storage_amount} 元 |
  14 +| 月均销量总数量 | {total_avg_sales_cnt} |
  15 +| 整体库销比 | {overall_ratio} |
  16 +
  17 +### 库存三项构成明细
  18 +
  19 +| 构成项 | 数量 | 金额(元) | 数量占比 | 金额占比 |
  20 +|--------|------|------------|----------|----------|
  21 +| 在库未锁 | {total_in_stock_unlocked_cnt} | {total_in_stock_unlocked_amount} | - | - |
  22 +| 在途 | {total_on_the_way_cnt} | {total_on_the_way_amount} | - | - |
  23 +| 计划数 | {total_has_plan_cnt} | {total_has_plan_amount} | - | - |
  24 +
  25 +---
  26 +
  27 +## 术语说明
  28 +
  29 +- **有效库存**: 在库未锁 + 在途 + 计划数
  30 +- **月均销量**: (90天出库数 + 未关单已锁 + 未关单出库 + 订件) / 3
  31 +- **库销比**: 有效库存总数量 / 月均销量总数量,反映库存周转效率
  32 +
  33 +---
  34 +
  35 +## 当前季节信息
  36 +
  37 +- **当前季节**: {current_season}
  38 +- **统计日期**: {statistics_date}
  39 +
  40 +---
  41 +
  42 +## 季节性因素参考
  43 +
  44 +| 季节 | 需求特征 | 库存策略建议 |
  45 +|------|---------|-------------|
  46 +| 春季(3-5月) | 需求回暖,维修保养高峰前期 | 适当增加库存,为旺季做准备 |
  47 +| 夏季(6-8月) | 空调、冷却系统配件需求旺盛 | 重点备货制冷相关配件,库销比可适当放宽至2.5 |
  48 +| 秋季(9-11月) | 需求平稳,换季保养需求 | 保持正常库存水平,关注轮胎、刹车片等 |
  49 +| 冬季(12-2月) | 电瓶、暖风系统需求增加,春节前备货期 | 提前备货,库销比可适当放宽至2.5-3.0 |
  50 +
  51 +---
  52 +
  53 +## 分析框架与判断标准
  54 +
  55 +### 库销比判断标准
  56 +| 库销比范围 | 判断等级 | 含义 |
  57 +|-----------|---------|------|
  58 +| < 1.0 | 库存不足 | 可能面临缺货风险,需要加快补货 |
  59 +| 1.0 - 2.0 | 合理 | 库存水平健康,周转效率良好 |
  60 +| 2.0 - 3.0 | 偏高 | 库存积压风险,需关注周转 |
  61 +| > 3.0 | 严重积压 | 资金占用过高,需立即优化 |
  62 +| = 999 | 无销量 | 月均销量为零,需特别关注 |
  63 +
  64 +### 库存结构健康标准
  65 +| 构成项 | 健康占比范围 | 风险提示 |
  66 +|--------|-------------|---------|
  67 +| 在库未锁 | 60%-80% | 过高说明周转慢,过低说明库存不足 |
  68 +| 在途 | 10%-25% | 过高说明到货延迟风险,过低说明补货不及时 |
  69 +| 计划数 | 5%-15% | 过高说明计划执行滞后 |
  70 +
  71 +### 资金占用风险等级
  72 +| 条件 | 风险等级 |
  73 +|------|---------|
  74 +| 库销比 > 3.0 或 在库未锁占比 > 85% | high |
  75 +| 库销比 2.0-3.0 或 在库未锁占比 80%-85% | medium |
  76 +| 库销比 < 2.0 且 结构合理 | low |
  77 +
  78 +---
  79 +
  80 +## 分析任务
  81 +
  82 +请严格按照以下步骤进行分析,每一步都要展示推理过程:
  83 +
  84 +### 步骤1:计算关键指标
  85 +首先计算以下指标(请在分析中展示计算过程):
  86 +- 各构成项的数量占比 = 构成项数量 / 有效库存总数量 × 100%
  87 +- 各构成项的金额占比 = 构成项金额 / 有效库存总金额 × 100%
  88 +- 单件平均成本 = 有效库存总金额 / 有效库存总数量
  89 +
  90 +### 步骤2:库销比诊断
  91 +- 对照判断标准,确定当前库销比所处等级
  92 +- 说明该等级的业务含义
  93 +- 与行业经验值(1.5-2.5)进行对比
  94 +
  95 +### 步骤3:库存结构分析
  96 +- 对照健康标准,评估各构成项占比是否合理
  97 +- 识别偏离健康范围的构成项
  98 +- 分析偏离的可能原因
  99 +
  100 +### 步骤4:风险评估
  101 +- 根据风险等级判断条件,确定当前风险等级
  102 +- 列出具体的风险点
  103 +
  104 +### 步骤5:季节性考量
  105 +- 结合当前季节特征,评估库存水平是否适合当前季节
  106 +- 考虑即将到来的季节变化,是否需要提前调整
  107 +
  108 +### 步骤6:形成建议
  109 +- 基于以上分析,提出2-3条具体可操作的改善建议
  110 +- 每条建议需说明预期效果
  111 +- 建议需考虑季节性因素
  112 +
  113 +---
  114 +
  115 +## 输出格式
  116 +
  117 +直接输出JSON对象,**不要**包含 ```json 标记:
  118 +
  119 +{{
  120 + "analysis_process": {{
  121 + "calculated_metrics": {{
  122 + "in_stock_ratio": "在库未锁数量占比(计算过程:xxx / xxx = xx%)",
  123 + "on_way_ratio": "在途数量占比(计算过程)",
  124 + "plan_ratio": "计划数占比(计算过程)",
  125 + "avg_cost": "单件平均成本(计算过程)"
  126 + }},
  127 + "ratio_diagnosis": {{
  128 + "current_value": "当前库销比数值",
  129 + "level": "不足/合理/偏高/严重积压/无销量",
  130 + "reasoning": "判断依据:对照标准xxx,当前值xxx,因此判断为xxx",
  131 + "benchmark_comparison": "与行业经验值1.5-2.5对比的结论"
  132 + }},
  133 + "structure_analysis": {{
  134 + "in_stock_evaluation": "在库未锁占比评估(对照标准60%-80%,当前xx%,结论)",
  135 + "on_way_evaluation": "在途占比评估(对照标准10%-25%,当前xx%,结论)",
  136 + "plan_evaluation": "计划数占比评估(对照标准5%-15%,当前xx%,结论)",
  137 + "abnormal_items": ["偏离健康范围的项目及原因分析"]
  138 + }},
  139 + "seasonal_analysis": {{
  140 + "current_season": "当前季节",
  141 + "season_demand_feature": "当前季节需求特征",
  142 + "inventory_fitness": "当前库存水平是否适合本季节(结合季节性因素评估)",
  143 + "upcoming_season_preparation": "对即将到来季节的准备建议"
  144 + }}
  145 + }},
  146 + "conclusion": {{
  147 + "capital_assessment": {{
  148 + "total_evaluation": "总资金占用评估(基于以上分析得出的一句话结论)",
  149 + "structure_ratio": "各构成部分的资金比例分析结论",
  150 + "risk_level": "high/medium/low(基于风险等级判断条件得出)"
  151 + }},
  152 + "ratio_diagnosis": {{
  153 + "level": "不足/合理/偏高/严重积压",
  154 + "analysis": "库销比分析结论",
  155 + "benchmark": "行业参考值对比结论"
  156 + }},
  157 + "recommendations": [
  158 + {{
  159 + "action": "具体建议1",
  160 + "reason": "建议依据",
  161 + "expected_effect": "预期效果"
  162 + }},
  163 + {{
  164 + "action": "具体建议2",
  165 + "reason": "建议依据",
  166 + "expected_effect": "预期效果"
  167 + }}
  168 + ]
  169 + }}
  170 +}}
  171 +
  172 +---
  173 +
  174 +## 重要约束
  175 +
  176 +1. **输出必须是合法的JSON对象**
  177 +2. **分析必须基于提供的数据,不要编造数据**
  178 +3. **每个结论都必须有明确的推理依据**
  179 +4. **建议必须具体可操作,避免空泛的表述**
  180 +5. **risk_level 只能是 high、medium、low 三个值之一**
  181 +6. **如果数据全为零,请在分析中说明无有效数据,并给出相应建议**
  182 +7. **所有百分比计算结果保留两位小数**
prompts/report_replenishment_summary.md 0 → 100644
  1 +# 补货建议分析提示词
  2 +
  3 +你是一位汽车配件采购策略顾问,擅长制定科学的补货计划和资金分配方案。请基于以下补货建议统计数据,进行专业的补货策略分析。
  4 +
  5 +---
  6 +
  7 +## 统计数据
  8 +
  9 +| 指标 | 数值 |
  10 +|------|------|
  11 +| 补货配件总种类数 | {total_count} |
  12 +| 补货总金额 | {total_amount} 元 |
  13 +
  14 +### 各优先级统计
  15 +
  16 +| 优先级 | 配件种类数 | 金额(元) |
  17 +|--------|-----------|------------|
  18 +| 急需补货(优先级1) | {urgent_count} | {urgent_amount} |
  19 +| 建议补货(优先级2) | {suggested_count} | {suggested_amount} |
  20 +| 可选补货(优先级3) | {optional_count} | {optional_amount} |
  21 +
  22 +---
  23 +
  24 +## 术语说明
  25 +
  26 +- **急需补货(优先级1)**: 库销比 < 0.5 且月均销量 >= 1,库存严重不足,面临断货风险
  27 +- **建议补货(优先级2)**: 库销比 0.5-1.0 且月均销量 >= 1,库存偏低,建议及时补充
  28 +- **可选补货(优先级3)**: 库销比 1.0-目标值 且月均销量 >= 1,库存尚可,可灵活安排
  29 +
  30 +---
  31 +
  32 +## 当前季节信息
  33 +
  34 +- **当前季节**: {current_season}
  35 +- **统计日期**: {statistics_date}
  36 +
  37 +---
  38 +
  39 +## 季节性因素参考
  40 +
  41 +| 季节 | 补货策略调整 | 重点补货品类 |
  42 +|------|-------------|-------------|
  43 +| 春季(3-5月) | 为夏季旺季提前备货 | 空调、冷却系统配件 |
  44 +| 夏季(6-8月) | 制冷配件紧急补货优先级更高 | 空调压缩机、冷凝器、制冷剂 |
  45 +| 秋季(9-11月) | 为冬季备货,减少夏季配件补货 | 电瓶、暖风系统、防冻液 |
  46 +| 冬季(12-2月) | 春节前加快补货节奏 | 电瓶、启动机、暖风配件 |
  47 +
  48 +---
  49 +
  50 +## 分析框架与判断标准
  51 +
  52 +### 紧迫度评估标准
  53 +| 急需补货占比 | 紧迫度等级 | 风险等级 | 建议 |
  54 +|-------------|-----------|---------|------|
  55 +| > 30% | 非常紧迫 | high | 立即启动紧急补货流程 |
  56 +| 15% - 30% | 较紧迫 | medium | 优先处理急需补货 |
  57 +| < 15% | 一般 | low | 按正常流程处理 |
  58 +
  59 +### 资金分配优先级原则
  60 +| 优先级 | 建议预算占比 | 执行时间 |
  61 +|--------|-------------|---------|
  62 +| 急需补货 | 50%-70% | 1-3天内 |
  63 +| 建议补货 | 20%-35% | 1-2周内 |
  64 +| 可选补货 | 10%-15% | 2-4周内 |
  65 +
  66 +### 风险预警阈值
  67 +| 风险类型 | 触发条件 | 预警等级 |
  68 +|---------|---------|---------|
  69 +| 资金压力 | 急需补货金额占比 > 60% | 高 |
  70 +| 过度补货 | 可选补货金额占比 > 40% | 中 |
  71 +
  72 +---
  73 +
  74 +## 分析任务
  75 +
  76 +请严格按照以下步骤进行分析,展示推理过程:
  77 +
  78 +### 步骤1:计算关键指标
  79 +- 各优先级数量占比 = 数量 / 总数量 × 100%
  80 +- 各优先级金额占比 = 金额 / 总金额 × 100%
  81 +
  82 +### 步骤2:紧迫度评估
  83 +- 对照标准确定紧迫度等级和风险等级
  84 +- 判断是否需要立即行动
  85 +
  86 +### 步骤3:资金分配建议
  87 +- 对照建议预算占比,判断当前分布是否合理
  88 +- 给出具体资金分配建议
  89 +
  90 +### 步骤4:执行节奏规划
  91 +- 规划各类补货的执行时间
  92 +
  93 +### 步骤5:风险识别
  94 +- 对照风险预警阈值,识别潜在风险
  95 +
  96 +---
  97 +
  98 +## 输出格式
  99 +
  100 +直接输出JSON对象,**不要**包含 ```json 标记:
  101 +
  102 +{{
  103 + "analysis_process": {{
  104 + "calculated_metrics": {{
  105 + "urgent_count_ratio": "急需补货数量占比(计算:xxx / xxx = xx%)",
  106 + "urgent_amount_ratio": "急需补货金额占比(计算)",
  107 + "suggested_count_ratio": "建议补货数量占比(计算)",
  108 + "suggested_amount_ratio": "建议补货金额占比(计算)",
  109 + "optional_count_ratio": "可选补货数量占比(计算)",
  110 + "optional_amount_ratio": "可选补货金额占比(计算)"
  111 + }},
  112 + "urgency_diagnosis": {{
  113 + "urgent_ratio": "急需补货数量占比",
  114 + "level": "非常紧迫/较紧迫/一般",
  115 + "reasoning": "判断依据:对照标准xxx,当前占比xxx%,因此判断为xxx"
  116 + }},
  117 + "budget_analysis": {{
  118 + "current_distribution": "当前各优先级金额分布情况",
  119 + "comparison_with_standard": "与建议预算占比对比分析",
  120 + "adjustment_needed": "是否需要调整及原因"
  121 + }},
  122 + "risk_identification": {{
  123 + "capital_pressure_check": "资金压力检查(急需占比是否>60%)",
  124 + "over_replenishment_check": "过度补货检查(可选占比是否>40%)",
  125 + "identified_risks": ["识别到的风险1", "识别到的风险2"]
  126 + }},
  127 + "seasonal_analysis": {{
  128 + "current_season": "当前季节",
  129 + "seasonal_priority_items": "当季重点补货品类是否在急需列表中",
  130 + "timeline_adjustment": "是否需要根据季节调整补货时间(如春节前加快)",
  131 + "next_season_preparation": "为下一季节需要提前准备的配件"
  132 + }}
  133 + }},
  134 + "conclusion": {{
  135 + "urgency_assessment": {{
  136 + "urgent_ratio_evaluation": "急需补货占比评估结论",
  137 + "risk_level": "high/medium/low",
  138 + "immediate_action_needed": true或false
  139 + }},
  140 + "budget_allocation": {{
  141 + "recommended_order": "建议资金分配顺序(基于分析得出)",
  142 + "urgent_budget": "急需补货建议预算(具体金额或比例)",
  143 + "suggested_budget": "建议补货建议预算",
  144 + "optional_budget": "可选补货建议预算"
  145 + }},
  146 + "execution_plan": {{
  147 + "urgent_timeline": "急需补货执行时间(1-3天内)",
  148 + "suggested_timeline": "建议补货执行时间(1-2周内)",
  149 + "optional_timeline": "可选补货执行时间(2-4周内)"
  150 + }},
  151 + "risk_warnings": [
  152 + {{
  153 + "risk_type": "风险类型",
  154 + "description": "风险描述",
  155 + "mitigation": "应对建议"
  156 + }}
  157 + ]
  158 + }}
  159 +}}
  160 +
  161 +---
  162 +
  163 +## 重要约束
  164 +
  165 +1. **输出必须是合法的JSON对象**
  166 +2. **分析必须基于提供的数据,不要编造数据**
  167 +3. **每个结论都必须有明确的推理依据和数据支撑**
  168 +4. **建议必须具体可操作,包含时间和金额参考**
  169 +5. **risk_level 只能是 high、medium、low 三个值之一**
  170 +6. **immediate_action_needed 必须是布尔值 true 或 false**
  171 +7. **risk_warnings 数组至少包含1条,最多3条**
  172 +8. **如果数据全为零,请在分析中说明无补货建议数据**
  173 +9. **所有百分比计算结果保留两位小数**
prompts/report_sales_analysis.md 0 → 100644
  1 +# 销量分析提示词
  2 +
  3 +你是一位汽车配件销售数据分析师,擅长从销量数据中洞察需求趋势和业务机会。请基于以下销量统计数据,进行专业的销量分析。
  4 +
  5 +---
  6 +
  7 +## 统计数据
  8 +
  9 +| 指标 | 数值 |
  10 +|------|------|
  11 +| 月均销量总数量 | {total_avg_sales_cnt} |
  12 +| 月均销量总金额 | {total_avg_sales_amount} 元 |
  13 +| 有销量配件数 | {has_sales_part_count} |
  14 +| 无销量配件数 | {no_sales_part_count} |
  15 +
  16 +### 销量构成明细
  17 +
  18 +| 构成项 | 数量 | 说明 |
  19 +|--------|------|------|
  20 +| 90天出库数 | {total_out_stock_cnt} | 近90天实际出库,反映正常销售 |
  21 +| 未关单已锁 | {total_storage_locked_cnt} | 已锁定库存但订单未关闭,反映待处理订单 |
  22 +| 未关单出库 | {total_out_stock_ongoing_cnt} | 已出库但订单未关闭,反映在途交付 |
  23 +| 订件 | {total_buy_cnt} | 客户预订的配件数量,反映预订需求 |
  24 +
  25 +---
  26 +
  27 +## 术语说明
  28 +
  29 +- **月均销量**: (90天出库数 + 未关单已锁 + 未关单出库 + 订件) / 3
  30 +- **有销量配件**: 月均销量 > 0 的配件
  31 +- **无销量配件**: 月均销量 = 0 的配件
  32 +- **SKU活跃率**: 有销量配件数 / 总配件数 × 100%
  33 +
  34 +---
  35 +
  36 +## 当前季节信息
  37 +
  38 +- **当前季节**: {current_season}
  39 +- **统计日期**: {statistics_date}
  40 +
  41 +---
  42 +
  43 +## 季节性因素参考
  44 +
  45 +| 季节 | 销量特征 | 关注重点 |
  46 +|------|---------|---------|
  47 +| 春季(3-5月) | 销量逐步回升,保养类配件需求增加 | 关注机油、滤芯等保养件销量变化 |
  48 +| 夏季(6-8月) | 空调、冷却系统配件销量高峰 | 制冷配件销量应明显上升,否则需关注 |
  49 +| 秋季(9-11月) | 销量平稳,换季保养需求 | 轮胎、刹车片等安全件需求增加 |
  50 +| 冬季(12-2月) | 电瓶、暖风配件需求增加,春节前订单高峰 | 订件占比可能上升,属正常现象 |
  51 +
  52 +---
  53 +
  54 +## 分析框架与判断标准
  55 +
  56 +### 销量构成健康标准
  57 +| 构成项 | 健康占比范围 | 异常信号 |
  58 +|--------|-------------|---------|
  59 +| 90天出库数 | > 70% | 占比过低说明正常销售不足,可能存在订单积压 |
  60 +| 未关单已锁 | < 15% | 占比过高说明订单处理效率低,需关注 |
  61 +| 未关单出库 | < 10% | 占比过高说明交付周期长,客户体验受影响 |
  62 +| 订件 | 5%-15% | 过高说明预订需求旺盛但库存不足,过低说明预订渠道不畅 |
  63 +
  64 +### SKU活跃度判断标准
  65 +| 活跃率范围 | 判断等级 | 建议 |
  66 +|-----------|---------|------|
  67 +| > 80% | 优秀 | SKU管理良好,保持现状 |
  68 +| 70%-80% | 良好 | 可适当优化无销量SKU |
  69 +| 50%-70% | 一般 | 需要重点关注SKU精简 |
  70 +| < 50% | 较差 | SKU管理存在严重问题,需立即优化 |
  71 +
  72 +### 需求趋势判断依据
  73 +| 信号 | 趋势判断 |
  74 +|------|---------|
  75 +| 订件占比上升 + 未关单占比上升 | 上升(需求增长但供应跟不上) |
  76 +| 90天出库占比稳定 + 各项占比均衡 | 稳定(供需平衡) |
  77 +| 90天出库占比下降 + 订件占比下降 | 下降(需求萎缩) |
  78 +
  79 +---
  80 +
  81 +## 分析任务
  82 +
  83 +请严格按照以下步骤进行分析,每一步都要展示推理过程:
  84 +
  85 +### 步骤1:计算关键指标
  86 +首先计算以下指标(请在分析中展示计算过程):
  87 +- 各构成项占比 = 构成项数量 / (90天出库数 + 未关单已锁 + 未关单出库 + 订件) × 100%
  88 +- SKU活跃率 = 有销量配件数 / (有销量配件数 + 无销量配件数) × 100%
  89 +- 单件平均销售金额 = 月均销量总金额 / 月均销量总数量
  90 +
  91 +### 步骤2:销量构成分析
  92 +- 对照健康标准,评估各构成项占比是否合理
  93 +- 识别主要销量来源
  94 +- 分析未关单(已锁+出库)对整体销量的影响
  95 +
  96 +### 步骤3:SKU活跃度评估
  97 +- 对照活跃度标准,确定当前活跃率等级
  98 +- 分析无销量配件占比的业务影响
  99 +- 提出SKU优化方向
  100 +
  101 +### 步骤4:季节性分析
  102 +- 结合当前季节特征,评估销量表现是否符合季节预期
  103 +- 分析季节性配件的销量是否正常
  104 +
  105 +### 步骤5:需求趋势判断
  106 +- 根据各构成项的占比关系,判断需求趋势
  107 +- 结合季节因素,说明判断依据
  108 +- 给出短期需求预测(考虑季节变化)
  109 +
  110 +---
  111 +
  112 +## 输出格式
  113 +
  114 +直接输出JSON对象,**不要**包含 ```json 标记:
  115 +
  116 +{{
  117 + "analysis_process": {{
  118 + "calculated_metrics": {{
  119 + "out_stock_ratio": "90天出库占比(计算过程:xxx / xxx = xx%)",
  120 + "locked_ratio": "未关单已锁占比(计算过程)",
  121 + "ongoing_ratio": "未关单出库占比(计算过程)",
  122 + "buy_ratio": "订件占比(计算过程)",
  123 + "sku_active_rate": "SKU活跃率(计算过程:xxx / xxx = xx%)",
  124 + "avg_sales_price": "单件平均销售金额(计算过程)"
  125 + }},
  126 + "composition_diagnosis": {{
  127 + "out_stock_evaluation": "90天出库占比评估(对照标准>70%,当前xx%,结论)",
  128 + "locked_evaluation": "未关单已锁占比评估(对照标准<15%,当前xx%,结论)",
  129 + "ongoing_evaluation": "未关单出库占比评估(对照标准<10%,当前xx%,结论)",
  130 + "buy_evaluation": "订件占比评估(对照标准5%-15%,当前xx%,结论)",
  131 + "abnormal_items": ["偏离健康范围的项目及原因分析"]
  132 + }},
  133 + "activity_diagnosis": {{
  134 + "current_rate": "当前SKU活跃率",
  135 + "level": "优秀/良好/一般/较差",
  136 + "reasoning": "判断依据:对照标准xxx,当前值xxx,因此判断为xxx"
  137 + }},
  138 + "trend_diagnosis": {{
  139 + "signals": ["观察到的趋势信号1", "观察到的趋势信号2"],
  140 + "reasoning": "基于以上信号,判断需求趋势为xxx,因为xxx"
  141 + }},
  142 + "seasonal_analysis": {{
  143 + "current_season": "当前季节",
  144 + "expected_performance": "本季节预期销量特征",
  145 + "actual_vs_expected": "实际表现与季节预期对比",
  146 + "seasonal_items_status": "季节性配件销量状态评估"
  147 + }}
  148 + }},
  149 + "conclusion": {{
  150 + "composition_analysis": {{
  151 + "main_driver": "主要销量来源分析(基于占比计算得出)",
  152 + "pending_orders_impact": "未关单对销量的影响(基于占比计算得出)",
  153 + "booking_trend": "订件趋势分析(基于占比计算得出)"
  154 + }},
  155 + "activity_assessment": {{
  156 + "active_ratio": "活跃SKU占比评估结论",
  157 + "optimization_suggestion": "SKU优化建议(基于活跃度等级给出)"
  158 + }},
  159 + "demand_trend": {{
  160 + "direction": "上升/稳定/下降",
  161 + "evidence": "判断依据(列出具体数据支撑)",
  162 + "seasonal_factor": "季节因素对趋势的影响",
  163 + "forecast": "短期需求预测(考虑季节变化)"
  164 + }}
  165 + }}
  166 +}}
  167 +
  168 +---
  169 +
  170 +## 重要约束
  171 +
  172 +1. **输出必须是合法的JSON对象**
  173 +2. **分析必须基于提供的数据,不要编造数据**
  174 +3. **每个结论都必须有明确的推理依据和数据支撑**
  175 +4. **建议必须具体可操作,避免空泛的表述**
  176 +5. **direction 只能是"上升"、"稳定"、"下降"三个值之一**
  177 +6. **如果数据全为零,请在分析中说明无有效数据,并给出相应建议**
  178 +7. **所有百分比计算结果保留两位小数**
prompts/sql_agent.md
@@ -60,7 +60,7 @@ CREATE TABLE part_ratio ( @@ -60,7 +60,7 @@ CREATE TABLE part_ratio (
60 60
61 | 指标 | 公式 | 说明 | 61 | 指标 | 公式 | 说明 |
62 |------|------|------| 62 |------|------|------|
63 -| 有效库存 | `in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt + transfer_cnt + gen_transfer_cnt` | 可用库存总量 | 63 +| 有效库存 | `in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt` | 可用库存总量 |
64 | 月均销量 | `(out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3` | 基于90天数据计算 | 64 | 月均销量 | `(out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3` | 基于90天数据计算 |
65 | 库销比 | `有效库存 / 月均销量` | 当月均销量 > 0 时有效 | 65 | 库销比 | `有效库存 / 月均销量` | 当月均销量 > 0 时有效 |
66 66
sql/migrate_analysis_report.sql
1 -- ============================================================================ 1 -- ============================================================================
2 -- AI 补货建议分析报告表 2 -- AI 补货建议分析报告表
3 -- ============================================================================ 3 -- ============================================================================
4 --- 版本: 2.0.0  
5 --- 更新日期: 2026-02-05  
6 --- 变更说明: 重构报告模块,聚焦补货决策支持(区别于传统库销分析) 4 +-- 版本: 3.0.0
  5 +-- 更新日期: 2026-02-10
  6 +-- 变更说明: 重构为四大数据驱动板块(库存概览/销量分析/健康度/补货建议)
7 -- ============================================================================ 7 -- ============================================================================
8 8
9 DROP TABLE IF EXISTS ai_analysis_report; 9 DROP TABLE IF EXISTS ai_analysis_report;
@@ -15,33 +15,23 @@ CREATE TABLE ai_analysis_report ( @@ -15,33 +15,23 @@ CREATE TABLE ai_analysis_report (
15 dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称', 15 dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称',
16 brand_grouping_id BIGINT COMMENT '品牌组合ID', 16 brand_grouping_id BIGINT COMMENT '品牌组合ID',
17 report_type VARCHAR(32) DEFAULT 'replenishment' COMMENT '报告类型', 17 report_type VARCHAR(32) DEFAULT 'replenishment' COMMENT '报告类型',
18 -  
19 - -- 报告各模块 (JSON 结构化存储) - 宏观决策分析  
20 - -- 注:字段名保持兼容,实际存储内容已更新为新模块  
21 - replenishment_insights JSON COMMENT '整体态势研判(规模评估/结构分析/时机判断) - 原overall_assessment',  
22 - urgency_assessment JSON COMMENT '风险预警与应对(供应/资金/市场/执行风险) - 原risk_alerts',  
23 - strategy_recommendations JSON COMMENT '采购策略建议(优先级/批量机会/分批/供应商协调) - 原procurement_strategy',  
24 - execution_guide JSON COMMENT '已废弃,置为NULL',  
25 - expected_outcomes JSON COMMENT '效果预期与建议(库存健康/资金效率/后续行动) - 原expected_impact',  
26 -  
27 - -- 统计信息  
28 - total_suggest_cnt INT DEFAULT 0 COMMENT '总建议数量',  
29 - total_suggest_amount DECIMAL(14,2) DEFAULT 0 COMMENT '总建议金额',  
30 - shortage_risk_cnt INT DEFAULT 0 COMMENT '缺货风险配件数',  
31 - excess_risk_cnt INT DEFAULT 0 COMMENT '过剩风险配件数',  
32 - stagnant_cnt INT DEFAULT 0 COMMENT '呆滞件数量',  
33 - low_freq_cnt INT DEFAULT 0 COMMENT '低频件数量',  
34 - 18 +
  19 + -- 四大板块 (JSON 结构化存储,每个字段包含 stats + llm_analysis)
  20 + inventory_overview JSON COMMENT '库存总体概览(统计数据+LLM分析)',
  21 + sales_analysis JSON COMMENT '销量分析(统计数据+LLM分析)',
  22 + inventory_health JSON COMMENT '库存构成健康度(统计数据+图表数据+LLM分析)',
  23 + replenishment_summary JSON COMMENT '补货建议生成情况(统计数据+LLM分析)',
  24 +
35 -- LLM 元数据 25 -- LLM 元数据
36 llm_provider VARCHAR(32) COMMENT 'LLM提供商', 26 llm_provider VARCHAR(32) COMMENT 'LLM提供商',
37 llm_model VARCHAR(64) COMMENT 'LLM模型名称', 27 llm_model VARCHAR(64) COMMENT 'LLM模型名称',
38 llm_tokens INT DEFAULT 0 COMMENT 'LLM Token消耗', 28 llm_tokens INT DEFAULT 0 COMMENT 'LLM Token消耗',
39 execution_time_ms INT DEFAULT 0 COMMENT '执行耗时(毫秒)', 29 execution_time_ms INT DEFAULT 0 COMMENT '执行耗时(毫秒)',
40 - 30 +
41 statistics_date VARCHAR(16) COMMENT '统计日期', 31 statistics_date VARCHAR(16) COMMENT '统计日期',
42 create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', 32 create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
43 - 33 +
44 INDEX idx_task_no (task_no), 34 INDEX idx_task_no (task_no),
45 INDEX idx_group_date (group_id, statistics_date), 35 INDEX idx_group_date (group_id, statistics_date),
46 INDEX idx_dealer_grouping (dealer_grouping_id) 36 INDEX idx_dealer_grouping (dealer_grouping_id)
47 -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议分析报告表-结构化补货决策支持报告'; 37 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议分析报告表-重构版';
src/fw_pms_ai/agent/analysis_report_node.py
1 """ 1 """
2 分析报告生成节点 2 分析报告生成节点
3 3
4 -在补货建议工作流的最后一个节点执行,生成结构化分析报告 4 +在补货建议工作流的最后一个节点执行,生成结构化分析报告。
  5 +包含四大板块的统计计算函数:库存概览、销量分析、库存健康度、补货建议。
5 """ 6 """
6 7
7 import logging 8 import logging
8 -import time  
9 -import json  
10 -import os  
11 -from typing import Dict, Any  
12 -from decimal import Decimal  
13 -from datetime import datetime 9 +from decimal import Decimal, ROUND_HALF_UP
14 10
15 -from langchain_core.messages import HumanMessage 11 +logger = logging.getLogger(__name__)
16 12
17 -from ..llm import get_llm_client  
18 -from ..models import AnalysisReport  
19 -from ..services.result_writer import ResultWriter  
20 13
21 -logger = logging.getLogger(__name__) 14 +def _to_decimal(value) -> Decimal:
  15 + """安全转换为 Decimal"""
  16 + if value is None:
  17 + return Decimal("0")
  18 + return Decimal(str(value))
22 19
23 20
24 -def _load_prompt(filename: str) -> str:  
25 - """从prompts目录加载提示词文件"""  
26 - prompts_dir = os.path.join(  
27 - os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))),  
28 - "prompts" 21 +def calculate_inventory_overview(part_ratios: list[dict]) -> dict:
  22 + """
  23 + 计算库存总体概览统计数据
  24 +
  25 + 有效库存 = in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt
  26 + 资金占用 = in_stock_unlocked_cnt + on_the_way_cnt(仅计算实际占用资金的库存)
  27 +
  28 + Args:
  29 + part_ratios: PartRatio 字典列表
  30 +
  31 + Returns:
  32 + 库存概览统计字典
  33 + """
  34 + total_in_stock_unlocked_cnt = Decimal("0")
  35 + total_in_stock_unlocked_amount = Decimal("0")
  36 + total_on_the_way_cnt = Decimal("0")
  37 + total_on_the_way_amount = Decimal("0")
  38 + total_has_plan_cnt = Decimal("0")
  39 + total_has_plan_amount = Decimal("0")
  40 + total_avg_sales_cnt = Decimal("0")
  41 + # 资金占用合计 = (在库未锁 + 在途) * 成本价
  42 + total_capital_occupation = Decimal("0")
  43 +
  44 + for p in part_ratios:
  45 + cost_price = _to_decimal(p.get("cost_price", 0))
  46 +
  47 + in_stock = _to_decimal(p.get("in_stock_unlocked_cnt", 0))
  48 + on_way = _to_decimal(p.get("on_the_way_cnt", 0))
  49 + has_plan = _to_decimal(p.get("has_plan_cnt", 0))
  50 +
  51 + total_in_stock_unlocked_cnt += in_stock
  52 + total_in_stock_unlocked_amount += in_stock * cost_price
  53 + total_on_the_way_cnt += on_way
  54 + total_on_the_way_amount += on_way * cost_price
  55 + total_has_plan_cnt += has_plan
  56 + total_has_plan_amount += has_plan * cost_price
  57 +
  58 + # 资金占用 = 在库未锁 + 在途
  59 + total_capital_occupation += (in_stock + on_way) * cost_price
  60 +
  61 + # 月均销量
  62 + out_stock = _to_decimal(p.get("out_stock_cnt", 0))
  63 + locked = _to_decimal(p.get("storage_locked_cnt", 0))
  64 + ongoing = _to_decimal(p.get("out_stock_ongoing_cnt", 0))
  65 + buy = _to_decimal(p.get("buy_cnt", 0))
  66 + avg_sales = (out_stock + locked + ongoing + buy) / Decimal("3")
  67 + total_avg_sales_cnt += avg_sales
  68 +
  69 + total_valid_storage_cnt = (
  70 + total_in_stock_unlocked_cnt
  71 + + total_on_the_way_cnt
  72 + + total_has_plan_cnt
29 ) 73 )
30 - filepath = os.path.join(prompts_dir, filename)  
31 -  
32 - if not os.path.exists(filepath):  
33 - raise FileNotFoundError(f"Prompt文件未找到: {filepath}")  
34 -  
35 - with open(filepath, "r", encoding="utf-8") as f:  
36 - return f.read() 74 + total_valid_storage_amount = (
  75 + total_in_stock_unlocked_amount
  76 + + total_on_the_way_amount
  77 + + total_has_plan_amount
  78 + )
  79 +
  80 + # 库销比:月均销量为零时标记为特殊值
  81 + if total_avg_sales_cnt > 0:
  82 + overall_ratio = total_valid_storage_cnt / total_avg_sales_cnt
  83 + else:
  84 + overall_ratio = Decimal("999")
37 85
  86 + return {
  87 + "total_valid_storage_cnt": total_valid_storage_cnt,
  88 + "total_valid_storage_amount": total_valid_storage_amount,
  89 + "total_capital_occupation": total_capital_occupation,
  90 + "total_in_stock_unlocked_cnt": total_in_stock_unlocked_cnt,
  91 + "total_in_stock_unlocked_amount": total_in_stock_unlocked_amount,
  92 + "total_on_the_way_cnt": total_on_the_way_cnt,
  93 + "total_on_the_way_amount": total_on_the_way_amount,
  94 + "total_has_plan_cnt": total_has_plan_cnt,
  95 + "total_has_plan_amount": total_has_plan_amount,
  96 + "total_avg_sales_cnt": total_avg_sales_cnt,
  97 + "overall_ratio": overall_ratio,
  98 + "part_count": len(part_ratios),
  99 + }
38 100
39 -def _calculate_suggestion_stats(part_results: list) -> dict: 101 +
  102 +def calculate_sales_analysis(part_ratios: list[dict]) -> dict:
40 """ 103 """
41 - 基于完整数据计算补货建议统计  
42 -  
43 - 统计维度:  
44 - 1. 总体统计:总数量、总金额  
45 - 2. 优先级分布:高/中/低优先级配件数及金额  
46 - 3. 价格区间分布:低价/中价/高价配件分布  
47 - 4. 周转频次分布:高频/中频/低频配件分布  
48 - 5. 补货规模分布:大额/中额/小额补货配件分布 104 + 计算销量分析统计数据
  105 +
  106 + 月均销量 = (out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3
  107 +
  108 + Args:
  109 + part_ratios: PartRatio 字典列表
  110 +
  111 + Returns:
  112 + 销量分析统计字典
49 """ 113 """
50 - stats = {  
51 - # 总体统计  
52 - "total_parts_cnt": 0,  
53 - "total_suggest_cnt": 0,  
54 - "total_suggest_amount": Decimal("0"),  
55 -  
56 - # 优先级分布: 1=高, 2=中, 3=低  
57 - "priority_high_cnt": 0,  
58 - "priority_high_amount": Decimal("0"),  
59 - "priority_medium_cnt": 0,  
60 - "priority_medium_amount": Decimal("0"),  
61 - "priority_low_cnt": 0,  
62 - "priority_low_amount": Decimal("0"),  
63 -  
64 - # 价格区间分布 (成本价)  
65 - "price_low_cnt": 0,  
66 - "price_low_amount": Decimal("0"),  
67 - "price_medium_cnt": 0,  
68 - "price_medium_amount": Decimal("0"),  
69 - "price_high_cnt": 0,  
70 - "price_high_amount": Decimal("0"),  
71 -  
72 - # 周转频次分布 (月均销量)  
73 - "turnover_high_cnt": 0,  
74 - "turnover_high_amount": Decimal("0"),  
75 - "turnover_medium_cnt": 0,  
76 - "turnover_medium_amount": Decimal("0"),  
77 - "turnover_low_cnt": 0,  
78 - "turnover_low_amount": Decimal("0"),  
79 -  
80 - # 补货金额分布  
81 - "replenish_large_cnt": 0,  
82 - "replenish_large_amount": Decimal("0"),  
83 - "replenish_medium_cnt": 0,  
84 - "replenish_medium_amount": Decimal("0"),  
85 - "replenish_small_cnt": 0,  
86 - "replenish_small_amount": Decimal("0"), 114 + total_out_stock_cnt = Decimal("0")
  115 + total_storage_locked_cnt = Decimal("0")
  116 + total_out_stock_ongoing_cnt = Decimal("0")
  117 + total_buy_cnt = Decimal("0")
  118 + total_avg_sales_amount = Decimal("0")
  119 + has_sales_part_count = 0
  120 + no_sales_part_count = 0
  121 +
  122 + for p in part_ratios:
  123 + cost_price = _to_decimal(p.get("cost_price", 0))
  124 +
  125 + out_stock = _to_decimal(p.get("out_stock_cnt", 0))
  126 + locked = _to_decimal(p.get("storage_locked_cnt", 0))
  127 + ongoing = _to_decimal(p.get("out_stock_ongoing_cnt", 0))
  128 + buy = _to_decimal(p.get("buy_cnt", 0))
  129 +
  130 + total_out_stock_cnt += out_stock
  131 + total_storage_locked_cnt += locked
  132 + total_out_stock_ongoing_cnt += ongoing
  133 + total_buy_cnt += buy
  134 +
  135 + avg_sales = (out_stock + locked + ongoing + buy) / Decimal("3")
  136 + total_avg_sales_amount += avg_sales * cost_price
  137 +
  138 + if avg_sales > 0:
  139 + has_sales_part_count += 1
  140 + else:
  141 + no_sales_part_count += 1
  142 +
  143 + total_avg_sales_cnt = (
  144 + total_out_stock_cnt + total_storage_locked_cnt + total_out_stock_ongoing_cnt + total_buy_cnt
  145 + ) / Decimal("3")
  146 +
  147 + return {
  148 + "total_avg_sales_cnt": total_avg_sales_cnt,
  149 + "total_avg_sales_amount": total_avg_sales_amount,
  150 + "total_out_stock_cnt": total_out_stock_cnt,
  151 + "total_storage_locked_cnt": total_storage_locked_cnt,
  152 + "total_out_stock_ongoing_cnt": total_out_stock_ongoing_cnt,
  153 + "total_buy_cnt": total_buy_cnt,
  154 + "has_sales_part_count": has_sales_part_count,
  155 + "no_sales_part_count": no_sales_part_count,
87 } 156 }
88 -  
89 - if not part_results:  
90 - return stats  
91 -  
92 - for pr in part_results:  
93 - # 兼容对象和字典两种形式  
94 - if hasattr(pr, "total_suggest_cnt"):  
95 - suggest_cnt = pr.total_suggest_cnt  
96 - suggest_amount = pr.total_suggest_amount  
97 - cost_price = pr.cost_price  
98 - avg_sales = pr.total_avg_sales_cnt  
99 - priority = pr.priority 157 +
  158 +
  159 +def _classify_part(p: dict) -> str:
  160 + """
  161 + 将配件分类为缺货/呆滞/低频/正常
  162 +
  163 + 分类规则(按优先级顺序判断):
  164 + - 缺货件: 有效库存 = 0 且 月均销量 >= 1
  165 + - 呆滞件: 有效库存 > 0 且 90天出库数 = 0
  166 + - 低频件: 月均销量 < 1 或 出库次数 < 3 或 出库间隔 >= 30天
  167 + - 正常件: 不属于以上三类
  168 + """
  169 + in_stock = _to_decimal(p.get("in_stock_unlocked_cnt", 0))
  170 + on_way = _to_decimal(p.get("on_the_way_cnt", 0))
  171 + has_plan = _to_decimal(p.get("has_plan_cnt", 0))
  172 + valid_storage = in_stock + on_way + has_plan
  173 +
  174 + out_stock = _to_decimal(p.get("out_stock_cnt", 0))
  175 + locked = _to_decimal(p.get("storage_locked_cnt", 0))
  176 + ongoing = _to_decimal(p.get("out_stock_ongoing_cnt", 0))
  177 + buy = _to_decimal(p.get("buy_cnt", 0))
  178 + avg_sales = (out_stock + locked + ongoing + buy) / Decimal("3")
  179 +
  180 + out_times = int(p.get("out_times", 0) or 0)
  181 + out_duration = int(p.get("out_duration", 0) or 0)
  182 +
  183 + # 缺货件
  184 + if valid_storage == 0 and avg_sales >= 1:
  185 + return "shortage"
  186 +
  187 + # 呆滞件
  188 + if valid_storage > 0 and out_stock == 0:
  189 + return "stagnant"
  190 +
  191 + # 低频件
  192 + if avg_sales < 1 or out_times < 3 or out_duration >= 30:
  193 + return "low_freq"
  194 +
  195 + return "normal"
  196 +
  197 +
  198 +def calculate_inventory_health(part_ratios: list[dict]) -> dict:
  199 + """
  200 + 计算库存构成健康度统计数据
  201 +
  202 + 将每个配件归类为缺货件/呆滞件/低频件/正常件,统计各类型数量/金额/百分比,
  203 + 并生成 chart_data 供前端图表使用。
  204 +
  205 + Args:
  206 + part_ratios: PartRatio 字典列表
  207 +
  208 + Returns:
  209 + 健康度统计字典(含 chart_data)
  210 + """
  211 + categories = {
  212 + "shortage": {"count": 0, "amount": Decimal("0")},
  213 + "stagnant": {"count": 0, "amount": Decimal("0")},
  214 + "low_freq": {"count": 0, "amount": Decimal("0")},
  215 + "normal": {"count": 0, "amount": Decimal("0")},
  216 + }
  217 +
  218 + for p in part_ratios:
  219 + cat = _classify_part(p)
  220 + cost_price = _to_decimal(p.get("cost_price", 0))
  221 +
  222 + # 有效库存金额
  223 + in_stock = _to_decimal(p.get("in_stock_unlocked_cnt", 0))
  224 + on_way = _to_decimal(p.get("on_the_way_cnt", 0))
  225 + has_plan = _to_decimal(p.get("has_plan_cnt", 0))
  226 + valid_storage = in_stock + on_way + has_plan
  227 + amount = valid_storage * cost_price
  228 +
  229 + categories[cat]["count"] += 1
  230 + categories[cat]["amount"] += amount
  231 +
  232 + total_count = len(part_ratios)
  233 + total_amount = sum(c["amount"] for c in categories.values())
  234 +
  235 + # 计算百分比
  236 + result = {}
  237 + for cat_name, data in categories.items():
  238 + count_pct = (data["count"] / total_count * 100) if total_count > 0 else 0.0
  239 + amount_pct = (float(data["amount"]) / float(total_amount) * 100) if total_amount > 0 else 0.0
  240 + result[cat_name] = {
  241 + "count": data["count"],
  242 + "amount": data["amount"],
  243 + "count_pct": round(count_pct, 2),
  244 + "amount_pct": round(amount_pct, 2),
  245 + }
  246 +
  247 + result["total_count"] = total_count
  248 + result["total_amount"] = total_amount
  249 +
  250 + # chart_data 供前端 Chart.js 使用
  251 + labels = ["缺货件", "呆滞件", "低频件", "正常件"]
  252 + cat_keys = ["shortage", "stagnant", "low_freq", "normal"]
  253 + result["chart_data"] = {
  254 + "labels": labels,
  255 + "count_values": [categories[k]["count"] for k in cat_keys],
  256 + "amount_values": [float(categories[k]["amount"]) for k in cat_keys],
  257 + }
  258 +
  259 + return result
  260 +
  261 +
  262 +def calculate_replenishment_summary(part_results: list) -> dict:
  263 + """
  264 + 计算补货建议生成情况统计数据
  265 +
  266 + 按优先级分类统计:
  267 + - priority=1: 急需补货
  268 + - priority=2: 建议补货
  269 + - priority=3: 可选补货
  270 +
  271 + Args:
  272 + part_results: 配件汇总结果列表(字典或 ReplenishmentPartSummary 对象)
  273 +
  274 + Returns:
  275 + 补货建议统计字典
  276 + """
  277 + urgent = {"count": 0, "amount": Decimal("0")}
  278 + suggested = {"count": 0, "amount": Decimal("0")}
  279 + optional = {"count": 0, "amount": Decimal("0")}
  280 +
  281 + for item in part_results:
  282 + # 兼容字典和对象两种形式
  283 + if isinstance(item, dict):
  284 + priority = int(item.get("priority", 0))
  285 + amount = _to_decimal(item.get("total_suggest_amount", 0))
100 else: 286 else:
101 - suggest_cnt = int(pr.get("total_suggest_cnt", 0))  
102 - suggest_amount = Decimal(str(pr.get("total_suggest_amount", 0)))  
103 - cost_price = Decimal(str(pr.get("cost_price", 0)))  
104 - avg_sales = Decimal(str(pr.get("total_avg_sales_cnt", 0)))  
105 - priority = int(pr.get("priority", 2))  
106 -  
107 - # 总体统计  
108 - stats["total_parts_cnt"] += 1  
109 - stats["total_suggest_cnt"] += suggest_cnt  
110 - stats["total_suggest_amount"] += suggest_amount  
111 -  
112 - # 优先级分布 287 + priority = getattr(item, "priority", 0)
  288 + amount = _to_decimal(getattr(item, "total_suggest_amount", 0))
  289 +
113 if priority == 1: 290 if priority == 1:
114 - stats["priority_high_cnt"] += 1  
115 - stats["priority_high_amount"] += suggest_amount 291 + urgent["count"] += 1
  292 + urgent["amount"] += amount
116 elif priority == 2: 293 elif priority == 2:
117 - stats["priority_medium_cnt"] += 1  
118 - stats["priority_medium_amount"] += suggest_amount  
119 - else:  
120 - stats["priority_low_cnt"] += 1  
121 - stats["priority_low_amount"] += suggest_amount  
122 -  
123 - # 价格区间分布: <50低价, 50-200中价, >200高价  
124 - if cost_price < 50:  
125 - stats["price_low_cnt"] += 1  
126 - stats["price_low_amount"] += suggest_amount  
127 - elif cost_price <= 200:  
128 - stats["price_medium_cnt"] += 1  
129 - stats["price_medium_amount"] += suggest_amount  
130 - else:  
131 - stats["price_high_cnt"] += 1  
132 - stats["price_high_amount"] += suggest_amount  
133 -  
134 - # 周转频次分布: 月均销量 >=5高频, 1-5中频, <1低频  
135 - if avg_sales >= 5:  
136 - stats["turnover_high_cnt"] += 1  
137 - stats["turnover_high_amount"] += suggest_amount  
138 - elif avg_sales >= 1:  
139 - stats["turnover_medium_cnt"] += 1  
140 - stats["turnover_medium_amount"] += suggest_amount  
141 - else:  
142 - stats["turnover_low_cnt"] += 1  
143 - stats["turnover_low_amount"] += suggest_amount  
144 -  
145 - # 补货金额分布: >=5000大额, 1000-5000中额, <1000小额  
146 - if suggest_amount >= 5000:  
147 - stats["replenish_large_cnt"] += 1  
148 - stats["replenish_large_amount"] += suggest_amount  
149 - elif suggest_amount >= 1000:  
150 - stats["replenish_medium_cnt"] += 1  
151 - stats["replenish_medium_amount"] += suggest_amount 294 + suggested["count"] += 1
  295 + suggested["amount"] += amount
  296 + elif priority == 3:
  297 + optional["count"] += 1
  298 + optional["amount"] += amount
  299 +
  300 + total_count = urgent["count"] + suggested["count"] + optional["count"]
  301 + total_amount = urgent["amount"] + suggested["amount"] + optional["amount"]
  302 +
  303 + return {
  304 + "urgent": urgent,
  305 + "suggested": suggested,
  306 + "optional": optional,
  307 + "total_count": total_count,
  308 + "total_amount": total_amount,
  309 + }
  310 +
  311 +
  312 +# ============================================================
  313 +# LLM 分析函数
  314 +# ============================================================
  315 +
  316 +import os
  317 +import json
  318 +import time
  319 +from langchain_core.messages import SystemMessage, HumanMessage
  320 +
  321 +
  322 +def _load_prompt(filename: str) -> str:
  323 + """从 prompts 目录加载提示词文件"""
  324 + prompt_path = os.path.join(
  325 + os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))),
  326 + "prompts",
  327 + filename,
  328 + )
  329 + with open(prompt_path, "r", encoding="utf-8") as f:
  330 + return f.read()
  331 +
  332 +
  333 +def _format_decimal(value) -> str:
  334 + """将 Decimal 格式化为字符串,用于填充提示词"""
  335 + if value is None:
  336 + return "0"
  337 + return str(round(float(value), 2))
  338 +
  339 +
  340 +def _get_season_from_date(date_str: str) -> str:
  341 + """
  342 + 根据日期字符串获取季节
  343 +
  344 + Args:
  345 + date_str: 日期字符串,格式如 "2024-01-15" 或 "20240115"
  346 +
  347 + Returns:
  348 + 季节名称:春季/夏季/秋季/冬季
  349 + """
  350 + from datetime import datetime
  351 +
  352 + try:
  353 + # 尝试解析不同格式的日期
  354 + if "-" in date_str:
  355 + dt = datetime.strptime(date_str[:10], "%Y-%m-%d")
152 else: 356 else:
153 - stats["replenish_small_cnt"] += 1  
154 - stats["replenish_small_amount"] += suggest_amount  
155 -  
156 - return stats  
157 -  
158 -  
159 -def _calculate_risk_stats(part_ratios: list) -> dict:  
160 - """计算风险统计数据"""  
161 - stats = {  
162 - "shortage_cnt": 0,  
163 - "shortage_amount": Decimal("0"),  
164 - "stagnant_cnt": 0,  
165 - "stagnant_amount": Decimal("0"),  
166 - "low_freq_cnt": 0,  
167 - "low_freq_amount": Decimal("0"), 357 + dt = datetime.strptime(date_str[:8], "%Y%m%d")
  358 + month = dt.month
  359 + except (ValueError, TypeError):
  360 + # 解析失败时使用当前月份
  361 + month = datetime.now().month
  362 +
  363 + if month in (3, 4, 5):
  364 + return "春季(3-5月)"
  365 + elif month in (6, 7, 8):
  366 + return "夏季(6-8月)"
  367 + elif month in (9, 10, 11):
  368 + return "秋季(9-11月)"
  369 + else:
  370 + return "冬季(12-2月)"
  371 +
  372 +
  373 +def _parse_llm_json(content: str) -> dict:
  374 + """
  375 + 解析 LLM 返回的 JSON 内容
  376 +
  377 + 尝试直接解析,如果失败则尝试提取 ```json 代码块中的内容。
  378 + """
  379 + text = content.strip()
  380 +
  381 + # 尝试直接解析
  382 + try:
  383 + return json.loads(text)
  384 + except json.JSONDecodeError:
  385 + pass
  386 +
  387 + # 尝试提取 ```json ... ``` 代码块
  388 + import re
  389 + match = re.search(r"```json\s*(.*?)\s*```", text, re.DOTALL)
  390 + if match:
  391 + try:
  392 + return json.loads(match.group(1))
  393 + except json.JSONDecodeError:
  394 + pass
  395 +
  396 + # 尝试提取 { ... } 块
  397 + start = text.find("{")
  398 + end = text.rfind("}")
  399 + if start != -1 and end != -1 and end > start:
  400 + try:
  401 + return json.loads(text[start : end + 1])
  402 + except json.JSONDecodeError:
  403 + pass
  404 +
  405 + # 解析失败
  406 + raise json.JSONDecodeError("无法从 LLM 响应中解析 JSON", text, 0)
  407 +
  408 +
  409 +def llm_analyze_inventory_overview(stats: dict, statistics_date: str = "", llm_client=None) -> tuple[dict, dict]:
  410 + """
  411 + LLM 分析库存概览
  412 +
  413 + Args:
  414 + stats: calculate_inventory_overview 的输出
  415 + statistics_date: 统计日期
  416 + llm_client: LLM 客户端实例,为 None 时自动获取
  417 +
  418 + Returns:
  419 + (llm_analysis_dict, usage_dict)
  420 + """
  421 + from ..llm import get_llm_client
  422 +
  423 + if llm_client is None:
  424 + llm_client = get_llm_client()
  425 +
  426 + current_season = _get_season_from_date(statistics_date)
  427 +
  428 + prompt_template = _load_prompt("report_inventory_overview.md")
  429 + prompt = prompt_template.format(
  430 + part_count=stats.get("part_count", 0),
  431 + total_valid_storage_cnt=_format_decimal(stats.get("total_valid_storage_cnt")),
  432 + total_valid_storage_amount=_format_decimal(stats.get("total_valid_storage_amount")),
  433 + total_avg_sales_cnt=_format_decimal(stats.get("total_avg_sales_cnt")),
  434 + overall_ratio=_format_decimal(stats.get("overall_ratio")),
  435 + total_in_stock_unlocked_cnt=_format_decimal(stats.get("total_in_stock_unlocked_cnt")),
  436 + total_in_stock_unlocked_amount=_format_decimal(stats.get("total_in_stock_unlocked_amount")),
  437 + total_on_the_way_cnt=_format_decimal(stats.get("total_on_the_way_cnt")),
  438 + total_on_the_way_amount=_format_decimal(stats.get("total_on_the_way_amount")),
  439 + total_has_plan_cnt=_format_decimal(stats.get("total_has_plan_cnt")),
  440 + total_has_plan_amount=_format_decimal(stats.get("total_has_plan_amount")),
  441 + current_season=current_season,
  442 + statistics_date=statistics_date or "未知",
  443 + )
  444 +
  445 + messages = [HumanMessage(content=prompt)]
  446 + response = llm_client.invoke(messages)
  447 +
  448 + try:
  449 + analysis = _parse_llm_json(response.content)
  450 + except json.JSONDecodeError:
  451 + logger.warning(f"库存概览 LLM JSON 解析失败,原始响应: {response.content[:200]}")
  452 + analysis = {"error": "JSON解析失败", "raw": response.content[:200]}
  453 +
  454 + usage = {
  455 + "provider": response.usage.provider,
  456 + "model": response.usage.model,
  457 + "prompt_tokens": response.usage.prompt_tokens,
  458 + "completion_tokens": response.usage.completion_tokens,
168 } 459 }
169 -  
170 - for pr in part_ratios:  
171 - valid_storage = Decimal(str(pr.get("valid_storage_cnt", 0) or 0))  
172 - avg_sales = Decimal(str(pr.get("avg_sales_cnt", 0) or 0))  
173 - out_stock = Decimal(str(pr.get("out_stock_cnt", 0) or 0))  
174 - cost_price = Decimal(str(pr.get("cost_price", 0) or 0))  
175 -  
176 - # 呆滞件: 有库存但90天无出库  
177 - if valid_storage > 0 and out_stock == 0:  
178 - stats["stagnant_cnt"] += 1  
179 - stats["stagnant_amount"] += valid_storage * cost_price  
180 -  
181 - # 低频件: 无库存且月均销量<1  
182 - elif valid_storage == 0 and avg_sales < 1:  
183 - stats["low_freq_cnt"] += 1  
184 -  
185 - # 缺货件: 无库存且月均销量>=1  
186 - elif valid_storage == 0 and avg_sales >= 1:  
187 - stats["shortage_cnt"] += 1  
188 - # 缺货损失估算:月均销量 * 成本价  
189 - stats["shortage_amount"] += avg_sales * cost_price  
190 -  
191 - return stats 460 + return analysis, usage
192 461
193 462
194 -def _build_suggestion_summary(suggestion_stats: dict) -> str: 463 +def llm_analyze_sales(stats: dict, statistics_date: str = "", llm_client=None) -> tuple[dict, dict]:
195 """ 464 """
196 - 基于预计算的统计数据构建结构化补货建议摘要  
197 -  
198 - 摘要包含:  
199 - - 补货总体规模  
200 - - 优先级分布  
201 - - 价格区间分布  
202 - - 周转频次分布  
203 - - 补货金额分布 465 + LLM 分析销量
  466 +
  467 + Args:
  468 + stats: calculate_sales_analysis 的输出
  469 + statistics_date: 统计日期
  470 + llm_client: LLM 客户端实例
  471 +
  472 + Returns:
  473 + (llm_analysis_dict, usage_dict)
204 """ 474 """
205 - if suggestion_stats["total_parts_cnt"] == 0:  
206 - return "暂无补货建议"  
207 -  
208 - lines = []  
209 -  
210 - # 总体规模  
211 - lines.append(f"### 补货总体规模")  
212 - lines.append(f"- 涉及配件种类: {suggestion_stats['total_parts_cnt']}种")  
213 - lines.append(f"- 建议补货总数量: {suggestion_stats['total_suggest_cnt']}件")  
214 - lines.append(f"- 建议补货总金额: {suggestion_stats['total_suggest_amount']:.2f}元")  
215 - lines.append("")  
216 -  
217 - # 优先级分布  
218 - lines.append(f"### 优先级分布")  
219 - lines.append(f"| 优先级 | 配件数 | 金额(元) | 占比 |")  
220 - lines.append(f"|--------|--------|----------|------|")  
221 - total_amount = suggestion_stats['total_suggest_amount'] or Decimal("1")  
222 -  
223 - if suggestion_stats['priority_high_cnt'] > 0:  
224 - pct = suggestion_stats['priority_high_amount'] / total_amount * 100  
225 - lines.append(f"| 高优先级 | {suggestion_stats['priority_high_cnt']} | {suggestion_stats['priority_high_amount']:.2f} | {pct:.1f}% |")  
226 - if suggestion_stats['priority_medium_cnt'] > 0:  
227 - pct = suggestion_stats['priority_medium_amount'] / total_amount * 100  
228 - lines.append(f"| 中优先级 | {suggestion_stats['priority_medium_cnt']} | {suggestion_stats['priority_medium_amount']:.2f} | {pct:.1f}% |")  
229 - if suggestion_stats['priority_low_cnt'] > 0:  
230 - pct = suggestion_stats['priority_low_amount'] / total_amount * 100  
231 - lines.append(f"| 低优先级 | {suggestion_stats['priority_low_cnt']} | {suggestion_stats['priority_low_amount']:.2f} | {pct:.1f}% |")  
232 - lines.append("")  
233 -  
234 - # 价格区间分布  
235 - lines.append(f"### 价格区间分布 (按成本价)")  
236 - lines.append(f"| 价格区间 | 配件数 | 金额(元) | 占比 |")  
237 - lines.append(f"|----------|--------|----------|------|")  
238 - if suggestion_stats['price_low_cnt'] > 0:  
239 - pct = suggestion_stats['price_low_amount'] / total_amount * 100  
240 - lines.append(f"| 低价(<50元) | {suggestion_stats['price_low_cnt']} | {suggestion_stats['price_low_amount']:.2f} | {pct:.1f}% |")  
241 - if suggestion_stats['price_medium_cnt'] > 0:  
242 - pct = suggestion_stats['price_medium_amount'] / total_amount * 100  
243 - lines.append(f"| 中价(50-200元) | {suggestion_stats['price_medium_cnt']} | {suggestion_stats['price_medium_amount']:.2f} | {pct:.1f}% |")  
244 - if suggestion_stats['price_high_cnt'] > 0:  
245 - pct = suggestion_stats['price_high_amount'] / total_amount * 100  
246 - lines.append(f"| 高价(>200元) | {suggestion_stats['price_high_cnt']} | {suggestion_stats['price_high_amount']:.2f} | {pct:.1f}% |")  
247 - lines.append("")  
248 -  
249 - # 周转频次分布  
250 - lines.append(f"### 周转频次分布 (按月均销量)")  
251 - lines.append(f"| 周转频次 | 配件数 | 金额(元) | 占比 |")  
252 - lines.append(f"|----------|--------|----------|------|")  
253 - if suggestion_stats['turnover_high_cnt'] > 0:  
254 - pct = suggestion_stats['turnover_high_amount'] / total_amount * 100  
255 - lines.append(f"| 高频(≥5件/月) | {suggestion_stats['turnover_high_cnt']} | {suggestion_stats['turnover_high_amount']:.2f} | {pct:.1f}% |")  
256 - if suggestion_stats['turnover_medium_cnt'] > 0:  
257 - pct = suggestion_stats['turnover_medium_amount'] / total_amount * 100  
258 - lines.append(f"| 中频(1-5件/月) | {suggestion_stats['turnover_medium_cnt']} | {suggestion_stats['turnover_medium_amount']:.2f} | {pct:.1f}% |")  
259 - if suggestion_stats['turnover_low_cnt'] > 0:  
260 - pct = suggestion_stats['turnover_low_amount'] / total_amount * 100  
261 - lines.append(f"| 低频(<1件/月) | {suggestion_stats['turnover_low_cnt']} | {suggestion_stats['turnover_low_amount']:.2f} | {pct:.1f}% |")  
262 - lines.append("")  
263 -  
264 - # 补货金额分布  
265 - lines.append(f"### 单配件补货金额分布")  
266 - lines.append(f"| 补货规模 | 配件数 | 金额(元) | 占比 |")  
267 - lines.append(f"|----------|--------|----------|------|")  
268 - if suggestion_stats['replenish_large_cnt'] > 0:  
269 - pct = suggestion_stats['replenish_large_amount'] / total_amount * 100  
270 - lines.append(f"| 大额(≥5000元) | {suggestion_stats['replenish_large_cnt']} | {suggestion_stats['replenish_large_amount']:.2f} | {pct:.1f}% |")  
271 - if suggestion_stats['replenish_medium_cnt'] > 0:  
272 - pct = suggestion_stats['replenish_medium_amount'] / total_amount * 100  
273 - lines.append(f"| 中额(1000-5000元) | {suggestion_stats['replenish_medium_cnt']} | {suggestion_stats['replenish_medium_amount']:.2f} | {pct:.1f}% |")  
274 - if suggestion_stats['replenish_small_cnt'] > 0:  
275 - pct = suggestion_stats['replenish_small_amount'] / total_amount * 100  
276 - lines.append(f"| 小额(<1000元) | {suggestion_stats['replenish_small_cnt']} | {suggestion_stats['replenish_small_amount']:.2f} | {pct:.1f}% |")  
277 -  
278 - return "\n".join(lines) 475 + from ..llm import get_llm_client
279 476
  477 + if llm_client is None:
  478 + llm_client = get_llm_client()
280 479
281 -def generate_analysis_report_node(state: dict) -> dict: 480 + current_season = _get_season_from_date(statistics_date)
  481 +
  482 + prompt_template = _load_prompt("report_sales_analysis.md")
  483 + prompt = prompt_template.format(
  484 + total_avg_sales_cnt=_format_decimal(stats.get("total_avg_sales_cnt")),
  485 + total_avg_sales_amount=_format_decimal(stats.get("total_avg_sales_amount")),
  486 + has_sales_part_count=stats.get("has_sales_part_count", 0),
  487 + no_sales_part_count=stats.get("no_sales_part_count", 0),
  488 + total_out_stock_cnt=_format_decimal(stats.get("total_out_stock_cnt")),
  489 + total_storage_locked_cnt=_format_decimal(stats.get("total_storage_locked_cnt")),
  490 + total_out_stock_ongoing_cnt=_format_decimal(stats.get("total_out_stock_ongoing_cnt")),
  491 + total_buy_cnt=_format_decimal(stats.get("total_buy_cnt")),
  492 + current_season=current_season,
  493 + statistics_date=statistics_date or "未知",
  494 + )
  495 +
  496 + messages = [HumanMessage(content=prompt)]
  497 + response = llm_client.invoke(messages)
  498 +
  499 + try:
  500 + analysis = _parse_llm_json(response.content)
  501 + except json.JSONDecodeError:
  502 + logger.warning(f"销量分析 LLM JSON 解析失败,原始响应: {response.content[:200]}")
  503 + analysis = {"error": "JSON解析失败", "raw": response.content[:200]}
  504 +
  505 + usage = {
  506 + "provider": response.usage.provider,
  507 + "model": response.usage.model,
  508 + "prompt_tokens": response.usage.prompt_tokens,
  509 + "completion_tokens": response.usage.completion_tokens,
  510 + }
  511 + return analysis, usage
  512 +
  513 +
  514 +def llm_analyze_inventory_health(stats: dict, statistics_date: str = "", llm_client=None) -> tuple[dict, dict]:
282 """ 515 """
283 - 生成分析报告节点  
284 -  
285 - 输入: part_ratios, llm_suggestions, allocated_details, part_results  
286 - 输出: analysis_report 516 + LLM 分析库存健康度
  517 +
  518 + Args:
  519 + stats: calculate_inventory_health 的输出
  520 + statistics_date: 统计日期
  521 + llm_client: LLM 客户端实例
  522 +
  523 + Returns:
  524 + (llm_analysis_dict, usage_dict)
287 """ 525 """
288 - start_time = time.time()  
289 -  
290 - task_no = state.get("task_no", "")  
291 - group_id = state.get("group_id", 0)  
292 - dealer_grouping_id = state.get("dealer_grouping_id", 0)  
293 - dealer_grouping_name = state.get("dealer_grouping_name", "")  
294 - brand_grouping_id = state.get("brand_grouping_id")  
295 - statistics_date = state.get("statistics_date", "")  
296 -  
297 - part_ratios = state.get("part_ratios", [])  
298 - part_results = state.get("part_results", [])  
299 - allocated_details = state.get("allocated_details", [])  
300 -  
301 - logger.info(f"[{task_no}] 开始生成分析报告: dealer={dealer_grouping_name}")  
302 - 526 + from ..llm import get_llm_client
  527 +
  528 + if llm_client is None:
  529 + llm_client = get_llm_client()
  530 +
  531 + current_season = _get_season_from_date(statistics_date)
  532 +
  533 + prompt_template = _load_prompt("report_inventory_health.md")
  534 + prompt = prompt_template.format(
  535 + total_count=stats.get("total_count", 0),
  536 + total_amount=_format_decimal(stats.get("total_amount")),
  537 + shortage_count=stats.get("shortage", {}).get("count", 0),
  538 + shortage_count_pct=stats.get("shortage", {}).get("count_pct", 0),
  539 + shortage_amount=_format_decimal(stats.get("shortage", {}).get("amount")),
  540 + shortage_amount_pct=stats.get("shortage", {}).get("amount_pct", 0),
  541 + stagnant_count=stats.get("stagnant", {}).get("count", 0),
  542 + stagnant_count_pct=stats.get("stagnant", {}).get("count_pct", 0),
  543 + stagnant_amount=_format_decimal(stats.get("stagnant", {}).get("amount")),
  544 + stagnant_amount_pct=stats.get("stagnant", {}).get("amount_pct", 0),
  545 + low_freq_count=stats.get("low_freq", {}).get("count", 0),
  546 + low_freq_count_pct=stats.get("low_freq", {}).get("count_pct", 0),
  547 + low_freq_amount=_format_decimal(stats.get("low_freq", {}).get("amount")),
  548 + low_freq_amount_pct=stats.get("low_freq", {}).get("amount_pct", 0),
  549 + normal_count=stats.get("normal", {}).get("count", 0),
  550 + normal_count_pct=stats.get("normal", {}).get("count_pct", 0),
  551 + normal_amount=_format_decimal(stats.get("normal", {}).get("amount")),
  552 + normal_amount_pct=stats.get("normal", {}).get("amount_pct", 0),
  553 + current_season=current_season,
  554 + statistics_date=statistics_date or "未知",
  555 + )
  556 +
  557 + messages = [HumanMessage(content=prompt)]
  558 + response = llm_client.invoke(messages)
  559 +
303 try: 560 try:
304 - # 计算风险统计  
305 - risk_stats = _calculate_risk_stats(part_ratios)  
306 -  
307 - # 计算补货建议统计 (基于完整数据)  
308 - suggestion_stats = _calculate_suggestion_stats(part_results)  
309 -  
310 - # 构建结构化建议汇总  
311 - suggestion_summary = _build_suggestion_summary(suggestion_stats)  
312 -  
313 - # 加载 Prompt  
314 - prompt_template = _load_prompt("analysis_report.md")  
315 -  
316 - # 填充 Prompt 变量  
317 - prompt = prompt_template.format(  
318 - dealer_grouping_id=dealer_grouping_id,  
319 - dealer_grouping_name=dealer_grouping_name,  
320 - statistics_date=statistics_date,  
321 - suggestion_summary=suggestion_summary,  
322 - shortage_cnt=risk_stats["shortage_cnt"],  
323 - shortage_amount=f"{risk_stats['shortage_amount']:.2f}",  
324 - stagnant_cnt=risk_stats["stagnant_cnt"],  
325 - stagnant_amount=f"{risk_stats['stagnant_amount']:.2f}",  
326 - low_freq_cnt=risk_stats["low_freq_cnt"],  
327 - low_freq_amount="0.00", # 低频件无库存  
328 - )  
329 -  
330 - # 调用 LLM 561 + analysis = _parse_llm_json(response.content)
  562 + except json.JSONDecodeError:
  563 + logger.warning(f"健康度 LLM JSON 解析失败,原始响应: {response.content[:200]}")
  564 + analysis = {"error": "JSON解析失败", "raw": response.content[:200]}
  565 +
  566 + usage = {
  567 + "provider": response.usage.provider,
  568 + "model": response.usage.model,
  569 + "prompt_tokens": response.usage.prompt_tokens,
  570 + "completion_tokens": response.usage.completion_tokens,
  571 + }
  572 + return analysis, usage
  573 +
  574 +
  575 +def llm_analyze_replenishment_summary(stats: dict, statistics_date: str = "", llm_client=None) -> tuple[dict, dict]:
  576 + """
  577 + LLM 分析补货建议
  578 +
  579 + Args:
  580 + stats: calculate_replenishment_summary 的输出
  581 + statistics_date: 统计日期
  582 + llm_client: LLM 客户端实例
  583 +
  584 + Returns:
  585 + (llm_analysis_dict, usage_dict)
  586 + """
  587 + from ..llm import get_llm_client
  588 +
  589 + if llm_client is None:
331 llm_client = get_llm_client() 590 llm_client = get_llm_client()
332 - response = llm_client.invoke(  
333 - messages=[HumanMessage(content=prompt)],  
334 - )  
335 -  
336 - # 解析 JSON 响应  
337 - response_text = response.content.strip()  
338 - # 移除可能的 markdown 代码块  
339 - if response_text.startswith("```"):  
340 - lines = response_text.split("\n")  
341 - response_text = "\n".join(lines[1:-1])  
342 -  
343 - report_data = json.loads(response_text)  
344 -  
345 - # 复用已计算的统计数据  
346 - total_suggest_cnt = suggestion_stats["total_suggest_cnt"]  
347 - total_suggest_amount = suggestion_stats["total_suggest_amount"]  
348 -  
349 - execution_time_ms = int((time.time() - start_time) * 1000)  
350 -  
351 - # 创建报告对象  
352 - # 新 prompt 字段名映射到现有数据库字段:  
353 - # overall_assessment -> replenishment_insights  
354 - # risk_alerts -> urgency_assessment  
355 - # procurement_strategy -> strategy_recommendations  
356 - # expected_impact -> expected_outcomes  
357 - # execution_guide 已移除,置为 None  
358 - report = AnalysisReport(  
359 - task_no=task_no,  
360 - group_id=group_id,  
361 - dealer_grouping_id=dealer_grouping_id,  
362 - dealer_grouping_name=dealer_grouping_name,  
363 - brand_grouping_id=brand_grouping_id,  
364 - report_type="replenishment",  
365 - replenishment_insights=report_data.get("overall_assessment"),  
366 - urgency_assessment=report_data.get("risk_alerts"),  
367 - strategy_recommendations=report_data.get("procurement_strategy"),  
368 - execution_guide=None,  
369 - expected_outcomes=report_data.get("expected_impact"),  
370 - total_suggest_cnt=total_suggest_cnt,  
371 - total_suggest_amount=total_suggest_amount,  
372 - shortage_risk_cnt=risk_stats["shortage_cnt"],  
373 - excess_risk_cnt=risk_stats["stagnant_cnt"],  
374 - stagnant_cnt=risk_stats["stagnant_cnt"],  
375 - low_freq_cnt=risk_stats["low_freq_cnt"],  
376 - llm_provider=getattr(llm_client, "provider", ""),  
377 - llm_model=getattr(llm_client, "model", ""),  
378 - llm_tokens=response.usage.total_tokens,  
379 - execution_time_ms=execution_time_ms,  
380 - statistics_date=statistics_date,  
381 - )  
382 -  
383 - # 保存到数据库  
384 - result_writer = ResultWriter()  
385 - try:  
386 - result_writer.save_analysis_report(report)  
387 - finally:  
388 - result_writer.close()  
389 -  
390 - logger.info(  
391 - f"[{task_no}] 分析报告生成完成: "  
392 - f"shortage={risk_stats['shortage_cnt']}, "  
393 - f"stagnant={risk_stats['stagnant_cnt']}, "  
394 - f"time={execution_time_ms}ms"  
395 - )  
396 - 591 +
  592 + current_season = _get_season_from_date(statistics_date)
  593 +
  594 + prompt_template = _load_prompt("report_replenishment_summary.md")
  595 + prompt = prompt_template.format(
  596 + total_count=stats.get("total_count", 0),
  597 + total_amount=_format_decimal(stats.get("total_amount")),
  598 + urgent_count=stats.get("urgent", {}).get("count", 0),
  599 + urgent_amount=_format_decimal(stats.get("urgent", {}).get("amount")),
  600 + suggested_count=stats.get("suggested", {}).get("count", 0),
  601 + suggested_amount=_format_decimal(stats.get("suggested", {}).get("amount")),
  602 + optional_count=stats.get("optional", {}).get("count", 0),
  603 + optional_amount=_format_decimal(stats.get("optional", {}).get("amount")),
  604 + current_season=current_season,
  605 + statistics_date=statistics_date or "未知",
  606 + )
  607 +
  608 + messages = [HumanMessage(content=prompt)]
  609 + response = llm_client.invoke(messages)
  610 +
  611 + try:
  612 + analysis = _parse_llm_json(response.content)
  613 + except json.JSONDecodeError:
  614 + logger.warning(f"补货建议 LLM JSON 解析失败,原始响应: {response.content[:200]}")
  615 + analysis = {"error": "JSON解析失败", "raw": response.content[:200]}
  616 +
  617 + usage = {
  618 + "provider": response.usage.provider,
  619 + "model": response.usage.model,
  620 + "prompt_tokens": response.usage.prompt_tokens,
  621 + "completion_tokens": response.usage.completion_tokens,
  622 + }
  623 + return analysis, usage
  624 +
  625 +
  626 +# ============================================================
  627 +# LangGraph 并发子图
  628 +# ============================================================
  629 +
  630 +from typing import TypedDict, Optional, Any, Annotated, Dict
  631 +
  632 +from langgraph.graph import StateGraph, START, END
  633 +
  634 +
  635 +def _merge_dict(left: Optional[dict], right: Optional[dict]) -> Optional[dict]:
  636 + """合并字典,保留非 None 的值"""
  637 + if right is not None:
  638 + return right
  639 + return left
  640 +
  641 +
  642 +def _sum_int(left: int, right: int) -> int:
  643 + """累加整数"""
  644 + return (left or 0) + (right or 0)
  645 +
  646 +
  647 +def _merge_str(left: Optional[str], right: Optional[str]) -> Optional[str]:
  648 + """合并字符串,保留非 None 的值"""
  649 + if right is not None:
  650 + return right
  651 + return left
  652 +
  653 +
  654 +class ReportLLMState(TypedDict, total=False):
  655 + """并发 LLM 分析子图的状态"""
  656 +
  657 + # 输入:四大板块的统计数据(只读,由主函数写入)
  658 + inventory_overview_stats: Annotated[Optional[dict], _merge_dict]
  659 + sales_analysis_stats: Annotated[Optional[dict], _merge_dict]
  660 + inventory_health_stats: Annotated[Optional[dict], _merge_dict]
  661 + replenishment_summary_stats: Annotated[Optional[dict], _merge_dict]
  662 +
  663 + # 输入:统计日期(用于季节判断)
  664 + statistics_date: Annotated[Optional[str], _merge_str]
  665 +
  666 + # 输出:四大板块的 LLM 分析结果(各节点独立写入)
  667 + inventory_overview_analysis: Annotated[Optional[dict], _merge_dict]
  668 + sales_analysis_analysis: Annotated[Optional[dict], _merge_dict]
  669 + inventory_health_analysis: Annotated[Optional[dict], _merge_dict]
  670 + replenishment_summary_analysis: Annotated[Optional[dict], _merge_dict]
  671 +
  672 + # LLM 使用量(累加)
  673 + total_prompt_tokens: Annotated[int, _sum_int]
  674 + total_completion_tokens: Annotated[int, _sum_int]
  675 + llm_provider: Annotated[Optional[str], _merge_dict]
  676 + llm_model: Annotated[Optional[str], _merge_dict]
  677 +
  678 +
  679 +def _node_inventory_overview(state: ReportLLMState) -> ReportLLMState:
  680 + """并发节点:库存概览 LLM 分析"""
  681 + stats = state.get("inventory_overview_stats")
  682 + statistics_date = state.get("statistics_date", "")
  683 + if not stats:
  684 + return {"inventory_overview_analysis": {"error": "无统计数据"}}
  685 +
  686 + try:
  687 + analysis, usage = llm_analyze_inventory_overview(stats, statistics_date)
397 return { 688 return {
398 - "analysis_report": report.to_dict(),  
399 - "end_time": time.time(), 689 + "inventory_overview_analysis": analysis,
  690 + "total_prompt_tokens": usage.get("prompt_tokens", 0),
  691 + "total_completion_tokens": usage.get("completion_tokens", 0),
  692 + "llm_provider": usage.get("provider", ""),
  693 + "llm_model": usage.get("model", ""),
400 } 694 }
401 -  
402 except Exception as e: 695 except Exception as e:
403 - logger.error(f"[{task_no}] 分析报告生成失败: {e}", exc_info=True)  
404 -  
405 - # 返回空报告,不中断整个流程 696 + logger.error(f"库存概览 LLM 分析失败: {e}")
  697 + return {"inventory_overview_analysis": {"error": str(e)}}
  698 +
  699 +
  700 +def _node_sales_analysis(state: ReportLLMState) -> ReportLLMState:
  701 + """并发节点:销量分析 LLM 分析"""
  702 + stats = state.get("sales_analysis_stats")
  703 + statistics_date = state.get("statistics_date", "")
  704 + if not stats:
  705 + return {"sales_analysis_analysis": {"error": "无统计数据"}}
  706 +
  707 + try:
  708 + analysis, usage = llm_analyze_sales(stats, statistics_date)
  709 + return {
  710 + "sales_analysis_analysis": analysis,
  711 + "total_prompt_tokens": usage.get("prompt_tokens", 0),
  712 + "total_completion_tokens": usage.get("completion_tokens", 0),
  713 + "llm_provider": usage.get("provider", ""),
  714 + "llm_model": usage.get("model", ""),
  715 + }
  716 + except Exception as e:
  717 + logger.error(f"销量分析 LLM 分析失败: {e}")
  718 + return {"sales_analysis_analysis": {"error": str(e)}}
  719 +
  720 +
  721 +def _node_inventory_health(state: ReportLLMState) -> ReportLLMState:
  722 + """并发节点:健康度 LLM 分析"""
  723 + stats = state.get("inventory_health_stats")
  724 + statistics_date = state.get("statistics_date", "")
  725 + if not stats:
  726 + return {"inventory_health_analysis": {"error": "无统计数据"}}
  727 +
  728 + try:
  729 + analysis, usage = llm_analyze_inventory_health(stats, statistics_date)
  730 + return {
  731 + "inventory_health_analysis": analysis,
  732 + "total_prompt_tokens": usage.get("prompt_tokens", 0),
  733 + "total_completion_tokens": usage.get("completion_tokens", 0),
  734 + "llm_provider": usage.get("provider", ""),
  735 + "llm_model": usage.get("model", ""),
  736 + }
  737 + except Exception as e:
  738 + logger.error(f"健康度 LLM 分析失败: {e}")
  739 + return {"inventory_health_analysis": {"error": str(e)}}
  740 +
  741 +
  742 +def _node_replenishment_summary(state: ReportLLMState) -> ReportLLMState:
  743 + """并发节点:补货建议 LLM 分析"""
  744 + stats = state.get("replenishment_summary_stats")
  745 + statistics_date = state.get("statistics_date", "")
  746 + if not stats:
  747 + return {"replenishment_summary_analysis": {"error": "无统计数据"}}
  748 +
  749 + try:
  750 + analysis, usage = llm_analyze_replenishment_summary(stats, statistics_date)
406 return { 751 return {
407 - "analysis_report": {  
408 - "error": str(e),  
409 - "task_no": task_no,  
410 - },  
411 - "end_time": time.time(), 752 + "replenishment_summary_analysis": analysis,
  753 + "total_prompt_tokens": usage.get("prompt_tokens", 0),
  754 + "total_completion_tokens": usage.get("completion_tokens", 0),
  755 + "llm_provider": usage.get("provider", ""),
  756 + "llm_model": usage.get("model", ""),
412 } 757 }
  758 + except Exception as e:
  759 + logger.error(f"补货建议 LLM 分析失败: {e}")
  760 + return {"replenishment_summary_analysis": {"error": str(e)}}
  761 +
  762 +
  763 +def build_report_llm_subgraph() -> StateGraph:
  764 + """
  765 + 构建并发 LLM 分析子图
  766 +
  767 + 四个 LLM 节点从 START fan-out 并发执行,结果 fan-in 汇总到 END。
  768 + """
  769 + graph = StateGraph(ReportLLMState)
  770 +
  771 + # 添加四个并发节点
  772 + graph.add_node("inventory_overview_llm", _node_inventory_overview)
  773 + graph.add_node("sales_analysis_llm", _node_sales_analysis)
  774 + graph.add_node("inventory_health_llm", _node_inventory_health)
  775 + graph.add_node("replenishment_summary_llm", _node_replenishment_summary)
  776 +
  777 + # fan-out: START → 四个节点
  778 + graph.add_edge(START, "inventory_overview_llm")
  779 + graph.add_edge(START, "sales_analysis_llm")
  780 + graph.add_edge(START, "inventory_health_llm")
  781 + graph.add_edge(START, "replenishment_summary_llm")
  782 +
  783 + # fan-in: 四个节点 → END
  784 + graph.add_edge("inventory_overview_llm", END)
  785 + graph.add_edge("sales_analysis_llm", END)
  786 + graph.add_edge("inventory_health_llm", END)
  787 + graph.add_edge("replenishment_summary_llm", END)
  788 +
  789 + return graph.compile()
  790 +
  791 +
  792 +# ============================================================
  793 +# 主节点函数
  794 +# ============================================================
  795 +
  796 +
  797 +def _serialize_stats(stats: dict) -> dict:
  798 + """将统计数据中的 Decimal 转换为 float,以便 JSON 序列化"""
  799 + result = {}
  800 + for k, v in stats.items():
  801 + if isinstance(v, Decimal):
  802 + result[k] = float(v)
  803 + elif isinstance(v, dict):
  804 + result[k] = _serialize_stats(v)
  805 + elif isinstance(v, list):
  806 + result[k] = [
  807 + _serialize_stats(item) if isinstance(item, dict) else (float(item) if isinstance(item, Decimal) else item)
  808 + for item in v
  809 + ]
  810 + else:
  811 + result[k] = v
  812 + return result
  813 +
  814 +
  815 +def generate_analysis_report_node(state: dict) -> dict:
  816 + """
  817 + 分析报告生成主节点
  818 +
  819 + 串联流程:
  820 + 1. 统计计算(四大板块)
  821 + 2. 并发 LLM 分析(LangGraph 子图)
  822 + 3. 汇总报告
  823 + 4. 写入数据库
  824 +
  825 + 单板块 LLM 失败不影响其他板块。
  826 +
  827 + Args:
  828 + state: AgentState 字典
  829 +
  830 + Returns:
  831 + 更新后的 state 字典
  832 + """
  833 + from .state import AgentState
  834 + from ..models import AnalysisReport
  835 + from ..services.result_writer import ResultWriter
  836 +
  837 + logger.info("[AnalysisReport] ========== 开始生成分析报告 ==========")
  838 + start_time = time.time()
  839 +
  840 + part_ratios = state.get("part_ratios", [])
  841 + part_results = state.get("part_results", [])
  842 +
  843 + # ---- 1. 统计计算 ----
  844 + logger.info(f"[AnalysisReport] 统计计算: part_ratios={len(part_ratios)}, part_results={len(part_results)}")
  845 +
  846 + inventory_overview_stats = calculate_inventory_overview(part_ratios)
  847 + sales_analysis_stats = calculate_sales_analysis(part_ratios)
  848 + inventory_health_stats = calculate_inventory_health(part_ratios)
  849 + replenishment_summary_stats = calculate_replenishment_summary(part_results)
  850 +
  851 + # 序列化统计数据(Decimal → float)
  852 + io_stats_serialized = _serialize_stats(inventory_overview_stats)
  853 + sa_stats_serialized = _serialize_stats(sales_analysis_stats)
  854 + ih_stats_serialized = _serialize_stats(inventory_health_stats)
  855 + rs_stats_serialized = _serialize_stats(replenishment_summary_stats)
  856 +
  857 + # ---- 2. 并发 LLM 分析 ----
  858 + logger.info("[AnalysisReport] 启动并发 LLM 分析子图")
  859 +
  860 + statistics_date = state.get("statistics_date", "")
  861 +
  862 + subgraph = build_report_llm_subgraph()
  863 + llm_state: ReportLLMState = {
  864 + "inventory_overview_stats": io_stats_serialized,
  865 + "sales_analysis_stats": sa_stats_serialized,
  866 + "inventory_health_stats": ih_stats_serialized,
  867 + "replenishment_summary_stats": rs_stats_serialized,
  868 + "statistics_date": statistics_date,
  869 + "inventory_overview_analysis": None,
  870 + "sales_analysis_analysis": None,
  871 + "inventory_health_analysis": None,
  872 + "replenishment_summary_analysis": None,
  873 + "total_prompt_tokens": 0,
  874 + "total_completion_tokens": 0,
  875 + "llm_provider": None,
  876 + "llm_model": None,
  877 + }
  878 +
  879 + try:
  880 + llm_result = subgraph.invoke(llm_state)
  881 + except Exception as e:
  882 + logger.error(f"[AnalysisReport] LLM 子图执行异常: {e}")
  883 + llm_result = llm_state # 使用初始状态(所有分析为 None)
  884 +
  885 + # ---- 3. 汇总报告 ----
  886 + inventory_overview_data = {
  887 + "stats": io_stats_serialized,
  888 + "llm_analysis": llm_result.get("inventory_overview_analysis") or {"error": "未生成"},
  889 + }
  890 + sales_analysis_data = {
  891 + "stats": sa_stats_serialized,
  892 + "llm_analysis": llm_result.get("sales_analysis_analysis") or {"error": "未生成"},
  893 + }
  894 + inventory_health_data = {
  895 + "stats": ih_stats_serialized,
  896 + "chart_data": ih_stats_serialized.get("chart_data"),
  897 + "llm_analysis": llm_result.get("inventory_health_analysis") or {"error": "未生成"},
  898 + }
  899 + replenishment_summary_data = {
  900 + "stats": rs_stats_serialized,
  901 + "llm_analysis": llm_result.get("replenishment_summary_analysis") or {"error": "未生成"},
  902 + }
  903 +
  904 + total_tokens = (
  905 + (llm_result.get("total_prompt_tokens") or 0)
  906 + + (llm_result.get("total_completion_tokens") or 0)
  907 + )
  908 + execution_time_ms = int((time.time() - start_time) * 1000)
  909 +
  910 + # ---- 4. 写入数据库 ----
  911 + report = AnalysisReport(
  912 + task_no=state.get("task_no", ""),
  913 + group_id=state.get("group_id", 0),
  914 + dealer_grouping_id=state.get("dealer_grouping_id", 0),
  915 + dealer_grouping_name=state.get("dealer_grouping_name"),
  916 + brand_grouping_id=state.get("brand_grouping_id"),
  917 + inventory_overview=inventory_overview_data,
  918 + sales_analysis=sales_analysis_data,
  919 + inventory_health=inventory_health_data,
  920 + replenishment_summary=replenishment_summary_data,
  921 + llm_provider=llm_result.get("llm_provider") or "",
  922 + llm_model=llm_result.get("llm_model") or "",
  923 + llm_tokens=total_tokens,
  924 + execution_time_ms=execution_time_ms,
  925 + statistics_date=state.get("statistics_date", ""),
  926 + )
  927 +
  928 + try:
  929 + writer = ResultWriter()
  930 + report_id = writer.save_analysis_report(report)
  931 + writer.close()
  932 + logger.info(f"[AnalysisReport] 报告已保存: id={report_id}, tokens={total_tokens}, 耗时={execution_time_ms}ms")
  933 + except Exception as e:
  934 + logger.error(f"[AnalysisReport] 报告写入数据库失败: {e}")
  935 +
  936 + # 返回更新后的状态
  937 + return {
  938 + "analysis_report": report.to_dict(),
  939 + "llm_provider": llm_result.get("llm_provider") or state.get("llm_provider", ""),
  940 + "llm_model": llm_result.get("llm_model") or state.get("llm_model", ""),
  941 + "llm_prompt_tokens": llm_result.get("total_prompt_tokens") or 0,
  942 + "llm_completion_tokens": llm_result.get("total_completion_tokens") or 0,
  943 + "current_node": "generate_analysis_report",
  944 + "next_node": "end",
  945 + }
src/fw_pms_ai/agent/sql_agent/agent.py
@@ -140,7 +140,7 @@ class SQLAgent: @@ -140,7 +140,7 @@ class SQLAgent:
140 out_stock_ongoing_cnt, stock_age, out_times, out_duration, 140 out_stock_ongoing_cnt, stock_age, out_times, out_duration,
141 transfer_cnt, gen_transfer_cnt, 141 transfer_cnt, gen_transfer_cnt,
142 part_biz_type, statistics_date, 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, 143 + (in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt) as valid_storage_cnt,
144 ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) as avg_sales_cnt 144 ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) as avg_sales_cnt
145 FROM part_ratio 145 FROM part_ratio
146 WHERE group_id = %s 146 WHERE group_id = %s
@@ -159,7 +159,7 @@ class SQLAgent: @@ -159,7 +159,7 @@ class SQLAgent:
159 # 优先处理有销量的配件 159 # 优先处理有销量的配件
160 sql += """ ORDER BY 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, 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, 162 + (in_stock_unlocked_cnt + on_the_way_cnt + has_plan_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 163 ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) DESC
164 """ 164 """
165 165
src/fw_pms_ai/agent/state.py
@@ -53,8 +53,8 @@ class AgentState(TypedDict, total=False): @@ -53,8 +53,8 @@ class AgentState(TypedDict, total=False):
53 dealer_grouping_name: Annotated[str, keep_last] 53 dealer_grouping_name: Annotated[str, keep_last]
54 statistics_date: Annotated[str, keep_last] 54 statistics_date: Annotated[str, keep_last]
55 55
56 - # part_ratio 原始数据  
57 - part_ratios: Annotated[List[dict], merge_dicts] 56 + # part_ratio 原始数据(使用 keep_last,因为只在 fetch_part_ratio 节点写入一次)
  57 + part_ratios: Annotated[List[dict], keep_last]
58 58
59 # SQL Agent 相关 59 # SQL Agent 相关
60 sql_queries: Annotated[List[str], merge_lists] 60 sql_queries: Annotated[List[str], merge_lists]
src/fw_pms_ai/api/routes/tasks.py
@@ -619,23 +619,14 @@ class AnalysisReportResponse(BaseModel): @@ -619,23 +619,14 @@ class AnalysisReportResponse(BaseModel):
619 group_id: int 619 group_id: int
620 dealer_grouping_id: int 620 dealer_grouping_id: int
621 dealer_grouping_name: Optional[str] = None 621 dealer_grouping_name: Optional[str] = None
622 - brand_grouping_id: Optional[int] = None  
623 report_type: str 622 report_type: str
624 -  
625 - # JSON 字段,使用 Any 或 Dict 来接收解析后的对象  
626 - replenishment_insights: Optional[Dict[str, Any]] = None  
627 - urgency_assessment: Optional[Dict[str, Any]] = None  
628 - strategy_recommendations: Optional[Dict[str, Any]] = None  
629 - execution_guide: Optional[Dict[str, Any]] = None  
630 - expected_outcomes: Optional[Dict[str, Any]] = None  
631 -  
632 - total_suggest_cnt: int = 0  
633 - total_suggest_amount: float = 0  
634 - shortage_risk_cnt: int = 0  
635 - excess_risk_cnt: int = 0  
636 - stagnant_cnt: int = 0  
637 - low_freq_cnt: int = 0  
638 - 623 +
  624 + # 四大板块(统计数据 + LLM 分析)
  625 + inventory_overview: Optional[Dict[str, Any]] = None
  626 + sales_analysis: Optional[Dict[str, Any]] = None
  627 + inventory_health: Optional[Dict[str, Any]] = None
  628 + replenishment_summary: Optional[Dict[str, Any]] = None
  629 +
639 llm_provider: Optional[str] = None 630 llm_provider: Optional[str] = None
640 llm_model: Optional[str] = None 631 llm_model: Optional[str] = None
641 llm_tokens: int = 0 632 llm_tokens: int = 0
@@ -664,17 +655,19 @@ async def get_analysis_report(task_no: str): @@ -664,17 +655,19 @@ async def get_analysis_report(task_no: str):
664 655
665 if not row: 656 if not row:
666 return None 657 return None
667 -  
668 - # 辅助函数:解析 JSON 字符串 658 +
  659 + # 解析 JSON 字段
669 def parse_json(value): 660 def parse_json(value):
670 - if not value: 661 + if value is None:
671 return None 662 return None
  663 + if isinstance(value, dict):
  664 + return value
672 if isinstance(value, str): 665 if isinstance(value, str):
673 try: 666 try:
674 return json.loads(value) 667 return json.loads(value)
675 - except json.JSONDecodeError: 668 + except (json.JSONDecodeError, TypeError):
676 return None 669 return None
677 - return value 670 + return None
678 671
679 return AnalysisReportResponse( 672 return AnalysisReportResponse(
680 id=row["id"], 673 id=row["id"],
@@ -682,22 +675,11 @@ async def get_analysis_report(task_no: str): @@ -682,22 +675,11 @@ async def get_analysis_report(task_no: str):
682 group_id=row["group_id"], 675 group_id=row["group_id"],
683 dealer_grouping_id=row["dealer_grouping_id"], 676 dealer_grouping_id=row["dealer_grouping_id"],
684 dealer_grouping_name=row.get("dealer_grouping_name"), 677 dealer_grouping_name=row.get("dealer_grouping_name"),
685 - brand_grouping_id=row.get("brand_grouping_id"),  
686 report_type=row.get("report_type", "replenishment"), 678 report_type=row.get("report_type", "replenishment"),
687 -  
688 - replenishment_insights=parse_json(row.get("replenishment_insights")),  
689 - urgency_assessment=parse_json(row.get("urgency_assessment")),  
690 - strategy_recommendations=parse_json(row.get("strategy_recommendations")),  
691 - execution_guide=parse_json(row.get("execution_guide")),  
692 - expected_outcomes=parse_json(row.get("expected_outcomes")),  
693 -  
694 - total_suggest_cnt=row.get("total_suggest_cnt") or 0,  
695 - total_suggest_amount=float(row.get("total_suggest_amount") or 0),  
696 - shortage_risk_cnt=row.get("shortage_risk_cnt") or 0,  
697 - excess_risk_cnt=row.get("excess_risk_cnt") or 0,  
698 - stagnant_cnt=row.get("stagnant_cnt") or 0,  
699 - low_freq_cnt=row.get("low_freq_cnt") or 0,  
700 - 679 + inventory_overview=parse_json(row.get("inventory_overview")),
  680 + sales_analysis=parse_json(row.get("sales_analysis")),
  681 + inventory_health=parse_json(row.get("inventory_health")),
  682 + replenishment_summary=parse_json(row.get("replenishment_summary")),
701 llm_provider=row.get("llm_provider"), 683 llm_provider=row.get("llm_provider"),
702 llm_model=row.get("llm_model"), 684 llm_model=row.get("llm_model"),
703 llm_tokens=row.get("llm_tokens") or 0, 685 llm_tokens=row.get("llm_tokens") or 0,
src/fw_pms_ai/models/analysis_report.py
1 """ 1 """
2 数据模型 - 分析报告 2 数据模型 - 分析报告
  3 +四大板块:库存概览、销量分析、库存健康度、补货建议
3 """ 4 """
4 5
5 from dataclasses import dataclass, field 6 from dataclasses import dataclass, field
6 -from decimal import Decimal  
7 from datetime import datetime 7 from datetime import datetime
8 -from typing import Optional, Dict, Any 8 +from typing import Any, Dict, Optional
9 9
10 10
11 @dataclass 11 @dataclass
12 class AnalysisReport: 12 class AnalysisReport:
13 - """AI补货建议分析报告"""  
14 - 13 + """分析报告数据模型"""
  14 +
15 task_no: str 15 task_no: str
16 group_id: int 16 group_id: int
17 dealer_grouping_id: int 17 dealer_grouping_id: int
18 - 18 +
19 id: Optional[int] = None 19 id: Optional[int] = None
20 dealer_grouping_name: Optional[str] = None 20 dealer_grouping_name: Optional[str] = None
21 brand_grouping_id: Optional[int] = None 21 brand_grouping_id: Optional[int] = None
22 report_type: str = "replenishment" 22 report_type: str = "replenishment"
23 -  
24 - # 报告各模块 (字典结构)  
25 - replenishment_insights: Optional[Dict[str, Any]] = None  
26 - urgency_assessment: Optional[Dict[str, Any]] = None  
27 - strategy_recommendations: Optional[Dict[str, Any]] = None  
28 - execution_guide: Optional[Dict[str, Any]] = None  
29 - expected_outcomes: Optional[Dict[str, Any]] = None  
30 -  
31 - # 统计信息  
32 - total_suggest_cnt: int = 0  
33 - total_suggest_amount: Decimal = Decimal("0")  
34 - shortage_risk_cnt: int = 0  
35 - excess_risk_cnt: int = 0  
36 - stagnant_cnt: int = 0  
37 - low_freq_cnt: int = 0  
38 - 23 +
  24 + # 四大板块
  25 + inventory_overview: Optional[Dict[str, Any]] = field(default=None)
  26 + sales_analysis: Optional[Dict[str, Any]] = field(default=None)
  27 + inventory_health: Optional[Dict[str, Any]] = field(default=None)
  28 + replenishment_summary: Optional[Dict[str, Any]] = field(default=None)
  29 +
39 # LLM 元数据 30 # LLM 元数据
40 llm_provider: str = "" 31 llm_provider: str = ""
41 llm_model: str = "" 32 llm_model: str = ""
42 llm_tokens: int = 0 33 llm_tokens: int = 0
43 execution_time_ms: int = 0 34 execution_time_ms: int = 0
44 - 35 +
45 statistics_date: str = "" 36 statistics_date: str = ""
46 create_time: Optional[datetime] = None 37 create_time: Optional[datetime] = None
47 38
48 - def to_dict(self) -> dict:  
49 - """转换为字典""" 39 + def to_dict(self) -> Dict[str, Any]:
  40 + """将报告转换为可序列化的字典"""
50 return { 41 return {
  42 + "id": self.id,
51 "task_no": self.task_no, 43 "task_no": self.task_no,
52 "group_id": self.group_id, 44 "group_id": self.group_id,
53 "dealer_grouping_id": self.dealer_grouping_id, 45 "dealer_grouping_id": self.dealer_grouping_id,
54 "dealer_grouping_name": self.dealer_grouping_name, 46 "dealer_grouping_name": self.dealer_grouping_name,
55 "brand_grouping_id": self.brand_grouping_id, 47 "brand_grouping_id": self.brand_grouping_id,
56 "report_type": self.report_type, 48 "report_type": self.report_type,
57 - "replenishment_insights": self.replenishment_insights,  
58 - "urgency_assessment": self.urgency_assessment,  
59 - "strategy_recommendations": self.strategy_recommendations,  
60 - "execution_guide": self.execution_guide,  
61 - "expected_outcomes": self.expected_outcomes,  
62 - "total_suggest_cnt": self.total_suggest_cnt,  
63 - "total_suggest_amount": float(self.total_suggest_amount),  
64 - "shortage_risk_cnt": self.shortage_risk_cnt,  
65 - "excess_risk_cnt": self.excess_risk_cnt,  
66 - "stagnant_cnt": self.stagnant_cnt,  
67 - "low_freq_cnt": self.low_freq_cnt, 49 + "inventory_overview": self.inventory_overview,
  50 + "sales_analysis": self.sales_analysis,
  51 + "inventory_health": self.inventory_health,
  52 + "replenishment_summary": self.replenishment_summary,
68 "llm_provider": self.llm_provider, 53 "llm_provider": self.llm_provider,
69 "llm_model": self.llm_model, 54 "llm_model": self.llm_model,
70 "llm_tokens": self.llm_tokens, 55 "llm_tokens": self.llm_tokens,
71 "execution_time_ms": self.execution_time_ms, 56 "execution_time_ms": self.execution_time_ms,
72 "statistics_date": self.statistics_date, 57 "statistics_date": self.statistics_date,
  58 + "create_time": self.create_time.isoformat() if self.create_time else None,
73 } 59 }
src/fw_pms_ai/models/part_ratio.py
@@ -46,9 +46,8 @@ class PartRatio: @@ -46,9 +46,8 @@ class PartRatio:
46 46
47 @property 47 @property
48 def valid_storage_cnt(self) -> Decimal: 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))) 49 + """有效库存数量 = 在库未锁 + 在途 + 计划数"""
  50 + return self.in_stock_unlocked_cnt + self.on_the_way_cnt + self.has_plan_cnt
52 51
53 @property 52 @property
54 def valid_storage_amount(self) -> Decimal: 53 def valid_storage_amount(self) -> Decimal:
src/fw_pms_ai/services/result_writer.py
@@ -325,8 +325,8 @@ class ResultWriter: @@ -325,8 +325,8 @@ class ResultWriter:
325 325
326 def save_analysis_report(self, report: AnalysisReport) -> int: 326 def save_analysis_report(self, report: AnalysisReport) -> int:
327 """ 327 """
328 - 保存分析报告  
329 - 328 + 保存分析报告(四大板块 JSON 结构)
  329 +
330 Returns: 330 Returns:
331 插入的报告ID 331 插入的报告ID
332 """ 332 """
@@ -338,20 +338,18 @@ class ResultWriter: @@ -338,20 +338,18 @@ class ResultWriter:
338 INSERT INTO ai_analysis_report ( 338 INSERT INTO ai_analysis_report (
339 task_no, group_id, dealer_grouping_id, dealer_grouping_name, 339 task_no, group_id, dealer_grouping_id, dealer_grouping_name,
340 brand_grouping_id, report_type, 340 brand_grouping_id, report_type,
341 - replenishment_insights, urgency_assessment, strategy_recommendations,  
342 - execution_guide, expected_outcomes,  
343 - total_suggest_cnt, total_suggest_amount, shortage_risk_cnt,  
344 - excess_risk_cnt, stagnant_cnt, low_freq_cnt, 341 + inventory_overview, sales_analysis,
  342 + inventory_health, replenishment_summary,
345 llm_provider, llm_model, llm_tokens, execution_time_ms, 343 llm_provider, llm_model, llm_tokens, execution_time_ms,
346 statistics_date, create_time 344 statistics_date, create_time
347 ) VALUES ( 345 ) VALUES (
348 %s, %s, %s, %s, %s, %s, 346 %s, %s, %s, %s, %s, %s,
349 - %s, %s, %s, %s, %s,  
350 - %s, %s, %s, %s, %s, %s,  
351 - %s, %s, %s, %s, %s, NOW() 347 + %s, %s, %s, %s,
  348 + %s, %s, %s, %s,
  349 + %s, NOW()
352 ) 350 )
353 """ 351 """
354 - 352 +
355 values = ( 353 values = (
356 report.task_no, 354 report.task_no,
357 report.group_id, 355 report.group_id,
@@ -359,27 +357,20 @@ class ResultWriter: @@ -359,27 +357,20 @@ class ResultWriter:
359 report.dealer_grouping_name, 357 report.dealer_grouping_name,
360 report.brand_grouping_id, 358 report.brand_grouping_id,
361 report.report_type, 359 report.report_type,
362 - json.dumps(report.replenishment_insights, ensure_ascii=False) if report.replenishment_insights else None,  
363 - json.dumps(report.urgency_assessment, ensure_ascii=False) if report.urgency_assessment else None,  
364 - json.dumps(report.strategy_recommendations, ensure_ascii=False) if report.strategy_recommendations else None,  
365 - json.dumps(report.execution_guide, ensure_ascii=False) if report.execution_guide else None,  
366 - json.dumps(report.expected_outcomes, ensure_ascii=False) if report.expected_outcomes else None,  
367 - report.total_suggest_cnt,  
368 - float(report.total_suggest_amount),  
369 - report.shortage_risk_cnt,  
370 - report.excess_risk_cnt,  
371 - report.stagnant_cnt,  
372 - report.low_freq_cnt, 360 + json.dumps(report.inventory_overview, ensure_ascii=False) if report.inventory_overview else None,
  361 + json.dumps(report.sales_analysis, ensure_ascii=False) if report.sales_analysis else None,
  362 + json.dumps(report.inventory_health, ensure_ascii=False) if report.inventory_health else None,
  363 + json.dumps(report.replenishment_summary, ensure_ascii=False) if report.replenishment_summary else None,
373 report.llm_provider, 364 report.llm_provider,
374 report.llm_model, 365 report.llm_model,
375 report.llm_tokens, 366 report.llm_tokens,
376 report.execution_time_ms, 367 report.execution_time_ms,
377 report.statistics_date, 368 report.statistics_date,
378 ) 369 )
379 - 370 +
380 cursor.execute(sql, values) 371 cursor.execute(sql, values)
381 conn.commit() 372 conn.commit()
382 - 373 +
383 report_id = cursor.lastrowid 374 report_id = cursor.lastrowid
384 logger.info(f"保存分析报告: task_no={report.task_no}, id={report_id}") 375 logger.info(f"保存分析报告: task_no={report.task_no}, id={report_id}")
385 return report_id 376 return report_id