Commit f52f1031dd7c12552d72f51e6c05468941f70ef8
1 parent
58a795a0
feat: 新增分析报告查询接口及前端展示,优化报告生成逻辑和提示词,并移除旧测试文件。
Showing
8 changed files
with
920 additions
and
113 deletions
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 | 16 | |
| 17 | 17 | --- |
| 18 | 18 | |
| 19 | -## 本期补货建议概览 | |
| 19 | +## 本期补货建议概览 (统计摘要) | |
| 20 | 20 | |
| 21 | 21 | {suggestion_summary} |
| 22 | 22 | |
| ... | ... | @@ -36,33 +36,33 @@ |
| 36 | 36 | |
| 37 | 37 | 请严格按以下4个模块输出 **JSON格式** 的整体分析报告。 |
| 38 | 38 | |
| 39 | -**注意**: 不要重复补货明细中已有的配件级别分析,聚焦于以下**宏观维度**。 | |
| 39 | +**注意**: 分析应基于提供的统计分布数据(如优先级、价格区间、周转频次等),按以下**宏观维度**展开。 | |
| 40 | 40 | |
| 41 | 41 | ### 模块1: 整体态势研判 (overall_assessment) |
| 42 | 42 | |
| 43 | 43 | 从全局视角评估本次补货建议: |
| 44 | 44 | |
| 45 | -1. **补货规模评估**: 本期补货总金额与历史同期相比是偏高、正常还是偏低?可能的原因是什么? | |
| 46 | -2. **结构特征分析**: 补货建议在品类、价格区间、周转频次上呈现什么分布特征?是否存在明显的集中或失衡? | |
| 45 | +1. **补货规模评估**: 结合涉及配件数和总金额,评估本期补货的力度和资金需求规模。 | |
| 46 | +2. **结构特征分析**: 基于价格区间和周转频次分布,分析补货建议的结构特征(如是否偏向高频低价件,或存在大量低频高价件)。 | |
| 47 | 47 | 3. **时机判断**: 当前是否处于补货的有利时机?需要考虑哪些时间因素(如节假日、促销季、供应商备货周期)? |
| 48 | 48 | |
| 49 | 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 | 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 | 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 | 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 | 159 | def _calculate_risk_stats(part_ratios: list) -> dict: |
| 40 | 160 | """计算风险统计数据""" |
| 41 | 161 | stats = { |
| ... | ... | @@ -71,35 +191,90 @@ def _calculate_risk_stats(part_ratios: list) -> dict: |
| 71 | 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 | 206 | return "暂无补货建议" |
| 78 | 207 | |
| 79 | 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 | 278 | return "\n".join(lines) |
| 104 | 279 | |
| 105 | 280 | |
| ... | ... | @@ -129,8 +304,11 @@ def generate_analysis_report_node(state: dict) -> dict: |
| 129 | 304 | # 计算风险统计 |
| 130 | 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 | 313 | # 加载 Prompt |
| 136 | 314 | prompt_template = _load_prompt("analysis_report.md") |
| ... | ... | @@ -164,15 +342,9 @@ def generate_analysis_report_node(state: dict) -> dict: |
| 164 | 342 | |
| 165 | 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 | 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) -> AgentState: |
| 175 | 175 | dealer_grouping_name=state["dealer_grouping_name"], |
| 176 | 176 | statistics_date=state["statistics_date"], |
| 177 | 177 | target_ratio=base_ratio if base_ratio > 0 else Decimal("1.3"), |
| 178 | - limit=1, | |
| 178 | + limit=1000, | |
| 179 | 179 | callback=save_batch_callback, |
| 180 | 180 | ) |
| 181 | 181 | ... | ... |
src/fw_pms_ai/api/routes/tasks.py
| ... | ... | @@ -3,7 +3,8 @@ |
| 3 | 3 | """ |
| 4 | 4 | |
| 5 | 5 | import logging |
| 6 | -from typing import Optional, List | |
| 6 | +from typing import Optional, List, Dict, Any | |
| 7 | +import json | |
| 7 | 8 | from datetime import datetime |
| 8 | 9 | from decimal import Decimal |
| 9 | 10 | |
| ... | ... | @@ -609,3 +610,102 @@ async def get_part_shop_details( |
| 609 | 610 | finally: |
| 610 | 611 | cursor.close() |
| 611 | 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 | 797 | color: white; |
| 798 | 798 | } |
| 799 | 799 | |
| 800 | + | |
| 800 | 801 | .pagination-btn svg { |
| 801 | 802 | width: 18px; |
| 802 | 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 | 1115 | Loading & Overlay |
| 807 | 1116 | ===================== */ |
| 808 | 1117 | .loading-overlay { | ... | ... |
ui/js/api.js
| ... | ... | @@ -90,6 +90,13 @@ const API = { |
| 90 | 90 | async getPartShopDetails(taskNo, partCode) { |
| 91 | 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 | 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 | 764 | <i data-lucide="list"></i> |
| 494 | 765 | 配件明细 |
| 495 | 766 | </button> |
| 496 | - | |
| 767 | + <button class="tab" data-tab="report"> | |
| 768 | + <i data-lucide="file-text"></i> | |
| 769 | + 分析报告 | |
| 770 | + </button> | |
| 497 | 771 | <button class="tab" data-tab="logs"> |
| 498 | 772 | <i data-lucide="activity"></i> |
| 499 | 773 | 执行日志 |
| ... | ... | @@ -505,7 +779,7 @@ const App = { |
| 505 | 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 | 810 | case 'logs': |
| 537 | 811 | this.renderLogsTab(container, this._currentLogs); |
| 538 | 812 | break; |
| 813 | + case 'report': | |
| 814 | + this.renderReportTab(container, task.task_no); | |
| 815 | + break; | |
| 539 | 816 | case 'info': |
| 540 | 817 | this.renderInfoTab(container, task); |
| 541 | 818 | break; | ... | ... |