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,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) -> dict: | @@ -71,35 +191,90 @@ def _calculate_risk_stats(part_ratios: list) -> 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) -> dict: | @@ -129,8 +304,11 @@ def generate_analysis_report_node(state: dict) -> 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) -> dict: | @@ -164,15 +342,9 @@ def generate_analysis_report_node(state: dict) -> 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) -> AgentState: | @@ -175,7 +175,7 @@ def sql_agent_node(state: AgentState) -> 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; |