Commit 58a795a043c03d60e37df20a1eab5853b29158fe

Authored by 朱焱飞
1 parent b14e72d3

feat: 添加分析报告生成功能,包括新的模型、代理节点、SQL迁移、提示词和测试。

README.md
@@ -22,7 +22,8 @@ graph LR @@ -22,7 +22,8 @@ graph LR
22 B --> C[FetchPartRatio] 22 B --> C[FetchPartRatio]
23 C --> D[SQLAgent<br/>LLM分析] 23 C --> D[SQLAgent<br/>LLM分析]
24 D --> E[AllocateBudget] 24 D --> E[AllocateBudget]
25 - E --> G[SaveResult] 25 + E --> F[AnalysisReport]
  26 + F --> G[SaveResult]
26 ``` 27 ```
27 28
28 详细架构图见 [docs/architecture.md](docs/architecture.md) 29 详细架构图见 [docs/architecture.md](docs/architecture.md)
@@ -95,7 +96,8 @@ fw-pms-ai/ @@ -95,7 +96,8 @@ fw-pms-ai/
95 1. FetchPartRatio - 从 part_ratio 表获取库销比数据 96 1. FetchPartRatio - 从 part_ratio 表获取库销比数据
96 2. SQLAgent - LLM 分析数据,生成补货建议 97 2. SQLAgent - LLM 分析数据,生成补货建议
97 3. AllocateBudget - 转换建议为补货明细 98 3. AllocateBudget - 转换建议为补货明细
98 -4. SaveResult - 写入数据库 99 +4. AnalysisReport - 生成分析报告(风险评估、行动方案)
  100 +5. SaveResult - 写入数据库
99 ``` 101 ```
100 102
101 ### 业务术语 103 ### 业务术语
docs/architecture.md
@@ -27,7 +27,8 @@ flowchart TB @@ -27,7 +27,8 @@ flowchart TB
27 C --> D{需要重试?} 27 C --> D{需要重试?}
28 D -->|是| C 28 D -->|是| C
29 D -->|否| E[allocate_budget] 29 D -->|否| E[allocate_budget]
30 - E --> F[END] 30 + E --> E2[generate_analysis_report]
  31 + E2 --> F[END]
31 end 32 end
32 33
33 subgraph Services ["业务服务层"] 34 subgraph Services ["业务服务层"]
@@ -62,6 +63,7 @@ flowchart TB @@ -62,6 +63,7 @@ flowchart TB
62 | `fetch_part_ratio` | 获取商家组合的配件库销比数据 | dealer_grouping_id | part_ratios[] | 63 | `fetch_part_ratio` | 获取商家组合的配件库销比数据 | dealer_grouping_id | part_ratios[] |
63 | `sql_agent` | LLM 分析配件数据,生成补货建议 | part_ratios[] | llm_suggestions[], part_results[] | 64 | `sql_agent` | LLM 分析配件数据,生成补货建议 | part_ratios[] | llm_suggestions[], part_results[] |
64 | `allocate_budget` | 转换 LLM 建议为补货明细 | llm_suggestions[] | details[] | 65 | `allocate_budget` | 转换 LLM 建议为补货明细 | llm_suggestions[] | details[] |
  66 +| `generate_analysis_report` | 生成分析报告 | part_ratios[], details[] | analysis_report |
65 67
66 --- 68 ---
67 69
@@ -121,3 +123,4 @@ src/fw_pms_ai/ @@ -121,3 +123,4 @@ src/fw_pms_ai/
121 | `ai_replenishment_detail` | 补货明细 | 123 | `ai_replenishment_detail` | 补货明细 |
122 | `ai_replenishment_part_summary` | 配件级汇总 | 124 | `ai_replenishment_part_summary` | 配件级汇总 |
123 | `ai_task_execution_log` | 执行日志 | 125 | `ai_task_execution_log` | 执行日志 |
  126 +| `ai_analysis_report` | 分析报告(JSON结构化) |
