From f52f1031dd7c12552d72f51e6c05468941f70ef8 Mon Sep 17 00:00:00 2001 From: zhuYanFei Date: Thu, 5 Feb 2026 18:12:39 +0800 Subject: [PATCH] feat: 新增分析报告查询接口及前端展示,优化报告生成逻辑和提示词,并移除旧测试文件。 --- prompts/analysis_report.md | 28 ++++++++++++++-------------- src/fw_pms_ai/agent/analysis_report_node.py | 246 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------- src/fw_pms_ai/agent/nodes.py | 2 +- src/fw_pms_ai/api/routes/tasks.py | 102 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- tests/test_analysis_report.py | 58 ---------------------------------------------------------- ui/css/style.css | 309 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ ui/js/api.js | 7 +++++++ ui/js/app.js | 281 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 8 files changed, 920 insertions(+), 113 deletions(-) delete mode 100644 tests/test_analysis_report.py diff --git a/prompts/analysis_report.md b/prompts/analysis_report.md index df88696..7da9790 100644 --- a/prompts/analysis_report.md +++ b/prompts/analysis_report.md @@ -1,8 +1,8 @@ # 智能补货建议分析报告 -你是一位资深汽车配件采购顾问。AI系统已经生成了详细的补货建议明细(包含每个配件的补货数量、理由等),现在需要你站在更宏观的视角,为采购决策者提供**整体性分析**。 +你是一位资深汽车配件采购顾问。AI系统已经基于全量数据生成了补货建议的**多维统计分布数据**,现在需要你站在更宏观的视角,为采购决策者提供**整体性分析**。 -> **核心定位**: 补货明细已回答了"补什么、补多少"的问题,本报告聚焦于"整体策略、风险预警、资金规划"等**决策层面**的洞察。 +> **核心定位**: 统计数据揭示了补货的宏观特征与分布情况,本报告聚焦于"整体策略、风险预警、资金规划"等**决策层面**的洞察。 --- @@ -16,7 +16,7 @@ --- -## 本期补货建议概览 +## 本期补货建议概览 (统计摘要) {suggestion_summary} @@ -36,33 +36,33 @@ 请严格按以下4个模块输出 **JSON格式** 的整体分析报告。 -**注意**: 不要重复补货明细中已有的配件级别分析,聚焦于以下**宏观维度**。 +**注意**: 分析应基于提供的统计分布数据(如优先级、价格区间、周转频次等),按以下**宏观维度**展开。 ### 模块1: 整体态势研判 (overall_assessment) 从全局视角评估本次补货建议: -1. **补货规模评估**: 本期补货总金额与历史同期相比是偏高、正常还是偏低?可能的原因是什么? -2. **结构特征分析**: 补货建议在品类、价格区间、周转频次上呈现什么分布特征?是否存在明显的集中或失衡? +1. **补货规模评估**: 结合涉及配件数和总金额,评估本期补货的力度和资金需求规模。 +2. **结构特征分析**: 基于价格区间和周转频次分布,分析补货建议的结构特征(如是否偏向高频低价件,或存在大量低频高价件)。 3. **时机判断**: 当前是否处于补货的有利时机?需要考虑哪些时间因素(如节假日、促销季、供应商备货周期)? ### 模块2: 风险预警与应对 (risk_alerts) 识别本次补货可能面临的风险并给出应对建议: -1. **供应风险**: 是否有配件可能面临缺货、涨价、交期延长等供应端问题? -2. **资金风险**: 本期补货是否会造成资金压力?是否存在呆滞风险较高的配件需要谨慎采购? -3. **市场风险**: 是否有配件需求可能下滑(如车型停产、季节性波动)? -4. **执行风险**: 补货建议中是否有需要人工复核的异常项?(如建议量远超历史、首次采购配件等) +1. **供应风险**: 高频或高优先级配件的供应保障是否关键? +2. **资金风险**: 大额补货配件(≥5000元)的占比是否过高?是否构成资金压力? +3. **库存结构风险**: 低频配件的补货比例是否合理?是否存在积压风险? +4. **执行重点**: 针对高优先级或大额补货的配件,建议采取什么复核策略? ### 模块3: 采购策略建议 (procurement_strategy) 提供整体性的采购执行策略: -1. **优先级排序原则**: 如果预算或精力有限,应按什么顺序安排采购?给出清晰的分级标准 -2. **批量采购机会**: 是否有可以合并下单以降低成本的机会?涉及哪些品类或供应商? -3. **分批采购建议**: 哪些配件可以分批次补货?建议的节奏是什么? -4. **供应商协调要点**: 是否需要提前与供应商确认交期、价格或备货?关键沟通事项有哪些? +1. **优先级排序原则**: 结合优先级分布数据,给出资金分配和采购执行的先后顺序建议。 +2. **批量采购机会**: 对于低价高频的配件,是否建议采用批量采购策略以优化成本? +3. **分批采购建议**: 对于大额或低频配件,是否建议分批次补货以控制风险? +4. **供应商协调要点**: 针对本期补货的结构特征(如大额占比高或高频占比高),与供应商沟通的侧重点是什么? ### 模块4: 效果预期与建议 (expected_impact) diff --git a/src/fw_pms_ai/agent/analysis_report_node.py b/src/fw_pms_ai/agent/analysis_report_node.py index e95daef..b3c0945 100644 --- a/src/fw_pms_ai/agent/analysis_report_node.py +++ b/src/fw_pms_ai/agent/analysis_report_node.py @@ -36,6 +36,126 @@ def _load_prompt(filename: str) -> str: return f.read() +def _calculate_suggestion_stats(part_results: list) -> dict: + """ + 基于完整数据计算补货建议统计 + + 统计维度: + 1. 总体统计:总数量、总金额 + 2. 优先级分布:高/中/低优先级配件数及金额 + 3. 价格区间分布:低价/中价/高价配件分布 + 4. 周转频次分布:高频/中频/低频配件分布 + 5. 补货规模分布:大额/中额/小额补货配件分布 + """ + stats = { + # 总体统计 + "total_parts_cnt": 0, + "total_suggest_cnt": 0, + "total_suggest_amount": Decimal("0"), + + # 优先级分布: 1=高, 2=中, 3=低 + "priority_high_cnt": 0, + "priority_high_amount": Decimal("0"), + "priority_medium_cnt": 0, + "priority_medium_amount": Decimal("0"), + "priority_low_cnt": 0, + "priority_low_amount": Decimal("0"), + + # 价格区间分布 (成本价) + "price_low_cnt": 0, + "price_low_amount": Decimal("0"), + "price_medium_cnt": 0, + "price_medium_amount": Decimal("0"), + "price_high_cnt": 0, + "price_high_amount": Decimal("0"), + + # 周转频次分布 (月均销量) + "turnover_high_cnt": 0, + "turnover_high_amount": Decimal("0"), + "turnover_medium_cnt": 0, + "turnover_medium_amount": Decimal("0"), + "turnover_low_cnt": 0, + "turnover_low_amount": Decimal("0"), + + # 补货金额分布 + "replenish_large_cnt": 0, + "replenish_large_amount": Decimal("0"), + "replenish_medium_cnt": 0, + "replenish_medium_amount": Decimal("0"), + "replenish_small_cnt": 0, + "replenish_small_amount": Decimal("0"), + } + + if not part_results: + return stats + + for pr in part_results: + # 兼容对象和字典两种形式 + if hasattr(pr, "total_suggest_cnt"): + suggest_cnt = pr.total_suggest_cnt + suggest_amount = pr.total_suggest_amount + cost_price = pr.cost_price + avg_sales = pr.total_avg_sales_cnt + priority = pr.priority + else: + suggest_cnt = int(pr.get("total_suggest_cnt", 0)) + suggest_amount = Decimal(str(pr.get("total_suggest_amount", 0))) + cost_price = Decimal(str(pr.get("cost_price", 0))) + avg_sales = Decimal(str(pr.get("total_avg_sales_cnt", 0))) + priority = int(pr.get("priority", 2)) + + # 总体统计 + stats["total_parts_cnt"] += 1 + stats["total_suggest_cnt"] += suggest_cnt + stats["total_suggest_amount"] += suggest_amount + + # 优先级分布 + if priority == 1: + stats["priority_high_cnt"] += 1 + stats["priority_high_amount"] += suggest_amount + elif priority == 2: + stats["priority_medium_cnt"] += 1 + stats["priority_medium_amount"] += suggest_amount + else: + stats["priority_low_cnt"] += 1 + stats["priority_low_amount"] += suggest_amount + + # 价格区间分布: <50低价, 50-200中价, >200高价 + if cost_price < 50: + stats["price_low_cnt"] += 1 + stats["price_low_amount"] += suggest_amount + elif cost_price <= 200: + stats["price_medium_cnt"] += 1 + stats["price_medium_amount"] += suggest_amount + else: + stats["price_high_cnt"] += 1 + stats["price_high_amount"] += suggest_amount + + # 周转频次分布: 月均销量 >=5高频, 1-5中频, <1低频 + if avg_sales >= 5: + stats["turnover_high_cnt"] += 1 + stats["turnover_high_amount"] += suggest_amount + elif avg_sales >= 1: + stats["turnover_medium_cnt"] += 1 + stats["turnover_medium_amount"] += suggest_amount + else: + stats["turnover_low_cnt"] += 1 + stats["turnover_low_amount"] += suggest_amount + + # 补货金额分布: >=5000大额, 1000-5000中额, <1000小额 + if suggest_amount >= 5000: + stats["replenish_large_cnt"] += 1 + stats["replenish_large_amount"] += suggest_amount + elif suggest_amount >= 1000: + stats["replenish_medium_cnt"] += 1 + stats["replenish_medium_amount"] += suggest_amount + else: + stats["replenish_small_cnt"] += 1 + stats["replenish_small_amount"] += suggest_amount + + return stats + + def _calculate_risk_stats(part_ratios: list) -> dict: """计算风险统计数据""" stats = { @@ -71,35 +191,90 @@ def _calculate_risk_stats(part_ratios: list) -> dict: return stats -def _build_suggestion_summary(part_results: list, allocated_details: list) -> str: - """构建补货建议汇总文本""" - if not part_results and not allocated_details: +def _build_suggestion_summary(suggestion_stats: dict) -> str: + """ + 基于预计算的统计数据构建结构化补货建议摘要 + + 摘要包含: + - 补货总体规模 + - 优先级分布 + - 价格区间分布 + - 周转频次分布 + - 补货金额分布 + """ + if suggestion_stats["total_parts_cnt"] == 0: return "暂无补货建议" lines = [] - total_cnt = 0 - total_amount = Decimal("0") - - # 优先使用 part_results (配件级汇总) - if part_results: - for pr in part_results[:10]: # 只取前10个 - if hasattr(pr, "part_code"): - lines.append( - f"- {pr.part_code} {pr.part_name}: " - f"建议{pr.total_suggest_cnt}件, " - f"金额{pr.total_suggest_amount:.2f}元, " - f"优先级{pr.priority}" - ) - total_cnt += pr.total_suggest_cnt - total_amount += pr.total_suggest_amount - elif isinstance(pr, dict): - lines.append( - f"- {pr.get('part_code', '')} {pr.get('part_name', '')}: " - f"建议{pr.get('total_suggest_cnt', 0)}件, " - f"金额{pr.get('total_suggest_amount', 0):.2f}元" - ) - - lines.insert(0, f"**总计**: {total_cnt}件配件, 金额{total_amount:.2f}元\n") + + # 总体规模 + lines.append(f"### 补货总体规模") + lines.append(f"- 涉及配件种类: {suggestion_stats['total_parts_cnt']}种") + lines.append(f"- 建议补货总数量: {suggestion_stats['total_suggest_cnt']}件") + lines.append(f"- 建议补货总金额: {suggestion_stats['total_suggest_amount']:.2f}元") + lines.append("") + + # 优先级分布 + lines.append(f"### 优先级分布") + lines.append(f"| 优先级 | 配件数 | 金额(元) | 占比 |") + lines.append(f"|--------|--------|----------|------|") + total_amount = suggestion_stats['total_suggest_amount'] or Decimal("1") + + if suggestion_stats['priority_high_cnt'] > 0: + pct = suggestion_stats['priority_high_amount'] / total_amount * 100 + lines.append(f"| 高优先级 | {suggestion_stats['priority_high_cnt']} | {suggestion_stats['priority_high_amount']:.2f} | {pct:.1f}% |") + if suggestion_stats['priority_medium_cnt'] > 0: + pct = suggestion_stats['priority_medium_amount'] / total_amount * 100 + lines.append(f"| 中优先级 | {suggestion_stats['priority_medium_cnt']} | {suggestion_stats['priority_medium_amount']:.2f} | {pct:.1f}% |") + if suggestion_stats['priority_low_cnt'] > 0: + pct = suggestion_stats['priority_low_amount'] / total_amount * 100 + lines.append(f"| 低优先级 | {suggestion_stats['priority_low_cnt']} | {suggestion_stats['priority_low_amount']:.2f} | {pct:.1f}% |") + lines.append("") + + # 价格区间分布 + lines.append(f"### 价格区间分布 (按成本价)") + lines.append(f"| 价格区间 | 配件数 | 金额(元) | 占比 |") + lines.append(f"|----------|--------|----------|------|") + if suggestion_stats['price_low_cnt'] > 0: + pct = suggestion_stats['price_low_amount'] / total_amount * 100 + lines.append(f"| 低价(<50元) | {suggestion_stats['price_low_cnt']} | {suggestion_stats['price_low_amount']:.2f} | {pct:.1f}% |") + if suggestion_stats['price_medium_cnt'] > 0: + pct = suggestion_stats['price_medium_amount'] / total_amount * 100 + lines.append(f"| 中价(50-200元) | {suggestion_stats['price_medium_cnt']} | {suggestion_stats['price_medium_amount']:.2f} | {pct:.1f}% |") + if suggestion_stats['price_high_cnt'] > 0: + pct = suggestion_stats['price_high_amount'] / total_amount * 100 + lines.append(f"| 高价(>200元) | {suggestion_stats['price_high_cnt']} | {suggestion_stats['price_high_amount']:.2f} | {pct:.1f}% |") + lines.append("") + + # 周转频次分布 + lines.append(f"### 周转频次分布 (按月均销量)") + lines.append(f"| 周转频次 | 配件数 | 金额(元) | 占比 |") + lines.append(f"|----------|--------|----------|------|") + if suggestion_stats['turnover_high_cnt'] > 0: + pct = suggestion_stats['turnover_high_amount'] / total_amount * 100 + lines.append(f"| 高频(≥5件/月) | {suggestion_stats['turnover_high_cnt']} | {suggestion_stats['turnover_high_amount']:.2f} | {pct:.1f}% |") + if suggestion_stats['turnover_medium_cnt'] > 0: + pct = suggestion_stats['turnover_medium_amount'] / total_amount * 100 + lines.append(f"| 中频(1-5件/月) | {suggestion_stats['turnover_medium_cnt']} | {suggestion_stats['turnover_medium_amount']:.2f} | {pct:.1f}% |") + if suggestion_stats['turnover_low_cnt'] > 0: + pct = suggestion_stats['turnover_low_amount'] / total_amount * 100 + lines.append(f"| 低频(<1件/月) | {suggestion_stats['turnover_low_cnt']} | {suggestion_stats['turnover_low_amount']:.2f} | {pct:.1f}% |") + lines.append("") + + # 补货金额分布 + lines.append(f"### 单配件补货金额分布") + lines.append(f"| 补货规模 | 配件数 | 金额(元) | 占比 |") + lines.append(f"|----------|--------|----------|------|") + if suggestion_stats['replenish_large_cnt'] > 0: + pct = suggestion_stats['replenish_large_amount'] / total_amount * 100 + lines.append(f"| 大额(≥5000元) | {suggestion_stats['replenish_large_cnt']} | {suggestion_stats['replenish_large_amount']:.2f} | {pct:.1f}% |") + if suggestion_stats['replenish_medium_cnt'] > 0: + pct = suggestion_stats['replenish_medium_amount'] / total_amount * 100 + lines.append(f"| 中额(1000-5000元) | {suggestion_stats['replenish_medium_cnt']} | {suggestion_stats['replenish_medium_amount']:.2f} | {pct:.1f}% |") + if suggestion_stats['replenish_small_cnt'] > 0: + pct = suggestion_stats['replenish_small_amount'] / total_amount * 100 + lines.append(f"| 小额(<1000元) | {suggestion_stats['replenish_small_cnt']} | {suggestion_stats['replenish_small_amount']:.2f} | {pct:.1f}% |") + return "\n".join(lines) @@ -129,8 +304,11 @@ def generate_analysis_report_node(state: dict) -> dict: # 计算风险统计 risk_stats = _calculate_risk_stats(part_ratios) - # 构建建议汇总 - suggestion_summary = _build_suggestion_summary(part_results, allocated_details) + # 计算补货建议统计 (基于完整数据) + suggestion_stats = _calculate_suggestion_stats(part_results) + + # 构建结构化建议汇总 + suggestion_summary = _build_suggestion_summary(suggestion_stats) # 加载 Prompt prompt_template = _load_prompt("analysis_report.md") @@ -164,15 +342,9 @@ def generate_analysis_report_node(state: dict) -> dict: report_data = json.loads(response_text) - # 计算统计信息 - total_suggest_cnt = sum( - d.suggest_cnt if hasattr(d, "suggest_cnt") else d.get("suggest_cnt", 0) - for d in allocated_details - ) - total_suggest_amount = sum( - d.suggest_amount if hasattr(d, "suggest_amount") else Decimal(str(d.get("suggest_amount", 0))) - for d in allocated_details - ) + # 复用已计算的统计数据 + total_suggest_cnt = suggestion_stats["total_suggest_cnt"] + total_suggest_amount = suggestion_stats["total_suggest_amount"] execution_time_ms = int((time.time() - start_time) * 1000) diff --git a/src/fw_pms_ai/agent/nodes.py b/src/fw_pms_ai/agent/nodes.py index 6f15c84..8a420e4 100644 --- a/src/fw_pms_ai/agent/nodes.py +++ b/src/fw_pms_ai/agent/nodes.py @@ -175,7 +175,7 @@ def sql_agent_node(state: AgentState) -> AgentState: dealer_grouping_name=state["dealer_grouping_name"], statistics_date=state["statistics_date"], target_ratio=base_ratio if base_ratio > 0 else Decimal("1.3"), - limit=1, + limit=1000, callback=save_batch_callback, ) diff --git a/src/fw_pms_ai/api/routes/tasks.py b/src/fw_pms_ai/api/routes/tasks.py index 389e416..66f3637 100644 --- a/src/fw_pms_ai/api/routes/tasks.py +++ b/src/fw_pms_ai/api/routes/tasks.py @@ -3,7 +3,8 @@ """ import logging -from typing import Optional, List +from typing import Optional, List, Dict, Any +import json from datetime import datetime from decimal import Decimal @@ -609,3 +610,102 @@ async def get_part_shop_details( finally: cursor.close() conn.close() + + +class AnalysisReportResponse(BaseModel): + """分析报告响应""" + id: int + task_no: str + group_id: int + dealer_grouping_id: int + dealer_grouping_name: Optional[str] = None + brand_grouping_id: Optional[int] = None + report_type: str + + # JSON 字段,使用 Any 或 Dict 来接收解析后的对象 + replenishment_insights: Optional[Dict[str, Any]] = None + urgency_assessment: Optional[Dict[str, Any]] = None + strategy_recommendations: Optional[Dict[str, Any]] = None + execution_guide: Optional[Dict[str, Any]] = None + expected_outcomes: Optional[Dict[str, Any]] = None + + total_suggest_cnt: int = 0 + total_suggest_amount: float = 0 + shortage_risk_cnt: int = 0 + excess_risk_cnt: int = 0 + stagnant_cnt: int = 0 + low_freq_cnt: int = 0 + + llm_provider: Optional[str] = None + llm_model: Optional[str] = None + llm_tokens: int = 0 + execution_time_ms: int = 0 + statistics_date: Optional[str] = None + create_time: Optional[str] = None + + +@router.get("/tasks/{task_no}/analysis-report", response_model=Optional[AnalysisReportResponse]) +async def get_analysis_report(task_no: str): + """获取任务的分析报告""" + conn = get_connection() + cursor = conn.cursor(dictionary=True) + + try: + cursor.execute( + """ + SELECT * FROM ai_analysis_report + WHERE task_no = %s + ORDER BY create_time DESC + LIMIT 1 + """, + (task_no,) + ) + row = cursor.fetchone() + + if not row: + return None + + # 辅助函数:解析 JSON 字符串 + def parse_json(value): + if not value: + return None + if isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError: + return None + return value + + return AnalysisReportResponse( + id=row["id"], + task_no=row["task_no"], + group_id=row["group_id"], + dealer_grouping_id=row["dealer_grouping_id"], + dealer_grouping_name=row.get("dealer_grouping_name"), + brand_grouping_id=row.get("brand_grouping_id"), + report_type=row.get("report_type", "replenishment"), + + replenishment_insights=parse_json(row.get("replenishment_insights")), + urgency_assessment=parse_json(row.get("urgency_assessment")), + strategy_recommendations=parse_json(row.get("strategy_recommendations")), + execution_guide=parse_json(row.get("execution_guide")), + expected_outcomes=parse_json(row.get("expected_outcomes")), + + total_suggest_cnt=row.get("total_suggest_cnt") or 0, + total_suggest_amount=float(row.get("total_suggest_amount") or 0), + shortage_risk_cnt=row.get("shortage_risk_cnt") or 0, + excess_risk_cnt=row.get("excess_risk_cnt") or 0, + stagnant_cnt=row.get("stagnant_cnt") or 0, + low_freq_cnt=row.get("low_freq_cnt") or 0, + + llm_provider=row.get("llm_provider"), + llm_model=row.get("llm_model"), + llm_tokens=row.get("llm_tokens") or 0, + execution_time_ms=row.get("execution_time_ms") or 0, + statistics_date=row.get("statistics_date"), + create_time=format_datetime(row.get("create_time")), + ) + + finally: + cursor.close() + conn.close() diff --git a/tests/test_analysis_report.py b/tests/test_analysis_report.py deleted file mode 100644 index 2ef222e..0000000 --- a/tests/test_analysis_report.py +++ /dev/null @@ -1,58 +0,0 @@ -""" -测试分析报告生成功能 -""" -import sys -sys.path.insert(0, "src") - -from fw_pms_ai.agent.analysis_report_node import generate_analysis_report_node - -def test_generate_analysis_report(): - """测试为 AI-FB34CA0EE6C4 生成分析报告""" - - # 模拟数据 - part_ratios = [ - {"part_code": "C211F280503-1800-AA", "part_name": "牌照灯总成", - "valid_storage_cnt": 0, "avg_sales_cnt": 2, "out_stock_cnt": 5, "cost_price": 14}, - {"part_code": "TEST-001", "part_name": "测试配件1", - "valid_storage_cnt": 10, "avg_sales_cnt": 0, "out_stock_cnt": 0, "cost_price": 100}, - {"part_code": "TEST-002", "part_name": "测试配件2", - "valid_storage_cnt": 0, "avg_sales_cnt": 0.5, "out_stock_cnt": 0, "cost_price": 50}, - ] - - part_results = [ - {"part_code": "C211F280503-1800-AA", "part_name": "牌照灯总成", - "total_suggest_cnt": 4, "total_suggest_amount": 56.0, "priority": 1}, - ] - - allocated_details = [ - {"part_code": "C211F280503-1800-AA", "suggest_cnt": 4, "suggest_amount": 56.0}, - ] - - # 构建 state - state = { - "task_no": "AI-FB34CA0EE6C4", - "group_id": 2, - "dealer_grouping_id": 48, - "dealer_grouping_name": "测试分组", - "brand_grouping_id": None, - "statistics_date": "2026-02-05", - "part_ratios": part_ratios, - "part_results": part_results, - "allocated_details": allocated_details, - } - - print("开始生成分析报告...") - result = generate_analysis_report_node(state) - - if "error" in result.get("analysis_report", {}): - print(f"\n❌ 生成失败: {result['analysis_report']['error']}") - return False - else: - print(f"\n✅ 分析报告生成成功!") - report = result.get("analysis_report", {}) - print(f" - replenishment_insights: {str(report.get('replenishment_insights', ''))[:100]}...") - print(f" - urgency_assessment: {str(report.get('urgency_assessment', ''))[:100]}...") - return True - -if __name__ == "__main__": - test_generate_analysis_report() diff --git a/ui/css/style.css b/ui/css/style.css index d6dda43..8420848 100644 --- a/ui/css/style.css +++ b/ui/css/style.css @@ -797,12 +797,321 @@ tbody tr:last-child td { color: white; } + .pagination-btn svg { width: 18px; height: 18px; } /* ===================== + Analysis Report (Independent Layouts) + ===================== */ +.report-container { + display: flex; + flex-direction: column; + gap: var(--spacing-2xl); + animation: fadeIn 0.6s cubic-bezier(0.22, 1, 0.36, 1); + padding: var(--spacing-md) 0; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +.report-module { + margin-bottom: var(--spacing-2xl); + position: relative; +} + +.report-section-title { + font-size: 1.5rem; + font-weight: 800; + margin-bottom: var(--spacing-xl); + display: flex; + align-items: center; + gap: var(--spacing-md); + color: var(--text-primary); + position: relative; + padding-left: var(--spacing-xs); + letter-spacing: -0.02em; +} + +.report-section-title svg { + width: 28px; + height: 28px; + color: var(--color-primary); + filter: drop-shadow(0 0 8px rgba(99, 102, 241, 0.5)); +} + +/* --- Module 1: Overall Assessment (Hero Grid) --- */ +.assessment-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: var(--spacing-xl); + background: linear-gradient(180deg, rgba(30, 41, 59, 0.4) 0%, rgba(15, 23, 42, 0) 100%); + border-radius: var(--radius-xl); + padding: var(--spacing-xl); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.assessment-item { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + padding-right: var(--spacing-lg); + border-right: 1px solid rgba(255, 255, 255, 0.05); +} + +/* Remove border-right for the last item in a row would be complex with auto-fit, + so we might need a media query or accept borders on the right. + For simplicity and cleaner look on mobile, we can remove border-right for all + and add bottom border for mobile, or just rely on spacing. + Let's keep it simple: Remove border-right and use background/spacing to separate. +*/ +.assessment-item { + border-right: none; + padding-right: 0; + position: relative; +} + +.assessment-item:not(:last-child)::after { + content: ''; + position: absolute; + right: -12px; + top: 10%; + height: 80%; + width: 1px; + background: rgba(255, 255, 255, 0.05); + display: none; /* Hidden by default for responsive */ +} + +@media (min-width: 1024px) { + .assessment-item:not(:last-child)::after { + display: block; + } +} + +.assessment-label { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-secondary); + font-weight: 600; + margin-bottom: var(--spacing-xs); +} + +.assessment-main { + font-size: 1.25rem; + color: var(--text-primary); + line-height: 1.6; + font-weight: 600; +} + +.assessment-sub { + font-size: 0.9rem; + color: var(--text-secondary); + margin-top: auto; + padding-top: var(--spacing-md); + line-height: 1.5; +} + +/* --- Module 2: Risk Alerts (Alert Feed) --- */ +.risk-feed { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.risk-item { + display: flex; + gap: var(--spacing-lg); + padding: var(--spacing-lg); + background: rgba(30, 41, 59, 0.3); + border-radius: var(--radius-lg); + border: 1px solid transparent; + border-left: 4px solid transparent; + transition: all 0.3s ease; +} + +.risk-item:hover { + background: rgba(30, 41, 59, 0.5); + transform: translateX(4px); + border-color: rgba(255, 255, 255, 0.05); +} + +.risk-item.high { border-left-color: var(--color-danger); } +.risk-item.medium { border-left-color: var(--color-warning); } +.risk-item.low { border-left-color: var(--color-info); } + +.risk-icon { + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.03); +} + +.risk-item.high .risk-icon { color: var(--color-danger); background: rgba(239, 68, 68, 0.1); } +.risk-item.medium .risk-icon { color: var(--color-warning); background: rgba(245, 158, 11, 0.1); } + +.risk-content { + flex: 1; +} + +.risk-title { + font-size: 1.05rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.risk-desc { + color: var(--text-secondary); + font-size: 0.95rem; + margin-bottom: var(--spacing-sm); +} + +.risk-action { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; + color: var(--text-primary); + background: rgba(255, 255, 255, 0.05); + padding: 4px 10px; + border-radius: 4px; +} + +/* --- Module 3: Strategy (Stepped Guide) --- */ +.strategy-steps { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: var(--spacing-lg); +} + +.strategy-step { + position: relative; + padding: var(--spacing-lg); + background: linear-gradient(145deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.01) 100%); + border: 1px solid rgba(255,255,255,0.05); + border-radius: var(--radius-lg); +} + +.strategy-number { + font-family: var(--font-mono); + font-size: 2.5rem; + font-weight: 700; + color: rgba(255, 255, 255, 0.05); + position: absolute; + top: var(--spacing-sm); + right: var(--spacing-lg); + line-height: 1; +} + +.strategy-title { + font-size: 1.1rem; + font-weight: 600; + color: var(--color-primary-light); + margin-bottom: var(--spacing-md); + position: relative; +} + +.strategy-list { + list-style: none; +} + +.strategy-list li { + position: relative; + padding-left: 20px; + margin-bottom: 8px; + color: var(--text-secondary); + font-size: 0.95rem; +} + +.strategy-list li::before { + content: '→'; + position: absolute; + left: 0; + color: var(--color-primary); + opacity: 0.7; +} + +/* --- Module 4: Impact (KPI Metrics) --- */ +.impact-panel { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: var(--spacing-xl); + background: radial-gradient(circle at center, rgba(16, 185, 129, 0.03) 0%, transparent 70%); + border-top: 1px solid rgba(16, 185, 129, 0.1); + border-bottom: 1px solid rgba(16, 185, 129, 0.1); + padding: var(--spacing-2xl) 0; +} + +.kpi-item { + text-align: center; + position: relative; +} + +.kpi-item:not(:last-child)::after { + content: ''; + position: absolute; + right: 0; + top: 20%; + height: 60%; + width: 1px; + background: rgba(255, 255, 255, 0.05); +} + +.kpi-value { + font-size: 2.5rem; + font-weight: 800; + background: linear-gradient(135deg, var(--text-primary) 0%, var(--text-secondary) 100%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + margin-bottom: var(--spacing-xs); + letter-spacing: -0.05em; + text-shadow: 0 2px 10px rgba(0,0,0,0.1); +} + +.kpi-label { + font-size: 0.9rem; + color: var(--color-success-light); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--spacing-sm); +} + +.kpi-desc { + font-size: 0.9rem; + color: var(--text-muted); + max-width: 80%; + margin: 0 auto; + line-height: 1.5; +} +.markdown-content p { + margin-bottom: 0.5rem; +} + +.markdown-content ul { + margin-left: 1.25rem; + margin-bottom: 0.5rem; +} + +.markdown-content li { + margin-bottom: 0.25rem; +} + + +/* ===================== Loading & Overlay ===================== */ .loading-overlay { diff --git a/ui/js/api.js b/ui/js/api.js index f146e91..fd89c7c 100644 --- a/ui/js/api.js +++ b/ui/js/api.js @@ -90,6 +90,13 @@ const API = { async getPartShopDetails(taskNo, partCode) { return this.get(`/tasks/${taskNo}/parts/${encodeURIComponent(partCode)}/shops`); }, + + /** + * 获取任务分析报告 + */ + async getAnalysisReport(taskNo) { + return this.get(`/tasks/${taskNo}/analysis-report`); + }, }; // 导出到全局 diff --git a/ui/js/app.js b/ui/js/app.js index f265a48..1d7e22e 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -96,6 +96,277 @@ const App = { this.loadPartSummaries(); }, + + /** + * 渲染分析报告标签页 + */ + async renderReportTab(container, taskNo) { + container.innerHTML = '
加载分析报告...
'; + + try { + const report = await API.getAnalysisReport(taskNo); + + if (!report) { + container.innerHTML = ` +
+ ${Components.renderEmptyState('file-x', '暂无分析报告', '该任务尚未生成分析报告')} +
+ `; + return; + } + + container.innerHTML = ` +
+
+ + 核心经营综述 +
+
+ ${this.renderOverallAssessment(report.replenishment_insights)} +
+
+ +
+
+ + 风险管控预警 +
+
+ ${this.renderRiskAlerts(report.urgency_assessment)} +
+
+ +
+
+ + 补货策略建议 +
+
+ ${this.renderStrategy(report.strategy_recommendations)} +
+
+ +
+
+ + 效果预期与建议 +
+
+ ${this.renderExpectedImpact(report.expected_outcomes)} +
+
+ `; + + lucide.createIcons(); + } catch (error) { + container.innerHTML = ` +
+ +

