Commit 58a795a043c03d60e37df20a1eab5853b29158fe

Authored by 朱焱飞
1 parent b14e72d3

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

README.md
... ... @@ -22,7 +22,8 @@ graph LR
22 22 B --> C[FetchPartRatio]
23 23 C --> D[SQLAgent<br/>LLM分析]
24 24 D --> E[AllocateBudget]
25   - E --> G[SaveResult]
  25 + E --> F[AnalysisReport]
  26 + F --> G[SaveResult]
26 27 ```
27 28  
28 29 详细架构图见 [docs/architecture.md](docs/architecture.md)
... ... @@ -95,7 +96,8 @@ fw-pms-ai/
95 96 1. FetchPartRatio - 从 part_ratio 表获取库销比数据
96 97 2. SQLAgent - LLM 分析数据,生成补货建议
97 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 27 C --> D{需要重试?}
28 28 D -->|是| C
29 29 D -->|否| E[allocate_budget]
30   - E --> F[END]
  30 + E --> E2[generate_analysis_report]
  31 + E2 --> F[END]
31 32 end
32 33  
33 34 subgraph Services ["业务服务层"]
... ... @@ -62,6 +63,7 @@ flowchart TB
62 63 | `fetch_part_ratio` | 获取商家组合的配件库销比数据 | dealer_grouping_id | part_ratios[] |
63 64 | `sql_agent` | LLM 分析配件数据,生成补货建议 | part_ratios[] | llm_suggestions[], part_results[] |
64 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 123 | `ai_replenishment_detail` | 补货明细 |
122 124 | `ai_replenishment_part_summary` | 配件级汇总 |
123 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 20 allocate_budget_node,
21 21 should_retry_sql,
22 22 )
  23 +from .analysis_report_node import generate_analysis_report_node
23 24 from ..models import ReplenishmentTask, TaskStatus, TaskExecutionLog, LogStatus, ReplenishmentPartSummary
24 25 from ..services import ResultWriter
25 26  
... ... @@ -45,7 +46,7 @@ class ReplenishmentAgent:
45 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 51 workflow = StateGraph(AgentState)
51 52  
... ... @@ -53,6 +54,7 @@ class ReplenishmentAgent:
53 54 workflow.add_node("fetch_part_ratio", fetch_part_ratio_node)
54 55 workflow.add_node("sql_agent", sql_agent_node)
55 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 60 workflow.set_entry_point("fetch_part_ratio")
... ... @@ -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 79 return workflow.compile()
77 80  
... ...
src/fw_pms_ai/agent/sql_agent/analyzer.py
... ... @@ -406,13 +406,13 @@ class PartAnalyzer:
406 406 confidence = float(result.get("confidence", 0.8))
407 407 part_decision_reason = result.get("part_decision_reason", "")
408 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 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 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 416 part_result.part_decision_reason = part_decision_reason
417 417 part_result.priority = priority
418 418 part_result.confidence = confidence
... ... @@ -430,11 +430,11 @@ class PartAnalyzer:
430 430 shop_suggestions_data = result.get("shop_suggestions", [])
431 431 if shop_suggestions_data:
432 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 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 438 part_result.need_replenishment_shop_count = need_replenishment_shop_count
439 439  
440 440 # 递归所有输入门店,确保每个门店都有记录
... ... @@ -445,10 +445,10 @@ class PartAnalyzer:
445 445 # 检查LLM是否有针对该门店的建议
446 446 if shop_id in shop_suggestion_map:
447 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 452 else:
453 453 # LLM未提及该门店,根据门店数据生成个性化默认理由
454 454 suggest_cnt = 0
... ...
src/fw_pms_ai/agent/state.py
... ... @@ -73,6 +73,9 @@ class AgentState(TypedDict, total=False):
73 73 # 配件汇总结果
74 74 part_results: Annotated[List[Any], merge_lists]
75 75  
  76 + # 分析报告
  77 + analysis_report: Annotated[Optional[dict], keep_last]
  78 +
76 79 # LLM 统计(使用累加,合并多个并行节点的 token 使用量)
77 80 llm_provider: Annotated[str, keep_last]
78 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 from .part_summary import ReplenishmentPartSummary
7 7 from .sql_result import SQLExecutionResult
8 8 from .suggestion import ReplenishmentSuggestion, PartAnalysisResult
  9 +from .analysis_report import AnalysisReport
9 10  
10 11 __all__ = [
11 12 "PartRatio",
... ... @@ -18,6 +19,7 @@ __all__ = [
18 19 "SQLExecutionResult",
19 20 "ReplenishmentSuggestion",
20 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 14 ReplenishmentDetail,
15 15 TaskExecutionLog,
16 16 ReplenishmentPartSummary,
  17 + AnalysisReport,
17 18 )
18 19  
19 20 logger = logging.getLogger(__name__)
... ... @@ -322,3 +323,67 @@ class ResultWriter:
322 323 finally:
323 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()
... ...