prompts/analysis_report.md 0 → 100644
  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. **资金风险**: 本期补货是否会造成资金压力?是否存在呆滞风险较高的配件需要谨慎采购?
  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. **分析基于提供的汇总数据,保持客观理性**
sql/migrate_analysis_report.sql 0 → 100644
  1 +-- ============================================================================
  2 +-- AI 补货建议分析报告表
  3 +-- ============================================================================
  4 +-- 版本: 2.0.0
  5 +-- 更新日期: 2026-02-05
  6 +-- 变更说明: 重构报告模块,聚焦补货决策支持(区别于传统库销分析)
  7 +-- ============================================================================
  8 +
  9 +DROP TABLE IF EXISTS ai_analysis_report;
  10 +CREATE TABLE ai_analysis_report (
  11 + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
  12 + task_no VARCHAR(32) NOT NULL COMMENT '任务编号',
  13 + group_id BIGINT NOT NULL COMMENT '集团ID',
  14 + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
  15 + dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称',
  16 + brand_grouping_id BIGINT COMMENT '品牌组合ID',
  17 + 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 +
  35 + -- LLM 元数据
  36 + llm_provider VARCHAR(32) COMMENT 'LLM提供商',
  37 + llm_model VARCHAR(64) COMMENT 'LLM模型名称',
  38 + llm_tokens INT DEFAULT 0 COMMENT 'LLM Token消耗',
  39 + execution_time_ms INT DEFAULT 0 COMMENT '执行耗时(毫秒)',
  40 +
  41 + statistics_date VARCHAR(16) COMMENT '统计日期',
  42 + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  43 +
  44 + INDEX idx_task_no (task_no),
  45 + INDEX idx_group_date (group_id, statistics_date),
  46 + INDEX idx_dealer_grouping (dealer_grouping_id)
  47 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议分析报告表-结构化补货决策支持报告';
src/fw_pms_ai/agent/analysis_report_node.py 0 → 100644
  1 +"""
  2 +分析报告生成节点
  3 +
  4 +在补货建议工作流的最后一个节点执行,生成结构化分析报告
  5 +"""
  6 +
  7 +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
  14 +
  15 +from langchain_core.messages import HumanMessage
  16 +
  17 +from ..llm import get_llm_client
  18 +from ..models import AnalysisReport
  19 +from ..services.result_writer import ResultWriter
  20 +
  21 +logger = logging.getLogger(__name__)
  22 +
  23 +
  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"
  29 + )
  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()
  37 +
  38 +
  39 +def _calculate_risk_stats(part_ratios: list) -> dict:
  40 + """计算风险统计数据"""
  41 + stats = {
  42 + "shortage_cnt": 0,
  43 + "shortage_amount": Decimal("0"),
  44 + "stagnant_cnt": 0,
  45 + "stagnant_amount": Decimal("0"),
  46 + "low_freq_cnt": 0,
  47 + "low_freq_amount": Decimal("0"),
  48 + }
  49 +
  50 + for pr in part_ratios:
  51 + valid_storage = Decimal(str(pr.get("valid_storage_cnt", 0) or 0))
  52 + avg_sales = Decimal(str(pr.get("avg_sales_cnt", 0) or 0))
  53 + out_stock = Decimal(str(pr.get("out_stock_cnt", 0) or 0))
  54 + cost_price = Decimal(str(pr.get("cost_price", 0) or 0))
  55 +
  56 + # 呆滞件: 有库存但90天无出库
  57 + if valid_storage > 0 and out_stock == 0:
  58 + stats["stagnant_cnt"] += 1
  59 + stats["stagnant_amount"] += valid_storage * cost_price
  60 +
  61 + # 低频件: 无库存且月均销量<1
  62 + elif valid_storage == 0 and avg_sales < 1:
  63 + stats["low_freq_cnt"] += 1
  64 +
  65 + # 缺货件: 无库存且月均销量>=1
  66 + elif valid_storage == 0 and avg_sales >= 1:
  67 + stats["shortage_cnt"] += 1
  68 + # 缺货损失估算:月均销量 * 成本价
  69 + stats["shortage_amount"] += avg_sales * cost_price
  70 +
  71 + return stats
  72 +
  73 +
  74 +def _build_suggestion_summary(part_results: list, allocated_details: list) -> str:
  75 + """构建补货建议汇总文本"""
  76 + if not part_results and not allocated_details:
  77 + return "暂无补货建议"
  78 +
  79 + lines = []
  80 + total_cnt = 0
  81 + total_amount = Decimal("0")
  82 +
  83 + # 优先使用 part_results (配件级汇总)
  84 + if part_results:
  85 + for pr in part_results[:10]: # 只取前10个
  86 + if hasattr(pr, "part_code"):
  87 + lines.append(
  88 + f"- {pr.part_code} {pr.part_name}: "
  89 + f"建议{pr.total_suggest_cnt}件, "
  90 + f"金额{pr.total_suggest_amount:.2f}元, "
  91 + f"优先级{pr.priority}"
  92 + )
  93 + total_cnt += pr.total_suggest_cnt
  94 + total_amount += pr.total_suggest_amount
  95 + elif isinstance(pr, dict):
  96 + lines.append(
  97 + f"- {pr.get('part_code', '')} {pr.get('part_name', '')}: "
  98 + f"建议{pr.get('total_suggest_cnt', 0)}件, "
  99 + f"金额{pr.get('total_suggest_amount', 0):.2f}元"
  100 + )
  101 +
  102 + lines.insert(0, f"**总计**: {total_cnt}件配件, 金额{total_amount:.2f}元\n")
  103 + return "\n".join(lines)
  104 +
  105 +
  106 +def generate_analysis_report_node(state: dict) -> dict:
  107 + """
  108 + 生成分析报告节点
  109 +
  110 + 输入: part_ratios, llm_suggestions, allocated_details, part_results
  111 + 输出: analysis_report
  112 + """
  113 + start_time = time.time()
  114 +
  115 + task_no = state.get("task_no", "")
  116 + group_id = state.get("group_id", 0)
  117 + dealer_grouping_id = state.get("dealer_grouping_id", 0)
  118 + dealer_grouping_name = state.get("dealer_grouping_name", "")
  119 + brand_grouping_id = state.get("brand_grouping_id")
  120 + statistics_date = state.get("statistics_date", "")
  121 +
  122 + part_ratios = state.get("part_ratios", [])
  123 + part_results = state.get("part_results", [])
  124 + allocated_details = state.get("allocated_details", [])
  125 +
  126 + logger.info(f"[{task_no}] 开始生成分析报告: dealer={dealer_grouping_name}")
  127 +
  128 + try:
  129 + # 计算风险统计
  130 + risk_stats = _calculate_risk_stats(part_ratios)
  131 +
  132 + # 构建建议汇总
  133 + suggestion_summary = _build_suggestion_summary(part_results, allocated_details)
  134 +
  135 + # 加载 Prompt
  136 + prompt_template = _load_prompt("analysis_report.md")
  137 +
  138 + # 填充 Prompt 变量
  139 + prompt = prompt_template.format(
  140 + dealer_grouping_id=dealer_grouping_id,
  141 + dealer_grouping_name=dealer_grouping_name,
  142 + statistics_date=statistics_date,
  143 + suggestion_summary=suggestion_summary,
  144 + shortage_cnt=risk_stats["shortage_cnt"],
  145 + shortage_amount=f"{risk_stats['shortage_amount']:.2f}",
  146 + stagnant_cnt=risk_stats["stagnant_cnt"],
  147 + stagnant_amount=f"{risk_stats['stagnant_amount']:.2f}",
  148 + low_freq_cnt=risk_stats["low_freq_cnt"],
  149 + low_freq_amount="0.00", # 低频件无库存
  150 + )
  151 +
  152 + # 调用 LLM
  153 + llm_client = get_llm_client()
  154 + response = llm_client.invoke(
  155 + messages=[HumanMessage(content=prompt)],
  156 + )
  157 +
  158 + # 解析 JSON 响应
  159 + response_text = response.content.strip()
  160 + # 移除可能的 markdown 代码块
  161 + if response_text.startswith("```"):
  162 + lines = response_text.split("\n")
  163 + response_text = "\n".join(lines[1:-1])
  164 +
  165 + report_data = json.loads(response_text)
  166 +
  167 + # 计算统计信息
  168 + total_suggest_cnt = sum(
  169 + d.suggest_cnt if hasattr(d, "suggest_cnt") else d.get("suggest_cnt", 0)
  170 + for d in allocated_details
  171 + )
  172 + total_suggest_amount = sum(
  173 + d.suggest_amount if hasattr(d, "suggest_amount") else Decimal(str(d.get("suggest_amount", 0)))
  174 + for d in allocated_details
  175 + )
  176 +
  177 + execution_time_ms = int((time.time() - start_time) * 1000)
  178 +
  179 + # 创建报告对象
  180 + # 新 prompt 字段名映射到现有数据库字段:
  181 + # overall_assessment -> replenishment_insights
  182 + # risk_alerts -> urgency_assessment
  183 + # procurement_strategy -> strategy_recommendations
  184 + # expected_impact -> expected_outcomes
  185 + # execution_guide 已移除,置为 None
  186 + report = AnalysisReport(
  187 + task_no=task_no,
  188 + group_id=group_id,
  189 + dealer_grouping_id=dealer_grouping_id,
  190 + dealer_grouping_name=dealer_grouping_name,
  191 + brand_grouping_id=brand_grouping_id,
  192 + report_type="replenishment",
  193 + replenishment_insights=report_data.get("overall_assessment"),
  194 + urgency_assessment=report_data.get("risk_alerts"),
  195 + strategy_recommendations=report_data.get("procurement_strategy"),
  196 + execution_guide=None,
  197 + expected_outcomes=report_data.get("expected_impact"),
  198 + total_suggest_cnt=total_suggest_cnt,
  199 + total_suggest_amount=total_suggest_amount,
  200 + shortage_risk_cnt=risk_stats["shortage_cnt"],
  201 + excess_risk_cnt=risk_stats["stagnant_cnt"],
  202 + stagnant_cnt=risk_stats["stagnant_cnt"],
  203 + low_freq_cnt=risk_stats["low_freq_cnt"],
  204 + llm_provider=getattr(llm_client, "provider", ""),
  205 + llm_model=getattr(llm_client, "model", ""),
  206 + llm_tokens=response.usage.total_tokens,
  207 + execution_time_ms=execution_time_ms,
  208 + statistics_date=statistics_date,
  209 + )
  210 +
  211 + # 保存到数据库
  212 + result_writer = ResultWriter()
  213 + try:
  214 + result_writer.save_analysis_report(report)
  215 + finally:
  216 + result_writer.close()
  217 +
  218 + logger.info(
  219 + f"[{task_no}] 分析报告生成完成: "
  220 + f"shortage={risk_stats['shortage_cnt']}, "
  221 + f"stagnant={risk_stats['stagnant_cnt']}, "
  222 + f"time={execution_time_ms}ms"
  223 + )
  224 +
  225 + return {
  226 + "analysis_report": report.to_dict(),
  227 + "end_time": time.time(),
  228 + }
  229 +
  230 + except Exception as e:
  231 + logger.error(f"[{task_no}] 分析报告生成失败: {e}", exc_info=True)
  232 +
  233 + # 返回空报告,不中断整个流程
  234 + return {
  235 + "analysis_report": {
  236 + "error": str(e),
  237 + "task_no": task_no,
  238 + },
  239 + "end_time": time.time(),
  240 + }
