Commit f52f1031dd7c12552d72f51e6c05468941f70ef8

Authored by 朱焱飞
1 parent 58a795a0

feat: 新增分析报告查询接口及前端展示,优化报告生成逻辑和提示词,并移除旧测试文件。

prompts/analysis_report.md
1 # 智能补货建议分析报告 1 # 智能补货建议分析报告
2 2
3 -你是一位资深汽车配件采购顾问。AI系统已经生成了详细的补货建议明细(包含每个配件的补货数量、理由等),现在需要你站在更宏观的视角,为采购决策者提供**整体性分析**。 3 +你是一位资深汽车配件采购顾问。AI系统已经基于全量数据生成了补货建议的**多维统计分布数据**,现在需要你站在更宏观的视角,为采购决策者提供**整体性分析**。
4 4
5 -> **核心定位**: 补货明细已回答了"补什么、补多少"的问题,本报告聚焦于"整体策略、风险预警、资金规划"等**决策层面**的洞察。 5 +> **核心定位**: 统计数据揭示了补货的宏观特征与分布情况,本报告聚焦于"整体策略、风险预警、资金规划"等**决策层面**的洞察。
6 6
7 --- 7 ---
8 8
@@ -16,7 +16,7 @@ @@ -16,7 +16,7 @@
16 16
17 --- 17 ---
18 18
19 -## 本期补货建议概览 19 +## 本期补货建议概览 (统计摘要)
20 20
21 {suggestion_summary} 21 {suggestion_summary}
22 22
@@ -36,33 +36,33 @@ @@ -36,33 +36,33 @@
36 36
37 请严格按以下4个模块输出 **JSON格式** 的整体分析报告。 37 请严格按以下4个模块输出 **JSON格式** 的整体分析报告。
38 38
39 -**注意**: 不要重复补货明细中已有的配件级别分析,聚焦于以下**宏观维度** 39 +**注意**: 分析应基于提供的统计分布数据(如优先级、价格区间、周转频次等),按以下**宏观维度**展开
40 40
41 ### 模块1: 整体态势研判 (overall_assessment) 41 ### 模块1: 整体态势研判 (overall_assessment)
42 42
43 从全局视角评估本次补货建议: 43 从全局视角评估本次补货建议:
44 44
45 -1. **补货规模评估**: 本期补货总金额与历史同期相比是偏高、正常还是偏低?可能的原因是什么?  
46 -2. **结构特征分析**: 补货建议在品类、价格区间、周转频次上呈现什么分布特征?是否存在明显的集中或失衡? 45 +1. **补货规模评估**: 结合涉及配件数和总金额,评估本期补货的力度和资金需求规模。
  46 +2. **结构特征分析**: 基于价格区间和周转频次分布,分析补货建议的结构特征(如是否偏向高频低价件,或存在大量低频高价件)。
47 3. **时机判断**: 当前是否处于补货的有利时机?需要考虑哪些时间因素(如节假日、促销季、供应商备货周期)? 47 3. **时机判断**: 当前是否处于补货的有利时机?需要考虑哪些时间因素(如节假日、促销季、供应商备货周期)?
48 48
49 ### 模块2: 风险预警与应对 (risk_alerts) 49 ### 模块2: 风险预警与应对 (risk_alerts)
50 50
51 识别本次补货可能面临的风险并给出应对建议: 51 识别本次补货可能面临的风险并给出应对建议:
52 52
53 -1. **供应风险**: 是否有配件可能面临缺货、涨价、交期延长等供应端问题?  
54 -2. **资金风险**: 本期补货是否会造成资金压力?是否存在呆滞风险较高的配件需要谨慎采购?  
55 -3. **市场风险**: 是否有配件需求可能下滑(如车型停产、季节性波动)?  
56 -4. **执行风险**: 补货建议中是否有需要人工复核的异常项?(如建议量远超历史、首次采购配件等) 53 +1. **供应风险**: 高频或高优先级配件的供应保障是否关键?
  54 +2. **资金风险**: 大额补货配件(≥5000元)的占比是否过高?是否构成资金压力?
  55 +3. **库存结构风险**: 低频配件的补货比例是否合理?是否存在积压风险?
  56 +4. **执行重点**: 针对高优先级或大额补货的配件,建议采取什么复核策略?
57 57
58 ### 模块3: 采购策略建议 (procurement_strategy) 58 ### 模块3: 采购策略建议 (procurement_strategy)
59 59
60 提供整体性的采购执行策略: 60 提供整体性的采购执行策略:
61 61
62 -1. **优先级排序原则**: 如果预算或精力有限,应按什么顺序安排采购?给出清晰的分级标准  
63 -2. **批量采购机会**: 是否有可以合并下单以降低成本的机会?涉及哪些品类或供应商?  
64 -3. **分批采购建议**: 哪些配件可以分批次补货?建议的节奏是什么?  
65 -4. **供应商协调要点**: 是否需要提前与供应商确认交期、价格或备货?关键沟通事项有哪些? 62 +1. **优先级排序原则**: 结合优先级分布数据,给出资金分配和采购执行的先后顺序建议。
  63 +2. **批量采购机会**: 对于低价高频的配件,是否建议采用批量采购策略以优化成本?
  64 +3. **分批采购建议**: 对于大额或低频配件,是否建议分批次补货以控制风险?
  65 +4. **供应商协调要点**: 针对本期补货的结构特征(如大额占比高或高频占比高),与供应商沟通的侧重点是什么?