加载报告失败: ${error.message}

+
+ `; + lucide.createIcons(); + } + }, + + renderOverallAssessment(insights) { + if (!insights) return ''; + + let heroHtml = ''; + + // Scale (Hero Main) + if (insights.scale_evaluation) { + heroHtml += ` +
+
补货规模
+
${insights.scale_evaluation.current_vs_historical || '-'}
+
${insights.scale_evaluation.possible_reasons || ''}
+
`; + } + + // Structure (Hero Middle) + if (insights.structure_analysis) { + const data = insights.structure_analysis; + const details = [ + data.category_distribution ? `• ${data.category_distribution}` : '', + data.price_range_distribution ? `• ${data.price_range_distribution}` : '', + data.turnover_distribution ? `• ${data.turnover_distribution}` : '' + ].filter(Boolean).join('
'); + + heroHtml += ` +
+
结构特征
+
${data.imbalance_warning || '结构均衡'}
+
${details}
+
`; + } + + // Timing (Hero End) + if (insights.timing_judgment) { + const data = insights.timing_judgment; + const isPos = data.is_favorable; + heroHtml += ` +
+
时机判断
+
+ ${isPos ? '有利时机' : '建议观望'} +
+
+ ${data.recommendation}
+ ${data.timing_factors || ''} +
+
`; + } + + return `
${heroHtml}
`; + }, + + renderRiskAlerts(risks) { + if (!risks) return ''; + + let feedHtml = '
'; + + const addRiskItem = (level, type, desc, action) => { + let icon = 'alert-circle'; + if (level === 'high') icon = 'alert-octagon'; + if (level === 'low') icon = 'info'; + + feedHtml += ` +
+
+ +
+
+
+ ${type} + ${level.toUpperCase()} +
+
${desc}
+ ${action ? `
${action}
` : ''} +
+
`; + }; + + // Supply Risks + if (risks.supply_risks && Array.isArray(risks.supply_risks)) { + risks.supply_risks.forEach(r => addRiskItem( + r.likelihood === '高' ? 'high' : 'medium', + r.risk_type || '供应风险', + r.affected_scope, + r.mitigation + )); + } + + // Capital Risks + if (risks.capital_risks) { + const data = risks.capital_risks; + addRiskItem('medium', '资金风险', data.cash_flow_pressure, data.recommendation); + } + + // Market Risks + if (risks.market_risks && Array.isArray(risks.market_risks)) { + risks.market_risks.forEach(r => addRiskItem('medium', '市场风险', r.risk_description, r.recommendation)); + } + + // Execution + if (risks.execution_anomalies && Array.isArray(risks.execution_anomalies)) { + risks.execution_anomalies.forEach(a => addRiskItem('high', a.anomaly_type || '执行异常', a.description, a.review_suggestion)); + } + + feedHtml += '
'; + return feedHtml; + }, + + renderStrategy(strategy) { + if (!strategy) return ''; + + let html = '
'; + + const addStep = (num, title, items) => { + const listItems = Array.isArray(items) ? items : [items]; + const listHtml = listItems.map(i => `
  • ${i}
  • `).join(''); + html += ` +
    +
    0${num}
    +
    ${title}
    +
      ${listHtml}
    +
    `; + }; + + // 1. Priority + if (strategy.priority_principle) { + const p = strategy.priority_principle; + addStep(1, '优先级排序', [ + `P1: ${p.tier1_criteria}`, + `P2: ${p.tier2_criteria}`, + `P3: ${p.tier3_criteria}` + ]); + } + + // 2. Phased + if (strategy.phased_procurement) { + addStep(2, '分批节奏', [ + `节奏: ${strategy.phased_procurement.suggested_rhythm}`, + `范围: ${strategy.phased_procurement.recommended_parts}` + ]); + } + + // 3. Coordination + if (strategy.supplier_coordination) { + addStep(3, '供应商协同', [ + strategy.supplier_coordination.key_communications, + `时机: ${strategy.supplier_coordination.timing_suggestions}` + ]); + } + + html += '
    '; + return html; + }, + + renderExpectedImpact(impact) { + if (!impact) return ''; + + let html = '
    '; + + // Inventory + if (impact.inventory_health) { + html += ` +
    +
    库存健康度
    +
    ${Components.formatAmount(impact.inventory_health.shortage_reduction || 0)}
    +
    ${impact.inventory_health.structure_improvement}
    +
    `; + } + + // Efficiency + if (impact.capital_efficiency) { + html += ` +
    +
    资金效率
    +
    ${Components.formatAmount(impact.capital_efficiency.investment_amount)}
    +
    ${impact.capital_efficiency.expected_return}
    +
    `; + } + + // Next + if (impact.follow_up_actions) { + html += ` +
    +
    下一步关注
    +
    Key Actions
    +
    ${impact.follow_up_actions.next_steps}
    +
    `; + } + + html += '
    '; + return html; + }, + + // 辅助方法:renderReportCard, renderRiskCard, renderImpactCard 已被新的独立渲染逻辑取代,保留为空或删除 + renderReportCard(title, data) { return ''; }, + renderRiskCard(title, data, level) { return ''; }, + renderImpactCard(title, data) { return ''; }, + /** * 初始化应用 */ @@ -493,7 +764,10 @@ const App = { 配件明细 - +