src/fw_pms_ai/agent/replenishment.py
@@ -20,6 +20,7 @@ from .nodes import ( @@ -20,6 +20,7 @@ from .nodes import (
20 allocate_budget_node, 20 allocate_budget_node,
21 should_retry_sql, 21 should_retry_sql,
22 ) 22 )
  23 +from .analysis_report_node import generate_analysis_report_node
23 from ..models import ReplenishmentTask, TaskStatus, TaskExecutionLog, LogStatus, ReplenishmentPartSummary 24 from ..models import ReplenishmentTask, TaskStatus, TaskExecutionLog, LogStatus, ReplenishmentPartSummary
24 from ..services import ResultWriter 25 from ..services import ResultWriter
25 26
@@ -45,7 +46,7 @@ class ReplenishmentAgent: @@ -45,7 +46,7 @@ class ReplenishmentAgent:
45 构建 LangGraph 工作流 46 构建 LangGraph 工作流
46 47
47 工作流结构: 48 工作流结构:
48 - fetch_part_ratio → sql_agent → allocate_budget → END 49 + fetch_part_ratio → sql_agent → allocate_budget → generate_analysis_report → END
49 """ 50 """
50 workflow = StateGraph(AgentState) 51 workflow = StateGraph(AgentState)
51 52
@@ -53,6 +54,7 @@ class ReplenishmentAgent: @@ -53,6 +54,7 @@ class ReplenishmentAgent:
53 workflow.add_node("fetch_part_ratio", fetch_part_ratio_node) 54 workflow.add_node("fetch_part_ratio", fetch_part_ratio_node)
54 workflow.add_node("sql_agent", sql_agent_node) 55 workflow.add_node("sql_agent", sql_agent_node)
55 workflow.add_node("allocate_budget", allocate_budget_node) 56 workflow.add_node("allocate_budget", allocate_budget_node)
  57 + workflow.add_node("generate_analysis_report", generate_analysis_report_node)