66 66
67 ### 模块4: 效果预期与建议 (expected_impact) 67 ### 模块4: 效果预期与建议 (expected_impact)
68 68
src/fw_pms_ai/agent/analysis_report_node.py
@@ -36,6 +36,126 @@ def _load_prompt(filename: str) -> str: @@ -36,6 +36,126 @@ def _load_prompt(filename: str) -> str:
36 return f.read() 36 return f.read()
37 37
38 38
  39 +def _calculate_suggestion_stats(part_results: list) -> dict:
  40 + """
  41 + 基于完整数据计算补货建议统计
  42 +
  43 + 统计维度:
  44 + 1. 总体统计:总数量、总金额
  45 + 2. 优先级分布:高/中/低优先级配件数及金额
  46 + 3. 价格区间分布:低价/中价/高价配件分布
  47 + 4. 周转频次分布:高频/中频/低频配件分布
  48 + 5. 补货规模分布:大额/中额/小额补货配件分布
  49 + """
  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"),
  87 + }
  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
  100 + 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 + # 优先级分布
  113 + if priority == 1:
  114 + stats["priority_high_cnt"] += 1
  115 + stats["priority_high_amount"] += suggest_amount
  116 + 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
  152 + else:
  153 + stats["replenish_small_cnt"] += 1
  154 + stats["replenish_small_amount"] += suggest_amount
  155 +
  156 + return stats
  157 +
  158 +
39 def _calculate_risk_stats(part_ratios: list) -> dict: 159 def _calculate_risk_stats(part_ratios: list) -> dict:
40 """计算风险统计数据""" 160 """计算风险统计数据"""
41 stats = { 161 stats = {
@@ -71,35 +191,90 @@ def _calculate_risk_stats(part_ratios: list) -&gt; dict: @@ -71,35 +191,90 @@ def _calculate_risk_stats(part_ratios: list) -&gt; dict:
71 return stats 191 return stats
72 192
73 193
74 -def _build_suggestion_summary(part_results: list, allocated_details: list) -> str:  
75 - """构建补货建议汇总文本"""  
76 - if not part_results and not allocated_details: 194 +def _build_suggestion_summary(suggestion_stats: dict) -> str:
  195 + """
  196 + 基于预计算的统计数据构建结构化补货建议摘要
  197 +
  198 + 摘要包含:
  199 + - 补货总体规模
  200 + - 优先级分布
  201 + - 价格区间分布
  202 + - 周转频次分布
  203 + - 补货金额分布
  204 + """
  205 + if suggestion_stats["total_parts_cnt"] == 0:
77 return "暂无补货建议" 206 return "暂无补货建议"
78 207
79 lines = [] 208 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") 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 +
103 return "\n".join(lines) 278 return "\n".join(lines)
104 279
105 280
@@ -129,8 +304,11 @@ def generate_analysis_report_node(state: dict) -&gt; dict: @@ -129,8 +304,11 @@ def generate_analysis_report_node(state: dict) -&gt; dict:
129 # 计算风险统计 304 # 计算风险统计
130 risk_stats = _calculate_risk_stats(part_ratios) 305 risk_stats = _calculate_risk_stats(part_ratios)
131 306
132 - # 构建建议汇总  
133 - suggestion_summary = _build_suggestion_summary(part_results, allocated_details) 307 + # 计算补货建议统计 (基于完整数据)
  308 + suggestion_stats = _calculate_suggestion_stats(part_results)
  309 +
  310 + # 构建结构化建议汇总
  311 + suggestion_summary = _build_suggestion_summary(suggestion_stats)
134 312
135 # 加载 Prompt 313 # 加载 Prompt
136 prompt_template = _load_prompt("analysis_report.md") 314 prompt_template = _load_prompt("analysis_report.md")
@@ -164,15 +342,9 @@ def generate_analysis_report_node(state: dict) -&gt; dict: @@ -164,15 +342,9 @@ def generate_analysis_report_node(state: dict) -&gt; dict:
164 342
165 report_data = json.loads(response_text) 343 report_data = json.loads(response_text)
166 344
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 - ) 345 + # 复用已计算的统计数据
  346 + total_suggest_cnt = suggestion_stats["total_suggest_cnt"]
  347 + total_suggest_amount = suggestion_stats["total_suggest_amount"]
176 348
177 execution_time_ms = int((time.time() - start_time) * 1000) 349 execution_time_ms = int((time.time() - start_time) * 1000)
178 350
src/fw_pms_ai/agent/nodes.py
@@ -175,7 +175,7 @@ def sql_agent_node(state: AgentState) -&gt; AgentState: @@ -175,7 +175,7 @@ def sql_agent_node(state: AgentState) -&gt; AgentState:
175 dealer_grouping_name=state["dealer_grouping_name"], 175 dealer_grouping_name=state["dealer_grouping_name"],
176 statistics_date=state["statistics_date"], 176 statistics_date=state["statistics_date"],
177 target_ratio=base_ratio if base_ratio > 0 else Decimal("1.3"), 177 target_ratio=base_ratio if base_ratio > 0 else Decimal("1.3"),
178 - limit=1, 178 + limit=1000,
179 callback=save_batch_callback, 179 callback=save_batch_callback,
180 ) 180 )
181 181
src/fw_pms_ai/api/routes/tasks.py
@@ -3,7 +3,8 @@ @@ -3,7 +3,8 @@
3 """ 3 """
4 4
5 import logging 5 import logging
6 -from typing import Optional, List 6 +from typing import Optional, List, Dict, Any
  7 +import json