56 58
57 # 设置入口 59 # 设置入口
58 workflow.set_entry_point("fetch_part_ratio") 60 workflow.set_entry_point("fetch_part_ratio")
@@ -70,8 +72,9 @@ class ReplenishmentAgent: @@ -70,8 +72,9 @@ class ReplenishmentAgent:
70 } 72 }
71 ) 73 )
72 74
73 - # allocate_budget → END  
74 - workflow.add_edge("allocate_budget", END) 75 + # allocate_budget → generate_analysis_report → END
  76 + workflow.add_edge("allocate_budget", "generate_analysis_report")
  77 + workflow.add_edge("generate_analysis_report", END)
75 78
76 return workflow.compile() 79 return workflow.compile()
77 80
src/fw_pms_ai/agent/sql_agent/analyzer.py
@@ -406,13 +406,13 @@ class PartAnalyzer: @@ -406,13 +406,13 @@ class PartAnalyzer:
406 confidence = float(result.get("confidence", 0.8)) 406 confidence = float(result.get("confidence", 0.8))
407 part_decision_reason = result.get("part_decision_reason", "") 407 part_decision_reason = result.get("part_decision_reason", "")
408 need_replenishment = result.get("need_replenishment", False) 408 need_replenishment = result.get("need_replenishment", False)
409 - priority = int(result.get("priority", 2)) 409 + priority = int(result.get("priority") or 2)
410 410
411 # 更新配件结果 411 # 更新配件结果
412 part_result.need_replenishment = need_replenishment 412 part_result.need_replenishment = need_replenishment
413 - part_result.total_suggest_cnt = int(result.get("total_suggest_cnt", 0)) 413 + part_result.total_suggest_cnt = int(result.get("total_suggest_cnt") or 0)
414 part_result.total_suggest_amount = Decimal(str(result.get("total_suggest_amount", 0))) 414 part_result.total_suggest_amount = Decimal(str(result.get("total_suggest_amount", 0)))
415 - part_result.shop_count = int(result.get("shop_count", len(shop_data_list))) 415 + part_result.shop_count = int(result.get("shop_count") or len(shop_data_list))
416 part_result.part_decision_reason = part_decision_reason 416 part_result.part_decision_reason = part_decision_reason
417 part_result.priority = priority 417 part_result.priority = priority
418 part_result.confidence = confidence 418 part_result.confidence = confidence
@@ -430,11 +430,11 @@ class PartAnalyzer: @@ -430,11 +430,11 @@ class PartAnalyzer:
430 shop_suggestions_data = result.get("shop_suggestions", []) 430 shop_suggestions_data = result.get("shop_suggestions", [])
431 if shop_suggestions_data: 431 if shop_suggestions_data:
432 for shop in shop_suggestions_data: 432 for shop in shop_suggestions_data:
433 - s_id = int(shop.get("shop_id", 0)) 433 + s_id = int(shop.get("shop_id") or 0)
434 shop_suggestion_map[s_id] = shop 434 shop_suggestion_map[s_id] = shop
435 435
436 # 统计需要补货的门店数 436 # 统计需要补货的门店数
437 - need_replenishment_shop_count = len([s for s in shop_suggestions_data if int(s.get("suggest_cnt", 0)) > 0]) 437 + need_replenishment_shop_count = len([s for s in shop_suggestions_data if int(s.get("suggest_cnt") or 0) > 0])
438 part_result.need_replenishment_shop_count = need_replenishment_shop_count 438 part_result.need_replenishment_shop_count = need_replenishment_shop_count
439 439
440 # 递归所有输入门店,确保每个门店都有记录 440 # 递归所有输入门店,确保每个门店都有记录
@@ -445,10 +445,10 @@ class PartAnalyzer: @@ -445,10 +445,10 @@ class PartAnalyzer:
445 # 检查LLM是否有针对该门店的建议 445 # 检查LLM是否有针对该门店的建议
446 if shop_id in shop_suggestion_map: 446 if shop_id in shop_suggestion_map:
447 s_item = shop_suggestion_map[shop_id] 447 s_item = shop_suggestion_map[shop_id]
448 - suggest_cnt = int(s_item.get("suggest_cnt", 0))  
449 - suggest_amount = Decimal(str(s_item.get("suggest_amount", 0)))  
450 - reason = s_item.get("reason", part_decision_reason)  
451 - shop_priority = int(s_item.get("priority", priority)) 448 + suggest_cnt = int(s_item.get("suggest_cnt") or 0)
  449 + suggest_amount = Decimal(str(s_item.get("suggest_amount") or 0))
  450 + reason = s_item.get("reason") or part_decision_reason
  451 + shop_priority = int(s_item.get("priority") or priority)
452 else: 452 else:
453 # LLM未提及该门店,根据门店数据生成个性化默认理由 453 # LLM未提及该门店,根据门店数据生成个性化默认理由
454 suggest_cnt = 0 454 suggest_cnt = 0
src/fw_pms_ai/agent/state.py
@@ -73,6 +73,9 @@ class AgentState(TypedDict, total=False): @@ -73,6 +73,9 @@ class AgentState(TypedDict, total=False):
73 # 配件汇总结果 73 # 配件汇总结果
74 part_results: Annotated[List[Any], merge_lists] 74 part_results: Annotated[List[Any], merge_lists]
75 75
  76 + # 分析报告
  77 + analysis_report: Annotated[Optional[dict], keep_last]
  78 +
76 # LLM 统计(使用累加,合并多个并行节点的 token 使用量) 79 # LLM 统计(使用累加,合并多个并行节点的 token 使用量)
77 llm_provider: Annotated[str, keep_last] 80 llm_provider: Annotated[str, keep_last]
78 llm_model: Annotated[str, keep_last] 81 llm_model: Annotated[str, keep_last]
src/fw_pms_ai/models/__init__.py
@@ -6,6 +6,7 @@ from .execution_log import TaskExecutionLog, LogStatus @@ -6,6 +6,7 @@ from .execution_log import TaskExecutionLog, LogStatus
6 from .part_summary import ReplenishmentPartSummary 6 from .part_summary import ReplenishmentPartSummary
7 from .sql_result import SQLExecutionResult 7 from .sql_result import SQLExecutionResult
8 from .suggestion import ReplenishmentSuggestion, PartAnalysisResult 8 from .suggestion import ReplenishmentSuggestion, PartAnalysisResult
  9 +from .analysis_report import AnalysisReport