7 from datetime import datetime 8 from datetime import datetime
8 from decimal import Decimal 9 from decimal import Decimal
9 10
@@ -609,3 +610,102 @@ async def get_part_shop_details( @@ -609,3 +610,102 @@ async def get_part_shop_details(
609 finally: 610 finally:
610 cursor.close() 611 cursor.close()
611 conn.close() 612 conn.close()
  613 +
  614 +
  615 +class AnalysisReportResponse(BaseModel):
  616 + """分析报告响应"""
  617 + id: int
  618 + task_no: str
  619 + group_id: int
  620 + dealer_grouping_id: int
  621 + dealer_grouping_name: Optional[str] = None
  622 + brand_grouping_id: Optional[int] = None
  623 + 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 +
  639 + llm_provider: Optional[str] = None
  640 + llm_model: Optional[str] = None
  641 + llm_tokens: int = 0
  642 + execution_time_ms: int = 0
  643 + statistics_date: Optional[str] = None
  644 + create_time: Optional[str] = None
  645 +
  646 +
  647 +@router.get("/tasks/{task_no}/analysis-report", response_model=Optional[AnalysisReportResponse])
  648 +async def get_analysis_report(task_no: str):
  649 + """获取任务的分析报告"""
  650 + conn = get_connection()
  651 + cursor = conn.cursor(dictionary=True)
  652 +
  653 + try:
  654 + cursor.execute(
  655 + """
  656 + SELECT * FROM ai_analysis_report
  657 + WHERE task_no = %s
  658 + ORDER BY create_time DESC
  659 + LIMIT 1
  660 + """,
  661 + (task_no,)
  662 + )
  663 + row = cursor.fetchone()
  664 +
  665 + if not row:
  666 + return None
  667 +
  668 + # 辅助函数:解析 JSON 字符串
  669 + def parse_json(value):
  670 + if not value:
  671 + return None
  672 + if isinstance(value, str):
  673 + try:
  674 + return json.loads(value)
  675 + except json.JSONDecodeError:
  676 + return None
  677 + return value
  678 +
  679 + return AnalysisReportResponse(
  680 + id=row["id"],
  681 + task_no=row["task_no"],
  682 + group_id=row["group_id"],
  683 + dealer_grouping_id=row["dealer_grouping_id"],
  684 + 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"),
  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 +
  701 + llm_provider=row.get("llm_provider"),
  702 + llm_model=row.get("llm_model"),
  703 + llm_tokens=row.get("llm_tokens") or 0,
  704 + execution_time_ms=row.get("execution_time_ms") or 0,
  705 + statistics_date=row.get("statistics_date"),
  706 + create_time=format_datetime(row.get("create_time")),
  707 + )
  708 +
  709 + finally:
  710 + cursor.close()
  711 + conn.close()
tests/test_analysis_report.py deleted
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()  
ui/css/style.css
@@ -797,12 +797,321 @@ tbody tr:last-child td { @@ -797,12 +797,321 @@ tbody tr:last-child td {
797 color: white; 797 color: white;
798 } 798 }
799 799
  800 +
800 .pagination-btn svg { 801 .pagination-btn svg {
801 width: 18px; 802 width: 18px;
802 height: 18px; 803 height: 18px;
803 } 804 }
804 805
805 /* ===================== 806 /* =====================
  807 + Analysis Report (Independent Layouts)
  808 + ===================== */
  809 +.report-container {
  810 + display: flex;
  811 + flex-direction: column;
  812 + gap: var(--spacing-2xl);
  813 + animation: fadeIn 0.6s cubic-bezier(0.22, 1, 0.36, 1);
  814 + padding: var(--spacing-md) 0;
  815 +}
  816 +
  817 +@keyframes fadeIn {
  818 + from { opacity: 0; transform: translateY(20px); }
  819 + to { opacity: 1; transform: translateY(0); }
  820 +}
  821 +
  822 +.report-module {
  823 + margin-bottom: var(--spacing-2xl);
  824 + position: relative;
  825 +}
  826 +
  827 +.report-section-title {
  828 + font-size: 1.5rem;
  829 + font-weight: 800;
  830 + margin-bottom: var(--spacing-xl);
  831 + display: flex;
  832 + align-items: center;
  833 + gap: var(--spacing-md);
  834 + color: var(--text-primary);
  835 + position: relative;
  836 + padding-left: var(--spacing-xs);
  837 + letter-spacing: -0.02em;
  838 +}
  839 +
  840 +.report-section-title svg {
  841 + width: 28px;
  842 + height: 28px;
  843 + color: var(--color-primary);
  844 + filter: drop-shadow(0 0 8px rgba(99, 102, 241, 0.5));
  845 +}
  846 +
  847 +/* --- Module 1: Overall Assessment (Hero Grid) --- */
  848 +.assessment-grid {
  849 + display: grid;
  850 + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  851 + gap: var(--spacing-xl);
  852 + background: linear-gradient(180deg, rgba(30, 41, 59, 0.4) 0%, rgba(15, 23, 42, 0) 100%);
  853 + border-radius: var(--radius-xl);
  854 + padding: var(--spacing-xl);
  855 + border: 1px solid rgba(255, 255, 255, 0.05);
  856 +}
  857 +
  858 +.assessment-item {
  859 + display: flex;
  860 + flex-direction: column;
  861 + gap: var(--spacing-sm);
  862 + padding-right: var(--spacing-lg);
  863 + border-right: 1px solid rgba(255, 255, 255, 0.05);
  864 +}
  865 +
  866 +/* Remove border-right for the last item in a row would be complex with auto-fit,
  867 + so we might need a media query or accept borders on the right.
  868 + For simplicity and cleaner look on mobile, we can remove border-right for all
  869 + and add bottom border for mobile, or just rely on spacing.
  870 + Let's keep it simple: Remove border-right and use background/spacing to separate.
  871 +*/
  872 +.assessment-item {
  873 + border-right: none;
  874 + padding-right: 0;
  875 + position: relative;
  876 +}
  877 +
  878 +.assessment-item:not(:last-child)::after {
  879 + content: '';
  880 + position: absolute;
  881 + right: -12px;
  882 + top: 10%;
  883 + height: 80%;
  884 + width: 1px;
  885 + background: rgba(255, 255, 255, 0.05);
  886 + display: none; /* Hidden by default for responsive */
  887 +}
  888 +
  889 +@media (min-width: 1024px) {
  890 + .assessment-item:not(:last-child)::after {
  891 + display: block;
  892 + }
  893 +}
  894 +
  895 +.assessment-label {
  896 + font-size: 0.85rem;
  897 + text-transform: uppercase;
  898 + letter-spacing: 0.1em;
  899 + color: var(--text-secondary);
  900 + font-weight: 600;
  901 + margin-bottom: var(--spacing-xs);
  902 +}
  903 +
  904 +.assessment-main {
  905 + font-size: 1.25rem;
  906 + color: var(--text-primary);
  907 + line-height: 1.6;
  908 + font-weight: 600;
  909 +}
  910 +
  911 +.assessment-sub {
  912 + font-size: 0.9rem;
  913 + color: var(--text-secondary);
  914 + margin-top: auto;
  915 + padding-top: var(--spacing-md);
  916 + line-height: 1.5;
  917 +}
  918 +
  919 +/* --- Module 2: Risk Alerts (Alert Feed) --- */
  920 +.risk-feed {
  921 + display: flex;
  922 + flex-direction: column;
  923 + gap: var(--spacing-md);
  924 +}
  925 +
  926 +.risk-item {
  927 + display: flex;
  928 + gap: var(--spacing-lg);
  929 + padding: var(--spacing-lg);
  930 + background: rgba(30, 41, 59, 0.3);
  931 + border-radius: var(--radius-lg);
  932 + border: 1px solid transparent;
  933 + border-left: 4px solid transparent;
  934 + transition: all 0.3s ease;
  935 +}
  936 +
  937 +.risk-item:hover {
  938 + background: rgba(30, 41, 59, 0.5);
  939 + transform: translateX(4px);
  940 + border-color: rgba(255, 255, 255, 0.05);
  941 +}
  942 +
  943 +.risk-item.high { border-left-color: var(--color-danger); }
  944 +.risk-item.medium { border-left-color: var(--color-warning); }
  945 +.risk-item.low { border-left-color: var(--color-info); }
  946 +
  947 +.risk-icon {
  948 + flex-shrink: 0;
  949 + width: 40px;
  950 + height: 40px;
  951 + border-radius: 50%;
  952 + display: flex;
  953 + align-items: center;
  954 + justify-content: center;
  955 + background: rgba(255, 255, 255, 0.03);
  956 +}
  957 +
  958 +.risk-item.high .risk-icon { color: var(--color-danger); background: rgba(239, 68, 68, 0.1); }
  959 +.risk-item.medium .risk-icon { color: var(--color-warning); background: rgba(245, 158, 11, 0.1); }
  960 +
  961 +.risk-content {
  962 + flex: 1;
  963 +}
  964 +
  965 +.risk-title {
  966 + font-size: 1.05rem;
  967 + font-weight: 600;
  968 + color: var(--text-primary);
  969 + margin-bottom: 4px;
  970 + display: flex;
  971 + align-items: center;
  972 + gap: var(--spacing-sm);
  973 +}
  974 +
  975 +.risk-desc {
  976 + color: var(--text-secondary);
  977 + font-size: 0.95rem;
  978 + margin-bottom: var(--spacing-sm);
  979 +}
  980 +
  981 +.risk-action {
  982 + display: inline-flex;
  983 + align-items: center;
  984 + gap: 6px;
  985 + font-size: 0.85rem;
  986 + color: var(--text-primary);
  987 + background: rgba(255, 255, 255, 0.05);
  988 + padding: 4px 10px;
  989 + border-radius: 4px;
  990 +}
  991 +
  992 +/* --- Module 3: Strategy (Stepped Guide) --- */
  993 +.strategy-steps {
  994 + display: grid;
  995 + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  996 + gap: var(--spacing-lg);
  997 +}
  998 +
  999 +.strategy-step {
  1000 + position: relative;
  1001 + padding: var(--spacing-lg);
  1002 + background: linear-gradient(145deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.01) 100%);
  1003 + border: 1px solid rgba(255,255,255,0.05);
  1004 + border-radius: var(--radius-lg);
  1005 +}
  1006 +
  1007 +.strategy-number {
  1008 + font-family: var(--font-mono);
  1009 + font-size: 2.5rem;
  1010 + font-weight: 700;
  1011 + color: rgba(255, 255, 255, 0.05);
  1012 + position: absolute;
  1013 + top: var(--spacing-sm);
  1014 + right: var(--spacing-lg);
  1015 + line-height: 1;
  1016 +}
  1017 +
  1018 +.strategy-title {
  1019 + font-size: 1.1rem;
  1020 + font-weight: 600;
  1021 + color: var(--color-primary-light);
  1022 + margin-bottom: var(--spacing-md);
  1023 + position: relative;
  1024 +}
  1025 +
  1026 +.strategy-list {
  1027 + list-style: none;
  1028 +}
  1029 +
  1030 +.strategy-list li {
  1031 + position: relative;
  1032 + padding-left: 20px;
  1033 + margin-bottom: 8px;
  1034 + color: var(--text-secondary);
  1035 + font-size: 0.95rem;
  1036 +}
  1037 +
  1038 +.strategy-list li::before {
  1039 + content: '→';
  1040 + position: absolute;
  1041 + left: 0;
  1042 + color: var(--color-primary);
  1043 + opacity: 0.7;
  1044 +}
  1045 +
  1046 +/* --- Module 4: Impact (KPI Metrics) --- */
  1047 +.impact-panel {
  1048 + display: grid;
  1049 + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  1050 + gap: var(--spacing-xl);
  1051 + background: radial-gradient(circle at center, rgba(16, 185, 129, 0.03) 0%, transparent 70%);
  1052 + border-top: 1px solid rgba(16, 185, 129, 0.1);
  1053 + border-bottom: 1px solid rgba(16, 185, 129, 0.1);
  1054 + padding: var(--spacing-2xl) 0;
  1055 +}
  1056 +
  1057 +.kpi-item {
  1058 + text-align: center;
  1059 + position: relative;
  1060 +}
  1061 +
  1062 +.kpi-item:not(:last-child)::after {
  1063 + content: '';
  1064 + position: absolute;
  1065 + right: 0;
  1066 + top: 20%;
  1067 + height: 60%;
  1068 + width: 1px;
  1069 + background: rgba(255, 255, 255, 0.05);
  1070 +}
  1071 +
  1072 +.kpi-value {
  1073 + font-size: 2.5rem;
  1074 + font-weight: 800;
  1075 + background: linear-gradient(135deg, var(--text-primary) 0%, var(--text-secondary) 100%);
  1076 + -webkit-background-clip: text;
  1077 + background-clip: text;
  1078 + -webkit-text-fill-color: transparent;
  1079 + margin-bottom: var(--spacing-xs);
  1080 + letter-spacing: -0.05em;
  1081 + text-shadow: 0 2px 10px rgba(0,0,0,0.1);
  1082 +}
  1083 +
  1084 +.kpi-label {
  1085 + font-size: 0.9rem;
  1086 + color: var(--color-success-light);
  1087 + font-weight: 600;
  1088 + text-transform: uppercase;
  1089 + letter-spacing: 0.05em;
  1090 + margin-bottom: var(--spacing-sm);
  1091 +}
  1092 +
  1093 +.kpi-desc {
  1094 + font-size: 0.9rem;
  1095 + color: var(--text-muted);
  1096 + max-width: 80%;
  1097 + margin: 0 auto;
  1098 + line-height: 1.5;
  1099 +}
  1100 +.markdown-content p {
  1101 + margin-bottom: 0.5rem;
  1102 +}
  1103 +
  1104 +.markdown-content ul {
  1105 + margin-left: 1.25rem;
  1106 + margin-bottom: 0.5rem;
  1107 +}
  1108 +
  1109 +.markdown-content li {
  1110 + margin-bottom: 0.25rem;
  1111 +}
  1112 +
  1113 +
  1114 +/* =====================
806 Loading & Overlay 1115 Loading & Overlay
807 ===================== */ 1116 ===================== */
808 .loading-overlay { 1117 .loading-overlay {
ui/js/api.js
@@ -90,6 +90,13 @@ const API = { @@ -90,6 +90,13 @@ const API = {
90 async getPartShopDetails(taskNo, partCode) { 90 async getPartShopDetails(taskNo, partCode) {
91 return this.get(`/tasks/${taskNo}/parts/${encodeURIComponent(partCode)}/shops`); 91 return this.get(`/tasks/${taskNo}/parts/${encodeURIComponent(partCode)}/shops`);
92 }, 92 },
  93 +
  94 + /**
  95 + * 获取任务分析报告
  96 + */
  97 + async getAnalysisReport(taskNo) {
  98 + return this.get(`/tasks/${taskNo}/analysis-report`);
  99 + },
93 }; 100 };
94 101
95 // 导出到全局 102 // 导出到全局
ui/js/app.js
@@ -96,6 +96,277 @@ const App = { @@ -96,6 +96,277 @@ const App = {
96 this.loadPartSummaries(); 96 this.loadPartSummaries();
97 }, 97 },
98 98
  99 +
  100 + /**
  101 + * 渲染分析报告标签页
  102 + */
  103 + async renderReportTab(container, taskNo) {
  104 + container.innerHTML = '<div class="loading-shops">加载分析报告...</div>';
  105 +
  106 + try {
  107 + const report = await API.getAnalysisReport(taskNo);
  108 +
  109 + if (!report) {
  110 + container.innerHTML = `
  111 + <div class="card">
  112 + ${Components.renderEmptyState('file-x', '暂无分析报告', '该任务尚未生成分析报告')}
  113 + </div>
  114 + `;
  115 + return;
  116 + }
  117 +
  118 + container.innerHTML = `
  119 + <div class="report-module">
  120 + <div class="report-section-title">
  121 + <i data-lucide="layout-dashboard"></i>
  122 + 核心经营综述
  123 + </div>
  124 + <div class="report-grid">
  125 + ${this.renderOverallAssessment(report.replenishment_insights)}
  126 + </div>
  127 + </div>
  128 +
  129 + <div class="report-module">
  130 + <div class="report-section-title">
  131 + <i data-lucide="alert-triangle"></i>
  132 + 风险管控预警
  133 + </div>
  134 + <div class="report-grid">
  135 + ${this.renderRiskAlerts(report.urgency_assessment)}
  136 + </div>
  137 + </div>
  138 +
  139 + <div class="report-module">
  140 + <div class="report-section-title">
  141 + <i data-lucide="target"></i>
  142 + 补货策略建议
  143 + </div>
  144 + <div class="report-grid">
  145 + ${this.renderStrategy(report.strategy_recommendations)}
  146 + </div>
  147 + </div>
  148 +
  149 + <div class="report-module">
  150 + <div class="report-section-title">
  151 + <i data-lucide="trending-up"></i>
  152 + 效果预期与建议
  153 + </div>
  154 + <div class="report-grid">
  155 + ${this.renderExpectedImpact(report.expected_outcomes)}
  156 + </div>
  157 + </div>
  158 + `;
  159 +
  160 + lucide.createIcons();
  161 + } catch (error) {
  162 + container.innerHTML = `
  163 + <div class="card" style="text-align: center; color: var(--color-danger);">
  164 + <i data-lucide="alert-circle" style="width: 48px; height: 48px; margin-bottom: 1rem;"></i>
  165 + <p>加载报告失败: ${error.message}</p>
  166 + </div>
  167 + `;
  168 + lucide.createIcons();
  169 + }
  170 + },
  171 +
  172 + renderOverallAssessment(insights) {
  173 + if (!insights) return '';
  174 +
  175 + let heroHtml = '';
  176 +
  177 + // Scale (Hero Main)
  178 + if (insights.scale_evaluation) {
  179 + heroHtml += `
  180 + <div class="assessment-item">
  181 + <div class="assessment-label">补货规模</div>
  182 + <div class="assessment-main">${insights.scale_evaluation.current_vs_historical || '-'}</div>
  183 + <div class="assessment-sub">${insights.scale_evaluation.possible_reasons || ''}</div>
  184 + </div>`;
  185 + }
  186 +
  187 + // Structure (Hero Middle)
  188 + if (insights.structure_analysis) {
  189 + const data = insights.structure_analysis;
  190 + const details = [
  191 + data.category_distribution ? `• ${data.category_distribution}` : '',
  192 + data.price_range_distribution ? `• ${data.price_range_distribution}` : '',
  193 + data.turnover_distribution ? `• ${data.turnover_distribution}` : ''
  194 + ].filter(Boolean).join('<br>');
  195 +
  196 + heroHtml += `
  197 + <div class="assessment-item">
  198 + <div class="assessment-label">结构特征</div>
  199 + <div class="assessment-main">${data.imbalance_warning || '结构均衡'}</div>
  200 + <div class="assessment-sub">${details}</div>
  201 + </div>`;
  202 + }
  203 +
  204 + // Timing (Hero End)
  205 + if (insights.timing_judgment) {
  206 + const data = insights.timing_judgment;
  207 + const isPos = data.is_favorable;
  208 + heroHtml += `
  209 + <div class="assessment-item">
  210 + <div class="assessment-label">时机判断</div>
  211 + <div class="assessment-main" style="color:${isPos ? 'var(--color-success)' : 'var(--color-warning)'}">
  212 + ${isPos ? '有利时机' : '建议观望'}
  213 + </div>
  214 + <div class="assessment-sub">
  215 + ${data.recommendation}<br>
  216 + <span style="opacity:0.7;font-size:0.85em;display:block;margin-top:4px;">${data.timing_factors || ''}</span>
  217 + </div>
  218 + </div>`;
  219 + }
  220 +
  221 + return `<div class="assessment-grid">${heroHtml}</div>`;
  222 + },
  223 +
  224 + renderRiskAlerts(risks) {
  225 + if (!risks) return '';
  226 +
  227 + let feedHtml = '<div class="risk-feed">';
  228 +
  229 + const addRiskItem = (level, type, desc, action) => {
  230 + let icon = 'alert-circle';
  231 + if (level === 'high') icon = 'alert-octagon';
  232 + if (level === 'low') icon = 'info';
  233 +
  234 + feedHtml += `
  235 + <div class="risk-item ${level}">
  236 + <div class="risk-icon">
  237 + <i data-lucide="${icon}"></i>
  238 + </div>
  239 + <div class="risk-content">
  240 + <div class="risk-title">
  241 + ${type}
  242 + <span class="badgex">${level.toUpperCase()}</span>
  243 + </div>
  244 + <div class="risk-desc">${desc}</div>
  245 + ${action ? `<div class="risk-action"><i data-lucide="arrow-right-circle" style="width:14px;"></i> ${action}</div>` : ''}
  246 + </div>
  247 + </div>`;
  248 + };
  249 +
  250 + // Supply Risks
  251 + if (risks.supply_risks && Array.isArray(risks.supply_risks)) {
  252 + risks.supply_risks.forEach(r => addRiskItem(
  253 + r.likelihood === '高' ? 'high' : 'medium',
  254 + r.risk_type || '供应风险',
  255 + r.affected_scope,
  256 + r.mitigation
  257 + ));
  258 + }
  259 +
  260 + // Capital Risks
  261 + if (risks.capital_risks) {
  262 + const data = risks.capital_risks;
  263 + addRiskItem('medium', '资金风险', data.cash_flow_pressure, data.recommendation);
  264 + }
  265 +
  266 + // Market Risks
  267 + if (risks.market_risks && Array.isArray(risks.market_risks)) {
  268 + risks.market_risks.forEach(r => addRiskItem('medium', '市场风险', r.risk_description, r.recommendation));
  269 + }
  270 +
  271 + // Execution
  272 + if (risks.execution_anomalies && Array.isArray(risks.execution_anomalies)) {
  273 + risks.execution_anomalies.forEach(a => addRiskItem('high', a.anomaly_type || '执行异常', a.description, a.review_suggestion));
  274 + }
  275 +
  276 + feedHtml += '</div>';
  277 + return feedHtml;
  278 + },
  279 +
  280 + renderStrategy(strategy) {
  281 + if (!strategy) return '';
  282 +
  283 + let html = '<div class="strategy-steps">';
  284 +
  285 + const addStep = (num, title, items) => {
  286 + const listItems = Array.isArray(items) ? items : [items];
  287 + const listHtml = listItems.map(i => `<li>${i}</li>`).join('');
  288 + html += `
  289 + <div class="strategy-step">
  290 + <div class="strategy-number">0${num}</div>
  291 + <div class="strategy-title">${title}</div>
  292 + <ul class="strategy-list">${listHtml}</ul>
  293 + </div>`;
  294 + };
  295 +
  296 + // 1. Priority
  297 + if (strategy.priority_principle) {
  298 + const p = strategy.priority_principle;
  299 + addStep(1, '优先级排序', [
  300 + `<strong style="color:var(--color-danger)">P1:</strong> ${p.tier1_criteria}`,
  301 + `<strong style="color:var(--color-warning)">P2:</strong> ${p.tier2_criteria}`,
  302 + `<span style="opacity:0.7">P3: ${p.tier3_criteria}</span>`
  303 + ]);
  304 + }
  305 +
  306 + // 2. Phased
  307 + if (strategy.phased_procurement) {
  308 + addStep(2, '分批节奏', [
  309 + `节奏: ${strategy.phased_procurement.suggested_rhythm}`,
  310 + `范围: ${strategy.phased_procurement.recommended_parts}`
  311 + ]);
  312 + }
  313 +
  314 + // 3. Coordination
  315 + if (strategy.supplier_coordination) {
  316 + addStep(3, '供应商协同', [
  317 + strategy.supplier_coordination.key_communications,
  318 + `时机: ${strategy.supplier_coordination.timing_suggestions}`
  319 + ]);
  320 + }
  321 +
  322 + html += '</div>';
  323 + return html;
  324 + },
  325 +
  326 + renderExpectedImpact(impact) {
  327 + if (!impact) return '';
  328 +
  329 + let html = '<div class="impact-panel">';
  330 +
  331 + // Inventory
  332 + if (impact.inventory_health) {
  333 + html += `
  334 + <div class="kpi-item">
  335 + <div class="kpi-label">库存健康度</div>
  336 + <div class="kpi-value">${Components.formatAmount(impact.inventory_health.shortage_reduction || 0)}</div>
  337 + <div class="kpi-desc">${impact.inventory_health.structure_improvement}</div>
  338 + </div>`;
  339 + }
  340 +
  341 + // Efficiency
  342 + if (impact.capital_efficiency) {
  343 + html += `
  344 + <div class="kpi-item">
  345 + <div class="kpi-label">资金效率</div>
  346 + <div class="kpi-value">${Components.formatAmount(impact.capital_efficiency.investment_amount)}</div>
  347 + <div class="kpi-desc">${impact.capital_efficiency.expected_return}</div>
  348 + </div>`;
  349 + }
  350 +
  351 + // Next
  352 + if (impact.follow_up_actions) {
  353 + html += `
  354 + <div class="kpi-item">
  355 + <div class="kpi-label">下一步关注</div>
  356 + <div class="kpi-value" style="font-size:1.5rem;background:none;-webkit-text-fill-color:var(--text-primary);">Key Actions</div>
  357 + <div class="kpi-desc" style="text-align:left;display:inline-block;">${impact.follow_up_actions.next_steps}</div>
  358 + </div>`;
  359 + }
  360 +
  361 + html += '</div>';
  362 + return html;
  363 + },
  364 +
  365 + // 辅助方法:renderReportCard, renderRiskCard, renderImpactCard 已被新的独立渲染逻辑取代,保留为空或删除
  366 + renderReportCard(title, data) { return ''; },
  367 + renderRiskCard(title, data, level) { return ''; },
  368 + renderImpactCard(title, data) { return ''; },
  369 +
99 /** 370 /**
100 * 初始化应用 371 * 初始化应用
101 */ 372 */
@@ -493,7 +764,10 @@ const App = { @@ -493,7 +764,10 @@ const App = {
493 <i data-lucide="list"></i> 764 <i data-lucide="list"></i>
494 配件明细 765 配件明细
495 </button> 766 </button>
496 - 767 + <button class="tab" data-tab="report">
  768 + <i data-lucide="file-text"></i>
  769 + 分析报告
  770 + </button>
497 <button class="tab" data-tab="logs"> 771 <button class="tab" data-tab="logs">
498 <i data-lucide="activity"></i> 772 <i data-lucide="activity"></i>
499 执行日志 773 执行日志
@@ -505,7 +779,7 @@ const App = { @@ -505,7 +779,7 @@ const App = {
505 </div> 779 </div>
506 780
507 <!-- 标签页内容 --> 781 <!-- 标签页内容 -->
508 - <div id="tab-content"></div> 782 + <div id="tab-content" class="report-container"></div>
509 `; 783 `;
510 784
511 // 绑定标签页事件 785 // 绑定标签页事件
@@ -536,6 +810,9 @@ const App = { @@ -536,6 +810,9 @@ const App = {
536 case 'logs': 810 case 'logs':
537 this.renderLogsTab(container, this._currentLogs); 811 this.renderLogsTab(container, this._currentLogs);
538 break; 812 break;
  813 + case 'report':
  814 + this.renderReportTab(container, task.task_no);
  815 + break;
539 case 'info': 816 case 'info':
540 this.renderInfoTab(container, task); 817 this.renderInfoTab(container, task);
541 break; 818 break;