9 10
10 __all__ = [ 11 __all__ = [
11 "PartRatio", 12 "PartRatio",
@@ -18,6 +19,7 @@ __all__ = [ @@ -18,6 +19,7 @@ __all__ = [
18 "SQLExecutionResult", 19 "SQLExecutionResult",
19 "ReplenishmentSuggestion", 20 "ReplenishmentSuggestion",
20 "PartAnalysisResult", 21 "PartAnalysisResult",
  22 + "AnalysisReport",
21 ] 23 ]
22 24
23 25
src/fw_pms_ai/models/analysis_report.py 0 → 100644
  1 +"""
  2 +数据模型 - 分析报告
  3 +"""
  4 +
  5 +from dataclasses import dataclass, field
  6 +from decimal import Decimal
  7 +from datetime import datetime
  8 +from typing import Optional, Dict, Any
  9 +
  10 +
  11 +@dataclass
  12 +class AnalysisReport:
  13 + """AI补货建议分析报告"""
  14 +
  15 + task_no: str
  16 + group_id: int
  17 + dealer_grouping_id: int
  18 +
  19 + id: Optional[int] = None
  20 + dealer_grouping_name: Optional[str] = None
  21 + brand_grouping_id: Optional[int] = None
  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 +
  39 + # LLM 元数据
  40 + llm_provider: str = ""
  41 + llm_model: str = ""
  42 + llm_tokens: int = 0
  43 + execution_time_ms: int = 0
  44 +
  45 + statistics_date: str = ""
  46 + create_time: Optional[datetime] = None
  47 +
  48 + def to_dict(self) -> dict:
  49 + """转换为字典"""
  50 + return {
  51 + "task_no": self.task_no,
  52 + "group_id": self.group_id,
  53 + "dealer_grouping_id": self.dealer_grouping_id,
  54 + "dealer_grouping_name": self.dealer_grouping_name,
  55 + "brand_grouping_id": self.brand_grouping_id,
  56 + "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,
  68 + "llm_provider": self.llm_provider,
  69 + "llm_model": self.llm_model,
  70 + "llm_tokens": self.llm_tokens,
  71 + "execution_time_ms": self.execution_time_ms,
  72 + "statistics_date": self.statistics_date,
  73 + }
src/fw_pms_ai/services/result_writer.py
@@ -14,6 +14,7 @@ from ..models import ( @@ -14,6 +14,7 @@ from ..models import (
14 ReplenishmentDetail, 14 ReplenishmentDetail,
15 TaskExecutionLog, 15 TaskExecutionLog,
16 ReplenishmentPartSummary, 16 ReplenishmentPartSummary,
  17 + AnalysisReport,
17 ) 18 )
18 19
19 logger = logging.getLogger(__name__) 20 logger = logging.getLogger(__name__)
@@ -322,3 +323,67 @@ class ResultWriter: @@ -322,3 +323,67 @@ class ResultWriter:
322 finally: 323 finally:
323 cursor.close() 324 cursor.close()
324 325
  326 + def save_analysis_report(self, report: AnalysisReport) -> int:
  327 + """
  328 + 保存分析报告
  329 +
  330 + Returns:
  331 + 插入的报告ID
  332 + """
  333 + conn = self._get_connection()
  334 + cursor = conn.cursor()
  335 +
  336 + try:
  337 + sql = """
  338 + INSERT INTO ai_analysis_report (
  339 + task_no, group_id, dealer_grouping_id, dealer_grouping_name,
  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,
  345 + llm_provider, llm_model, llm_tokens, execution_time_ms,
  346 + statistics_date, create_time
  347 + ) VALUES (
  348 + %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()
  352 + )
  353 + """
  354 +
  355 + values = (
  356 + report.task_no,
  357 + report.group_id,
  358 + report.dealer_grouping_id,
  359 + report.dealer_grouping_name,
  360 + report.brand_grouping_id,
  361 + 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,
  373 + report.llm_provider,
  374 + report.llm_model,
  375 + report.llm_tokens,
  376 + report.execution_time_ms,
  377 + report.statistics_date,
  378 + )
  379 +
  380 + cursor.execute(sql, values)
  381 + conn.commit()
  382 +
  383 + report_id = cursor.lastrowid
  384 + logger.info(f"保存分析报告: task_no={report.task_no}, id={report_id}")
  385 + return report_id
  386 +
  387 + finally:
  388 + cursor.close()
  389 +
tests/test_analysis_report.py 0 → 100644
  1 +"""
  2 +测试分析报告生成功能
  3 +"""
  4 +import sys
  5 +sys.path.insert(0, "src")
  6 +
  7 +from fw_pms_ai.agent.analysis_report_node import generate_analysis_report_node
  8 +
  9 +def test_generate_analysis_report():
  10 + """测试为 AI-FB34CA0EE6C4 生成分析报告"""
  11 +
  12 + # 模拟数据
  13 + part_ratios = [
  14 + {"part_code": "C211F280503-1800-AA", "part_name": "牌照灯总成",
  15 + "valid_storage_cnt": 0, "avg_sales_cnt": 2, "out_stock_cnt": 5, "cost_price": 14},
  16 + {"part_code": "TEST-001", "part_name": "测试配件1",
  17 + "valid_storage_cnt": 10, "avg_sales_cnt": 0, "out_stock_cnt": 0, "cost_price": 100},
  18 + {"part_code": "TEST-002", "part_name": "测试配件2",
  19 + "valid_storage_cnt": 0, "avg_sales_cnt": 0.5, "out_stock_cnt": 0, "cost_price": 50},
  20 + ]
  21 +
  22 + part_results = [
  23 + {"part_code": "C211F280503-1800-AA", "part_name": "牌照灯总成",
  24 + "total_suggest_cnt": 4, "total_suggest_amount": 56.0, "priority": 1},
  25 + ]
  26 +
  27 + allocated_details = [
  28 + {"part_code": "C211F280503-1800-AA", "suggest_cnt": 4, "suggest_amount": 56.0},
  29 + ]
  30 +
  31 + # 构建 state
  32 + state = {
  33 + "task_no": "AI-FB34CA0EE6C4",
  34 + "group_id": 2,
  35 + "dealer_grouping_id": 48,
  36 + "dealer_grouping_name": "测试分组",
  37 + "brand_grouping_id": None,
  38 + "statistics_date": "2026-02-05",
  39 + "part_ratios": part_ratios,
  40 + "part_results": part_results,
  41 + "allocated_details": allocated_details,
  42 + }
  43 +
  44 + print("开始生成分析报告...")
  45 + result = generate_analysis_report_node(state)
  46 +
  47 + if "error" in result.get("analysis_report", {}):
  48 + print(f"\n❌ 生成失败: {result['analysis_report']['error']}")
  49 + return False
  50 + else:
  51 + print(f"\n✅ 分析报告生成成功!")
  52 + report = result.get("analysis_report", {})
  53 + print(f" - replenishment_insights: {str(report.get('replenishment_insights', ''))[:100]}...")
  54 + print(f" - urgency_assessment: {str(report.get('urgency_assessment', ''))[:100]}...")
  55 + return True
  56 +
  57 +if __name__ == "__main__":
  58 + test_generate_analysis_report()