diff --git a/.kiro/specs/refactor-analysis-report/design.md b/.kiro/specs/refactor-analysis-report/design.md new file mode 100644 index 0000000..b9abccb --- /dev/null +++ b/.kiro/specs/refactor-analysis-report/design.md @@ -0,0 +1,502 @@ +# 设计文档:重构分析报告功能 + +## 概述 + +重构 AI 补货建议系统的分析报告功能,将现有的四模块宏观决策报告(整体态势研判/风险预警/采购策略/效果预期)替换为四大数据驱动板块(库存总体概览/销量分析/库存构成健康度/补货建议生成情况)。每个板块包含精确的统计数据和 LLM 生成的分析文本。 + +核心设计变更: +- 从"LLM 主导分析"转变为"数据统计 + LLM 辅助分析"模式 +- 使用 LangGraph 动态节点并发生成四个板块的 LLM 分析 +- 前端新增 Chart.js 图表支持库存健康度可视化 +- 数据库表结构完全重建,四个 JSON 字段分别存储各板块数据 + +## 架构 + +### 整体数据流 + +```mermaid +graph TD + A[allocate_budget 节点完成] --> B[generate_analysis_report 节点] + B --> C[统计计算阶段] + C --> C1[库存概览统计] + C --> C2[销量分析统计] + C --> C3[健康度统计] + C --> C4[补货建议统计] + C1 & C2 & C3 & C4 --> D[LangGraph 并发 LLM 分析] + D --> D1[库存概览 LLM 节点] + D --> D2[销量分析 LLM 节点] + D --> D3[健康度 LLM 节点] + D --> D4[补货建议 LLM 节点] + D1 & D2 & D3 & D4 --> E[汇总报告] + E --> F[Result_Writer 写入数据库] + F --> G[API 返回前端] + G --> H[Report_UI 渲染] +``` + +### LangGraph 并发子图设计 + +在现有工作流 `allocate_budget → generate_analysis_report → END` 中,`generate_analysis_report` 节点内部使用一个 LangGraph 子图实现并发: + +```mermaid +graph TD + START[统计计算完成] --> FORK{并发分发} + FORK --> N1[inventory_overview_llm] + FORK --> N2[sales_analysis_llm] + FORK --> N3[inventory_health_llm] + FORK --> N4[replenishment_summary_llm] + N1 & N2 & N3 & N4 --> JOIN[汇总合并] + JOIN --> END_NODE[返回完整报告] +``` + +实现方式:使用 `langgraph.graph.StateGraph` 构建子图,通过 `add_node` 添加四个并发 LLM 节点,使用 fan-out/fan-in 模式(从 START 分发到四个节点,四个节点汇聚到 END)。每个节点独立调用 LLM,失败不影响其他节点。 + +## 组件与接口 + +### 1. 统计计算模块 (analysis_report_node.py) + +#### `calculate_inventory_overview(part_ratios: list[dict]) -> dict` +计算库存总体概览统计数据。有效库存使用 `valid_storage_cnt`(三项公式:in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt)。 + +输入:PartRatio 字典列表 +输出: +```python +{ + "total_valid_storage_cnt": Decimal, # 有效库存总数量(五项之和) + "total_valid_storage_amount": Decimal, # 有效库存总金额(资金占用) + "total_in_stock_unlocked_cnt": Decimal, # 在库未锁总数量 + "total_in_stock_unlocked_amount": Decimal, + "total_on_the_way_cnt": Decimal, # 在途总数量 + "total_on_the_way_amount": Decimal, + "total_has_plan_cnt": Decimal, # 计划数总数量 + "total_has_plan_amount": Decimal, + "total_transfer_cnt": Decimal, # 主动调拨在途总数量 + "total_transfer_amount": Decimal, + "total_gen_transfer_cnt": Decimal, # 自动调拨在途总数量 + "total_gen_transfer_amount": Decimal, + "total_avg_sales_cnt": Decimal, # 月均销量总数量 + "overall_ratio": Decimal, # 整体库销比 + "part_count": int, # 配件总种类数 +} +``` + +#### `calculate_sales_analysis(part_ratios: list[dict]) -> dict` +计算销量分析统计数据。 + +输入:PartRatio 字典列表 +输出: +```python +{ + "total_avg_sales_cnt": Decimal, # 月均销量总数量 + "total_avg_sales_amount": Decimal, # 月均销量总金额 + "total_out_stock_cnt": Decimal, # 90天出库数总量 + "total_storage_locked_cnt": Decimal, # 未关单已锁总量 + "total_out_stock_ongoing_cnt": Decimal, # 未关单出库总量 + "total_buy_cnt": int, # 订件总量 + "has_sales_part_count": int, # 有销量配件数 + "no_sales_part_count": int, # 无销量配件数 +} +``` + +#### `calculate_inventory_health(part_ratios: list[dict]) -> dict` +计算库存构成健康度统计数据。 + +输入:PartRatio 字典列表 +输出: +```python +{ + "shortage": {"count": int, "amount": Decimal, "count_pct": float, "amount_pct": float}, + "stagnant": {"count": int, "amount": Decimal, "count_pct": float, "amount_pct": float}, + "low_freq": {"count": int, "amount": Decimal, "count_pct": float, "amount_pct": float}, + "normal": {"count": int, "amount": Decimal, "count_pct": float, "amount_pct": float}, + "total_count": int, + "total_amount": Decimal, +} +``` + +#### `calculate_replenishment_summary(part_results: list) -> dict` +计算补货建议生成情况统计数据。 + +输入:part_results 列表(配件汇总结果) +输出: +```python +{ + "urgent": {"count": int, "amount": Decimal}, # 急需补货 (priority=1) + "suggested": {"count": int, "amount": Decimal}, # 建议补货 (priority=2) + "optional": {"count": int, "amount": Decimal}, # 可选补货 (priority=3) + "total_count": int, + "total_amount": Decimal, +} +``` + +### 2. LLM 分析节点 + +四个独立的 LLM 分析函数,每个接收对应板块的统计数据,调用 LLM 生成分析文本: + +- `llm_analyze_inventory_overview(stats: dict) -> str` — 返回 JSON 字符串 +- `llm_analyze_sales(stats: dict) -> str` — 返回 JSON 字符串 +- `llm_analyze_inventory_health(stats: dict) -> str` — 返回 JSON 字符串 +- `llm_analyze_replenishment_summary(stats: dict) -> str` — 返回 JSON 字符串 + +每个函数加载对应的提示词模板(统一放在 `prompts/analysis_report.md` 中,按板块分段),填充统计数据,调用 LLM,解析 JSON 响应。 + +### 3. 提示词文件 + +拆分为四个独立提示词文件,每个板块一个,确保分析专业且有实际决策价值: + +#### prompts/report_inventory_overview.md — 库存概览分析 + +角色:资深汽车配件库存管理专家。输入统计数据后,要求 LLM 分析: +- **资金占用评估**:总库存金额是否合理,各构成部分(在库未锁/在途/计划数/主动调拨在途/自动调拨在途)的资金分配比例是否健康 +- **库销比诊断**:当前整体库销比处于什么水平(<1 库存不足,1-2 合理,2-3 偏高,>3 严重积压),对比行业经验给出判断 +- **库存结构建议**:基于五项构成的比例,给出具体的库存结构优化方向 + +输出 JSON 格式: +```json +{ + "capital_assessment": { + "total_evaluation": "总资金占用评估", + "structure_ratio": "各构成部分的资金比例分析", + "risk_level": "high/medium/low" + }, + "ratio_diagnosis": { + "level": "不足/合理/偏高/严重积压", + "analysis": "库销比具体分析", + "benchmark": "行业参考值对比" + }, + "recommendations": ["具体建议1", "具体建议2"] +} +``` + +#### prompts/report_sales_analysis.md — 销量分析 + +角色:汽车配件销售数据分析师。输入统计数据后,要求 LLM 分析: +- **销量构成解读**:90天出库数占比最大说明正常销售为主,未关单已锁/出库占比高说明有大量待处理订单,订件占比高说明客户预订需求旺盛 +- **销售活跃度**:有销量 vs 无销量配件的比例反映 SKU 活跃度,无销量占比过高说明 SKU 管理需要优化 +- **需求趋势判断**:基于各组成部分的比例关系,判断当前需求是稳定、上升还是下降趋势 + +输出 JSON 格式: +```json +{ + "composition_analysis": { + "main_driver": "主要销量来源分析", + "pending_orders_impact": "未关单对销量的影响", + "booking_trend": "订件趋势分析" + }, + "activity_assessment": { + "active_ratio": "活跃SKU占比评估", + "optimization_suggestion": "SKU优化建议" + }, + "demand_trend": { + "direction": "上升/稳定/下降", + "evidence": "判断依据", + "forecast": "短期需求预测" + } +} +``` + +#### prompts/report_inventory_health.md — 库存健康度分析 + +角色:汽车配件库存健康度诊断专家。输入统计数据后,要求 LLM 分析: +- **健康度评分**:基于正常件占比给出整体健康度评分(正常件>70%为健康,50-70%为亚健康,<50%为不健康) +- **问题诊断**:呆滞件占比高说明采购决策需要优化,缺货件占比高说明补货不及时,低频件占比高说明 SKU 精简空间大 +- **资金释放机会**:呆滞件和低频件占用的资金可以通过促销、退货等方式释放,给出具体金额估算 +- **改善优先级**:按影响程度排序,给出最应优先处理的问题类型 + +输出 JSON 格式: +```json +{ + "health_score": { + "score": "健康/亚健康/不健康", + "normal_ratio_evaluation": "正常件占比评估" + }, + "problem_diagnosis": { + "stagnant_analysis": "呆滞件问题分析及原因", + "shortage_analysis": "缺货件问题分析及影响", + "low_freq_analysis": "低频件问题分析及建议" + }, + "capital_release": { + "stagnant_releasable": "呆滞件可释放资金估算", + "low_freq_releasable": "低频件可释放资金估算", + "action_plan": "资金释放行动方案" + }, + "priority_actions": ["最优先处理事项1", "最优先处理事项2"] +} +``` + +#### prompts/report_replenishment_summary.md — 补货建议分析 + +角色:汽车配件采购策略顾问。输入统计数据后,要求 LLM 分析: +- **紧迫度评估**:急需补货占比反映当前缺货风险程度,急需占比>30%说明库存管理存在较大问题 +- **资金分配建议**:基于各优先级的金额分布,给出资金分配的先后顺序和比例建议 +- **执行节奏建议**:急需补货应立即执行,建议补货可在1-2周内完成,可选补货可根据资金情况灵活安排 +- **风险提示**:如果可选补货金额占比过高,提示可能存在过度补货风险 + +输出 JSON 格式: +```json +{ + "urgency_assessment": { + "urgent_ratio_evaluation": "急需补货占比评估", + "risk_level": "high/medium/low", + "immediate_action_needed": true/false + }, + "budget_allocation": { + "recommended_order": "建议资金分配顺序", + "urgent_budget": "急需补货建议预算", + "suggested_budget": "建议补货建议预算", + "optional_budget": "可选补货建议预算" + }, + "execution_plan": { + "urgent_timeline": "急需补货执行时间建议", + "suggested_timeline": "建议补货执行时间建议", + "optional_timeline": "可选补货执行时间建议" + }, + "risk_warnings": ["风险提示1", "风险提示2"] +} +``` + +### 4. API 接口 (tasks.py) + +更新 `GET /api/tasks/{task_no}/analysis-report` 端点: + +响应模型 `AnalysisReportResponse`: +```python +class AnalysisReportResponse(BaseModel): + id: int + task_no: str + group_id: int + dealer_grouping_id: int + dealer_grouping_name: Optional[str] + report_type: str + + inventory_overview: Optional[Dict[str, Any]] # 库存概览(统计+分析) + sales_analysis: Optional[Dict[str, Any]] # 销量分析(统计+分析) + inventory_health: Optional[Dict[str, Any]] # 健康度(统计+分析+图表数据) + replenishment_summary: Optional[Dict[str, Any]] # 补货建议(统计+分析) + + llm_provider: Optional[str] + llm_model: Optional[str] + llm_tokens: int + execution_time_ms: int + statistics_date: Optional[str] + create_time: Optional[str] +``` + +### 5. 前端渲染 (app.js) + +`renderReportTab()` 重写,渲染四个板块: + +1. **库存概览板块**: 统计卡片(总数量、总金额、库销比)+ 五项构成明细表(在库未锁/在途/计划数/主动调拨在途/自动调拨在途)+ LLM 分析文本 +2. **销量分析板块**: 统计卡片(月均销量、总金额)+ 构成明细表 + LLM 分析文本 +3. **健康度板块**: 统计卡片 + Chart.js 环形图(数量占比 + 金额占比)+ LLM 分析文本 +4. **补货建议板块**: 优先级统计表 + LLM 分析文本 + +### 6. Chart.js 集成 + +在 `ui/index.html` 中通过 CDN 引入 Chart.js: +```html + +``` + +健康度板块使用两个环形图(Doughnut Chart): +- 数量占比图:缺货/呆滞/低频/正常 四类的数量百分比 +- 金额占比图:缺货/呆滞/低频/正常 四类的金额百分比 + +## 数据模型 + +### 数据库表 (ai_analysis_report) + +```sql +DROP TABLE IF EXISTS ai_analysis_report; +CREATE TABLE ai_analysis_report ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + task_no VARCHAR(32) NOT NULL COMMENT '任务编号', + group_id BIGINT NOT NULL COMMENT '集团ID', + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID', + dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称', + brand_grouping_id BIGINT COMMENT '品牌组合ID', + report_type VARCHAR(32) DEFAULT 'replenishment' COMMENT '报告类型', + + -- 四大板块 (JSON 结构化存储,每个字段包含 stats + llm_analysis) + inventory_overview JSON COMMENT '库存总体概览(统计数据+LLM分析)', + sales_analysis JSON COMMENT '销量分析(统计数据+LLM分析)', + inventory_health JSON COMMENT '库存构成健康度(统计数据+图表数据+LLM分析)', + replenishment_summary JSON COMMENT '补货建议生成情况(统计数据+LLM分析)', + + -- LLM 元数据 + llm_provider VARCHAR(32) COMMENT 'LLM提供商', + llm_model VARCHAR(64) COMMENT 'LLM模型名称', + llm_tokens INT DEFAULT 0 COMMENT 'LLM Token消耗', + execution_time_ms INT DEFAULT 0 COMMENT '执行耗时(毫秒)', + + statistics_date VARCHAR(16) COMMENT '统计日期', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + + INDEX idx_task_no (task_no), + INDEX idx_group_date (group_id, statistics_date), + INDEX idx_dealer_grouping (dealer_grouping_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议分析报告表-重构版'; +``` + +### Python 数据模型 (AnalysisReport) + +```python +@dataclass +class AnalysisReport: + task_no: str + group_id: int + dealer_grouping_id: int + + id: Optional[int] = None + dealer_grouping_name: Optional[str] = None + brand_grouping_id: Optional[int] = None + report_type: str = "replenishment" + + # 四大板块 + inventory_overview: Optional[Dict[str, Any]] = None + sales_analysis: Optional[Dict[str, Any]] = None + inventory_health: Optional[Dict[str, Any]] = None + replenishment_summary: Optional[Dict[str, Any]] = None + + # LLM 元数据 + llm_provider: str = "" + llm_model: str = "" + llm_tokens: int = 0 + execution_time_ms: int = 0 + + statistics_date: str = "" + create_time: Optional[datetime] = None +``` + +### 每个板块的 JSON 数据结构 + +每个板块的 JSON 包含 `stats`(统计数据)和 `llm_analysis`(LLM 分析文本)两部分: + +```python +# inventory_overview 示例 +{ + "stats": { + "total_valid_storage_cnt": 12500, + "total_valid_storage_amount": 3250000.00, + "total_in_stock_unlocked_cnt": 8000, + "total_in_stock_unlocked_amount": 2080000.00, + "total_on_the_way_cnt": 2500, + "total_on_the_way_amount": 650000.00, + "total_has_plan_cnt": 1000, + "total_has_plan_amount": 260000.00, + "total_transfer_cnt": 600, + "total_transfer_amount": 156000.00, + "total_gen_transfer_cnt": 400, + "total_gen_transfer_amount": 104000.00, + "total_avg_sales_cnt": 5000, + "overall_ratio": 2.5, + "part_count": 800 + }, + "llm_analysis": { ... } # LLM 返回的 JSON 分析对象 +} + +# inventory_health 示例(额外包含 chart_data) +{ + "stats": { + "shortage": {"count": 50, "amount": 125000, "count_pct": 6.25, "amount_pct": 3.85}, + "stagnant": {"count": 120, "amount": 480000, "count_pct": 15.0, "amount_pct": 14.77}, + "low_freq": {"count": 200, "amount": 300000, "count_pct": 25.0, "amount_pct": 9.23}, + "normal": {"count": 430, "amount": 2345000, "count_pct": 53.75, "amount_pct": 72.15}, + "total_count": 800, + "total_amount": 3250000 + }, + "chart_data": { + "labels": ["缺货件", "呆滞件", "低频件", "正常件"], + "count_values": [50, 120, 200, 430], + "amount_values": [125000, 480000, 300000, 2345000] + }, + "llm_analysis": { ... } +} +``` + + +## 正确性属性 + +*正确性属性是系统在所有合法执行中都应保持为真的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规范与机器可验证正确性保证之间的桥梁。* + +### Property 1: 库存概览统计一致性 + +*对于任意* PartRatio 字典列表,`calculate_inventory_overview` 的输出应满足: +- `total_valid_storage_cnt` = 所有配件 `(in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt)` 之和 +- `total_valid_storage_amount` = 所有配件 `(in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt) × cost_price` 之和 +- `total_in_stock_unlocked_cnt + total_on_the_way_cnt + total_has_plan_cnt` = `total_valid_storage_cnt`(构成不变量) +- 当 `total_avg_sales_cnt > 0` 时,`overall_ratio` = `total_valid_storage_cnt / total_avg_sales_cnt` + +**Validates: Requirements 2.1, 2.2, 2.3** + +### Property 2: 销量分析统计一致性 + +*对于任意* PartRatio 字典列表,`calculate_sales_analysis` 的输出应满足: +- `total_avg_sales_cnt` = 所有配件 `(out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3` 之和 +- `(total_out_stock_cnt + total_storage_locked_cnt + total_out_stock_ongoing_cnt + total_buy_cnt) / 3` = `total_avg_sales_cnt`(构成不变量) +- `has_sales_part_count + no_sales_part_count` = 总配件数 + +**Validates: Requirements 3.1, 3.2, 3.4** + +### Property 3: 健康度分类完备性与一致性 + +*对于任意* PartRatio 字典列表,`calculate_inventory_health` 的输出应满足: +- `shortage.count + stagnant.count + low_freq.count + normal.count` = `total_count`(分类完备) +- `shortage.amount + stagnant.amount + low_freq.amount + normal.amount` = `total_amount`(金额守恒) +- 每种类型的 `count_pct` = `count / total_count × 100` +- 所有 `count_pct` 之和 ≈ 100.0(浮点精度容差内) + +**Validates: Requirements 4.1, 4.2** + +### Property 4: 补货建议统计一致性 + +*对于任意* part_results 列表,`calculate_replenishment_summary` 的输出应满足: +- `urgent.count + suggested.count + optional.count` = `total_count` +- `urgent.amount + suggested.amount + optional.amount` = `total_amount` + +**Validates: Requirements 5.1, 5.2** + +### Property 5: 报告数据模型序列化 round-trip + +*对于任意* 合法的 AnalysisReport 对象,调用 `to_dict()` 后再用返回的字典构造新的 AnalysisReport 对象,两个对象的核心字段应相等。 + +**Validates: Requirements 10.2, 10.3** + +## 错误处理 + +| 错误场景 | 处理方式 | +|---------|---------| +| LLM 单板块调用失败 | 该板块 `llm_analysis` 置为 `{"error": "错误信息"}`,其他板块正常生成 | +| LLM 返回非法 JSON | 记录原始响应到日志,`llm_analysis` 置为 `{"error": "JSON解析失败", "raw": "原始文本前200字符"}` | +| PartRatio 列表为空 | 所有统计值为 0,LLM 分析说明无数据 | +| part_results 列表为空 | 补货建议板块统计值为 0,LLM 分析说明无补货建议 | +| 数据库写入失败 | 记录错误日志,不中断主工作流,返回包含错误信息的报告 | +| 提示词文件缺失 | 抛出 FileNotFoundError,由上层节点捕获并记录 | + +## 测试策略 + +### 属性测试 (Property-Based Testing) + +使用 `hypothesis` 库进行属性测试,每个属性至少运行 100 次迭代。 + +- **Property 1**: 生成随机 PartRatio 字典列表(随机数量、随机字段值),验证库存概览统计的不变量 + - Tag: **Feature: refactor-analysis-report, Property 1: 库存概览统计一致性** +- **Property 2**: 生成随机 PartRatio 字典列表,验证销量分析统计的不变量 + - Tag: **Feature: refactor-analysis-report, Property 2: 销量分析统计一致性** +- **Property 3**: 生成随机 PartRatio 字典列表,验证健康度分类的完备性和一致性 + - Tag: **Feature: refactor-analysis-report, Property 3: 健康度分类完备性与一致性** +- **Property 4**: 生成随机 part_results 列表(随机优先级和金额),验证补货建议统计的一致性 + - Tag: **Feature: refactor-analysis-report, Property 4: 补货建议统计一致性** +- **Property 5**: 生成随机 AnalysisReport 对象,验证 to_dict round-trip + - Tag: **Feature: refactor-analysis-report, Property 5: 报告数据模型序列化 round-trip** + +### 单元测试 + +- 边界情况:空列表输入、月均销量为零、所有配件同一类型 +- LLM 响应解析:合法 JSON、非法 JSON、空响应 +- API 端点:有报告数据、无报告数据 + +### 集成测试 + +- 完整报告生成流程(mock LLM) +- 数据库写入和读取一致性 +- 前端渲染(手动验证) diff --git a/.kiro/specs/refactor-analysis-report/requirements.md b/.kiro/specs/refactor-analysis-report/requirements.md new file mode 100644 index 0000000..ab16d55 --- /dev/null +++ b/.kiro/specs/refactor-analysis-report/requirements.md @@ -0,0 +1,139 @@ +# 需求文档:重构分析报告功能 + +## 简介 + +重构 AI 补货建议系统的分析报告功能。先清理现有分析报告相关代码(仅限分析报告模块,不涉及其他模块),再基于新结构重建。新报告涵盖库存总体概览、销量分析、库存构成健康度、补货建议生成情况四大板块。每个板块包含统计数据和 LLM 生成的分析文本。考虑使用 LangGraph 动态节点并发生成各板块统计及分析,最后汇总。涉及全栈改动:数据库表结构、数据模型、Agent 节点、LLM 提示词、API 接口、前端页面。 + +## 术语表 + +- **Report_Generator**: 分析报告生成节点(analysis_report_node),负责统计计算和调用 LLM 生成报告 +- **Report_API**: 分析报告 API 接口,负责从数据库读取报告并返回给前端 +- **Report_UI**: 前端报告渲染模块,负责展示报告数据和图表 +- **PartRatio**: 配件库销比数据,包含库存、销量、成本等原始数据 +- **有效库存**: valid_storage_cnt = in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt(在库未锁 + 在途 + 计划数) +- **月均销量**: (90天出库数 + 未关单已锁 + 未关单出库 + 订件) / 3 +- **库销比**: 有效库存(valid_storage_cnt) / 月均销量 +- **呆滞件**: 有效库存(valid_storage_cnt) > 0 且 90天出库数 = 0 的配件 +- **低频件**: 月均销量 < 1 或 出库次数 < 3 或 出库间隔 >= 30天 的配件 +- **缺货件**: 有效库存(valid_storage_cnt) = 0 且 月均销量 >= 1 的配件 +- **正常件**: 不属于呆滞件、低频件、缺货件的配件 +- **急需补货**: 优先级 1,库销比 < 0.5 且月均销量 >= 1 +- **建议补货**: 优先级 2,库销比 0.5-1.0 且月均销量 >= 1 +- **可选补货**: 优先级 3,库销比 1.0-目标值 且月均销量 >= 1 +- **Result_Writer**: 数据库写入服务,负责将报告持久化到 MySQL + +## 需求 + +### 需求 1:清理现有分析报告代码 + +**用户故事:** 作为开发者,我希望先清理现有分析报告相关代码,以便在干净的基础上重构新报告功能。 + +#### 验收标准 + +1. WHEN 开始重构, THE 开发者 SHALL 清理 analysis_report_node.py 中的现有报告生成逻辑 +2. WHEN 开始重构, THE 开发者 SHALL 清理 analysis_report.py 中的现有数据模型 +3. WHEN 开始重构, THE 开发者 SHALL 清理 prompts/analysis_report.md 中的现有提示词 +4. WHEN 开始重构, THE 开发者 SHALL 清理前端 app.js 中的现有报告渲染代码(renderReportTab、renderOverallAssessment、renderRiskAlerts、renderStrategy、renderExpectedImpact) +5. WHEN 清理代码, THE 开发者 SHALL 仅清理分析报告相关代码,保持其他模块代码不变 + +### 需求 2:库存总体概览统计 + +**用户故事:** 作为采购决策者,我希望看到库存总体概览(总数量、总金额/资金占用、库销比及库存构成明细),以便快速了解当前库存状况和资金占用情况。 + +#### 验收标准 + +1. WHEN Report_Generator 接收到 PartRatio 列表, THE Report_Generator SHALL 使用 valid_storage_cnt(in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt)计算所有配件的有效库存总数量和总金额(有效库存 × 成本价之和) +2. WHEN Report_Generator 计算库存构成, THE Report_Generator SHALL 分别统计在库未锁(in_stock_unlocked_cnt)、在途(on_the_way_cnt)、计划数(has_plan_cnt)的总数量/总金额 +3. WHEN Report_Generator 计算库销比, THE Report_Generator SHALL 使用有效库存总数量除以月均销量总数量得到整体库销比 +4. WHEN 库存概览统计完成, THE Report_Generator SHALL 将统计数据作为上下文传递给 LLM 生成库存概览分析文本 +5. IF 月均销量总数量为零, THEN THE Report_Generator SHALL 将库销比标记为特殊值并在分析中说明无销量数据 + +### 需求 3:销量分析统计 + +**用户故事:** 作为采购决策者,我希望看到销量分析(月均销量及各组成部分明细),以便了解销售趋势和需求分布。 + +#### 验收标准 + +1. WHEN Report_Generator 接收到 PartRatio 列表, THE Report_Generator SHALL 计算所有配件的月均销量总数量和总金额(月均销量 × 成本价之和) +2. WHEN Report_Generator 计算销量构成, THE Report_Generator SHALL 分别统计90天出库数(out_stock_cnt)总量、未关单已锁(storage_locked_cnt)总量、未关单出库(out_stock_ongoing_cnt)总量、订件(buy_cnt)总量 +3. WHEN 销量统计完成, THE Report_Generator SHALL 将统计数据作为上下文传递给 LLM 生成销量分析文本 +4. WHEN Report_Generator 计算销量分布, THE Report_Generator SHALL 统计有销量配件数(月均销量 > 0)和无销量配件数(月均销量 = 0) + +### 需求 4:库存构成健康度统计 + +**用户故事:** 作为采购决策者,我希望看到库存健康度分析(缺货/呆滞/低频/正常各类型的数量和金额占比),以便识别库存结构问题。 + +#### 验收标准 + +1. WHEN Report_Generator 分类配件, THE Report_Generator SHALL 将每个配件归类为缺货件、呆滞件、低频件或正常件中的一种 +2. WHEN Report_Generator 统计各类型, THE Report_Generator SHALL 计算每种类型的配件数量、占总数量的百分比、涉及金额(有效库存 × 成本价)、占总金额的百分比 +3. WHEN 健康度统计完成, THE Report_Generator SHALL 将统计数据作为上下文传递给 LLM 生成健康度分析文本 +4. WHEN Report_API 返回健康度数据, THE Report_API SHALL 返回包含各类型数量和金额的结构化数据,供前端生成图表 +5. WHEN Report_UI 展示健康度数据, THE Report_UI SHALL 使用图表展示各类型配件的数量占比和金额占比 + +### 需求 5:补货建议生成情况统计 + +**用户故事:** 作为采购决策者,我希望看到本次补货建议的生成情况(急需/建议/可选各优先级的配件数和金额),以便了解补货的紧迫程度和资金需求。 + +#### 验收标准 + +1. WHEN Report_Generator 统计补货建议, THE Report_Generator SHALL 按优先级(急需补货/建议补货/可选补货)分别统计配件种类数和建议补货总金额 +2. WHEN Report_Generator 统计补货总量, THE Report_Generator SHALL 计算所有优先级的配件总种类数和总金额 +3. WHEN 补货统计完成, THE Report_Generator SHALL 将统计数据作为上下文传递给 LLM 生成补货建议分析文本 + +### 需求 6:数据库表结构重构 + +**用户故事:** 作为开发者,我希望数据库表结构能够存储新的报告模块数据,以便支持四大板块的数据持久化。 + +#### 验收标准 + +1. THE ai_analysis_report 表 SHALL 包含库存概览模块的 JSON 字段(inventory_overview)存储统计数据和 LLM 分析 +2. THE ai_analysis_report 表 SHALL 包含销量分析模块的 JSON 字段(sales_analysis)存储统计数据和 LLM 分析 +3. THE ai_analysis_report 表 SHALL 包含健康度模块的 JSON 字段(inventory_health)存储统计数据和 LLM 分析 +4. THE ai_analysis_report 表 SHALL 包含补货建议模块的 JSON 字段(replenishment_summary)存储统计数据和 LLM 分析 +5. THE ai_analysis_report 表 SHALL 保留 task_no、group_id、dealer_grouping_id 等基础字段和 LLM 元数据字段 + +### 需求 7:LLM 提示词重构与并发生成 + +**用户故事:** 作为开发者,我希望使用 LangGraph 动态节点并发生成各板块的 LLM 分析,提高报告生成效率。 + +#### 验收标准 + +1. WHEN Report_Generator 生成报告, THE Report_Generator SHALL 使用 LangGraph 动态节点并发调用 LLM 生成四个板块的分析文本 +2. WHEN 每个板块节点调用 LLM, THE 节点 SHALL 在提示词中仅包含该板块相关的统计数据 +3. WHEN LLM 生成分析, THE LLM 输出 SHALL 为合法的 JSON 对象 +4. WHEN 所有板块分析完成, THE Report_Generator SHALL 汇总四个板块的统计数据和 LLM 分析结果为一个完整报告 +5. IF 某个板块的 LLM 调用失败, THEN THE Report_Generator SHALL 记录错误日志,该板块分析文本置为错误提示,其他板块继续正常生成 + +### 需求 8:API 接口重构 + +**用户故事:** 作为前端开发者,我希望 API 返回新结构的报告数据,以便前端能够渲染四大板块。 + +#### 验收标准 + +1. WHEN 前端请求分析报告, THE Report_API SHALL 返回包含四个模块(inventory_overview、sales_analysis、inventory_health、replenishment_summary)的 JSON 响应 +2. WHEN 报告中包含 JSON 字符串字段, THE Report_API SHALL 将其解析为 JSON 对象后返回 +3. IF 指定 task_no 无报告数据, THEN THE Report_API SHALL 返回 null + +### 需求 9:前端报告页面重构 + +**用户故事:** 作为采购决策者,我希望在前端看到结构清晰的四大板块报告,包含统计数据卡片、图表和 LLM 分析文本。 + +#### 验收标准 + +1. WHEN Report_UI 渲染报告, THE Report_UI SHALL 按库存概览、销量分析、健康度、补货建议的顺序展示四个板块 +2. WHEN Report_UI 渲染库存概览板块, THE Report_UI SHALL 展示总库存数量、总金额(资金占用)、库销比的统计卡片,以及三项构成明细(在库未锁/在途/计划数)和 LLM 分析文本 +3. WHEN Report_UI 渲染销量分析板块, THE Report_UI SHALL 展示月均销量总量、销量构成明细(90天出库/未关单已锁/未关单出库/订件)和 LLM 分析文本 +4. WHEN Report_UI 渲染健康度板块, THE Report_UI SHALL 展示各类型配件的数量/金额占比图表和 LLM 分析文本 +5. WHEN Report_UI 渲染补货建议板块, THE Report_UI SHALL 展示各优先级的配件数/金额统计和 LLM 分析文本 +6. WHEN Report_UI 渲染健康度图表, THE Report_UI SHALL 使用 Chart.js 生成饼图或环形图展示数量占比和金额占比 + +### 需求 10:数据模型与写入服务重构 + +**用户故事:** 作为开发者,我希望 Python 数据模型和数据库写入服务能够适配新的报告结构。 + +#### 验收标准 + +1. THE AnalysisReport 数据模型 SHALL 包含 inventory_overview、sales_analysis、inventory_health、replenishment_summary 四个字典字段 +2. WHEN Result_Writer 保存报告, THE Result_Writer SHALL 将四个模块的字典数据序列化为 JSON 字符串写入对应数据库字段 +3. THE AnalysisReport 数据模型 SHALL 提供 to_dict 方法将报告转换为可序列化的字典 diff --git a/.kiro/specs/refactor-analysis-report/tasks.md b/.kiro/specs/refactor-analysis-report/tasks.md new file mode 100644 index 0000000..6be77e1 --- /dev/null +++ b/.kiro/specs/refactor-analysis-report/tasks.md @@ -0,0 +1,126 @@ +# 实现计划:重构分析报告功能 + +## 概述 + +全栈重构分析报告功能,从清理现有代码开始,依次重建数据库表、数据模型、统计计算、LLM 提示词、并发节点、API 接口、前端页面。使用 Python + LangGraph + FastAPI + Chart.js 技术栈。 + +## 任务 + +- [x] 1. 清理现有分析报告专属文件 + - [x] 1.1 清空 `src/fw_pms_ai/models/analysis_report.py`,仅保留文件头注释和必要 import,移除现有 AnalysisReport dataclass 的所有字段和方法 + - [x] 1.2 清空 `src/fw_pms_ai/agent/analysis_report_node.py`,仅保留文件头注释和必要 import,移除 `_load_prompt`、`_calculate_suggestion_stats`、`_calculate_risk_stats`、`_build_suggestion_summary`、`generate_analysis_report_node` 等所有函数 + - [x] 1.3 清空 `prompts/analysis_report.md` 的内容 + - 注意:此步骤仅清理分析报告专属文件,不修改 `tasks.py`、`app.js`、`result_writer.py` 等共享文件(这些文件中的报告相关代码将在后续重建步骤中以替换方式更新) + - _Requirements: 1.1, 1.2, 1.3, 1.5_ + +- [x] 2. 重建数据库表和数据模型 + - [x] 2.1 重写 `sql/migrate_analysis_report.sql`,创建新表结构(inventory_overview、sales_analysis、inventory_health、replenishment_summary 四个 JSON 字段 + LLM 元数据字段) + - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5_ + - [x] 2.2 在已清空的 `src/fw_pms_ai/models/analysis_report.py` 中编写新的 AnalysisReport dataclass(四个 Dict 字段 + to_dict 方法) + - _Requirements: 10.1, 10.3_ + - [x] 2.3 替换 `src/fw_pms_ai/services/result_writer.py` 中的 `save_analysis_report` 方法为新版本,适配新表结构(四个 JSON 字段序列化写入),不修改该文件中的其他方法 + - _Requirements: 10.2_ + - [ ]* 2.4 编写属性测试:报告数据模型序列化 round-trip + - **Property 5: 报告数据模型序列化 round-trip** + - **Validates: Requirements 10.2, 10.3** + +- [x] 3. 实现四大板块统计计算函数 + - [x] 3.1 在已清空的 `src/fw_pms_ai/agent/analysis_report_node.py` 中实现 `calculate_inventory_overview(part_ratios)` 函数 + - 计算有效库存(valid_storage_cnt = in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt)总数量/总金额、三项构成明细、库销比 + - _Requirements: 2.1, 2.2, 2.3, 2.5_ + - [x] 3.2 实现 `calculate_sales_analysis(part_ratios)` 函数 + - 计算月均销量总数量/总金额、各组成部分(out_stock_cnt/storage_locked_cnt/out_stock_ongoing_cnt/buy_cnt)总量、有销量/无销量配件数 + - _Requirements: 3.1, 3.2, 3.4_ + - [x] 3.3 实现 `calculate_inventory_health(part_ratios)` 函数 + - 将配件分类为缺货/呆滞/低频/正常,计算各类型数量/金额/百分比,生成 chart_data + - _Requirements: 4.1, 4.2_ + - [x] 3.4 实现 `calculate_replenishment_summary(part_results)` 函数 + - 按优先级(1=急需/2=建议/3=可选)统计配件种类数和金额 + - _Requirements: 5.1, 5.2_ + - [ ]* 3.5 编写属性测试:库存概览统计一致性 + - **Property 1: 库存概览统计一致性** + - **Validates: Requirements 2.1, 2.2, 2.3** + - [ ]* 3.6 编写属性测试:销量分析统计一致性 + - **Property 2: 销量分析统计一致性** + - **Validates: Requirements 3.1, 3.2, 3.4** + - [ ]* 3.7 编写属性测试:健康度分类完备性与一致性 + - **Property 3: 健康度分类完备性与一致性** + - **Validates: Requirements 4.1, 4.2** + - [ ]* 3.8 编写属性测试:补货建议统计一致性 + - **Property 4: 补货建议统计一致性** + - **Validates: Requirements 5.1, 5.2** + +- [ ] 4. Checkpoint - 确保统计计算函数和属性测试通过 + - 确保所有测试通过,如有问题请向用户确认。 + +- [x] 5. 创建 LLM 提示词文件 + - [x] 5.1 创建 `prompts/report_inventory_overview.md`,包含库存概览分析提示词(资金占用评估、库销比诊断、库存结构建议),确保分析专业且有实际决策价值 + - _Requirements: 7.2_ + - [x] 5.2 创建 `prompts/report_sales_analysis.md`,包含销量分析提示词(销量构成解读、销售活跃度、需求趋势判断),确保分析专业且有实际决策价值 + - _Requirements: 7.2_ + - [x] 5.3 创建 `prompts/report_inventory_health.md`,包含健康度分析提示词(健康度评分、问题诊断、资金释放机会、改善优先级),确保分析专业且有实际决策价值 + - _Requirements: 7.2_ + - [x] 5.4 创建 `prompts/report_replenishment_summary.md`,包含补货建议分析提示词(紧迫度评估、资金分配建议、执行节奏、风险提示),确保分析专业且有实际决策价值 + - _Requirements: 7.2_ + - [x] 5.5 删除旧的 `prompts/analysis_report.md` 文件(已在步骤1.3清空,此处正式删除) + - _Requirements: 1.3_ + +- [x] 6. 实现 LangGraph 并发 LLM 分析节点 + - [x] 6.1 在 `src/fw_pms_ai/agent/analysis_report_node.py` 中实现四个 LLM 分析函数(`llm_analyze_inventory_overview`、`llm_analyze_sales`、`llm_analyze_inventory_health`、`llm_analyze_replenishment_summary`),每个函数加载对应提示词、填充统计数据、调用 LLM、解析 JSON 响应 + - _Requirements: 7.2, 7.3_ + - [x] 6.2 使用 LangGraph StateGraph 构建并发子图,四个 LLM 节点从 START fan-out 并发执行,结果 fan-in 汇总 + - _Requirements: 7.1, 7.4_ + - [x] 6.3 实现新的 `generate_analysis_report_node(state)` 主函数,串联统计计算 → 并发 LLM 分析 → 汇总报告 → 写入数据库,单板块 LLM 失败不影响其他板块 + - _Requirements: 7.4, 7.5_ + - [x] 6.4 确认 `src/fw_pms_ai/agent/replenishment.py` 中的工作流引用无需修改(`generate_analysis_report_node` 函数签名保持不变) + - _Requirements: 7.1_ + +- [ ] 7. Checkpoint - 确保后端报告生成流程完整 + - 确保所有测试通过,如有问题请向用户确认。 + +- [x] 8. 重建 API 接口 + - [x] 8.1 替换 `src/fw_pms_ai/api/routes/tasks.py` 中的 `AnalysisReportResponse` 模型为新版本(inventory_overview、sales_analysis、inventory_health、replenishment_summary 四个 Dict 字段),不修改该文件中的其他模型和端点 + - _Requirements: 8.1_ + - [x] 8.2 替换 `get_analysis_report` 端点实现,从新表读取数据并解析 JSON 字段,不修改该文件中的其他端点 + - _Requirements: 8.1, 8.2, 8.3_ + +- [x] 9. 重建前端报告页面 + - [x] 9.1 在 `ui/index.html` 中引入 Chart.js CDN + - _Requirements: 9.6_ + - [x] 9.2 替换 `ui/js/app.js` 中的 `renderReportTab` 方法为新版本,渲染四大板块框架,同时移除旧的 `renderOverallAssessment`、`renderRiskAlerts`、`renderStrategy`、`renderExpectedImpact` 方法,不修改该文件中的其他方法 + - _Requirements: 9.1, 1.4_ + - [x] 9.3 实现 `renderInventoryOverview` 方法,渲染库存概览板块(统计卡片 + 五项构成明细 + LLM 分析文本) + - _Requirements: 9.2_ + - [x] 9.4 实现 `renderSalesAnalysis` 方法,渲染销量分析板块(统计卡片 + 构成明细 + LLM 分析文本) + - _Requirements: 9.3_ + - [x] 9.5 实现 `renderInventoryHealth` 方法,渲染健康度板块(统计卡片 + Chart.js 环形图 + LLM 分析文本) + - _Requirements: 9.4, 9.6_ + - [x] 9.6 实现 `renderReplenishmentSummary` 方法,渲染补货建议板块(优先级统计表 + LLM 分析文本) + - _Requirements: 9.5_ + - [x] 9.7 在 `ui/css/style.css` 中添加新报告板块的样式(统计卡片、图表容器、分析文本区域),不修改现有样式 + - _Requirements: 9.1_ + +- [ ] 10. Final Checkpoint - 全栈集成验证 + - 确保所有测试通过,如有问题请向用户确认。 + +## 安全约束:不影响补货建议功能 + +本次重构严格限定在分析报告模块范围内,以下补货建议相关代码禁止修改: + +- `src/fw_pms_ai/agent/nodes.py` — 补货建议核心节点(fetch_part_ratio、sql_agent、allocate_budget) +- `src/fw_pms_ai/agent/replenishment.py` — 补货建议工作流(仅确认无需修改,不做任何改动) +- `src/fw_pms_ai/agent/sql_agent/` — SQL Agent 目录 +- `src/fw_pms_ai/models/part_ratio.py` — 配件库销比模型 +- `src/fw_pms_ai/models/replenishment_*.py` — 补货建议相关模型 +- `result_writer.py` 中的 `save_task`、`update_task`、`save_details`、`save_part_summaries`、`save_execution_log` 等方法 +- `tasks.py` 中的任务列表、任务详情、配件明细、配件汇总、执行日志等端点 +- `app.js` 中的任务列表、任务详情、配件明细等渲染方法 +- `prompts/` 中的 `part_shop_analysis*.md`、`suggestion*.md`、`sql_agent.md` 等提示词文件 + +## 备注 + +- 标记 `*` 的任务为可选任务,可跳过以加快 MVP 进度 +- 每个任务引用了具体的需求编号以保证可追溯性 +- 属性测试使用 `hypothesis` 库,每个属性至少 100 次迭代 +- Checkpoint 用于阶段性验证,确保增量正确 +- 共享文件(tasks.py、app.js、result_writer.py、style.css)中的修改均采用"替换特定函数/类"方式,明确不修改其他部分 diff --git a/README.md b/README.md index 87b5d5d..4511233 100644 --- a/README.md +++ b/README.md @@ -1,122 +1,239 @@ # fw-pms-ai -AI 配件系统 - 基于 Python + LangChain + LangGraph +fw-pms 配件管理系统 AI 扩展平台 — 基于 Python + LangChain + LangGraph ## 项目简介 -本项目是 `fw-pms` 的 AI 扩展模块,使用大语言模型 (LLM) 和 Agent 技术,为配件管理系统提供智能化的补货建议能力。 +本项目是 `fw-pms` 配件管理系统的 **AI 能力扩展平台**,使用大语言模型 (LLM) 和 Agent 技术,为配件业务提供多种智能化功能。 -## 核心技术 +### 功能模块 -### LangChain + LangGraph - -| 技术 | 作用 | -|------|------| -| **LangChain** | LLM 框架,提供模型抽象、Prompt 管理、消息格式化 | -| **LangGraph** | Agent 工作流编排,管理状态机、定义节点和边、支持条件分支 | -| **SQL Agent** | 自定义 Text-to-SQL 实现,支持错误重试和 LLM 数据分析 | +| 状态 | 模块 | 说明 | +|------|------|------| +| ✅ 已实现 | **智能补货建议** | 分析库销比数据,LLM 逐配件分析各门店补货需求,生成结构化建议 | +| ✅ 已实现 | **分析报告** | 四大板块(库存概览/销量分析/健康度/补货建议)并发 LLM 分析 | +| 🚧 规划中 | **需求预测** | 基于历史销量预测未来配件需求 | +| 🚧 规划中 | **异常检测** | 识别库存和销量数据异常 | +| 🚧 规划中 | **智能定价** | AI 辅助配件定价建议 | + +## 技术栈 + +| 组件 | 技术 | 版本要求 | +|------|------|---------| +| 编程语言 | Python | ≥ 3.11 | +| Agent 框架 | LangChain + LangGraph | LangChain ≥ 0.3, LangGraph ≥ 0.2 | +| LLM 集成 | 智谱 GLM / 豆包 / OpenAI 兼容 / Anthropic 兼容 | — | +| Web API | FastAPI + Uvicorn | FastAPI ≥ 0.109 | +| 数据库 | MySQL (mysql-connector-python + SQLAlchemy) | SQLAlchemy ≥ 2.0 | +| 任务调度 | APScheduler | ≥ 3.10 | +| 配置管理 | Pydantic Settings + python-dotenv | Pydantic ≥ 2.0 | +| HTTP 客户端 | httpx | ≥ 0.25 | +| 重试机制 | tenacity | ≥ 8.0 | + +## 系统架构 ```mermaid -graph LR - A[用户请求] --> B[LangGraph Agent] - B --> C[FetchPartRatio] - C --> D[SQLAgent
LLM分析] - D --> E[AllocateBudget] - E --> F[AnalysisReport] - F --> G[SaveResult] +flowchart TB + subgraph Scheduler ["定时调度 (APScheduler)"] + S[每日凌晨触发] + end + + subgraph API ["FastAPI API 层"] + A[/tasks, details, logs, reports/] + end + + subgraph Agent ["LangGraph 工作流"] + direction TB + B[1. fetch_part_ratio] --> C[2. sql_agent] + C --> D{需要重试?} + D -->|是| C + D -->|否| E[3. allocate_budget] + E --> F[4. generate_analysis_report] + F --> G[END] + end + + subgraph ReportSubgraph ["分析报告并发子图"] + direction LR + R1[库存概览 LLM] & R2[销量分析 LLM] & R3[健康度 LLM] & R4[补货建议 LLM] + end + + subgraph Services ["业务服务层"] + DS[DataService] + RW[ResultWriter] + RP[Repository] + end + + subgraph LLM ["LLM 适配层"] + L1[GLMClient] + L2[DoubaoClient] + L3[OpenAICompatClient] + L4[AnthropicCompatClient] + end + + subgraph DB ["数据存储"] + MySQL[(MySQL)] + end + + S --> Agent + A --> Services + B --> DS + C --> LLM + F --> ReportSubgraph + ReportSubgraph --> LLM + E --> RW + DS --> MySQL + RW --> MySQL + RP --> MySQL ``` 详细架构图见 [docs/architecture.md](docs/architecture.md) -## 功能模块 +> 平台采用模块化设计,新增 AI 功能模块只需添加对应的 Agent 工作流节点和提示词文件。 -### ✅ 已实现 +## 补货建议工作流 -| 模块 | 功能 | 说明 | -|------|------|------| -| **SQL Agent** | LLM 分析 | 直接分析 part_ratio 数据生成补货建议 | -| **补货分配** | Replenishment | 转换 LLM 建议为补货明细 | +补货建议模块的核心是一个 **4 节点 LangGraph 工作流**,按顺序执行: -### 🚧 计划中 +| 序号 | 节点 | 功能 | 说明 | +|------|------|------|------| +| 1 | `fetch_part_ratio` | 获取库销比数据 | 通过 dealer_grouping_id 从 part_ratio 表查询配件数据 | +| 2 | `sql_agent` | LLM 分析 + 建议生成 | 按 part_code 分组,逐配件分析各门店补货需求,支持错误重试 | +| 3 | `allocate_budget` | 转换补货明细 | 将 LLM 建议转换为结构化的补货明细记录 | +| 4 | `generate_analysis_report` | 生成分析报告 | 统计计算 + 4 路并发 LLM 分析生成结构化报告 | -| 模块 | 功能 | -|------|------| -| 预测引擎 | 基于历史销量预测未来需求 | -| 异常检测 | 识别数据异常 | +### 分析报告四大板块 + +| 板块 | 统计计算 | LLM 分析 | +|------|---------|---------| +| **库存概览** | 有效库存、资金占用、配件总数 | 库存状况综合评价 | +| **销量分析** | 月均销量、出库频次、销售趋势 | 销售趋势洞察 | +| **库存健康度** | 缺货/呆滞/低频/正常分类统计 | 健康度风险提示 | +| **补货建议汇总** | 按优先级分类统计补货数量和金额 | 补货策略建议 | + +> 四个 LLM 分析节点使用 LangGraph 子图 **并发执行**,单板块失败不影响其他板块。 + +## 业务术语 + +| 术语 | 定义 | 处理方式 | +|------|------|---------| +| **呆滞件** | 有效库存 > 0,90天出库数 = 0 | 不做计划 | +| **低频件** | 月均销量 < 1 或 出库次数 < 3 或 出库间隔 ≥ 30天 | 不做计划 | +| **缺货件** | 有效库存 = 0,月均销量 ≥ 1 | 需要补货 | +| **正常件** | 不属于以上三类 | 按需补货 | ## 项目结构 ``` fw-pms-ai/ ├── src/fw_pms_ai/ -│ ├── agent/ # LangGraph Agent -│ │ ├── state.py # Agent 状态定义 -│ │ ├── nodes.py # 工作流节点 -│ │ ├── sql_agent.py # SQL Agent(Text-to-SQL + 建议生成) -│ │ └── replenishment.py -│ ├── api/ # FastAPI 接口 -│ │ ├── app.py # 应用入口 -│ │ └── routes/ # 路由模块 -│ ├── config/ # 配置管理 -│ ├── llm/ # LLM 集成 -│ │ ├── base.py # 抽象基类 -│ │ ├── glm.py # 智谱 GLM -│ │ ├── doubao.py # 豆包 -│ │ ├── openai_compat.py -│ │ └── anthropic_compat.py -│ ├── models/ # 数据模型 -│ │ ├── task.py # 任务和明细模型 -│ │ ├── execution_log.py # 执行日志模型 -│ │ ├── part_ratio.py # 库销比模型 -│ │ ├── part_summary.py # 配件汇总模型 -│ │ ├── sql_result.py # SQL执行结果模型 -│ │ └── suggestion.py # 补货建议模型 -│ ├── services/ # 业务服务 -│ │ ├── db.py # 数据库连接 -│ │ ├── data_service.py # 数据查询服务 -│ │ └── result_writer.py # 结果写入服务 -│ ├── scheduler/ # 定时任务 -│ └── main.py -├── prompts/ # AI Prompt 文件 -│ ├── sql_agent.md # SQL Agent 系统提示词 -│ ├── suggestion.md # 补货建议提示词 -│ ├── suggestion_system.md -│ ├── part_shop_analysis.md -│ └── part_shop_analysis_system.md -├── ui/ # 前端静态文件 -├── sql/ # 数据库迁移脚本 -├── pyproject.toml +│ ├── main.py # 应用入口 +│ ├── agent/ # LangGraph Agent 工作流 +│ │ ├── state.py # Agent 状态定义 (TypedDict + Annotated reducer) +│ │ ├── nodes.py # 工作流节点 (fetch/sql_agent/allocate) +│ │ ├── analysis_report_node.py # 分析报告节点 (统计计算 + 并发 LLM 子图) +│ │ ├── replenishment.py # ReplenishmentAgent 主类 (构建图 + 运行) +│ │ └── sql_agent/ # SQL Agent 子包 +│ │ ├── agent.py # SQLAgent 主类 +│ │ ├── executor.py # SQL 执行器 +│ │ ├── analyzer.py # 配件分析器 (逐配件 LLM 分析) +│ │ └── prompts.py # 提示词加载 +│ ├── api/ # FastAPI REST API +│ │ ├── app.py # FastAPI 应用 (CORS + 静态文件 + 路由) +│ │ └── routes/ +│ │ └── tasks.py # 任务/明细/日志/汇总/报告 API +│ ├── config/ +│ │ └── settings.py # Pydantic Settings 配置管理 +│ ├── llm/ # LLM 适配层 +│ │ ├── base.py # BaseLLMClient 抽象基类 +│ │ ├── glm.py # 智谱 GLM 客户端 +│ │ ├── doubao.py # 豆包客户端 +│ │ ├── openai_compat.py # OpenAI 兼容客户端 (火山引擎等) +│ │ └── anthropic_compat.py # Anthropic 兼容客户端 +│ ├── models/ # 数据模型 (Pydantic) +│ │ ├── task.py # 任务模型 + 状态枚举 +│ │ ├── suggestion.py # 补货建议模型 +│ │ ├── part_ratio.py # 配件库销比模型 +│ │ ├── part_summary.py # 配件汇总模型 +│ │ ├── analysis_report.py # 分析报告模型 +│ │ ├── execution_log.py # 执行日志模型 +│ │ └── sql_result.py # SQL 执行结果模型 +│ ├── services/ # 业务服务层 +│ │ ├── db.py # 数据库连接管理 +│ │ ├── data_service.py # 数据查询服务 +│ │ ├── result_writer.py # 结果写入服务 +│ │ └── repository/ # 仓库模式 +│ │ ├── task_repo.py # 任务仓库 +│ │ ├── detail_repo.py # 明细仓库 +│ │ └── log_repo.py # 日志仓库 +│ └── scheduler/ +│ └── tasks.py # APScheduler 定时任务 + CLI 参数解析 +├── prompts/ # AI 提示词文件 +│ ├── sql_agent.md # SQL Agent 系统提示词 +│ ├── suggestion.md # 补货建议生成提示词 +│ ├── suggestion_system.md # 补货建议系统提示词 +│ ├── part_shop_analysis.md # 配件门店分析提示词 +│ ├── part_shop_analysis_system.md +│ ├── report_inventory_overview.md # 分析报告-库存概览提示词 +│ ├── report_sales_analysis.md # 分析报告-销量分析提示词 +│ ├── report_inventory_health.md # 分析报告-库存健康度提示词 +│ └── report_replenishment_summary.md # 分析报告-补货建议提示词 +├── ui/ # 前端静态文件 +│ ├── index.html # 主页面 +│ ├── css/ # 样式 +│ ├── js/ # JavaScript +│ └── merchant-report/ # 商家组合报告页面 +├── sql/ # 数据库脚本 +│ ├── init.sql # 初始化建表 (4张表) +│ └── migrate_analysis_report.sql # 分析报告表迁移 +├── docs/ # 文档 +│ ├── architecture.md # 系统架构文档 +│ └── 商家组合维度分析需求设计.md +├── pyproject.toml # 项目配置 (hatchling 构建) +├── .env # 环境变量 └── README.md ``` +## 数据表说明 -## 工作流程 +| 表名 | 说明 | SQL 文件 | +|------|------|---------| +| `part_ratio` | 配件库销比数据(来源表,只读) | — | +| `ai_replenishment_task` | 任务记录 | init.sql | +| `ai_replenishment_detail` | 配件级别补货建议明细 | init.sql | +| `ai_replenishment_part_summary` | 配件汇总表(按配件编码聚合) | init.sql | +| `ai_task_execution_log` | 任务执行日志(每步骤详情) | init.sql | +| `ai_analysis_report` | 结构化分析报告(四大板块 JSON) | migrate_analysis_report.sql | -``` -1. FetchPartRatio - 从 part_ratio 表获取库销比数据 -2. SQLAgent - LLM 分析数据,生成补货建议 -3. AllocateBudget - 转换建议为补货明细 -4. AnalysisReport - 生成分析报告(风险评估、行动方案) -5. SaveResult - 写入数据库 -``` +## API 接口 -### 业务术语 +基于 FastAPI 提供 REST API,支持前端 UI 对接。 -| 术语 | 定义 | 处理 | +| 方法 | 路径 | 说明 | |------|------|------| -| **呆滞件** | 有库存,90天无销量 | 不做计划 | -| **低频件** | 无库存,月均销量<1 | 不做计划 | -| **缺货件** | 无库存,月均销量≥1 | 需要补货 | +| GET | `/api/tasks` | 任务列表(分页、状态/组合/日期筛选) | +| GET | `/api/tasks/{task_no}` | 任务详情 | +| GET | `/api/tasks/{task_no}/details` | 配件建议明细(分页、排序、搜索) | +| GET | `/api/tasks/{task_no}/logs` | 执行日志 | +| GET | `/api/tasks/{task_no}/part-summaries` | 配件汇总列表(分页、优先级筛选) | +| GET | `/api/tasks/{task_no}/parts/{part_code}/shops` | 指定配件的门店明细 | +| GET | `/api/tasks/{task_no}/analysis-report` | 分析报告 | +| GET | `/health` | 健康检查 | +| GET | `/` | 主页面(静态文件) | -## 数据表说明 +运行后访问 `/docs` 查看 Swagger 文档。 -| 表名 | 说明 | -|------|------| -| `part_ratio` | 配件库销比数据(来源表) | -| `ai_replenishment_task` | 任务记录 | -| `ai_replenishment_detail` | 配件级别补货建议 | -| `ai_replenishment_part_summary` | 配件汇总表 | -| `ai_task_execution_log` | 任务执行日志 | +## LLM 集成 + +支持 4 种 LLM 客户端,通过环境变量自动选择: + +| 客户端 | 环境变量 | 说明 | +|--------|---------|------| +| `OpenAICompatClient` | `OPENAI_COMPAT_API_KEY` | OpenAI 兼容接口(火山引擎、智谱等) | +| `AnthropicCompatClient` | `ANTHROPIC_API_KEY` | Anthropic 兼容接口 | +| `GLMClient` | `GLM_API_KEY` | 智谱 GLM 原生 SDK | +| `DoubaoClient` | `DOUBAO_API_KEY` | 豆包 | + +优先级:`OpenAI Compat` > `Anthropic Compat` > `GLM` > `Doubao` ## 快速开始 @@ -131,39 +248,53 @@ pip install -e . ```bash cp .env.example .env +# 编辑 .env 填写以下配置 ``` 必填配置项: -- `GLM_API_KEY` / `ANTHROPIC_API_KEY` - LLM API Key -- `MYSQL_HOST`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE` + +```env +# LLM (至少配置一种) +OPENAI_COMPAT_API_KEY=your-key +OPENAI_COMPAT_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 +OPENAI_COMPAT_MODEL=glm-4-7-251222 + +# 数据库 +MYSQL_HOST=localhost +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=your-password +MYSQL_DATABASE=fw_pms +``` ### 3. 初始化数据库 ```bash +# 基础表结构 mysql -u root -p fw_pms < sql/init.sql + +# 分析报告表 +mysql -u root -p fw_pms < sql/migrate_analysis_report.sql ``` ### 4. 运行 ```bash -# 启动定时任务调度器 +# 启动定时任务调度器(默认每日 02:00 执行) fw-pms-ai -# 立即执行一次 +# 立即执行一次(所有商家组合) fw-pms-ai --run-once -# 指定参数 -fw-pms-ai --run-once --group-id 2 +# 指定集团和商家组合 +fw-pms-ai --run-once --group-id 2 --dealer-grouping-id 100 ``` -## AI Prompt 文件 +### 5. 启动 API 服务 -Prompt 文件存放在 `prompts/` 目录: - -| 文件 | 用途 | -|------|------| -| `suggestion.md` | 补货建议生成(含业务术语定义) | -| `analyze_inventory.md` | 库存分析 | +```bash +uvicorn fw_pms_ai.api.app:app --host 0.0.0.0 --port 8000 --reload +``` ## 开发 @@ -171,6 +302,26 @@ Prompt 文件存放在 `prompts/` 目录: # 安装开发依赖 pip install -e ".[dev]" +# 代码格式化 +black src/ +ruff check src/ + # 运行测试 pytest tests/ -v ``` + +## 提示词文件 + +提示词文件存放在 `prompts/` 目录,供 LLM 调用时使用: + +| 文件 | 用途 | +|------|------| +| `sql_agent.md` | SQL Agent 生成 SQL 查询的系统提示词 | +| `suggestion.md` | 配件补货建议生成 | +| `suggestion_system.md` | 补货建议系统角色提示词 | +| `part_shop_analysis.md` | 配件门店级别分析 | +| `part_shop_analysis_system.md` | 门店分析系统角色提示词 | +| `report_inventory_overview.md` | 分析报告 — 库存概览板块 | +| `report_sales_analysis.md` | 分析报告 — 销量分析板块 | +| `report_inventory_health.md` | 分析报告 — 库存健康度板块 | +| `report_replenishment_summary.md` | 分析报告 — 补货建议板块 | diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index 9ca54e3..0000000 --- a/docs/architecture.md +++ /dev/null @@ -1,126 +0,0 @@ -# fw-pms-ai 系统架构 - -## 技术栈 - -| 组件 | 技术 | -|------|------| -| 编程语言 | Python 3.11+ | -| Agent 框架 | LangChain + LangGraph | -| LLM | 智谱 GLM / 豆包 / OpenAI 兼容接口 | -| 数据库 | MySQL | -| API 框架 | FastAPI | -| 任务调度 | APScheduler | - ---- - -## 系统架构图 - -```mermaid -flowchart TB - subgraph API ["FastAPI API 层"] - A[/tasks endpoint/] - end - - subgraph Agent ["LangGraph Agent"] - direction TB - B[fetch_part_ratio] --> C[sql_agent] - C --> D{需要重试?} - D -->|是| C - D -->|否| E[allocate_budget] - E --> E2[generate_analysis_report] - E2 --> F[END] - end - - subgraph Services ["业务服务层"] - G[DataService] - H[ResultWriter] - end - - subgraph LLM ["LLM 集成"] - I[GLM] - J[Doubao] - K[OpenAI Compat] - end - - subgraph DB ["数据存储"] - L[(MySQL)] - end - - A --> Agent - B --> G - C --> LLM - E --> H - G --> L - H --> L -``` - ---- - -## 工作流节点说明 - -| 节点 | 职责 | 输入 | 输出 | -|------|------|------|------| -| `fetch_part_ratio` | 获取商家组合的配件库销比数据 | dealer_grouping_id | part_ratios[] | -| `sql_agent` | LLM 分析配件数据,生成补货建议 | part_ratios[] | llm_suggestions[], part_results[] | -| `allocate_budget` | 转换 LLM 建议为补货明细 | llm_suggestions[] | details[] | -| `generate_analysis_report` | 生成分析报告 | part_ratios[], details[] | analysis_report | - ---- - -## 核心数据流 - -```mermaid -sequenceDiagram - participant API - participant Agent - participant SQLAgent - participant LLM - participant DB - - API->>Agent: 创建任务 - Agent->>DB: 保存任务记录 - Agent->>DB: 查询 part_ratio - Agent->>SQLAgent: 分析配件数据 - - loop 每个配件 - SQLAgent->>LLM: 发送分析请求 - LLM-->>SQLAgent: 返回补货建议 - end - - SQLAgent-->>Agent: 汇总建议 - Agent->>DB: 保存补货明细 - Agent->>DB: 更新任务状态 - Agent-->>API: 返回结果 -``` - ---- - -## 目录结构 - -``` -src/fw_pms_ai/ -├── agent/ # LangGraph 工作流 -│ ├── state.py # 状态定义 (TypedDict) -│ ├── nodes.py # 工作流节点 -│ ├── sql_agent.py # SQL Agent 实现 -│ └── replenishment.py # 主入口 -├── api/ # REST API -├── config/ # 配置管理 -├── llm/ # LLM 适配器 -├── models/ # 数据模型 -├── services/ # 业务服务 -└── scheduler/ # 定时任务 -``` - ---- - -## 数据库表结构 - -| 表名 | 用途 | -|------|------| -| `part_ratio` | 配件库销比数据(只读) | -| `ai_replenishment_task` | 任务记录 | -| `ai_replenishment_detail` | 补货明细 | -| `ai_replenishment_part_summary` | 配件级汇总 | -| `ai_task_execution_log` | 执行日志 | -| `ai_analysis_report` | 分析报告(JSON结构化) | diff --git a/docs/商家组合维度分析需求设计.md b/docs/商家组合维度分析需求设计.md new file mode 100644 index 0000000..6f75584 --- /dev/null +++ b/docs/商家组合维度分析需求设计.md @@ -0,0 +1,856 @@ +# 商家组合维度分析报告 - 需求分析与设计文档 + +> **版本**: 2.0.0 +> **日期**: 2026-02-12 +> **项目**: fw-pms-ai(AI配件补货建议系统) + +--- + +## 1. 业务背景与目标 + +### 1.1 业务痛点 + +汽车配件管理面临以下核心挑战: + +| 痛点 | 描述 | 影响 | +|------|------|------| +| **库存失衡** | 部分配件长期缺货,部分配件严重呆滞 | 缺货导致客户流失,呆滞占用资金 | +| **人工决策低效** | 传统补货依赖采购员经验判断 | 效率低、易出错、难以规模化 | +| **多门店协调困难** | 同一商家组合下的多门店库存无法统一调配 | 资源利用率低,部分门店过剩而另一部分缺货 | +| **数据利用不足** | 丰富的销售数据未能有效转化为决策依据 | 补货缺乏数据支撑,决策质量参差不齐 | + +### 1.2 项目目标 + +```mermaid +mindmap + root((商家组合维度分析)) + 智能补货建议 + LLM驱动分析 + 配件级决策 + 门店级分配 + 风险识别与预警 + 呆滞件识别 + 低频件过滤 + 缺货预警 + 数据驱动分析报告 + 库存概览 + 销量分析 + 库存健康度 + 补货建议汇总 + 可视化展示 + 分析报告 + 配件明细 + 执行日志 +``` + +**核心价值主张**: +1. **智能化**:通过 LLM 自动分析库销比数据,生成专业补货建议 +2. **精细化**:从商家组合维度统一分析,再下钻到配件级、门店级 +3. **专业化**:输出的分析理由贴合采购人员专业度,包含具体数据指标 + +--- + +## 2. 功能模块设计 + +### 2.1 功能架构 + +```mermaid +flowchart TB + subgraph 用户层["🖥️ 用户层"] + UI[Web管理界面] + end + + subgraph 应用层["⚙️ 应用层"] + API[FastAPI 接口层] + + subgraph Agent["🤖 LangGraph Agent"] + N1[获取配件库销比
fetch_part_ratio] + N2[LLM分析生成建议
sql_agent] + N3[转换补货明细
allocate_budget] + N4[生成分析报告
generate_analysis_report] + end + + subgraph ReportSubgraph["📊 分析报告并发子图"] + R1[库存概览 LLM] + R2[销量分析 LLM] + R3[健康度 LLM] + R4[补货建议 LLM] + end + end + + subgraph 基础设施["🔧 基础设施"] + LLM[LLM服务
智谱GLM/豆包/OpenAI/Anthropic] + DB[(MySQL数据库)] + end + + UI --> API + API --> Agent + N1 --> N2 --> N3 --> N4 + N4 --> ReportSubgraph + N1 -.-> DB + N2 -.-> LLM + N3 -.-> DB + R1 & R2 & R3 & R4 -.-> LLM +``` + +### 2.2 功能模块清单 + +| 模块 | 功能 | 输入 | 输出 | +|------|------|------|------| +| **数据获取** | 获取商家组合内所有配件的库销比数据 | dealer_grouping_id | part_ratios[] | +| **LLM分析** | 按配件分组分析,生成补货建议和决策理由 | part_ratios, base_ratio | llm_suggestions[] | +| **建议转换** | 将LLM建议转换为结构化的补货明细 | llm_suggestions[] | details[], part_summaries[] | +| **分析报告** | 四大板块统计计算 + 并发LLM分析 | part_ratios, part_results | analysis_report | + +--- + +## 3. 系统架构设计 + +### 3.1 整体架构 + +```mermaid +C4Component + title 商家组合维度分析系统 - 组件架构 + + Container_Boundary(web, "Web层") { + Component(ui, "前端UI", "HTML/CSS/JS", "任务管理、结果展示") + } + + Container_Boundary(api, "API层") { + Component(routes, "路由模块", "FastAPI", "REST API接口") + Component(scheduler, "定时调度", "APScheduler", "任务调度") + } + + Container_Boundary(agent, "Agent层") { + Component(workflow, "工作流引擎", "LangGraph", "状态机编排") + Component(nodes, "节点实现", "Python", "业务逻辑") + Component(report_subgraph, "报告子图", "LangGraph 并发子图", "4路并发LLM分析") + Component(prompts, "提示词", "Markdown", "LLM指令") + } + + Container_Boundary(service, "服务层") { + Component(data, "数据服务", "Python", "数据查询") + Component(writer, "写入服务", "Python", "结果持久化") + } + + Container_Boundary(infra, "基础设施") { + ComponentDb(mysql, "MySQL", "数据库", "业务数据存储") + Component(llm, "LLM", "GLM/Doubao/OpenAI/Anthropic", "大语言模型") + } + + Rel(ui, routes, "HTTP请求") + Rel(routes, workflow, "触发任务") + Rel(workflow, nodes, "执行") + Rel(nodes, report_subgraph, "fan-out/fan-in") + Rel(nodes, prompts, "加载") + Rel(nodes, llm, "调用") + Rel(nodes, data, "查询") + Rel(nodes, writer, "写入") + Rel(data, mysql, "SQL") + Rel(writer, mysql, "SQL") +``` + +### 3.2 工作流状态机 + +```mermaid +stateDiagram-v2 + [*] --> FetchPartRatio: 启动任务 + + FetchPartRatio --> SQLAgent: 获取库销比数据 + FetchPartRatio --> [*]: 无数据 + + SQLAgent --> SQLAgent: 重试(错误 & 次数<3) + SQLAgent --> AllocateBudget: 分析完成 + SQLAgent --> [*]: 重试失败 + + AllocateBudget --> GenerateReport: 转换完成 + + GenerateReport --> [*]: 生成报告 + + state GenerateReport { + [*] --> 统计计算 + 统计计算 --> 并发LLM子图 + + state 并发LLM子图 { + [*] --> 库存概览LLM + [*] --> 销量分析LLM + [*] --> 健康度LLM + [*] --> 补货建议LLM + 库存概览LLM --> [*] + 销量分析LLM --> [*] + 健康度LLM --> [*] + 补货建议LLM --> [*] + } + + 并发LLM子图 --> 汇总写入 + 汇总写入 --> [*] + } +``` + +--- + +## 4. 核心算法说明 + +### 4.1 三层决策逻辑 + +```mermaid +flowchart LR + subgraph L1["第一层: 配件级判断"] + A1[汇总商家组合内
所有门店数据] + A2[计算整体库销比] + A3{是否需要补货?} + A4[生成配件级理由] + end + + subgraph L2["第二层: 门店级分配"] + B1[按库销比从低到高排序] + B2[计算各门店缺口] + B3[分配补货数量] + end + + subgraph L3["第三层: 决策理由生成"] + C1[状态判定标签] + C2[关键指标数据] + C3[缺口分析] + C4[天数说明] + end + + A1 --> A2 --> A3 + A3 -->|是| L2 + A3 -->|否| A4 + B1 --> B2 --> B3 + B3 --> L3 + C1 --> C2 --> C3 --> C4 +``` + +### 4.2 补货数量计算公式 + +``` +suggest_cnt = ceil(目标库销比 × 月均销量 - 当前库存) +``` + +其中: +- **有效库存** = `in_stock_unlocked_cnt` + `on_the_way_cnt` + `has_plan_cnt` +- **月均销量** = (`out_stock_cnt` + `storage_locked_cnt` + `out_stock_ongoing_cnt` + `buy_cnt`) / 3 +- **资金占用** = (`in_stock_unlocked_cnt` + `on_the_way_cnt`) × `cost_price` + +### 4.3 配件分类与处理规则 + +| 分类 | 判定条件 | 处理策略 | +|------|----------|----------| +| **缺货件** | 有效库存 = 0 且 月均销量 ≥ 1 | 优先补货 | +| **呆滞件** | 有效库存 > 0 且 90天出库数 = 0 | 不补货,建议清理 | +| **低频件** | 月均销量 < 1 或 出库次数 < 3 或 出库间隔 ≥ 30天 | 不补货 | +| **正常件** | 不属于以上三类 | 按缺口补货 | + +> 分类优先级:缺货件 > 呆滞件 > 低频件 > 正常件(按顺序判断,命中即止) + +### 4.4 优先级判定标准 + +```mermaid +flowchart TD + A{库存状态} -->|库存=0 且 销量活跃| H[高优先级
急需补货] + A -->|库销比<0.5| M[中优先级
建议补货] + A -->|0.5≤库销比<目标值| L[低优先级
可选补货] + A -->|库销比≥目标值| N[无需补货
库存充足] + + style H fill:#ff6b6b + style M fill:#feca57 + style L fill:#48dbfb + style N fill:#2ecc71 +``` + +--- + +## 5. 数据模型设计 + +### 5.1 ER图 + +```mermaid +erDiagram + AI_REPLENISHMENT_TASK ||--o{ AI_REPLENISHMENT_DETAIL : contains + AI_REPLENISHMENT_TASK ||--o{ AI_REPLENISHMENT_PART_SUMMARY : contains + AI_REPLENISHMENT_TASK ||--o{ AI_TASK_EXECUTION_LOG : logs + AI_REPLENISHMENT_TASK ||--o| AI_ANALYSIS_REPORT : generates + PART_RATIO }o--|| AI_REPLENISHMENT_DETAIL : references + + AI_REPLENISHMENT_TASK { + bigint id PK + varchar task_no UK "AI-开头" + bigint group_id "集团ID" + bigint dealer_grouping_id + varchar dealer_grouping_name + bigint brand_grouping_id "品牌组合ID" + decimal plan_amount "计划采购金额" + decimal actual_amount "实际分配金额" + int part_count "配件数量" + decimal base_ratio "基准库销比" + tinyint status "0运行/1成功/2失败" + varchar llm_provider + varchar llm_model + int llm_total_tokens + varchar statistics_date + datetime start_time + datetime end_time + } + + AI_REPLENISHMENT_DETAIL { + bigint id PK + varchar task_no FK + bigint group_id + bigint dealer_grouping_id + bigint brand_grouping_id + bigint shop_id "库房ID" + varchar shop_name + varchar part_code "配件编码" + varchar part_name + varchar unit + decimal cost_price + decimal current_ratio "当前库销比" + decimal base_ratio "基准库销比" + decimal post_plan_ratio "计划后库销比" + decimal valid_storage_cnt "有效库存" + decimal avg_sales_cnt "月均销量" + int suggest_cnt "建议数量" + decimal suggest_amount "建议金额" + text suggestion_reason "决策理由" + int priority "1高/2中/3低" + float llm_confidence "LLM置信度" + } + + AI_REPLENISHMENT_PART_SUMMARY { + bigint id PK + varchar task_no FK + bigint group_id + bigint dealer_grouping_id + varchar part_code "配件编码" + varchar part_name + varchar unit + decimal cost_price + decimal total_storage_cnt "总库存" + decimal total_avg_sales_cnt "总月均销量" + decimal group_current_ratio "商家组合库销比" + int total_suggest_cnt "总建议数量" + decimal total_suggest_amount "总建议金额" + int shop_count "涉及门店数" + int need_replenishment_shop_count "需补货门店数" + text part_decision_reason "配件级理由" + int priority "1高/2中/3低" + float llm_confidence + } + + AI_TASK_EXECUTION_LOG { + bigint id PK + varchar task_no FK + bigint group_id + bigint brand_grouping_id + varchar brand_grouping_name + bigint dealer_grouping_id + varchar dealer_grouping_name + varchar step_name "步骤名称" + int step_order "步骤顺序" + tinyint status "0进行/1成功/2失败/3跳过" + text input_data "输入JSON" + text output_data "输出JSON" + text error_message + int retry_count + text sql_query + text llm_prompt + text llm_response + int llm_tokens "Token消耗" + int execution_time_ms "耗时" + } + + AI_ANALYSIS_REPORT { + bigint id PK + varchar task_no FK + bigint group_id + bigint dealer_grouping_id + varchar dealer_grouping_name + bigint brand_grouping_id + varchar report_type "默认replenishment" + json inventory_overview "库存概览" + json sales_analysis "销量分析" + json inventory_health "健康度" + json replenishment_summary "补货建议" + varchar llm_provider + varchar llm_model + int llm_tokens + int execution_time_ms + } + + PART_RATIO { + bigint id PK + bigint shop_id + varchar part_code + decimal in_stock_unlocked_cnt "在库未锁定" + decimal on_the_way_cnt "在途" + decimal has_plan_cnt "已有计划" + decimal out_stock_cnt "出库数" + decimal storage_locked_cnt "库存锁定" + decimal out_stock_ongoing_cnt "出库在途" + decimal buy_cnt "采购数" + decimal cost_price "成本价" + int out_times "出库次数" + int out_duration "平均出库间隔" + } +``` + +### 5.2 核心表结构 + +#### ai_replenishment_task(任务主表) +| 字段 | 类型 | 说明 | +|------|------|------| +| task_no | VARCHAR(32) | 任务编号,AI-开头,唯一 | +| group_id | BIGINT | 集团ID | +| dealer_grouping_id | BIGINT | 商家组合ID | +| dealer_grouping_name | VARCHAR(128) | 商家组合名称 | +| brand_grouping_id | BIGINT | 品牌组合ID | +| plan_amount | DECIMAL(14,2) | 计划采购金额(预算) | +| actual_amount | DECIMAL(14,2) | 实际分配金额 | +| part_count | INT | 配件数量 | +| base_ratio | DECIMAL(10,4) | 基准库销比 | +| status | TINYINT | 状态: 0运行中/1成功/2失败 | +| llm_provider | VARCHAR(32) | LLM提供商 | +| llm_model | VARCHAR(64) | LLM模型名称 | +| statistics_date | VARCHAR(16) | 统计日期 | +| start_time / end_time | DATETIME | 任务执行起止时间 | + +#### ai_replenishment_part_summary(配件汇总表) +| 字段 | 类型 | 说明 | +|------|------|------| +| task_no | VARCHAR(32) | 任务编号 | +| group_id | BIGINT | 集团ID | +| dealer_grouping_id | BIGINT | 商家组合ID | +| part_code | VARCHAR(64) | 配件编码 | +| part_name | VARCHAR(256) | 配件名称 | +| cost_price | DECIMAL(14,2) | 成本价 | +| total_storage_cnt | DECIMAL(14,2) | 商家组合内总库存 | +| total_avg_sales_cnt | DECIMAL(14,2) | 总月均销量 | +| group_current_ratio | DECIMAL(10,4) | 商家组合级库销比 | +| total_suggest_cnt | INT | 总建议数量 | +| total_suggest_amount | DECIMAL(14,2) | 总建议金额 | +| shop_count | INT | 涉及门店数 | +| need_replenishment_shop_count | INT | 需补货门店数 | +| part_decision_reason | TEXT | 配件级补货理由 | +| priority | INT | 优先级: 1高/2中/3低 | + +#### ai_analysis_report(分析报告表) +| 字段 | 类型 | 说明 | +|------|------|------| +| task_no | VARCHAR(32) | 任务编号 | +| group_id | BIGINT | 集团ID | +| dealer_grouping_id | BIGINT | 商家组合ID | +| report_type | VARCHAR(32) | 报告类型(默认 replenishment) | +| inventory_overview | JSON | 库存总体概览(stats + llm_analysis) | +| sales_analysis | JSON | 销量分析(stats + llm_analysis) | +| inventory_health | JSON | 库存健康度(stats + chart_data + llm_analysis) | +| replenishment_summary | JSON | 补货建议(stats + llm_analysis) | +| llm_provider | VARCHAR(32) | LLM提供商 | +| llm_model | VARCHAR(64) | LLM模型名称 | +| llm_tokens | INT | LLM Token总消耗 | +| execution_time_ms | INT | 执行耗时(毫秒) | + +--- + +## 6. API 接口设计 + +### 6.1 接口总览 + +```mermaid +flowchart LR + subgraph Tasks["任务管理"] + T1["GET /api/tasks"] + T2["GET /api/tasks/:task_no"] + end + + subgraph Details["明细查询"] + D1["GET /api/tasks/:task_no/details"] + D2["GET /api/tasks/:task_no/part-summaries"] + D3["GET /api/tasks/:task_no/parts/:part_code/shops"] + end + + subgraph Reports["报告模块"] + R1["GET /api/tasks/:task_no/analysis-report"] + R2["GET /api/tasks/:task_no/logs"] + end + + subgraph Health["健康检查"] + H1["GET /health"] + end +``` + +### 6.2 核心接口定义 + +#### 1. 获取任务列表 +``` +GET /api/tasks?page=1&page_size=20&status=1&dealer_grouping_id=100&statistics_date=20260212 +``` + +**响应示例**: +```json +{ + "items": [ + { + "id": 1, + "task_no": "AI-ABC12345", + "group_id": 2, + "dealer_grouping_id": 100, + "dealer_grouping_name": "华东区商家组合", + "brand_grouping_id": 50, + "plan_amount": 100000.00, + "actual_amount": 89520.50, + "part_count": 156, + "base_ratio": 1.5000, + "status": 1, + "status_text": "成功", + "llm_provider": "openai_compat", + "llm_model": "glm-4-7-251222", + "llm_total_tokens": 8500, + "statistics_date": "20260212", + "start_time": "2026-02-12 02:00:00", + "end_time": "2026-02-12 02:05:30", + "duration_seconds": 330, + "create_time": "2026-02-12 02:00:00" + } + ], + "total": 100, + "page": 1, + "page_size": 20 +} +``` + +#### 2. 获取配件汇总(商家组合维度) +``` +GET /api/tasks/{task_no}/part-summaries?sort_by=total_suggest_amount&sort_order=desc&priority=1 +``` + +**响应示例**: +```json +{ + "items": [ + { + "id": 1, + "task_no": "AI-ABC12345", + "part_code": "C211F280503", + "part_name": "机油滤芯", + "unit": "个", + "cost_price": 140.00, + "total_storage_cnt": 25, + "total_avg_sales_cnt": 18.5, + "group_current_ratio": 1.35, + "group_post_plan_ratio": 2.0, + "total_suggest_cnt": 12, + "total_suggest_amount": 1680.00, + "shop_count": 5, + "need_replenishment_shop_count": 3, + "part_decision_reason": "【配件决策】该配件在商家组合内总库存25件...", + "priority": 1, + "llm_confidence": 0.85 + } + ], + "total": 50, + "page": 1, + "page_size": 50 +} +``` + +#### 3. 获取门店级明细 +``` +GET /api/tasks/{task_no}/parts/{part_code}/shops +``` + +**响应示例**: +```json +{ + "total": 3, + "items": [ + { + "id": 101, + "task_no": "AI-ABC12345", + "shop_id": 1001, + "shop_name": "杭州西湖店", + "part_code": "C211F280503", + "part_name": "机油滤芯", + "cost_price": 140.00, + "valid_storage_cnt": 5, + "avg_sales_cnt": 6.2, + "current_ratio": 0.81, + "post_plan_ratio": 1.61, + "suggest_cnt": 5, + "suggest_amount": 700.00, + "suggestion_reason": "「建议补货」当前库存5件,月均销量6.2件...", + "priority": 1 + } + ] +} +``` + +#### 4. 获取配件建议明细 +``` +GET /api/tasks/{task_no}/details?page=1&page_size=50&sort_by=suggest_amount&sort_order=desc&part_code=C211 +``` + +#### 5. 获取分析报告 +``` +GET /api/tasks/{task_no}/analysis-report +``` + +**响应示例**: +```json +{ + "id": 1, + "task_no": "AI-ABC12345", + "group_id": 2, + "dealer_grouping_id": 100, + "report_type": "replenishment", + "inventory_overview": { + "stats": { + "total_valid_storage_cnt": 2500, + "total_valid_storage_amount": 350000.0, + "total_capital_occupation": 280000.0, + "overall_ratio": 1.35, + "part_count": 156 + }, + "llm_analysis": { "..." : "LLM生成的分析结论" } + }, + "sales_analysis": { + "stats": { "total_avg_sales_cnt": 1850, "..." : "..." }, + "llm_analysis": { "..." : "..." } + }, + "inventory_health": { + "stats": { "shortage": { "count": 12, "amount": 5000 }, "..." : "..." }, + "chart_data": { "labels": ["缺货件","呆滞件","低频件","正常件"], "..." : "..." }, + "llm_analysis": { "..." : "..." } + }, + "replenishment_summary": { + "stats": { "urgent": { "count": 15, "amount": 25000 }, "..." : "..." }, + "llm_analysis": { "..." : "..." } + }, + "llm_tokens": 3200, + "execution_time_ms": 12000 +} +``` + +#### 6. 获取执行日志 +``` +GET /api/tasks/{task_no}/logs +``` + +--- + +## 7. 前端交互设计 + +### 7.1 页面结构 + +```mermaid +flowchart TB + subgraph Dashboard["仪表盘"] + S1[统计卡片] + S2[最近任务列表] + end + + subgraph TaskList["任务列表页"] + L1[筛选条件] + L2[任务表格] + L3[分页控件] + end + + subgraph TaskDetail["任务详情页"] + D1[任务头部信息] + D2[统计卡片] + + subgraph Tabs["标签页"] + T1[配件明细] + T2[分析报告] + T3[执行日志] + T4[任务信息] + end + end + + Dashboard --> TaskList --> TaskDetail +``` + +### 7.2 配件明细交互 + +```mermaid +sequenceDiagram + participant U as 用户 + participant UI as 前端UI + participant API as 后端API + + U->>UI: 点击任务详情 + UI->>API: GET /api/tasks/{task_no}/part-summaries + API-->>UI: 返回配件汇总列表 + UI->>UI: 渲染配件表格(可排序/筛选/优先级) + + U->>UI: 点击展开某配件 + UI->>API: GET /api/tasks/{task_no}/parts/{part_code}/shops + API-->>UI: 返回门店级明细 + UI->>UI: 展开子表格显示门店数据 + + Note over UI: 门店数据包含:
库存、销量、库销比
建议数量、建议理由
计划后库销比 +``` + +### 7.3 关键UI组件 + +| 组件 | 功能 | 交互方式 | +|------|------|----------| +| **配件汇总表格** | 展示商家组合维度的配件建议 | 支持排序、筛选、分页、优先级筛选 | +| **可展开行** | 展示配件下的门店明细 | 点击行展开/收起 | +| **配件决策卡片** | 显示LLM生成的配件级理由 | 展开配件时显示 | +| **库销比指示器** | 直观显示库销比健康度 | 颜色渐变(红/黄/绿) | +| **分析报告面板** | 四大板块数据驱动展示 | 统计数据 + LLM 分析 + 图表 | + +--- + +## 8. 分析报告设计 + +### 8.1 报告模块结构 + +分析报告由 **统计计算** + **4路并发 LLM 分析** 的 LangGraph 子图生成。每个板块包含 `stats`(统计数据)和 `llm_analysis`(LLM 分析结论)。 + +```mermaid +flowchart TB + subgraph Report["分析报告四大板块"] + M1["板块1: 库存总体概览
inventory_overview"] + M2["板块2: 销量分析
sales_analysis"] + M3["板块3: 库存构成健康度
inventory_health"] + M4["板块4: 补货建议生成情况
replenishment_summary"] + end + + M1 --> S1[有效库存/资金占用] + M1 --> S2[在库/在途/已有计划] + M1 --> S3[整体库销比] + + M2 --> R1[月均销量/销售金额] + M2 --> R2[有销量/无销量配件数] + M2 --> R3[出库/锁定/采购统计] + + M3 --> P1[缺货件统计] + M3 --> P2[呆滞件统计] + M3 --> P3[低频件统计] + M3 --> P4[正常件统计] + M3 --> P5[chart_data图表数据] + + M4 --> E1[急需补货统计] + M4 --> E2[建议补货统计] + M4 --> E3[可选补货统计] +``` + +### 8.2 各板块统计计算与LLM分析 + +| 板块 | 统计计算 | LLM 分析 | 提示词文件 | +|------|---------|---------|-----------| +| **库存概览** | 有效库存、资金占用、配件总数、整体库销比 | 库存状况综合评价 | `report_inventory_overview.md` | +| **销量分析** | 月均销量、出库频次、有/无销量配件数 | 销售趋势洞察 | `report_sales_analysis.md` | +| **库存健康度** | 缺货/呆滞/低频/正常分类统计(数量/金额/占比) | 健康度风险提示 | `report_inventory_health.md` | +| **补货建议汇总** | 按优先级(急需/建议/可选)分类统计 | 补货策略建议 | `report_replenishment_summary.md` | + +> 四个 LLM 分析节点使用 LangGraph 子图 **并发执行**(fan-out / fan-in),单板块失败不影响其他板块。 + +### 8.3 并发子图实现 + +```mermaid +flowchart LR + START --> A[库存概览LLM] --> END2[END] + START --> B[销量分析LLM] --> END2 + START --> C[健康度LLM] --> END2 + START --> D[补货建议LLM] --> END2 +``` + +子图采用 `ReportLLMState` TypedDict 定义状态,使用 `Annotated` reducer 合并并发结果: +- 分析结果:`_merge_dict`(保留非 None) +- Token 用量:`_sum_int`(累加) + +--- + +## 9. 技术选型 + +| 组件 | 技术 | 选型理由 | +|------|------|----------| +| **编程语言** | Python 3.11+ | 丰富的AI/ML生态 | +| **Agent框架** | LangChain + LangGraph | 成熟的LLM编排框架,支持并发子图 | +| **API框架** | FastAPI | 高性能、自动文档 | +| **数据库** | MySQL | 与主系统保持一致 | +| **LLM** | 智谱GLM / 豆包 / OpenAI兼容 / Anthropic兼容 | 多模型支持,优先级自动选择 | +| **前端** | 原生HTML+CSS+JS | 轻量级,无构建依赖 | + +### LLM 客户端优先级 + +| 优先级 | 客户端 | 触发条件 | +|--------|--------|----------| +| 1 | `OpenAICompatClient` | `OPENAI_COMPAT_API_KEY` 已配置 | +| 2 | `AnthropicCompatClient` | `ANTHROPIC_API_KEY` 已配置 | +| 3 | `GLMClient` | `GLM_API_KEY` 已配置 | +| 4 | `DoubaoClient` | `DOUBAO_API_KEY` 已配置 | + +--- + +## 10. 部署与运维 + +### 10.1 部署架构 + +```mermaid +flowchart LR + subgraph Client["客户端"] + Browser[浏览器] + end + + subgraph Server["服务器"] + Nginx[Nginx
静态资源/反向代理] + API[FastAPI
API服务] + Scheduler[APScheduler
定时任务] + end + + subgraph External["外部服务"] + LLM[LLM API] + DB[(MySQL)] + end + + Browser --> Nginx + Nginx --> API + API --> LLM + API --> DB + Scheduler --> API +``` + +### 10.2 关键监控指标 + +| 指标 | 阈值 | 告警方式 | +|------|------|----------| +| 任务成功率 | < 95% | 邮件 | +| LLM响应时间 | > 30s | 日志 | +| Token消耗 | > 10000/任务 | 日志 | +| API响应时间 | > 2s | 监控 | + +--- + +## 附录 + +### A. 术语表 + +| 术语 | 定义 | +|------|------| +| 商家组合 | 多个经销商/门店的逻辑分组 | +| 库销比 | 库存数量 / 月均销量,衡量库存健康度 | +| 呆滞件 | 有库存但90天无出库数的配件 | +| 低频件 | 月均销量<1 或 出库次数<3 或 出库间隔≥30天的配件 | +| 有效库存 | 在库未锁定 + 在途 + 已有计划 | +| 资金占用 | (在库未锁定 + 在途) × 成本价 | + +### B. 参考文档 + +- [docs/architecture.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/docs/architecture.md) - 系统架构文档 +- [prompts/part_shop_analysis.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/part_shop_analysis.md) - 配件分析提示词 +- [prompts/report_inventory_overview.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/report_inventory_overview.md) - 库存概览提示词 +- [prompts/report_sales_analysis.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/report_sales_analysis.md) - 销量分析提示词 +- [prompts/report_inventory_health.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/report_inventory_health.md) - 库存健康度提示词 +- [prompts/report_replenishment_summary.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/report_replenishment_summary.md) - 补货建议提示词 + +### C. 版本变更记录 + +| 版本 | 日期 | 变更说明 | +|------|------|----------| +| 1.0.0 | 2026-02-09 | 初始版本 | +| 2.0.0 | 2026-02-12 | 根据实际实现更新:分析报告重构为四大数据驱动板块、ER图更新、API路径和字段对齐、新增LLM客户端等 | diff --git a/prompts/analysis_report.md b/prompts/analysis_report.md deleted file mode 100644 index 7da9790..0000000 --- a/prompts/analysis_report.md +++ /dev/null @@ -1,174 +0,0 @@ -# 智能补货建议分析报告 - -你是一位资深汽车配件采购顾问。AI系统已经基于全量数据生成了补货建议的**多维统计分布数据**,现在需要你站在更宏观的视角,为采购决策者提供**整体性分析**。 - -> **核心定位**: 统计数据揭示了补货的宏观特征与分布情况,本报告聚焦于"整体策略、风险预警、资金规划"等**决策层面**的洞察。 - ---- - -## 商家组合信息 - -| 项目 | 数值 | -|------|------| -| 商家组合ID | {dealer_grouping_id} | -| 商家组合名称 | {dealer_grouping_name} | -| 报告生成日期 | {statistics_date} | - ---- - -## 本期补货建议概览 (统计摘要) - -{suggestion_summary} - ---- - -## 库存健康度参考 - -| 状态分类 | 配件数量 | 涉及金额 | -|----------|----------|----------| -| 缺货件 | {shortage_cnt} | {shortage_amount} | -| 呆滞件 | {stagnant_cnt} | {stagnant_amount} | -| 低频件 | {low_freq_cnt} | {low_freq_amount} | - ---- - -## 分析任务 - -请严格按以下4个模块输出 **JSON格式** 的整体分析报告。 - -**注意**: 分析应基于提供的统计分布数据(如优先级、价格区间、周转频次等),按以下**宏观维度**展开。 - -### 模块1: 整体态势研判 (overall_assessment) - -从全局视角评估本次补货建议: - -1. **补货规模评估**: 结合涉及配件数和总金额,评估本期补货的力度和资金需求规模。 -2. **结构特征分析**: 基于价格区间和周转频次分布,分析补货建议的结构特征(如是否偏向高频低价件,或存在大量低频高价件)。 -3. **时机判断**: 当前是否处于补货的有利时机?需要考虑哪些时间因素(如节假日、促销季、供应商备货周期)? - -### 模块2: 风险预警与应对 (risk_alerts) - -识别本次补货可能面临的风险并给出应对建议: - -1. **供应风险**: 高频或高优先级配件的供应保障是否关键? -2. **资金风险**: 大额补货配件(≥5000元)的占比是否过高?是否构成资金压力? -3. **库存结构风险**: 低频配件的补货比例是否合理?是否存在积压风险? -4. **执行重点**: 针对高优先级或大额补货的配件,建议采取什么复核策略? - -### 模块3: 采购策略建议 (procurement_strategy) - -提供整体性的采购执行策略: - -1. **优先级排序原则**: 结合优先级分布数据,给出资金分配和采购执行的先后顺序建议。 -2. **批量采购机会**: 对于低价高频的配件,是否建议采用批量采购策略以优化成本? -3. **分批采购建议**: 对于大额或低频配件,是否建议分批次补货以控制风险? -4. **供应商协调要点**: 针对本期补货的结构特征(如大额占比高或高频占比高),与供应商沟通的侧重点是什么? - -### 模块4: 效果预期与建议 (expected_impact) - -预估按建议执行后的整体效果: - -1. **库存健康度改善**: 补货后整体库存结构预计如何变化?缺货率预计下降多少? -2. **资金效率预估**: 本期补货的预计投入产出如何?资金周转是否会改善? -3. **后续关注点**: 补货完成后需要持续关注哪些指标或配件?下一步建议行动是什么? - ---- - -## 输出格式 - -直接输出JSON对象,**不要**包含 ```json 标记: - -{{ - "overall_assessment": {{ - "scale_evaluation": {{ - "current_vs_historical": "与历史同期对比结论", - "possible_reasons": "规模变化的可能原因" - }}, - "structure_analysis": {{ - "category_distribution": "品类分布特征", - "price_range_distribution": "价格区间分布特征", - "turnover_distribution": "周转频次分布特征", - "imbalance_warning": "是否存在失衡及说明" - }}, - "timing_judgment": {{ - "is_favorable": true或false, - "timing_factors": "需要考虑的时间因素", - "recommendation": "时机相关建议" - }} - }}, - "risk_alerts": {{ - "supply_risks": [ - {{ - "risk_type": "风险类型(缺货/涨价/交期延长等)", - "affected_scope": "影响范围描述", - "likelihood": "可能性评估(高/中/低)", - "mitigation": "应对建议" - }} - ], - "capital_risks": {{ - "cash_flow_pressure": "资金压力评估", - "stagnation_warning": "呆滞风险提示", - "recommendation": "资金风险应对建议" - }}, - "market_risks": [ - {{ - "risk_description": "市场风险描述", - "affected_parts": "影响配件范围", - "recommendation": "应对建议" - }} - ], - "execution_anomalies": [ - {{ - "anomaly_type": "异常类型", - "description": "异常描述", - "review_suggestion": "复核建议" - }} - ] - }}, - "procurement_strategy": {{ - "priority_principle": {{ - "tier1_criteria": "第一优先级标准及说明", - "tier2_criteria": "第二优先级标准及说明", - "tier3_criteria": "可延后采购的标准及说明" - }}, - "batch_opportunities": {{ - "potential_savings": "潜在节省金额或比例", - "applicable_categories": "适用品类或供应商", - "execution_suggestion": "具体操作建议" - }}, - "phased_procurement": {{ - "recommended_parts": "建议分批采购的配件范围", - "suggested_rhythm": "建议的采购节奏" - }}, - "supplier_coordination": {{ - "key_communications": "关键沟通事项", - "timing_suggestions": "沟通时机建议" - }} - }}, - "expected_impact": {{ - "inventory_health": {{ - "structure_improvement": "库存结构改善预期", - "shortage_reduction": "缺货率预计下降幅度" - }}, - "capital_efficiency": {{ - "investment_amount": 本期补货投入金额, - "expected_return": "预期收益描述", - "turnover_improvement": "周转改善预期" - }}, - "follow_up_actions": {{ - "key_metrics_to_watch": "需持续关注的指标", - "next_steps": "下一步建议行动" - }} - }} -}} - ---- - -## 重要约束 - -1. **输出必须是合法的JSON对象** -2. **所有金额单位为元,保留2位小数** -3. **聚焦宏观分析,不要重复明细中已有的配件级别信息** -4. **风险和效果预估尽量量化** -5. **策略建议要具体可执行,避免空泛描述** -6. **分析基于提供的汇总数据,保持客观理性** diff --git a/prompts/report_inventory_health.md b/prompts/report_inventory_health.md new file mode 100644 index 0000000..09a0f0b --- /dev/null +++ b/prompts/report_inventory_health.md @@ -0,0 +1,200 @@ +# 库存健康度分析提示词 + +你是一位汽车配件库存健康度诊断专家,擅长从库存结构数据中识别问题并提出改善方案。请基于以下健康度统计数据,进行专业的库存健康度诊断。 + +--- + +## 统计数据 + +| 指标 | 数值 | +|------|------| +| 配件总种类数 | {total_count} | +| 库存总金额 | {total_amount} 元 | + +### 各类型配件统计 + +| 类型 | 数量 | 数量占比 | 金额(元) | 金额占比 | +|------|------|----------|------------|----------| +| 缺货件 | {shortage_count} | {shortage_count_pct}% | {shortage_amount} | {shortage_amount_pct}% | +| 呆滞件 | {stagnant_count} | {stagnant_count_pct}% | {stagnant_amount} | {stagnant_amount_pct}% | +| 低频件 | {low_freq_count} | {low_freq_count_pct}% | {low_freq_amount} | {low_freq_amount_pct}% | +| 正常件 | {normal_count} | {normal_count_pct}% | {normal_amount} | {normal_amount_pct}% | + +--- + +## 术语说明 + +- **缺货件**: 有效库存 = 0 且月均销量 >= 1,有需求但无库存 +- **呆滞件**: 有效库存 > 0 且90天出库数 = 0,有库存但无销售 +- **低频件**: 月均销量 < 1 或出库次数 < 3 或出库间隔 >= 30天 +- **正常件**: 不属于以上三类的配件 + +--- + +## 当前季节信息 + +- **当前季节**: {current_season} +- **统计日期**: {statistics_date} + +--- + +## 季节性因素参考 + +| 季节 | 健康度评估调整 | 特别关注 | +|------|--------------|---------| +| 春季(3-5月) | 呆滞件中可能包含冬季配件,属正常现象 | 关注冬季配件是否及时清理 | +| 夏季(6-8月) | 制冷配件缺货风险高,需重点关注 | 空调、冷却系统配件缺货影响大 | +| 秋季(9-11月) | 夏季配件可能转为低频,需提前处理 | 关注夏季配件库存消化 | +| 冬季(12-2月) | 电瓶、暖风配件缺货影响大 | 春节前缺货损失更大,需提前备货 | + +--- + +## 分析框架与判断标准 + +### 健康度评分标准 +| 正常件数量占比 | 健康度等级 | 说明 | +|---------------|-----------|------| +| > 70% | 健康 | 库存结构良好,继续保持 | +| 50% - 70% | 亚健康 | 存在优化空间,需关注问题件 | +| < 50% | 不健康 | 库存结构严重失衡,需立即改善 | + +### 各类型问题件风险评估标准 +| 类型 | 数量占比阈值 | 金额占比阈值 | 风险等级 | +|------|-------------|-------------|---------| +| 缺货件 | > 10% | - | 高风险(影响销售) | +| 呆滞件 | > 15% | > 20% | 高风险(资金占用) | +| 低频件 | > 25% | > 30% | 中风险(周转效率) | + +### 资金释放潜力评估 +| 类型 | 可释放比例 | 释放方式 | +|------|-----------|---------| +| 呆滞件 | 60%-80% | 促销清仓、退货供应商、调拨其他门店 | +| 低频件 | 30%-50% | 降价促销、减少补货、逐步淘汰 | + +--- + +## 分析任务 + +请严格按照以下步骤进行分析,每一步都要展示推理过程: + +### 步骤1:健康度评分 +- 读取正常件数量占比 +- 对照健康度评分标准,确定健康度等级 +- 说明判断依据 + +### 步骤2:问题件诊断 +对每类问题件进行分析: + +**缺货件分析:** +- 对照风险阈值(数量占比>10%),判断风险等级 +- 分析缺货对业务的影响(销售损失、客户流失) +- 推断可能原因(补货不及时、需求预测不准、供应链问题) + +**呆滞件分析:** +- 对照风险阈值(数量占比>15%或金额占比>20%),判断风险等级 +- 分析呆滞对资金的影响 +- 推断可能原因(采购决策失误、市场变化、产品更新换代) + +**低频件分析:** +- 对照风险阈值(数量占比>25%或金额占比>30%),判断风险等级 +- 分析低频件对SKU效率的影响 +- 推断可能原因(长尾需求、季节性产品、新品导入) + +### 步骤3:资金释放机会评估 +- 计算呆滞件可释放资金 = 呆滞件金额 × 可释放比例(60%-80%) +- 计算低频件可释放资金 = 低频件金额 × 可释放比例(30%-50%) +- 给出具体的资金释放行动方案 + +### 步骤4:改善优先级排序 +- 根据风险等级和影响程度,排序问题类型 +- 给出2-3条优先级最高的改善行动 + +--- + +## 输出格式 + +直接输出JSON对象,**不要**包含 ```json 标记: + +{{ + "analysis_process": {{ + "health_score_diagnosis": {{ + "normal_ratio": "正常件数量占比(直接读取:{normal_count_pct}%)", + "score": "健康/亚健康/不健康", + "reasoning": "判断依据:对照标准xxx,当前正常件占比为xxx%,因此判断为xxx" + }}, + "problem_diagnosis": {{ + "shortage": {{ + "risk_level": "高/中/低", + "threshold_comparison": "对照阈值>10%,当前{shortage_count_pct}%,结论", + "business_impact": "对业务的具体影响分析", + "possible_causes": ["可能原因1", "可能原因2"] + }}, + "stagnant": {{ + "risk_level": "高/中/低", + "threshold_comparison": "对照阈值(数量>15%或金额>20%),当前数量{stagnant_count_pct}%/金额{stagnant_amount_pct}%,结论", + "capital_impact": "对资金的具体影响分析", + "possible_causes": ["可能原因1", "可能原因2"] + }}, + "low_freq": {{ + "risk_level": "高/中/低", + "threshold_comparison": "对照阈值(数量>25%或金额>30%),当前数量{low_freq_count_pct}%/金额{low_freq_amount_pct}%,结论", + "efficiency_impact": "对SKU效率的具体影响分析", + "possible_causes": ["可能原因1", "可能原因2"] + }} + }}, + "capital_release_calculation": {{ + "stagnant_calculation": "呆滞件可释放资金 = {stagnant_amount} × 70% = xxx元(取中间值70%)", + "low_freq_calculation": "低频件可释放资金 = {low_freq_amount} × 40% = xxx元(取中间值40%)", + "total_releasable": "总可释放资金 = xxx元" + }}, + "seasonal_analysis": {{ + "current_season": "当前季节", + "seasonal_stagnant_items": "呆滞件中是否包含季节性配件(如冬季的空调配件)", + "seasonal_shortage_risk": "当季高需求配件的缺货风险评估", + "upcoming_season_alert": "下一季节需要关注的配件类型" + }} + }}, + "conclusion": {{ + "health_score": {{ + "score": "健康/亚健康/不健康", + "normal_ratio_evaluation": "正常件占比评估结论(基于分析得出)" + }}, + "problem_diagnosis": {{ + "stagnant_analysis": "呆滞件问题分析及原因(基于分析得出)", + "shortage_analysis": "缺货件问题分析及影响(基于分析得出)", + "low_freq_analysis": "低频件问题分析及建议(基于分析得出)" + }}, + "capital_release": {{ + "stagnant_releasable": "呆滞件可释放资金估算(基于计算得出)", + "low_freq_releasable": "低频件可释放资金估算(基于计算得出)", + "action_plan": "资金释放行动方案(具体步骤)" + }}, + "priority_actions": [ + {{ + "priority": 1, + "action": "最优先处理事项", + "reason": "优先原因", + "expected_effect": "预期效果" + }}, + {{ + "priority": 2, + "action": "次优先处理事项", + "reason": "优先原因", + "expected_effect": "预期效果" + }} + ] + }} +}} + +--- + +## 重要约束 + +1. **输出必须是合法的JSON对象** +2. **分析必须基于提供的数据,不要编造数据** +3. **每个结论都必须有明确的推理依据和数据支撑** +4. **资金释放估算应基于实际数据和给定的释放比例范围** +5. **score 只能是"健康"、"亚健康"、"不健康"三个值之一** +6. **priority_actions 数组至少包含2条,最多3条** +7. **如果数据全为零,请在分析中说明无有效数据,并给出相应建议** +8. **所有金额计算结果保留两位小数** diff --git a/prompts/report_inventory_overview.md b/prompts/report_inventory_overview.md new file mode 100644 index 0000000..211b000 --- /dev/null +++ b/prompts/report_inventory_overview.md @@ -0,0 +1,182 @@ +# 库存概览分析提示词 + +你是一位资深汽车配件库存管理专家,拥有20年以上的汽车后市场库存管理经验。请基于以下库存概览统计数据,进行专业的库存分析。 + +--- + +## 统计数据 + +| 指标 | 数值 | +|------|------| +| 配件总种类数 | {part_count} | +| 有效库存总数量 | {total_valid_storage_cnt} | +| 有效库存总金额(资金占用) | {total_valid_storage_amount} 元 | +| 月均销量总数量 | {total_avg_sales_cnt} | +| 整体库销比 | {overall_ratio} | + +### 库存三项构成明细 + +| 构成项 | 数量 | 金额(元) | 数量占比 | 金额占比 | +|--------|------|------------|----------|----------| +| 在库未锁 | {total_in_stock_unlocked_cnt} | {total_in_stock_unlocked_amount} | - | - | +| 在途 | {total_on_the_way_cnt} | {total_on_the_way_amount} | - | - | +| 计划数 | {total_has_plan_cnt} | {total_has_plan_amount} | - | - | + +--- + +## 术语说明 + +- **有效库存**: 在库未锁 + 在途 + 计划数 +- **月均销量**: (90天出库数 + 未关单已锁 + 未关单出库 + 订件) / 3 +- **库销比**: 有效库存总数量 / 月均销量总数量,反映库存周转效率 + +--- + +## 当前季节信息 + +- **当前季节**: {current_season} +- **统计日期**: {statistics_date} + +--- + +## 季节性因素参考 + +| 季节 | 需求特征 | 库存策略建议 | +|------|---------|-------------| +| 春季(3-5月) | 需求回暖,维修保养高峰前期 | 适当增加库存,为旺季做准备 | +| 夏季(6-8月) | 空调、冷却系统配件需求旺盛 | 重点备货制冷相关配件,库销比可适当放宽至2.5 | +| 秋季(9-11月) | 需求平稳,换季保养需求 | 保持正常库存水平,关注轮胎、刹车片等 | +| 冬季(12-2月) | 电瓶、暖风系统需求增加,春节前备货期 | 提前备货,库销比可适当放宽至2.5-3.0 | + +--- + +## 分析框架与判断标准 + +### 库销比判断标准 +| 库销比范围 | 判断等级 | 含义 | +|-----------|---------|------| +| < 1.0 | 库存不足 | 可能面临缺货风险,需要加快补货 | +| 1.0 - 2.0 | 合理 | 库存水平健康,周转效率良好 | +| 2.0 - 3.0 | 偏高 | 库存积压风险,需关注周转 | +| > 3.0 | 严重积压 | 资金占用过高,需立即优化 | +| = 999 | 无销量 | 月均销量为零,需特别关注 | + +### 库存结构健康标准 +| 构成项 | 健康占比范围 | 风险提示 | +|--------|-------------|---------| +| 在库未锁 | 60%-80% | 过高说明周转慢,过低说明库存不足 | +| 在途 | 10%-25% | 过高说明到货延迟风险,过低说明补货不及时 | +| 计划数 | 5%-15% | 过高说明计划执行滞后 | + +### 资金占用风险等级 +| 条件 | 风险等级 | +|------|---------| +| 库销比 > 3.0 或 在库未锁占比 > 85% | high | +| 库销比 2.0-3.0 或 在库未锁占比 80%-85% | medium | +| 库销比 < 2.0 且 结构合理 | low | + +--- + +## 分析任务 + +请严格按照以下步骤进行分析,每一步都要展示推理过程: + +### 步骤1:计算关键指标 +首先计算以下指标(请在分析中展示计算过程): +- 各构成项的数量占比 = 构成项数量 / 有效库存总数量 × 100% +- 各构成项的金额占比 = 构成项金额 / 有效库存总金额 × 100% +- 单件平均成本 = 有效库存总金额 / 有效库存总数量 + +### 步骤2:库销比诊断 +- 对照判断标准,确定当前库销比所处等级 +- 说明该等级的业务含义 +- 与行业经验值(1.5-2.5)进行对比 + +### 步骤3:库存结构分析 +- 对照健康标准,评估各构成项占比是否合理 +- 识别偏离健康范围的构成项 +- 分析偏离的可能原因 + +### 步骤4:风险评估 +- 根据风险等级判断条件,确定当前风险等级 +- 列出具体的风险点 + +### 步骤5:季节性考量 +- 结合当前季节特征,评估库存水平是否适合当前季节 +- 考虑即将到来的季节变化,是否需要提前调整 + +### 步骤6:形成建议 +- 基于以上分析,提出2-3条具体可操作的改善建议 +- 每条建议需说明预期效果 +- 建议需考虑季节性因素 + +--- + +## 输出格式 + +直接输出JSON对象,**不要**包含 ```json 标记: + +{{ + "analysis_process": {{ + "calculated_metrics": {{ + "in_stock_ratio": "在库未锁数量占比(计算过程:xxx / xxx = xx%)", + "on_way_ratio": "在途数量占比(计算过程)", + "plan_ratio": "计划数占比(计算过程)", + "avg_cost": "单件平均成本(计算过程)" + }}, + "ratio_diagnosis": {{ + "current_value": "当前库销比数值", + "level": "不足/合理/偏高/严重积压/无销量", + "reasoning": "判断依据:对照标准xxx,当前值xxx,因此判断为xxx", + "benchmark_comparison": "与行业经验值1.5-2.5对比的结论" + }}, + "structure_analysis": {{ + "in_stock_evaluation": "在库未锁占比评估(对照标准60%-80%,当前xx%,结论)", + "on_way_evaluation": "在途占比评估(对照标准10%-25%,当前xx%,结论)", + "plan_evaluation": "计划数占比评估(对照标准5%-15%,当前xx%,结论)", + "abnormal_items": ["偏离健康范围的项目及原因分析"] + }}, + "seasonal_analysis": {{ + "current_season": "当前季节", + "season_demand_feature": "当前季节需求特征", + "inventory_fitness": "当前库存水平是否适合本季节(结合季节性因素评估)", + "upcoming_season_preparation": "对即将到来季节的准备建议" + }} + }}, + "conclusion": {{ + "capital_assessment": {{ + "total_evaluation": "总资金占用评估(基于以上分析得出的一句话结论)", + "structure_ratio": "各构成部分的资金比例分析结论", + "risk_level": "high/medium/low(基于风险等级判断条件得出)" + }}, + "ratio_diagnosis": {{ + "level": "不足/合理/偏高/严重积压", + "analysis": "库销比分析结论", + "benchmark": "行业参考值对比结论" + }}, + "recommendations": [ + {{ + "action": "具体建议1", + "reason": "建议依据", + "expected_effect": "预期效果" + }}, + {{ + "action": "具体建议2", + "reason": "建议依据", + "expected_effect": "预期效果" + }} + ] + }} +}} + +--- + +## 重要约束 + +1. **输出必须是合法的JSON对象** +2. **分析必须基于提供的数据,不要编造数据** +3. **每个结论都必须有明确的推理依据** +4. **建议必须具体可操作,避免空泛的表述** +5. **risk_level 只能是 high、medium、low 三个值之一** +6. **如果数据全为零,请在分析中说明无有效数据,并给出相应建议** +7. **所有百分比计算结果保留两位小数** diff --git a/prompts/report_replenishment_summary.md b/prompts/report_replenishment_summary.md new file mode 100644 index 0000000..aeb5a45 --- /dev/null +++ b/prompts/report_replenishment_summary.md @@ -0,0 +1,173 @@ +# 补货建议分析提示词 + +你是一位汽车配件采购策略顾问,擅长制定科学的补货计划和资金分配方案。请基于以下补货建议统计数据,进行专业的补货策略分析。 + +--- + +## 统计数据 + +| 指标 | 数值 | +|------|------| +| 补货配件总种类数 | {total_count} | +| 补货总金额 | {total_amount} 元 | + +### 各优先级统计 + +| 优先级 | 配件种类数 | 金额(元) | +|--------|-----------|------------| +| 急需补货(优先级1) | {urgent_count} | {urgent_amount} | +| 建议补货(优先级2) | {suggested_count} | {suggested_amount} | +| 可选补货(优先级3) | {optional_count} | {optional_amount} | + +--- + +## 术语说明 + +- **急需补货(优先级1)**: 库销比 < 0.5 且月均销量 >= 1,库存严重不足,面临断货风险 +- **建议补货(优先级2)**: 库销比 0.5-1.0 且月均销量 >= 1,库存偏低,建议及时补充 +- **可选补货(优先级3)**: 库销比 1.0-目标值 且月均销量 >= 1,库存尚可,可灵活安排 + +--- + +## 当前季节信息 + +- **当前季节**: {current_season} +- **统计日期**: {statistics_date} + +--- + +## 季节性因素参考 + +| 季节 | 补货策略调整 | 重点补货品类 | +|------|-------------|-------------| +| 春季(3-5月) | 为夏季旺季提前备货 | 空调、冷却系统配件 | +| 夏季(6-8月) | 制冷配件紧急补货优先级更高 | 空调压缩机、冷凝器、制冷剂 | +| 秋季(9-11月) | 为冬季备货,减少夏季配件补货 | 电瓶、暖风系统、防冻液 | +| 冬季(12-2月) | 春节前加快补货节奏 | 电瓶、启动机、暖风配件 | + +--- + +## 分析框架与判断标准 + +### 紧迫度评估标准 +| 急需补货占比 | 紧迫度等级 | 风险等级 | 建议 | +|-------------|-----------|---------|------| +| > 30% | 非常紧迫 | high | 立即启动紧急补货流程 | +| 15% - 30% | 较紧迫 | medium | 优先处理急需补货 | +| < 15% | 一般 | low | 按正常流程处理 | + +### 资金分配优先级原则 +| 优先级 | 建议预算占比 | 执行时间 | +|--------|-------------|---------| +| 急需补货 | 50%-70% | 1-3天内 | +| 建议补货 | 20%-35% | 1-2周内 | +| 可选补货 | 10%-15% | 2-4周内 | + +### 风险预警阈值 +| 风险类型 | 触发条件 | 预警等级 | +|---------|---------|---------| +| 资金压力 | 急需补货金额占比 > 60% | 高 | +| 过度补货 | 可选补货金额占比 > 40% | 中 | + +--- + +## 分析任务 + +请严格按照以下步骤进行分析,展示推理过程: + +### 步骤1:计算关键指标 +- 各优先级数量占比 = 数量 / 总数量 × 100% +- 各优先级金额占比 = 金额 / 总金额 × 100% + +### 步骤2:紧迫度评估 +- 对照标准确定紧迫度等级和风险等级 +- 判断是否需要立即行动 + +### 步骤3:资金分配建议 +- 对照建议预算占比,判断当前分布是否合理 +- 给出具体资金分配建议 + +### 步骤4:执行节奏规划 +- 规划各类补货的执行时间 + +### 步骤5:风险识别 +- 对照风险预警阈值,识别潜在风险 + +--- + +## 输出格式 + +直接输出JSON对象,**不要**包含 ```json 标记: + +{{ + "analysis_process": {{ + "calculated_metrics": {{ + "urgent_count_ratio": "急需补货数量占比(计算:xxx / xxx = xx%)", + "urgent_amount_ratio": "急需补货金额占比(计算)", + "suggested_count_ratio": "建议补货数量占比(计算)", + "suggested_amount_ratio": "建议补货金额占比(计算)", + "optional_count_ratio": "可选补货数量占比(计算)", + "optional_amount_ratio": "可选补货金额占比(计算)" + }}, + "urgency_diagnosis": {{ + "urgent_ratio": "急需补货数量占比", + "level": "非常紧迫/较紧迫/一般", + "reasoning": "判断依据:对照标准xxx,当前占比xxx%,因此判断为xxx" + }}, + "budget_analysis": {{ + "current_distribution": "当前各优先级金额分布情况", + "comparison_with_standard": "与建议预算占比对比分析", + "adjustment_needed": "是否需要调整及原因" + }}, + "risk_identification": {{ + "capital_pressure_check": "资金压力检查(急需占比是否>60%)", + "over_replenishment_check": "过度补货检查(可选占比是否>40%)", + "identified_risks": ["识别到的风险1", "识别到的风险2"] + }}, + "seasonal_analysis": {{ + "current_season": "当前季节", + "seasonal_priority_items": "当季重点补货品类是否在急需列表中", + "timeline_adjustment": "是否需要根据季节调整补货时间(如春节前加快)", + "next_season_preparation": "为下一季节需要提前准备的配件" + }} + }}, + "conclusion": {{ + "urgency_assessment": {{ + "urgent_ratio_evaluation": "急需补货占比评估结论", + "risk_level": "high/medium/low", + "immediate_action_needed": true或false + }}, + "budget_allocation": {{ + "recommended_order": "建议资金分配顺序(基于分析得出)", + "urgent_budget": "急需补货建议预算(具体金额或比例)", + "suggested_budget": "建议补货建议预算", + "optional_budget": "可选补货建议预算" + }}, + "execution_plan": {{ + "urgent_timeline": "急需补货执行时间(1-3天内)", + "suggested_timeline": "建议补货执行时间(1-2周内)", + "optional_timeline": "可选补货执行时间(2-4周内)" + }}, + "risk_warnings": [ + {{ + "risk_type": "风险类型", + "description": "风险描述", + "mitigation": "应对建议" + }} + ] + }} +}} + +--- + +## 重要约束 + +1. **输出必须是合法的JSON对象** +2. **分析必须基于提供的数据,不要编造数据** +3. **每个结论都必须有明确的推理依据和数据支撑** +4. **建议必须具体可操作,包含时间和金额参考** +5. **risk_level 只能是 high、medium、low 三个值之一** +6. **immediate_action_needed 必须是布尔值 true 或 false** +7. **risk_warnings 数组至少包含1条,最多3条** +8. **如果数据全为零,请在分析中说明无补货建议数据** +9. **所有百分比计算结果保留两位小数** diff --git a/prompts/report_sales_analysis.md b/prompts/report_sales_analysis.md new file mode 100644 index 0000000..0718b84 --- /dev/null +++ b/prompts/report_sales_analysis.md @@ -0,0 +1,178 @@ +# 销量分析提示词 + +你是一位汽车配件销售数据分析师,擅长从销量数据中洞察需求趋势和业务机会。请基于以下销量统计数据,进行专业的销量分析。 + +--- + +## 统计数据 + +| 指标 | 数值 | +|------|------| +| 月均销量总数量 | {total_avg_sales_cnt} | +| 月均销量总金额 | {total_avg_sales_amount} 元 | +| 有销量配件数 | {has_sales_part_count} | +| 无销量配件数 | {no_sales_part_count} | + +### 销量构成明细 + +| 构成项 | 数量 | 说明 | +|--------|------|------| +| 90天出库数 | {total_out_stock_cnt} | 近90天实际出库,反映正常销售 | +| 未关单已锁 | {total_storage_locked_cnt} | 已锁定库存但订单未关闭,反映待处理订单 | +| 未关单出库 | {total_out_stock_ongoing_cnt} | 已出库但订单未关闭,反映在途交付 | +| 订件 | {total_buy_cnt} | 客户预订的配件数量,反映预订需求 | + +--- + +## 术语说明 + +- **月均销量**: (90天出库数 + 未关单已锁 + 未关单出库 + 订件) / 3 +- **有销量配件**: 月均销量 > 0 的配件 +- **无销量配件**: 月均销量 = 0 的配件 +- **SKU活跃率**: 有销量配件数 / 总配件数 × 100% + +--- + +## 当前季节信息 + +- **当前季节**: {current_season} +- **统计日期**: {statistics_date} + +--- + +## 季节性因素参考 + +| 季节 | 销量特征 | 关注重点 | +|------|---------|---------| +| 春季(3-5月) | 销量逐步回升,保养类配件需求增加 | 关注机油、滤芯等保养件销量变化 | +| 夏季(6-8月) | 空调、冷却系统配件销量高峰 | 制冷配件销量应明显上升,否则需关注 | +| 秋季(9-11月) | 销量平稳,换季保养需求 | 轮胎、刹车片等安全件需求增加 | +| 冬季(12-2月) | 电瓶、暖风配件需求增加,春节前订单高峰 | 订件占比可能上升,属正常现象 | + +--- + +## 分析框架与判断标准 + +### 销量构成健康标准 +| 构成项 | 健康占比范围 | 异常信号 | +|--------|-------------|---------| +| 90天出库数 | > 70% | 占比过低说明正常销售不足,可能存在订单积压 | +| 未关单已锁 | < 15% | 占比过高说明订单处理效率低,需关注 | +| 未关单出库 | < 10% | 占比过高说明交付周期长,客户体验受影响 | +| 订件 | 5%-15% | 过高说明预订需求旺盛但库存不足,过低说明预订渠道不畅 | + +### SKU活跃度判断标准 +| 活跃率范围 | 判断等级 | 建议 | +|-----------|---------|------| +| > 80% | 优秀 | SKU管理良好,保持现状 | +| 70%-80% | 良好 | 可适当优化无销量SKU | +| 50%-70% | 一般 | 需要重点关注SKU精简 | +| < 50% | 较差 | SKU管理存在严重问题,需立即优化 | + +### 需求趋势判断依据 +| 信号 | 趋势判断 | +|------|---------| +| 订件占比上升 + 未关单占比上升 | 上升(需求增长但供应跟不上) | +| 90天出库占比稳定 + 各项占比均衡 | 稳定(供需平衡) | +| 90天出库占比下降 + 订件占比下降 | 下降(需求萎缩) | + +--- + +## 分析任务 + +请严格按照以下步骤进行分析,每一步都要展示推理过程: + +### 步骤1:计算关键指标 +首先计算以下指标(请在分析中展示计算过程): +- 各构成项占比 = 构成项数量 / (90天出库数 + 未关单已锁 + 未关单出库 + 订件) × 100% +- SKU活跃率 = 有销量配件数 / (有销量配件数 + 无销量配件数) × 100% +- 单件平均销售金额 = 月均销量总金额 / 月均销量总数量 + +### 步骤2:销量构成分析 +- 对照健康标准,评估各构成项占比是否合理 +- 识别主要销量来源 +- 分析未关单(已锁+出库)对整体销量的影响 + +### 步骤3:SKU活跃度评估 +- 对照活跃度标准,确定当前活跃率等级 +- 分析无销量配件占比的业务影响 +- 提出SKU优化方向 + +### 步骤4:季节性分析 +- 结合当前季节特征,评估销量表现是否符合季节预期 +- 分析季节性配件的销量是否正常 + +### 步骤5:需求趋势判断 +- 根据各构成项的占比关系,判断需求趋势 +- 结合季节因素,说明判断依据 +- 给出短期需求预测(考虑季节变化) + +--- + +## 输出格式 + +直接输出JSON对象,**不要**包含 ```json 标记: + +{{ + "analysis_process": {{ + "calculated_metrics": {{ + "out_stock_ratio": "90天出库占比(计算过程:xxx / xxx = xx%)", + "locked_ratio": "未关单已锁占比(计算过程)", + "ongoing_ratio": "未关单出库占比(计算过程)", + "buy_ratio": "订件占比(计算过程)", + "sku_active_rate": "SKU活跃率(计算过程:xxx / xxx = xx%)", + "avg_sales_price": "单件平均销售金额(计算过程)" + }}, + "composition_diagnosis": {{ + "out_stock_evaluation": "90天出库占比评估(对照标准>70%,当前xx%,结论)", + "locked_evaluation": "未关单已锁占比评估(对照标准<15%,当前xx%,结论)", + "ongoing_evaluation": "未关单出库占比评估(对照标准<10%,当前xx%,结论)", + "buy_evaluation": "订件占比评估(对照标准5%-15%,当前xx%,结论)", + "abnormal_items": ["偏离健康范围的项目及原因分析"] + }}, + "activity_diagnosis": {{ + "current_rate": "当前SKU活跃率", + "level": "优秀/良好/一般/较差", + "reasoning": "判断依据:对照标准xxx,当前值xxx,因此判断为xxx" + }}, + "trend_diagnosis": {{ + "signals": ["观察到的趋势信号1", "观察到的趋势信号2"], + "reasoning": "基于以上信号,判断需求趋势为xxx,因为xxx" + }}, + "seasonal_analysis": {{ + "current_season": "当前季节", + "expected_performance": "本季节预期销量特征", + "actual_vs_expected": "实际表现与季节预期对比", + "seasonal_items_status": "季节性配件销量状态评估" + }} + }}, + "conclusion": {{ + "composition_analysis": {{ + "main_driver": "主要销量来源分析(基于占比计算得出)", + "pending_orders_impact": "未关单对销量的影响(基于占比计算得出)", + "booking_trend": "订件趋势分析(基于占比计算得出)" + }}, + "activity_assessment": {{ + "active_ratio": "活跃SKU占比评估结论", + "optimization_suggestion": "SKU优化建议(基于活跃度等级给出)" + }}, + "demand_trend": {{ + "direction": "上升/稳定/下降", + "evidence": "判断依据(列出具体数据支撑)", + "seasonal_factor": "季节因素对趋势的影响", + "forecast": "短期需求预测(考虑季节变化)" + }} + }} +}} + +--- + +## 重要约束 + +1. **输出必须是合法的JSON对象** +2. **分析必须基于提供的数据,不要编造数据** +3. **每个结论都必须有明确的推理依据和数据支撑** +4. **建议必须具体可操作,避免空泛的表述** +5. **direction 只能是"上升"、"稳定"、"下降"三个值之一** +6. **如果数据全为零,请在分析中说明无有效数据,并给出相应建议** +7. **所有百分比计算结果保留两位小数** diff --git a/prompts/sql_agent.md b/prompts/sql_agent.md index 8b06b04..f9d0cf6 100644 --- a/prompts/sql_agent.md +++ b/prompts/sql_agent.md @@ -60,7 +60,7 @@ CREATE TABLE part_ratio ( | 指标 | 公式 | 说明 | |------|------|------| -| 有效库存 | `in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt + transfer_cnt + gen_transfer_cnt` | 可用库存总量 | +| 有效库存 | `in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt` | 可用库存总量 | | 月均销量 | `(out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3` | 基于90天数据计算 | | 库销比 | `有效库存 / 月均销量` | 当月均销量 > 0 时有效 | diff --git a/sql/migrate_analysis_report.sql b/sql/migrate_analysis_report.sql index 0a90acd..acd232d 100644 --- a/sql/migrate_analysis_report.sql +++ b/sql/migrate_analysis_report.sql @@ -1,9 +1,9 @@ -- ============================================================================ -- AI 补货建议分析报告表 -- ============================================================================ --- 版本: 2.0.0 --- 更新日期: 2026-02-05 --- 变更说明: 重构报告模块,聚焦补货决策支持(区别于传统库销分析) +-- 版本: 3.0.0 +-- 更新日期: 2026-02-10 +-- 变更说明: 重构为四大数据驱动板块(库存概览/销量分析/健康度/补货建议) -- ============================================================================ DROP TABLE IF EXISTS ai_analysis_report; @@ -15,33 +15,23 @@ CREATE TABLE ai_analysis_report ( dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称', brand_grouping_id BIGINT COMMENT '品牌组合ID', report_type VARCHAR(32) DEFAULT 'replenishment' COMMENT '报告类型', - - -- 报告各模块 (JSON 结构化存储) - 宏观决策分析 - -- 注:字段名保持兼容,实际存储内容已更新为新模块 - replenishment_insights JSON COMMENT '整体态势研判(规模评估/结构分析/时机判断) - 原overall_assessment', - urgency_assessment JSON COMMENT '风险预警与应对(供应/资金/市场/执行风险) - 原risk_alerts', - strategy_recommendations JSON COMMENT '采购策略建议(优先级/批量机会/分批/供应商协调) - 原procurement_strategy', - execution_guide JSON COMMENT '已废弃,置为NULL', - expected_outcomes JSON COMMENT '效果预期与建议(库存健康/资金效率/后续行动) - 原expected_impact', - - -- 统计信息 - total_suggest_cnt INT DEFAULT 0 COMMENT '总建议数量', - total_suggest_amount DECIMAL(14,2) DEFAULT 0 COMMENT '总建议金额', - shortage_risk_cnt INT DEFAULT 0 COMMENT '缺货风险配件数', - excess_risk_cnt INT DEFAULT 0 COMMENT '过剩风险配件数', - stagnant_cnt INT DEFAULT 0 COMMENT '呆滞件数量', - low_freq_cnt INT DEFAULT 0 COMMENT '低频件数量', - + + -- 四大板块 (JSON 结构化存储,每个字段包含 stats + llm_analysis) + inventory_overview JSON COMMENT '库存总体概览(统计数据+LLM分析)', + sales_analysis JSON COMMENT '销量分析(统计数据+LLM分析)', + inventory_health JSON COMMENT '库存构成健康度(统计数据+图表数据+LLM分析)', + replenishment_summary JSON COMMENT '补货建议生成情况(统计数据+LLM分析)', + -- LLM 元数据 llm_provider VARCHAR(32) COMMENT 'LLM提供商', llm_model VARCHAR(64) COMMENT 'LLM模型名称', llm_tokens INT DEFAULT 0 COMMENT 'LLM Token消耗', execution_time_ms INT DEFAULT 0 COMMENT '执行耗时(毫秒)', - + statistics_date VARCHAR(16) COMMENT '统计日期', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - + INDEX idx_task_no (task_no), INDEX idx_group_date (group_id, statistics_date), INDEX idx_dealer_grouping (dealer_grouping_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议分析报告表-结构化补货决策支持报告'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议分析报告表-重构版'; diff --git a/src/fw_pms_ai/agent/analysis_report_node.py b/src/fw_pms_ai/agent/analysis_report_node.py index b3c0945..f45176c 100644 --- a/src/fw_pms_ai/agent/analysis_report_node.py +++ b/src/fw_pms_ai/agent/analysis_report_node.py @@ -1,412 +1,945 @@ """ 分析报告生成节点 -在补货建议工作流的最后一个节点执行,生成结构化分析报告 +在补货建议工作流的最后一个节点执行,生成结构化分析报告。 +包含四大板块的统计计算函数:库存概览、销量分析、库存健康度、补货建议。 """ import logging -import time -import json -import os -from typing import Dict, Any -from decimal import Decimal -from datetime import datetime +from decimal import Decimal, ROUND_HALF_UP -from langchain_core.messages import HumanMessage +logger = logging.getLogger(__name__) -from ..llm import get_llm_client -from ..models import AnalysisReport -from ..services.result_writer import ResultWriter -logger = logging.getLogger(__name__) +def _to_decimal(value) -> Decimal: + """安全转换为 Decimal""" + if value is None: + return Decimal("0") + return Decimal(str(value)) -def _load_prompt(filename: str) -> str: - """从prompts目录加载提示词文件""" - prompts_dir = os.path.join( - os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))), - "prompts" +def calculate_inventory_overview(part_ratios: list[dict]) -> dict: + """ + 计算库存总体概览统计数据 + + 有效库存 = in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt + 资金占用 = in_stock_unlocked_cnt + on_the_way_cnt(仅计算实际占用资金的库存) + + Args: + part_ratios: PartRatio 字典列表 + + Returns: + 库存概览统计字典 + """ + total_in_stock_unlocked_cnt = Decimal("0") + total_in_stock_unlocked_amount = Decimal("0") + total_on_the_way_cnt = Decimal("0") + total_on_the_way_amount = Decimal("0") + total_has_plan_cnt = Decimal("0") + total_has_plan_amount = Decimal("0") + total_avg_sales_cnt = Decimal("0") + # 资金占用合计 = (在库未锁 + 在途) * 成本价 + total_capital_occupation = Decimal("0") + + for p in part_ratios: + cost_price = _to_decimal(p.get("cost_price", 0)) + + in_stock = _to_decimal(p.get("in_stock_unlocked_cnt", 0)) + on_way = _to_decimal(p.get("on_the_way_cnt", 0)) + has_plan = _to_decimal(p.get("has_plan_cnt", 0)) + + total_in_stock_unlocked_cnt += in_stock + total_in_stock_unlocked_amount += in_stock * cost_price + total_on_the_way_cnt += on_way + total_on_the_way_amount += on_way * cost_price + total_has_plan_cnt += has_plan + total_has_plan_amount += has_plan * cost_price + + # 资金占用 = 在库未锁 + 在途 + total_capital_occupation += (in_stock + on_way) * cost_price + + # 月均销量 + out_stock = _to_decimal(p.get("out_stock_cnt", 0)) + locked = _to_decimal(p.get("storage_locked_cnt", 0)) + ongoing = _to_decimal(p.get("out_stock_ongoing_cnt", 0)) + buy = _to_decimal(p.get("buy_cnt", 0)) + avg_sales = (out_stock + locked + ongoing + buy) / Decimal("3") + total_avg_sales_cnt += avg_sales + + total_valid_storage_cnt = ( + total_in_stock_unlocked_cnt + + total_on_the_way_cnt + + total_has_plan_cnt ) - filepath = os.path.join(prompts_dir, filename) - - if not os.path.exists(filepath): - raise FileNotFoundError(f"Prompt文件未找到: {filepath}") - - with open(filepath, "r", encoding="utf-8") as f: - return f.read() + total_valid_storage_amount = ( + total_in_stock_unlocked_amount + + total_on_the_way_amount + + total_has_plan_amount + ) + + # 库销比:月均销量为零时标记为特殊值 + if total_avg_sales_cnt > 0: + overall_ratio = total_valid_storage_cnt / total_avg_sales_cnt + else: + overall_ratio = Decimal("999") + return { + "total_valid_storage_cnt": total_valid_storage_cnt, + "total_valid_storage_amount": total_valid_storage_amount, + "total_capital_occupation": total_capital_occupation, + "total_in_stock_unlocked_cnt": total_in_stock_unlocked_cnt, + "total_in_stock_unlocked_amount": total_in_stock_unlocked_amount, + "total_on_the_way_cnt": total_on_the_way_cnt, + "total_on_the_way_amount": total_on_the_way_amount, + "total_has_plan_cnt": total_has_plan_cnt, + "total_has_plan_amount": total_has_plan_amount, + "total_avg_sales_cnt": total_avg_sales_cnt, + "overall_ratio": overall_ratio, + "part_count": len(part_ratios), + } -def _calculate_suggestion_stats(part_results: list) -> dict: + +def calculate_sales_analysis(part_ratios: list[dict]) -> dict: """ - 基于完整数据计算补货建议统计 - - 统计维度: - 1. 总体统计:总数量、总金额 - 2. 优先级分布:高/中/低优先级配件数及金额 - 3. 价格区间分布:低价/中价/高价配件分布 - 4. 周转频次分布:高频/中频/低频配件分布 - 5. 补货规模分布:大额/中额/小额补货配件分布 + 计算销量分析统计数据 + + 月均销量 = (out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3 + + Args: + part_ratios: PartRatio 字典列表 + + Returns: + 销量分析统计字典 """ - 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"), + total_out_stock_cnt = Decimal("0") + total_storage_locked_cnt = Decimal("0") + total_out_stock_ongoing_cnt = Decimal("0") + total_buy_cnt = Decimal("0") + total_avg_sales_amount = Decimal("0") + has_sales_part_count = 0 + no_sales_part_count = 0 + + for p in part_ratios: + cost_price = _to_decimal(p.get("cost_price", 0)) + + out_stock = _to_decimal(p.get("out_stock_cnt", 0)) + locked = _to_decimal(p.get("storage_locked_cnt", 0)) + ongoing = _to_decimal(p.get("out_stock_ongoing_cnt", 0)) + buy = _to_decimal(p.get("buy_cnt", 0)) + + total_out_stock_cnt += out_stock + total_storage_locked_cnt += locked + total_out_stock_ongoing_cnt += ongoing + total_buy_cnt += buy + + avg_sales = (out_stock + locked + ongoing + buy) / Decimal("3") + total_avg_sales_amount += avg_sales * cost_price + + if avg_sales > 0: + has_sales_part_count += 1 + else: + no_sales_part_count += 1 + + total_avg_sales_cnt = ( + total_out_stock_cnt + total_storage_locked_cnt + total_out_stock_ongoing_cnt + total_buy_cnt + ) / Decimal("3") + + return { + "total_avg_sales_cnt": total_avg_sales_cnt, + "total_avg_sales_amount": total_avg_sales_amount, + "total_out_stock_cnt": total_out_stock_cnt, + "total_storage_locked_cnt": total_storage_locked_cnt, + "total_out_stock_ongoing_cnt": total_out_stock_ongoing_cnt, + "total_buy_cnt": total_buy_cnt, + "has_sales_part_count": has_sales_part_count, + "no_sales_part_count": no_sales_part_count, } - - 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 + + +def _classify_part(p: dict) -> str: + """ + 将配件分类为缺货/呆滞/低频/正常 + + 分类规则(按优先级顺序判断): + - 缺货件: 有效库存 = 0 且 月均销量 >= 1 + - 呆滞件: 有效库存 > 0 且 90天出库数 = 0 + - 低频件: 月均销量 < 1 或 出库次数 < 3 或 出库间隔 >= 30天 + - 正常件: 不属于以上三类 + """ + in_stock = _to_decimal(p.get("in_stock_unlocked_cnt", 0)) + on_way = _to_decimal(p.get("on_the_way_cnt", 0)) + has_plan = _to_decimal(p.get("has_plan_cnt", 0)) + valid_storage = in_stock + on_way + has_plan + + out_stock = _to_decimal(p.get("out_stock_cnt", 0)) + locked = _to_decimal(p.get("storage_locked_cnt", 0)) + ongoing = _to_decimal(p.get("out_stock_ongoing_cnt", 0)) + buy = _to_decimal(p.get("buy_cnt", 0)) + avg_sales = (out_stock + locked + ongoing + buy) / Decimal("3") + + out_times = int(p.get("out_times", 0) or 0) + out_duration = int(p.get("out_duration", 0) or 0) + + # 缺货件 + if valid_storage == 0 and avg_sales >= 1: + return "shortage" + + # 呆滞件 + if valid_storage > 0 and out_stock == 0: + return "stagnant" + + # 低频件 + if avg_sales < 1 or out_times < 3 or out_duration >= 30: + return "low_freq" + + return "normal" + + +def calculate_inventory_health(part_ratios: list[dict]) -> dict: + """ + 计算库存构成健康度统计数据 + + 将每个配件归类为缺货件/呆滞件/低频件/正常件,统计各类型数量/金额/百分比, + 并生成 chart_data 供前端图表使用。 + + Args: + part_ratios: PartRatio 字典列表 + + Returns: + 健康度统计字典(含 chart_data) + """ + categories = { + "shortage": {"count": 0, "amount": Decimal("0")}, + "stagnant": {"count": 0, "amount": Decimal("0")}, + "low_freq": {"count": 0, "amount": Decimal("0")}, + "normal": {"count": 0, "amount": Decimal("0")}, + } + + for p in part_ratios: + cat = _classify_part(p) + cost_price = _to_decimal(p.get("cost_price", 0)) + + # 有效库存金额 + in_stock = _to_decimal(p.get("in_stock_unlocked_cnt", 0)) + on_way = _to_decimal(p.get("on_the_way_cnt", 0)) + has_plan = _to_decimal(p.get("has_plan_cnt", 0)) + valid_storage = in_stock + on_way + has_plan + amount = valid_storage * cost_price + + categories[cat]["count"] += 1 + categories[cat]["amount"] += amount + + total_count = len(part_ratios) + total_amount = sum(c["amount"] for c in categories.values()) + + # 计算百分比 + result = {} + for cat_name, data in categories.items(): + count_pct = (data["count"] / total_count * 100) if total_count > 0 else 0.0 + amount_pct = (float(data["amount"]) / float(total_amount) * 100) if total_amount > 0 else 0.0 + result[cat_name] = { + "count": data["count"], + "amount": data["amount"], + "count_pct": round(count_pct, 2), + "amount_pct": round(amount_pct, 2), + } + + result["total_count"] = total_count + result["total_amount"] = total_amount + + # chart_data 供前端 Chart.js 使用 + labels = ["缺货件", "呆滞件", "低频件", "正常件"] + cat_keys = ["shortage", "stagnant", "low_freq", "normal"] + result["chart_data"] = { + "labels": labels, + "count_values": [categories[k]["count"] for k in cat_keys], + "amount_values": [float(categories[k]["amount"]) for k in cat_keys], + } + + return result + + +def calculate_replenishment_summary(part_results: list) -> dict: + """ + 计算补货建议生成情况统计数据 + + 按优先级分类统计: + - priority=1: 急需补货 + - priority=2: 建议补货 + - priority=3: 可选补货 + + Args: + part_results: 配件汇总结果列表(字典或 ReplenishmentPartSummary 对象) + + Returns: + 补货建议统计字典 + """ + urgent = {"count": 0, "amount": Decimal("0")} + suggested = {"count": 0, "amount": Decimal("0")} + optional = {"count": 0, "amount": Decimal("0")} + + for item in part_results: + # 兼容字典和对象两种形式 + if isinstance(item, dict): + priority = int(item.get("priority", 0)) + amount = _to_decimal(item.get("total_suggest_amount", 0)) 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 - - # 优先级分布 + priority = getattr(item, "priority", 0) + amount = _to_decimal(getattr(item, "total_suggest_amount", 0)) + if priority == 1: - stats["priority_high_cnt"] += 1 - stats["priority_high_amount"] += suggest_amount + urgent["count"] += 1 + urgent["amount"] += 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 + suggested["count"] += 1 + suggested["amount"] += amount + elif priority == 3: + optional["count"] += 1 + optional["amount"] += amount + + total_count = urgent["count"] + suggested["count"] + optional["count"] + total_amount = urgent["amount"] + suggested["amount"] + optional["amount"] + + return { + "urgent": urgent, + "suggested": suggested, + "optional": optional, + "total_count": total_count, + "total_amount": total_amount, + } + + +# ============================================================ +# LLM 分析函数 +# ============================================================ + +import os +import json +import time +from langchain_core.messages import SystemMessage, HumanMessage + + +def _load_prompt(filename: str) -> str: + """从 prompts 目录加载提示词文件""" + prompt_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))), + "prompts", + filename, + ) + with open(prompt_path, "r", encoding="utf-8") as f: + return f.read() + + +def _format_decimal(value) -> str: + """将 Decimal 格式化为字符串,用于填充提示词""" + if value is None: + return "0" + return str(round(float(value), 2)) + + +def _get_season_from_date(date_str: str) -> str: + """ + 根据日期字符串获取季节 + + Args: + date_str: 日期字符串,格式如 "2024-01-15" 或 "20240115" + + Returns: + 季节名称:春季/夏季/秋季/冬季 + """ + from datetime import datetime + + try: + # 尝试解析不同格式的日期 + if "-" in date_str: + dt = datetime.strptime(date_str[:10], "%Y-%m-%d") else: - stats["replenish_small_cnt"] += 1 - stats["replenish_small_amount"] += suggest_amount - - return stats - - -def _calculate_risk_stats(part_ratios: list) -> dict: - """计算风险统计数据""" - stats = { - "shortage_cnt": 0, - "shortage_amount": Decimal("0"), - "stagnant_cnt": 0, - "stagnant_amount": Decimal("0"), - "low_freq_cnt": 0, - "low_freq_amount": Decimal("0"), + dt = datetime.strptime(date_str[:8], "%Y%m%d") + month = dt.month + except (ValueError, TypeError): + # 解析失败时使用当前月份 + month = datetime.now().month + + if month in (3, 4, 5): + return "春季(3-5月)" + elif month in (6, 7, 8): + return "夏季(6-8月)" + elif month in (9, 10, 11): + return "秋季(9-11月)" + else: + return "冬季(12-2月)" + + +def _parse_llm_json(content: str) -> dict: + """ + 解析 LLM 返回的 JSON 内容 + + 尝试直接解析,如果失败则尝试提取 ```json 代码块中的内容。 + """ + text = content.strip() + + # 尝试直接解析 + try: + return json.loads(text) + except json.JSONDecodeError: + pass + + # 尝试提取 ```json ... ``` 代码块 + import re + match = re.search(r"```json\s*(.*?)\s*```", text, re.DOTALL) + if match: + try: + return json.loads(match.group(1)) + except json.JSONDecodeError: + pass + + # 尝试提取 { ... } 块 + start = text.find("{") + end = text.rfind("}") + if start != -1 and end != -1 and end > start: + try: + return json.loads(text[start : end + 1]) + except json.JSONDecodeError: + pass + + # 解析失败 + raise json.JSONDecodeError("无法从 LLM 响应中解析 JSON", text, 0) + + +def llm_analyze_inventory_overview(stats: dict, statistics_date: str = "", llm_client=None) -> tuple[dict, dict]: + """ + LLM 分析库存概览 + + Args: + stats: calculate_inventory_overview 的输出 + statistics_date: 统计日期 + llm_client: LLM 客户端实例,为 None 时自动获取 + + Returns: + (llm_analysis_dict, usage_dict) + """ + from ..llm import get_llm_client + + if llm_client is None: + llm_client = get_llm_client() + + current_season = _get_season_from_date(statistics_date) + + prompt_template = _load_prompt("report_inventory_overview.md") + prompt = prompt_template.format( + part_count=stats.get("part_count", 0), + total_valid_storage_cnt=_format_decimal(stats.get("total_valid_storage_cnt")), + total_valid_storage_amount=_format_decimal(stats.get("total_valid_storage_amount")), + total_avg_sales_cnt=_format_decimal(stats.get("total_avg_sales_cnt")), + overall_ratio=_format_decimal(stats.get("overall_ratio")), + total_in_stock_unlocked_cnt=_format_decimal(stats.get("total_in_stock_unlocked_cnt")), + total_in_stock_unlocked_amount=_format_decimal(stats.get("total_in_stock_unlocked_amount")), + total_on_the_way_cnt=_format_decimal(stats.get("total_on_the_way_cnt")), + total_on_the_way_amount=_format_decimal(stats.get("total_on_the_way_amount")), + total_has_plan_cnt=_format_decimal(stats.get("total_has_plan_cnt")), + total_has_plan_amount=_format_decimal(stats.get("total_has_plan_amount")), + current_season=current_season, + statistics_date=statistics_date or "未知", + ) + + messages = [HumanMessage(content=prompt)] + response = llm_client.invoke(messages) + + try: + analysis = _parse_llm_json(response.content) + except json.JSONDecodeError: + logger.warning(f"库存概览 LLM JSON 解析失败,原始响应: {response.content[:200]}") + analysis = {"error": "JSON解析失败", "raw": response.content[:200]} + + usage = { + "provider": response.usage.provider, + "model": response.usage.model, + "prompt_tokens": response.usage.prompt_tokens, + "completion_tokens": response.usage.completion_tokens, } - - for pr in part_ratios: - valid_storage = Decimal(str(pr.get("valid_storage_cnt", 0) or 0)) - avg_sales = Decimal(str(pr.get("avg_sales_cnt", 0) or 0)) - out_stock = Decimal(str(pr.get("out_stock_cnt", 0) or 0)) - cost_price = Decimal(str(pr.get("cost_price", 0) or 0)) - - # 呆滞件: 有库存但90天无出库 - if valid_storage > 0 and out_stock == 0: - stats["stagnant_cnt"] += 1 - stats["stagnant_amount"] += valid_storage * cost_price - - # 低频件: 无库存且月均销量<1 - elif valid_storage == 0 and avg_sales < 1: - stats["low_freq_cnt"] += 1 - - # 缺货件: 无库存且月均销量>=1 - elif valid_storage == 0 and avg_sales >= 1: - stats["shortage_cnt"] += 1 - # 缺货损失估算:月均销量 * 成本价 - stats["shortage_amount"] += avg_sales * cost_price - - return stats + return analysis, usage -def _build_suggestion_summary(suggestion_stats: dict) -> str: +def llm_analyze_sales(stats: dict, statistics_date: str = "", llm_client=None) -> tuple[dict, dict]: """ - 基于预计算的统计数据构建结构化补货建议摘要 - - 摘要包含: - - 补货总体规模 - - 优先级分布 - - 价格区间分布 - - 周转频次分布 - - 补货金额分布 + LLM 分析销量 + + Args: + stats: calculate_sales_analysis 的输出 + statistics_date: 统计日期 + llm_client: LLM 客户端实例 + + Returns: + (llm_analysis_dict, usage_dict) """ - if suggestion_stats["total_parts_cnt"] == 0: - return "暂无补货建议" - - lines = [] - - # 总体规模 - 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) + from ..llm import get_llm_client + if llm_client is None: + llm_client = get_llm_client() -def generate_analysis_report_node(state: dict) -> dict: + current_season = _get_season_from_date(statistics_date) + + prompt_template = _load_prompt("report_sales_analysis.md") + prompt = prompt_template.format( + total_avg_sales_cnt=_format_decimal(stats.get("total_avg_sales_cnt")), + total_avg_sales_amount=_format_decimal(stats.get("total_avg_sales_amount")), + has_sales_part_count=stats.get("has_sales_part_count", 0), + no_sales_part_count=stats.get("no_sales_part_count", 0), + total_out_stock_cnt=_format_decimal(stats.get("total_out_stock_cnt")), + total_storage_locked_cnt=_format_decimal(stats.get("total_storage_locked_cnt")), + total_out_stock_ongoing_cnt=_format_decimal(stats.get("total_out_stock_ongoing_cnt")), + total_buy_cnt=_format_decimal(stats.get("total_buy_cnt")), + current_season=current_season, + statistics_date=statistics_date or "未知", + ) + + messages = [HumanMessage(content=prompt)] + response = llm_client.invoke(messages) + + try: + analysis = _parse_llm_json(response.content) + except json.JSONDecodeError: + logger.warning(f"销量分析 LLM JSON 解析失败,原始响应: {response.content[:200]}") + analysis = {"error": "JSON解析失败", "raw": response.content[:200]} + + usage = { + "provider": response.usage.provider, + "model": response.usage.model, + "prompt_tokens": response.usage.prompt_tokens, + "completion_tokens": response.usage.completion_tokens, + } + return analysis, usage + + +def llm_analyze_inventory_health(stats: dict, statistics_date: str = "", llm_client=None) -> tuple[dict, dict]: """ - 生成分析报告节点 - - 输入: part_ratios, llm_suggestions, allocated_details, part_results - 输出: analysis_report + LLM 分析库存健康度 + + Args: + stats: calculate_inventory_health 的输出 + statistics_date: 统计日期 + llm_client: LLM 客户端实例 + + Returns: + (llm_analysis_dict, usage_dict) """ - start_time = time.time() - - task_no = state.get("task_no", "") - group_id = state.get("group_id", 0) - dealer_grouping_id = state.get("dealer_grouping_id", 0) - dealer_grouping_name = state.get("dealer_grouping_name", "") - brand_grouping_id = state.get("brand_grouping_id") - statistics_date = state.get("statistics_date", "") - - part_ratios = state.get("part_ratios", []) - part_results = state.get("part_results", []) - allocated_details = state.get("allocated_details", []) - - logger.info(f"[{task_no}] 开始生成分析报告: dealer={dealer_grouping_name}") - + from ..llm import get_llm_client + + if llm_client is None: + llm_client = get_llm_client() + + current_season = _get_season_from_date(statistics_date) + + prompt_template = _load_prompt("report_inventory_health.md") + prompt = prompt_template.format( + total_count=stats.get("total_count", 0), + total_amount=_format_decimal(stats.get("total_amount")), + shortage_count=stats.get("shortage", {}).get("count", 0), + shortage_count_pct=stats.get("shortage", {}).get("count_pct", 0), + shortage_amount=_format_decimal(stats.get("shortage", {}).get("amount")), + shortage_amount_pct=stats.get("shortage", {}).get("amount_pct", 0), + stagnant_count=stats.get("stagnant", {}).get("count", 0), + stagnant_count_pct=stats.get("stagnant", {}).get("count_pct", 0), + stagnant_amount=_format_decimal(stats.get("stagnant", {}).get("amount")), + stagnant_amount_pct=stats.get("stagnant", {}).get("amount_pct", 0), + low_freq_count=stats.get("low_freq", {}).get("count", 0), + low_freq_count_pct=stats.get("low_freq", {}).get("count_pct", 0), + low_freq_amount=_format_decimal(stats.get("low_freq", {}).get("amount")), + low_freq_amount_pct=stats.get("low_freq", {}).get("amount_pct", 0), + normal_count=stats.get("normal", {}).get("count", 0), + normal_count_pct=stats.get("normal", {}).get("count_pct", 0), + normal_amount=_format_decimal(stats.get("normal", {}).get("amount")), + normal_amount_pct=stats.get("normal", {}).get("amount_pct", 0), + current_season=current_season, + statistics_date=statistics_date or "未知", + ) + + messages = [HumanMessage(content=prompt)] + response = llm_client.invoke(messages) + try: - # 计算风险统计 - risk_stats = _calculate_risk_stats(part_ratios) - - # 计算补货建议统计 (基于完整数据) - suggestion_stats = _calculate_suggestion_stats(part_results) - - # 构建结构化建议汇总 - suggestion_summary = _build_suggestion_summary(suggestion_stats) - - # 加载 Prompt - prompt_template = _load_prompt("analysis_report.md") - - # 填充 Prompt 变量 - prompt = prompt_template.format( - dealer_grouping_id=dealer_grouping_id, - dealer_grouping_name=dealer_grouping_name, - statistics_date=statistics_date, - suggestion_summary=suggestion_summary, - shortage_cnt=risk_stats["shortage_cnt"], - shortage_amount=f"{risk_stats['shortage_amount']:.2f}", - stagnant_cnt=risk_stats["stagnant_cnt"], - stagnant_amount=f"{risk_stats['stagnant_amount']:.2f}", - low_freq_cnt=risk_stats["low_freq_cnt"], - low_freq_amount="0.00", # 低频件无库存 - ) - - # 调用 LLM + analysis = _parse_llm_json(response.content) + except json.JSONDecodeError: + logger.warning(f"健康度 LLM JSON 解析失败,原始响应: {response.content[:200]}") + analysis = {"error": "JSON解析失败", "raw": response.content[:200]} + + usage = { + "provider": response.usage.provider, + "model": response.usage.model, + "prompt_tokens": response.usage.prompt_tokens, + "completion_tokens": response.usage.completion_tokens, + } + return analysis, usage + + +def llm_analyze_replenishment_summary(stats: dict, statistics_date: str = "", llm_client=None) -> tuple[dict, dict]: + """ + LLM 分析补货建议 + + Args: + stats: calculate_replenishment_summary 的输出 + statistics_date: 统计日期 + llm_client: LLM 客户端实例 + + Returns: + (llm_analysis_dict, usage_dict) + """ + from ..llm import get_llm_client + + if llm_client is None: llm_client = get_llm_client() - response = llm_client.invoke( - messages=[HumanMessage(content=prompt)], - ) - - # 解析 JSON 响应 - response_text = response.content.strip() - # 移除可能的 markdown 代码块 - if response_text.startswith("```"): - lines = response_text.split("\n") - response_text = "\n".join(lines[1:-1]) - - report_data = json.loads(response_text) - - # 复用已计算的统计数据 - 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) - - # 创建报告对象 - # 新 prompt 字段名映射到现有数据库字段: - # overall_assessment -> replenishment_insights - # risk_alerts -> urgency_assessment - # procurement_strategy -> strategy_recommendations - # expected_impact -> expected_outcomes - # execution_guide 已移除,置为 None - report = AnalysisReport( - task_no=task_no, - group_id=group_id, - dealer_grouping_id=dealer_grouping_id, - dealer_grouping_name=dealer_grouping_name, - brand_grouping_id=brand_grouping_id, - report_type="replenishment", - replenishment_insights=report_data.get("overall_assessment"), - urgency_assessment=report_data.get("risk_alerts"), - strategy_recommendations=report_data.get("procurement_strategy"), - execution_guide=None, - expected_outcomes=report_data.get("expected_impact"), - total_suggest_cnt=total_suggest_cnt, - total_suggest_amount=total_suggest_amount, - shortage_risk_cnt=risk_stats["shortage_cnt"], - excess_risk_cnt=risk_stats["stagnant_cnt"], - stagnant_cnt=risk_stats["stagnant_cnt"], - low_freq_cnt=risk_stats["low_freq_cnt"], - llm_provider=getattr(llm_client, "provider", ""), - llm_model=getattr(llm_client, "model", ""), - llm_tokens=response.usage.total_tokens, - execution_time_ms=execution_time_ms, - statistics_date=statistics_date, - ) - - # 保存到数据库 - result_writer = ResultWriter() - try: - result_writer.save_analysis_report(report) - finally: - result_writer.close() - - logger.info( - f"[{task_no}] 分析报告生成完成: " - f"shortage={risk_stats['shortage_cnt']}, " - f"stagnant={risk_stats['stagnant_cnt']}, " - f"time={execution_time_ms}ms" - ) - + + current_season = _get_season_from_date(statistics_date) + + prompt_template = _load_prompt("report_replenishment_summary.md") + prompt = prompt_template.format( + total_count=stats.get("total_count", 0), + total_amount=_format_decimal(stats.get("total_amount")), + urgent_count=stats.get("urgent", {}).get("count", 0), + urgent_amount=_format_decimal(stats.get("urgent", {}).get("amount")), + suggested_count=stats.get("suggested", {}).get("count", 0), + suggested_amount=_format_decimal(stats.get("suggested", {}).get("amount")), + optional_count=stats.get("optional", {}).get("count", 0), + optional_amount=_format_decimal(stats.get("optional", {}).get("amount")), + current_season=current_season, + statistics_date=statistics_date or "未知", + ) + + messages = [HumanMessage(content=prompt)] + response = llm_client.invoke(messages) + + try: + analysis = _parse_llm_json(response.content) + except json.JSONDecodeError: + logger.warning(f"补货建议 LLM JSON 解析失败,原始响应: {response.content[:200]}") + analysis = {"error": "JSON解析失败", "raw": response.content[:200]} + + usage = { + "provider": response.usage.provider, + "model": response.usage.model, + "prompt_tokens": response.usage.prompt_tokens, + "completion_tokens": response.usage.completion_tokens, + } + return analysis, usage + + +# ============================================================ +# LangGraph 并发子图 +# ============================================================ + +from typing import TypedDict, Optional, Any, Annotated, Dict + +from langgraph.graph import StateGraph, START, END + + +def _merge_dict(left: Optional[dict], right: Optional[dict]) -> Optional[dict]: + """合并字典,保留非 None 的值""" + if right is not None: + return right + return left + + +def _sum_int(left: int, right: int) -> int: + """累加整数""" + return (left or 0) + (right or 0) + + +def _merge_str(left: Optional[str], right: Optional[str]) -> Optional[str]: + """合并字符串,保留非 None 的值""" + if right is not None: + return right + return left + + +class ReportLLMState(TypedDict, total=False): + """并发 LLM 分析子图的状态""" + + # 输入:四大板块的统计数据(只读,由主函数写入) + inventory_overview_stats: Annotated[Optional[dict], _merge_dict] + sales_analysis_stats: Annotated[Optional[dict], _merge_dict] + inventory_health_stats: Annotated[Optional[dict], _merge_dict] + replenishment_summary_stats: Annotated[Optional[dict], _merge_dict] + + # 输入:统计日期(用于季节判断) + statistics_date: Annotated[Optional[str], _merge_str] + + # 输出:四大板块的 LLM 分析结果(各节点独立写入) + inventory_overview_analysis: Annotated[Optional[dict], _merge_dict] + sales_analysis_analysis: Annotated[Optional[dict], _merge_dict] + inventory_health_analysis: Annotated[Optional[dict], _merge_dict] + replenishment_summary_analysis: Annotated[Optional[dict], _merge_dict] + + # LLM 使用量(累加) + total_prompt_tokens: Annotated[int, _sum_int] + total_completion_tokens: Annotated[int, _sum_int] + llm_provider: Annotated[Optional[str], _merge_dict] + llm_model: Annotated[Optional[str], _merge_dict] + + +def _node_inventory_overview(state: ReportLLMState) -> ReportLLMState: + """并发节点:库存概览 LLM 分析""" + stats = state.get("inventory_overview_stats") + statistics_date = state.get("statistics_date", "") + if not stats: + return {"inventory_overview_analysis": {"error": "无统计数据"}} + + try: + analysis, usage = llm_analyze_inventory_overview(stats, statistics_date) return { - "analysis_report": report.to_dict(), - "end_time": time.time(), + "inventory_overview_analysis": analysis, + "total_prompt_tokens": usage.get("prompt_tokens", 0), + "total_completion_tokens": usage.get("completion_tokens", 0), + "llm_provider": usage.get("provider", ""), + "llm_model": usage.get("model", ""), } - except Exception as e: - logger.error(f"[{task_no}] 分析报告生成失败: {e}", exc_info=True) - - # 返回空报告,不中断整个流程 + logger.error(f"库存概览 LLM 分析失败: {e}") + return {"inventory_overview_analysis": {"error": str(e)}} + + +def _node_sales_analysis(state: ReportLLMState) -> ReportLLMState: + """并发节点:销量分析 LLM 分析""" + stats = state.get("sales_analysis_stats") + statistics_date = state.get("statistics_date", "") + if not stats: + return {"sales_analysis_analysis": {"error": "无统计数据"}} + + try: + analysis, usage = llm_analyze_sales(stats, statistics_date) + return { + "sales_analysis_analysis": analysis, + "total_prompt_tokens": usage.get("prompt_tokens", 0), + "total_completion_tokens": usage.get("completion_tokens", 0), + "llm_provider": usage.get("provider", ""), + "llm_model": usage.get("model", ""), + } + except Exception as e: + logger.error(f"销量分析 LLM 分析失败: {e}") + return {"sales_analysis_analysis": {"error": str(e)}} + + +def _node_inventory_health(state: ReportLLMState) -> ReportLLMState: + """并发节点:健康度 LLM 分析""" + stats = state.get("inventory_health_stats") + statistics_date = state.get("statistics_date", "") + if not stats: + return {"inventory_health_analysis": {"error": "无统计数据"}} + + try: + analysis, usage = llm_analyze_inventory_health(stats, statistics_date) + return { + "inventory_health_analysis": analysis, + "total_prompt_tokens": usage.get("prompt_tokens", 0), + "total_completion_tokens": usage.get("completion_tokens", 0), + "llm_provider": usage.get("provider", ""), + "llm_model": usage.get("model", ""), + } + except Exception as e: + logger.error(f"健康度 LLM 分析失败: {e}") + return {"inventory_health_analysis": {"error": str(e)}} + + +def _node_replenishment_summary(state: ReportLLMState) -> ReportLLMState: + """并发节点:补货建议 LLM 分析""" + stats = state.get("replenishment_summary_stats") + statistics_date = state.get("statistics_date", "") + if not stats: + return {"replenishment_summary_analysis": {"error": "无统计数据"}} + + try: + analysis, usage = llm_analyze_replenishment_summary(stats, statistics_date) return { - "analysis_report": { - "error": str(e), - "task_no": task_no, - }, - "end_time": time.time(), + "replenishment_summary_analysis": analysis, + "total_prompt_tokens": usage.get("prompt_tokens", 0), + "total_completion_tokens": usage.get("completion_tokens", 0), + "llm_provider": usage.get("provider", ""), + "llm_model": usage.get("model", ""), } + except Exception as e: + logger.error(f"补货建议 LLM 分析失败: {e}") + return {"replenishment_summary_analysis": {"error": str(e)}} + + +def build_report_llm_subgraph() -> StateGraph: + """ + 构建并发 LLM 分析子图 + + 四个 LLM 节点从 START fan-out 并发执行,结果 fan-in 汇总到 END。 + """ + graph = StateGraph(ReportLLMState) + + # 添加四个并发节点 + graph.add_node("inventory_overview_llm", _node_inventory_overview) + graph.add_node("sales_analysis_llm", _node_sales_analysis) + graph.add_node("inventory_health_llm", _node_inventory_health) + graph.add_node("replenishment_summary_llm", _node_replenishment_summary) + + # fan-out: START → 四个节点 + graph.add_edge(START, "inventory_overview_llm") + graph.add_edge(START, "sales_analysis_llm") + graph.add_edge(START, "inventory_health_llm") + graph.add_edge(START, "replenishment_summary_llm") + + # fan-in: 四个节点 → END + graph.add_edge("inventory_overview_llm", END) + graph.add_edge("sales_analysis_llm", END) + graph.add_edge("inventory_health_llm", END) + graph.add_edge("replenishment_summary_llm", END) + + return graph.compile() + + +# ============================================================ +# 主节点函数 +# ============================================================ + + +def _serialize_stats(stats: dict) -> dict: + """将统计数据中的 Decimal 转换为 float,以便 JSON 序列化""" + result = {} + for k, v in stats.items(): + if isinstance(v, Decimal): + result[k] = float(v) + elif isinstance(v, dict): + result[k] = _serialize_stats(v) + elif isinstance(v, list): + result[k] = [ + _serialize_stats(item) if isinstance(item, dict) else (float(item) if isinstance(item, Decimal) else item) + for item in v + ] + else: + result[k] = v + return result + + +def generate_analysis_report_node(state: dict) -> dict: + """ + 分析报告生成主节点 + + 串联流程: + 1. 统计计算(四大板块) + 2. 并发 LLM 分析(LangGraph 子图) + 3. 汇总报告 + 4. 写入数据库 + + 单板块 LLM 失败不影响其他板块。 + + Args: + state: AgentState 字典 + + Returns: + 更新后的 state 字典 + """ + from .state import AgentState + from ..models import AnalysisReport + from ..services.result_writer import ResultWriter + + logger.info("[AnalysisReport] ========== 开始生成分析报告 ==========") + start_time = time.time() + + part_ratios = state.get("part_ratios", []) + part_results = state.get("part_results", []) + + # ---- 1. 统计计算 ---- + logger.info(f"[AnalysisReport] 统计计算: part_ratios={len(part_ratios)}, part_results={len(part_results)}") + + inventory_overview_stats = calculate_inventory_overview(part_ratios) + sales_analysis_stats = calculate_sales_analysis(part_ratios) + inventory_health_stats = calculate_inventory_health(part_ratios) + replenishment_summary_stats = calculate_replenishment_summary(part_results) + + # 序列化统计数据(Decimal → float) + io_stats_serialized = _serialize_stats(inventory_overview_stats) + sa_stats_serialized = _serialize_stats(sales_analysis_stats) + ih_stats_serialized = _serialize_stats(inventory_health_stats) + rs_stats_serialized = _serialize_stats(replenishment_summary_stats) + + # ---- 2. 并发 LLM 分析 ---- + logger.info("[AnalysisReport] 启动并发 LLM 分析子图") + + statistics_date = state.get("statistics_date", "") + + subgraph = build_report_llm_subgraph() + llm_state: ReportLLMState = { + "inventory_overview_stats": io_stats_serialized, + "sales_analysis_stats": sa_stats_serialized, + "inventory_health_stats": ih_stats_serialized, + "replenishment_summary_stats": rs_stats_serialized, + "statistics_date": statistics_date, + "inventory_overview_analysis": None, + "sales_analysis_analysis": None, + "inventory_health_analysis": None, + "replenishment_summary_analysis": None, + "total_prompt_tokens": 0, + "total_completion_tokens": 0, + "llm_provider": None, + "llm_model": None, + } + + try: + llm_result = subgraph.invoke(llm_state) + except Exception as e: + logger.error(f"[AnalysisReport] LLM 子图执行异常: {e}") + llm_result = llm_state # 使用初始状态(所有分析为 None) + + # ---- 3. 汇总报告 ---- + inventory_overview_data = { + "stats": io_stats_serialized, + "llm_analysis": llm_result.get("inventory_overview_analysis") or {"error": "未生成"}, + } + sales_analysis_data = { + "stats": sa_stats_serialized, + "llm_analysis": llm_result.get("sales_analysis_analysis") or {"error": "未生成"}, + } + inventory_health_data = { + "stats": ih_stats_serialized, + "chart_data": ih_stats_serialized.get("chart_data"), + "llm_analysis": llm_result.get("inventory_health_analysis") or {"error": "未生成"}, + } + replenishment_summary_data = { + "stats": rs_stats_serialized, + "llm_analysis": llm_result.get("replenishment_summary_analysis") or {"error": "未生成"}, + } + + total_tokens = ( + (llm_result.get("total_prompt_tokens") or 0) + + (llm_result.get("total_completion_tokens") or 0) + ) + execution_time_ms = int((time.time() - start_time) * 1000) + + # ---- 4. 写入数据库 ---- + report = AnalysisReport( + task_no=state.get("task_no", ""), + group_id=state.get("group_id", 0), + dealer_grouping_id=state.get("dealer_grouping_id", 0), + dealer_grouping_name=state.get("dealer_grouping_name"), + brand_grouping_id=state.get("brand_grouping_id"), + inventory_overview=inventory_overview_data, + sales_analysis=sales_analysis_data, + inventory_health=inventory_health_data, + replenishment_summary=replenishment_summary_data, + llm_provider=llm_result.get("llm_provider") or "", + llm_model=llm_result.get("llm_model") or "", + llm_tokens=total_tokens, + execution_time_ms=execution_time_ms, + statistics_date=state.get("statistics_date", ""), + ) + + try: + writer = ResultWriter() + report_id = writer.save_analysis_report(report) + writer.close() + logger.info(f"[AnalysisReport] 报告已保存: id={report_id}, tokens={total_tokens}, 耗时={execution_time_ms}ms") + except Exception as e: + logger.error(f"[AnalysisReport] 报告写入数据库失败: {e}") + + # 返回更新后的状态 + return { + "analysis_report": report.to_dict(), + "llm_provider": llm_result.get("llm_provider") or state.get("llm_provider", ""), + "llm_model": llm_result.get("llm_model") or state.get("llm_model", ""), + "llm_prompt_tokens": llm_result.get("total_prompt_tokens") or 0, + "llm_completion_tokens": llm_result.get("total_completion_tokens") or 0, + "current_node": "generate_analysis_report", + "next_node": "end", + } diff --git a/src/fw_pms_ai/agent/sql_agent/agent.py b/src/fw_pms_ai/agent/sql_agent/agent.py index 0a79343..c130a0d 100644 --- a/src/fw_pms_ai/agent/sql_agent/agent.py +++ b/src/fw_pms_ai/agent/sql_agent/agent.py @@ -140,7 +140,7 @@ class SQLAgent: out_stock_ongoing_cnt, stock_age, out_times, out_duration, transfer_cnt, gen_transfer_cnt, part_biz_type, statistics_date, - (in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt + transfer_cnt + gen_transfer_cnt) as valid_storage_cnt, + (in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt) as valid_storage_cnt, ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) as avg_sales_cnt FROM part_ratio WHERE group_id = %s @@ -159,7 +159,7 @@ class SQLAgent: # 优先处理有销量的配件 sql += """ ORDER BY CASE WHEN ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) > 0 THEN 0 ELSE 1 END, - (in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt + transfer_cnt + gen_transfer_cnt) / NULLIF((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3, 0) ASC, + (in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt) / NULLIF((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3, 0) ASC, ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) DESC """ diff --git a/src/fw_pms_ai/agent/state.py b/src/fw_pms_ai/agent/state.py index 2387a28..42a9633 100644 --- a/src/fw_pms_ai/agent/state.py +++ b/src/fw_pms_ai/agent/state.py @@ -53,8 +53,8 @@ class AgentState(TypedDict, total=False): dealer_grouping_name: Annotated[str, keep_last] statistics_date: Annotated[str, keep_last] - # part_ratio 原始数据 - part_ratios: Annotated[List[dict], merge_dicts] + # part_ratio 原始数据(使用 keep_last,因为只在 fetch_part_ratio 节点写入一次) + part_ratios: Annotated[List[dict], keep_last] # SQL Agent 相关 sql_queries: Annotated[List[str], merge_lists] diff --git a/src/fw_pms_ai/api/routes/tasks.py b/src/fw_pms_ai/api/routes/tasks.py index 66f3637..49604eb 100644 --- a/src/fw_pms_ai/api/routes/tasks.py +++ b/src/fw_pms_ai/api/routes/tasks.py @@ -619,23 +619,14 @@ class AnalysisReportResponse(BaseModel): 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 分析) + inventory_overview: Optional[Dict[str, Any]] = None + sales_analysis: Optional[Dict[str, Any]] = None + inventory_health: Optional[Dict[str, Any]] = None + replenishment_summary: Optional[Dict[str, Any]] = None + llm_provider: Optional[str] = None llm_model: Optional[str] = None llm_tokens: int = 0 @@ -664,17 +655,19 @@ async def get_analysis_report(task_no: str): if not row: return None - - # 辅助函数:解析 JSON 字符串 + + # 解析 JSON 字段 def parse_json(value): - if not value: + if value is None: return None + if isinstance(value, dict): + return value if isinstance(value, str): try: return json.loads(value) - except json.JSONDecodeError: + except (json.JSONDecodeError, TypeError): return None - return value + return None return AnalysisReportResponse( id=row["id"], @@ -682,22 +675,11 @@ async def get_analysis_report(task_no: str): 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, - + inventory_overview=parse_json(row.get("inventory_overview")), + sales_analysis=parse_json(row.get("sales_analysis")), + inventory_health=parse_json(row.get("inventory_health")), + replenishment_summary=parse_json(row.get("replenishment_summary")), llm_provider=row.get("llm_provider"), llm_model=row.get("llm_model"), llm_tokens=row.get("llm_tokens") or 0, diff --git a/src/fw_pms_ai/models/analysis_report.py b/src/fw_pms_ai/models/analysis_report.py index ba51bb5..907e1e8 100644 --- a/src/fw_pms_ai/models/analysis_report.py +++ b/src/fw_pms_ai/models/analysis_report.py @@ -1,73 +1,59 @@ """ 数据模型 - 分析报告 +四大板块:库存概览、销量分析、库存健康度、补货建议 """ from dataclasses import dataclass, field -from decimal import Decimal from datetime import datetime -from typing import Optional, Dict, Any +from typing import Any, Dict, Optional @dataclass class AnalysisReport: - """AI补货建议分析报告""" - + """分析报告数据模型""" + task_no: str group_id: int dealer_grouping_id: int - + id: Optional[int] = None dealer_grouping_name: Optional[str] = None brand_grouping_id: Optional[int] = None report_type: str = "replenishment" - - # 报告各模块 (字典结构) - 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: Decimal = Decimal("0") - shortage_risk_cnt: int = 0 - excess_risk_cnt: int = 0 - stagnant_cnt: int = 0 - low_freq_cnt: int = 0 - + + # 四大板块 + inventory_overview: Optional[Dict[str, Any]] = field(default=None) + sales_analysis: Optional[Dict[str, Any]] = field(default=None) + inventory_health: Optional[Dict[str, Any]] = field(default=None) + replenishment_summary: Optional[Dict[str, Any]] = field(default=None) + # LLM 元数据 llm_provider: str = "" llm_model: str = "" llm_tokens: int = 0 execution_time_ms: int = 0 - + statistics_date: str = "" create_time: Optional[datetime] = None - def to_dict(self) -> dict: - """转换为字典""" + def to_dict(self) -> Dict[str, Any]: + """将报告转换为可序列化的字典""" return { + "id": self.id, "task_no": self.task_no, "group_id": self.group_id, "dealer_grouping_id": self.dealer_grouping_id, "dealer_grouping_name": self.dealer_grouping_name, "brand_grouping_id": self.brand_grouping_id, "report_type": self.report_type, - "replenishment_insights": self.replenishment_insights, - "urgency_assessment": self.urgency_assessment, - "strategy_recommendations": self.strategy_recommendations, - "execution_guide": self.execution_guide, - "expected_outcomes": self.expected_outcomes, - "total_suggest_cnt": self.total_suggest_cnt, - "total_suggest_amount": float(self.total_suggest_amount), - "shortage_risk_cnt": self.shortage_risk_cnt, - "excess_risk_cnt": self.excess_risk_cnt, - "stagnant_cnt": self.stagnant_cnt, - "low_freq_cnt": self.low_freq_cnt, + "inventory_overview": self.inventory_overview, + "sales_analysis": self.sales_analysis, + "inventory_health": self.inventory_health, + "replenishment_summary": self.replenishment_summary, "llm_provider": self.llm_provider, "llm_model": self.llm_model, "llm_tokens": self.llm_tokens, "execution_time_ms": self.execution_time_ms, "statistics_date": self.statistics_date, + "create_time": self.create_time.isoformat() if self.create_time else None, } diff --git a/src/fw_pms_ai/models/part_ratio.py b/src/fw_pms_ai/models/part_ratio.py index 3df7039..ce9d717 100644 --- a/src/fw_pms_ai/models/part_ratio.py +++ b/src/fw_pms_ai/models/part_ratio.py @@ -46,9 +46,8 @@ class PartRatio: @property def valid_storage_cnt(self) -> Decimal: - """有效库存数量 = 在库未锁 + 在途 + 计划数 + 主动调拨在途 + 自动调拨在途""" - return (self.in_stock_unlocked_cnt + self.on_the_way_cnt + self.has_plan_cnt + - Decimal(str(self.transfer_cnt)) + Decimal(str(self.gen_transfer_cnt))) + """有效库存数量 = 在库未锁 + 在途 + 计划数""" + return self.in_stock_unlocked_cnt + self.on_the_way_cnt + self.has_plan_cnt @property def valid_storage_amount(self) -> Decimal: diff --git a/src/fw_pms_ai/services/result_writer.py b/src/fw_pms_ai/services/result_writer.py index bba883e..149000c 100644 --- a/src/fw_pms_ai/services/result_writer.py +++ b/src/fw_pms_ai/services/result_writer.py @@ -325,8 +325,8 @@ class ResultWriter: def save_analysis_report(self, report: AnalysisReport) -> int: """ - 保存分析报告 - + 保存分析报告(四大板块 JSON 结构) + Returns: 插入的报告ID """ @@ -338,20 +338,18 @@ class ResultWriter: INSERT INTO ai_analysis_report ( task_no, group_id, dealer_grouping_id, dealer_grouping_name, brand_grouping_id, report_type, - replenishment_insights, urgency_assessment, strategy_recommendations, - execution_guide, expected_outcomes, - total_suggest_cnt, total_suggest_amount, shortage_risk_cnt, - excess_risk_cnt, stagnant_cnt, low_freq_cnt, + inventory_overview, sales_analysis, + inventory_health, replenishment_summary, llm_provider, llm_model, llm_tokens, execution_time_ms, statistics_date, create_time ) VALUES ( %s, %s, %s, %s, %s, %s, - %s, %s, %s, %s, %s, - %s, %s, %s, %s, %s, %s, - %s, %s, %s, %s, %s, NOW() + %s, %s, %s, %s, + %s, %s, %s, %s, + %s, NOW() ) """ - + values = ( report.task_no, report.group_id, @@ -359,27 +357,20 @@ class ResultWriter: report.dealer_grouping_name, report.brand_grouping_id, report.report_type, - json.dumps(report.replenishment_insights, ensure_ascii=False) if report.replenishment_insights else None, - json.dumps(report.urgency_assessment, ensure_ascii=False) if report.urgency_assessment else None, - json.dumps(report.strategy_recommendations, ensure_ascii=False) if report.strategy_recommendations else None, - json.dumps(report.execution_guide, ensure_ascii=False) if report.execution_guide else None, - json.dumps(report.expected_outcomes, ensure_ascii=False) if report.expected_outcomes else None, - report.total_suggest_cnt, - float(report.total_suggest_amount), - report.shortage_risk_cnt, - report.excess_risk_cnt, - report.stagnant_cnt, - report.low_freq_cnt, + json.dumps(report.inventory_overview, ensure_ascii=False) if report.inventory_overview else None, + json.dumps(report.sales_analysis, ensure_ascii=False) if report.sales_analysis else None, + json.dumps(report.inventory_health, ensure_ascii=False) if report.inventory_health else None, + json.dumps(report.replenishment_summary, ensure_ascii=False) if report.replenishment_summary else None, report.llm_provider, report.llm_model, report.llm_tokens, report.execution_time_ms, report.statistics_date, ) - + cursor.execute(sql, values) conn.commit() - + report_id = cursor.lastrowid logger.info(f"保存分析报告: task_no={report.task_no}, id={report_id}") return report_id diff --git a/ui/css/style.css b/ui/css/style.css index 8420848..cda361d 100644 --- a/ui/css/style.css +++ b/ui/css/style.css @@ -2362,3 +2362,390 @@ tbody tr:last-child td { font-size: 0.75rem; color: var(--text-muted); } + + +/* ===================== + 新报告板块样式 + ===================== */ + +/* 报告统计卡片网格 */ +.report-stat-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--spacing-md); + margin-bottom: var(--spacing-xl); +} + +.report-stat-cards-4 { + grid-template-columns: repeat(4, 1fr); +} + +@media (max-width: 768px) { + .report-stat-cards-4 { + grid-template-columns: repeat(2, 1fr); + } +} + +/* 报告统计卡片 */ +.report-stat-card { + background: rgba(30, 41, 59, 0.6); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + padding: var(--spacing-lg); + transition: all var(--transition-base); +} + +.report-stat-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); + border-color: var(--border-color-light); +} + +.report-stat-label { + font-size: 0.8rem; + color: var(--text-secondary); + margin-bottom: var(--spacing-xs); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.report-stat-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-primary); + line-height: 1.3; +} + +.report-stat-sub { + font-size: 0.85rem; + font-weight: 400; + color: var(--text-muted); +} + +.report-stat-pct { + font-size: 0.8rem; + color: var(--text-secondary); + margin-top: var(--spacing-xs); +} + +/* 健康度卡片颜色变体 */ +.report-stat-card-danger { + border-left: 3px solid var(--color-danger); +} +.report-stat-card-danger .report-stat-value { + color: var(--color-danger-light); +} + +.report-stat-card-warning { + border-left: 3px solid var(--color-warning); +} +.report-stat-card-warning .report-stat-value { + color: var(--color-warning-light); +} + +.report-stat-card-info { + border-left: 3px solid var(--color-info); +} +.report-stat-card-info .report-stat-value { + color: var(--color-info-light); +} + +.report-stat-card-success { + border-left: 3px solid var(--color-success); +} +.report-stat-card-success .report-stat-value { + color: var(--color-success-light); +} + +/* 报告明细表格 */ +.report-detail-table { + background: rgba(30, 41, 59, 0.4); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + overflow: hidden; + margin-bottom: var(--spacing-xl); +} + +.report-detail-table table { + width: 100%; + border-collapse: collapse; +} + +.report-detail-table th { + background: var(--bg-surface); + padding: var(--spacing-md) var(--spacing-lg); + text-align: left; + font-weight: 600; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-color); +} + +.report-detail-table td { + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--border-color); + color: var(--text-primary); +} + +.report-detail-table tbody tr:last-child td { + border-bottom: none; +} + +.report-detail-table tbody tr:hover { + background: rgba(255, 255, 255, 0.02); +} + +/* 图表容器 */ +.report-charts-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-xl); + margin-bottom: var(--spacing-xl); +} + +@media (max-width: 768px) { + .report-charts-row { + grid-template-columns: 1fr; + } +} + +.report-chart-container { + background: rgba(30, 41, 59, 0.4); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + padding: var(--spacing-lg); + display: flex; + flex-direction: column; + align-items: center; +} + +.report-chart-title { + font-size: 0.9rem; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: var(--spacing-md); + text-align: center; +} + +.report-chart-container canvas { + max-width: 300px; + max-height: 300px; +} + +/* LLM 分析文本区域 */ +.report-analysis-text { + background: rgba(30, 41, 59, 0.3); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + padding: var(--spacing-xl); +} + +.analysis-block { + margin-bottom: var(--spacing-lg); +} + +.analysis-block:last-child { + margin-bottom: 0; +} + +.analysis-block-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: var(--spacing-sm); + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.analysis-block-title svg { + width: 18px; + height: 18px; + color: var(--color-primary); +} + +.analysis-block p { + color: var(--text-secondary); + line-height: 1.7; + margin-bottom: var(--spacing-xs); + font-size: 0.9rem; +} + +.analysis-block p:empty { + display: none; +} + +.analysis-benchmark { + font-style: italic; + opacity: 0.8; +} + +.analysis-rec-list { + list-style: none; + padding: 0; + margin: 0; +} + +.analysis-rec-list li { + position: relative; + padding-left: 20px; + margin-bottom: var(--spacing-sm); + color: var(--text-secondary); + font-size: 0.9rem; + line-height: 1.6; +} + +.analysis-rec-list li::before { + content: '→'; + position: absolute; + left: 0; + color: var(--color-primary); +} + +ol.analysis-rec-list { + counter-reset: rec-counter; +} + +ol.analysis-rec-list li { + counter-increment: rec-counter; +} + +ol.analysis-rec-list li::before { + content: counter(rec-counter) '.'; + font-weight: 600; +} + +/* 风险标签 */ +.risk-tag { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: var(--radius-full); + font-size: 0.7rem; + font-weight: 600; + margin-left: var(--spacing-sm); +} + +.risk-tag-high { + background: rgba(239, 68, 68, 0.15); + color: var(--color-danger-light); +} + +.risk-tag-medium { + background: rgba(245, 158, 11, 0.15); + color: var(--color-warning-light); +} + +.risk-tag-low { + background: rgba(16, 185, 129, 0.15); + color: var(--color-success-light); +} + +/* 可折叠推理过程 */ +.analysis-process-toggle { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + margin-top: var(--spacing-md); + background: rgba(99, 102, 241, 0.08); + border: 1px solid rgba(99, 102, 241, 0.2); + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition-fast); + color: var(--color-primary-light); + font-size: 0.85rem; + font-weight: 500; +} + +.analysis-process-toggle:hover { + background: rgba(99, 102, 241, 0.15); +} + +.analysis-process-toggle svg { + width: 16px; + height: 16px; + transition: transform var(--transition-fast); +} + +.analysis-process-toggle.expanded svg { + transform: rotate(180deg); +} + +.analysis-process-content { + display: none; + margin-top: var(--spacing-md); + padding: var(--spacing-md); + background: rgba(30, 41, 59, 0.5); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + font-size: 0.85rem; +} + +.analysis-process-content.expanded { + display: block; +} + +.process-section { + margin-bottom: var(--spacing-md); + padding-bottom: var(--spacing-md); + border-bottom: 1px solid var(--border-color); +} + +.process-section:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.process-section-title { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: var(--spacing-sm); +} + +.process-item { + display: flex; + margin-bottom: var(--spacing-xs); + line-height: 1.5; +} + +.process-item-label { + color: var(--text-muted); + min-width: 120px; + flex-shrink: 0; +} + +.process-item-value { + color: var(--text-secondary); + word-break: break-word; +} + +.process-item-value.highlight { + color: var(--color-primary-light); + font-weight: 500; +} + +/* 季节标签 */ +.season-tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 10px; + border-radius: var(--radius-full); + font-size: 0.75rem; + font-weight: 500; + background: rgba(59, 130, 246, 0.15); + color: var(--color-info-light); + margin-left: var(--spacing-sm); +} + +.season-tag svg { + width: 12px; + height: 12px; +} diff --git a/ui/index.html b/ui/index.html index cae05c6..1774fec 100644 --- a/ui/index.html +++ b/ui/index.html @@ -17,6 +17,9 @@ + + + diff --git a/ui/js/app.js b/ui/js/app.js index 1d7e22e..10fba26 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -98,7 +98,7 @@ const App = { /** - * 渲染分析报告标签页 + * 渲染分析报告标签页(四大板块:库存概览/销量分析/健康度/补货建议) */ async renderReportTab(container, taskNo) { container.innerHTML = '
加载分析报告...
'; @@ -116,46 +116,28 @@ const App = { } container.innerHTML = ` -
-
- - 核心经营综述 -
-
- ${this.renderOverallAssessment(report.replenishment_insights)} -
-
- -
-
- - 风险管控预警 -
-
- ${this.renderRiskAlerts(report.urgency_assessment)} -
-
- -
-
- - 补货策略建议 -
-
- ${this.renderStrategy(report.strategy_recommendations)} -
-
- -
-
- - 效果预期与建议 -
-
- ${this.renderExpectedImpact(report.expected_outcomes)} -
-
+
+
+
+
`; + + this.renderInventoryOverview( + document.getElementById('report-inventory-overview'), + report.inventory_overview + ); + this.renderSalesAnalysis( + document.getElementById('report-sales-analysis'), + report.sales_analysis + ); + this.renderInventoryHealth( + document.getElementById('report-inventory-health'), + report.inventory_health + ); + this.renderReplenishmentSummary( + document.getElementById('report-replenishment-summary'), + report.replenishment_summary + ); lucide.createIcons(); } catch (error) { @@ -169,203 +151,835 @@ const App = { } }, - renderOverallAssessment(insights) { - if (!insights) return ''; - - let heroHtml = ''; + /** + * 渲染库存概览板块 + */ + renderInventoryOverview(container, data) { + if (!data) { + container.innerHTML = ''; + return; + } + const stats = data.stats || {}; + const analysis = data.llm_analysis || {}; - // Scale (Hero Main) - if (insights.scale_evaluation) { - heroHtml += ` -
-
补货规模
-
${insights.scale_evaluation.current_vs_historical || '-'}
-
${insights.scale_evaluation.possible_reasons || ''}
-
`; + // 兼容新旧数据结构 + const conclusion = analysis.conclusion || analysis; + const process = analysis.analysis_process || null; + + const ratio = stats.overall_ratio; + const ratioDisplay = (ratio === 999 || ratio === null || ratio === undefined) ? '无销量' : Components.formatNumber(ratio); + + // LLM 分析文本渲染 + let analysisHtml = ''; + if (analysis.error) { + analysisHtml = `
分析生成失败: ${analysis.error}
`; + } else { + const sections = []; + + // 季节信息(如果有) + if (process && process.seasonal_analysis) { + const sa = process.seasonal_analysis; + sections.push(`
+
季节性分析 ${sa.current_season || ''}
+

${sa.season_demand_feature || ''}

+

${sa.inventory_fitness || ''}

+ ${sa.upcoming_season_preparation ? `

下季准备: ${sa.upcoming_season_preparation}

` : ''} +
`); + } + + if (conclusion.capital_assessment) { + const ca = conclusion.capital_assessment; + sections.push(`
+
资金占用评估 ${ca.risk_level === 'high' ? '高风险' : ca.risk_level === 'low' ? '低风险' : '中风险'}
+

${ca.total_evaluation || ''}

+

${ca.structure_ratio || ''}

+
`); + } + if (conclusion.ratio_diagnosis) { + const rd = conclusion.ratio_diagnosis; + sections.push(`
+
库销比诊断 — ${rd.level || ''}
+

${rd.analysis || ''}

+

${rd.benchmark || ''}

+
`); + } + if (conclusion.recommendations && conclusion.recommendations.length > 0) { + const recHtml = conclusion.recommendations.map(r => { + if (typeof r === 'object') { + return `
  • ${r.action || ''}${r.reason ? ` - ${r.reason}` : ''}${r.expected_effect ? `
    预期效果: ${r.expected_effect}` : ''}
  • `; + } + return `
  • ${r}
  • `; + }).join(''); + sections.push(`
    +
    库存结构建议
    + +
    `); + } + + // 推理过程(可折叠) + let processHtml = ''; + if (process) { + processHtml = this._renderAnalysisProcess(process, 'inventory-overview'); + } + + analysisHtml = sections.length > 0 ? `
    ${sections.join('')}${processHtml}
    ` : ''; } + + container.innerHTML = ` +
    + + 库存总体概览 +
    +
    +
    +
    有效库存总数量
    +
    ${Components.formatNumber(stats.total_valid_storage_cnt)}
    +
    +
    +
    资金占用(总金额)
    +
    ${Components.formatAmount(stats.total_valid_storage_amount)}
    +
    +
    +
    整体库销比
    +
    ${ratioDisplay}
    +
    +
    +
    配件种类数
    +
    ${stats.part_count || 0}
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    构成项数量金额
    在库未锁${Components.formatNumber(stats.total_in_stock_unlocked_cnt)}${Components.formatAmount(stats.total_in_stock_unlocked_amount)}
    在途${Components.formatNumber(stats.total_on_the_way_cnt)}${Components.formatAmount(stats.total_on_the_way_amount)}
    计划数${Components.formatNumber(stats.total_has_plan_cnt)}${Components.formatAmount(stats.total_has_plan_amount)}
    +
    + ${analysisHtml} + `; - // 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}
    -
    `; + // 绑定折叠事件 + this._bindProcessToggle(container); + }, + + /** + * 渲染销量分析板块 + */ + renderSalesAnalysis(container, data) { + if (!data) { + container.innerHTML = ''; + return; } + const stats = data.stats || {}; + const analysis = data.llm_analysis || {}; - // Timing (Hero End) - if (insights.timing_judgment) { - const data = insights.timing_judgment; - const isPos = data.is_favorable; - heroHtml += ` -
    -
    时机判断
    -
    - ${isPos ? '有利时机' : '建议观望'} + // 兼容新旧数据结构 + const conclusion = analysis.conclusion || analysis; + const process = analysis.analysis_process || null; + + // LLM 分析文本 + let analysisHtml = ''; + if (analysis.error) { + analysisHtml = `
    分析生成失败: ${analysis.error}
    `; + } else { + const sections = []; + + // 季节信息(如果有) + if (process && process.seasonal_analysis) { + const sa = process.seasonal_analysis; + sections.push(`
    +
    季节性分析 ${sa.current_season || ''}
    +

    ${sa.expected_performance || ''}

    +

    ${sa.actual_vs_expected || ''}

    + ${sa.seasonal_items_status ? `

    ${sa.seasonal_items_status}

    ` : ''} +
    `); + } + + if (conclusion.composition_analysis) { + const ca = conclusion.composition_analysis; + sections.push(`
    +
    销量构成解读
    +

    ${ca.main_driver || ''}

    +

    ${ca.pending_orders_impact || ''}

    +

    ${ca.booking_trend || ''}

    +
    `); + } + if (conclusion.activity_assessment) { + const aa = conclusion.activity_assessment; + sections.push(`
    +
    销售活跃度
    +

    ${aa.active_ratio || ''}

    +

    ${aa.optimization_suggestion || ''}

    +
    `); + } + if (conclusion.demand_trend) { + const dt = conclusion.demand_trend; + const dirIcon = dt.direction === '上升' ? 'trending-up' : dt.direction === '下降' ? 'trending-down' : 'minus'; + sections.push(`
    +
    需求趋势 — ${dt.direction || ''}
    +

    ${dt.evidence || ''}

    + ${dt.seasonal_factor ? `

    季节因素: ${dt.seasonal_factor}

    ` : ''} +

    ${dt.forecast || ''}

    +
    `); + } + + // 推理过程(可折叠) + let processHtml = ''; + if (process) { + processHtml = this._renderAnalysisProcess(process, 'sales-analysis'); + } + + analysisHtml = sections.length > 0 ? `
    ${sections.join('')}${processHtml}
    ` : ''; + } + + const totalParts = (stats.has_sales_part_count || 0) + (stats.no_sales_part_count || 0); + + container.innerHTML = ` +
    + + 销量分析 +
    +
    +
    +
    月均销量总数量
    +
    ${Components.formatNumber(stats.total_avg_sales_cnt)}
    -
    - ${data.recommendation}
    - ${data.timing_factors || ''} +
    +
    月均销量总金额
    +
    ${Components.formatAmount(stats.total_avg_sales_amount)}
    -
    `; - } +
    +
    有销量配件
    +
    ${stats.has_sales_part_count || 0} / ${totalParts}
    +
    +
    +
    无销量配件
    +
    ${stats.no_sales_part_count || 0} / ${totalParts}
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    构成项总量
    90天出库数${Components.formatNumber(stats.total_out_stock_cnt)}
    未关单已锁${Components.formatNumber(stats.total_storage_locked_cnt)}
    未关单出库${Components.formatNumber(stats.total_out_stock_ongoing_cnt)}
    订件${Components.formatNumber(stats.total_buy_cnt)}
    +
    + ${analysisHtml} + `; - return `
    ${heroHtml}
    `; + // 绑定折叠事件 + this._bindProcessToggle(container); }, - renderRiskAlerts(risks) { - if (!risks) return ''; - - let feedHtml = '
    '; + /** + * 渲染库存健康度板块(含 Chart.js 环形图) + */ + renderInventoryHealth(container, data) { + if (!data) { + container.innerHTML = ''; + return; + } + const stats = data.stats || {}; + const chartData = data.chart_data || {}; + const analysis = data.llm_analysis || {}; - const addRiskItem = (level, type, desc, action) => { - let icon = 'alert-circle'; - if (level === 'high') icon = 'alert-octagon'; - if (level === 'low') icon = 'info'; + // 兼容新旧数据结构 + const conclusion = analysis.conclusion || analysis; + const process = analysis.analysis_process || null; + + const shortage = stats.shortage || {}; + const stagnant = stats.stagnant || {}; + const low_freq = stats.low_freq || {}; + const normal = stats.normal || {}; + + // LLM 分析文本 + let analysisHtml = ''; + if (analysis.error) { + analysisHtml = `
    分析生成失败: ${analysis.error}
    `; + } else { + const sections = []; - feedHtml += ` -
    -
    - + // 季节信息(如果有) + if (process && process.seasonal_analysis) { + const sa = process.seasonal_analysis; + sections.push(`
    +
    季节性分析 ${sa.current_season || ''}
    + ${sa.seasonal_stagnant_items ? `

    ${sa.seasonal_stagnant_items}

    ` : ''} + ${sa.seasonal_shortage_risk ? `

    ${sa.seasonal_shortage_risk}

    ` : ''} + ${sa.upcoming_season_alert ? `

    下季关注: ${sa.upcoming_season_alert}

    ` : ''} +
    `); + } + + if (conclusion.health_score) { + const hs = conclusion.health_score; + sections.push(`
    +
    健康度评分 — ${hs.score || ''}
    +

    ${hs.normal_ratio_evaluation || ''}

    +
    `); + } + if (conclusion.problem_diagnosis) { + const pd = conclusion.problem_diagnosis; + sections.push(`
    +
    问题诊断
    + ${pd.stagnant_analysis ? `

    呆滞件: ${pd.stagnant_analysis}

    ` : ''} + ${pd.shortage_analysis ? `

    缺货件: ${pd.shortage_analysis}

    ` : ''} + ${pd.low_freq_analysis ? `

    低频件: ${pd.low_freq_analysis}

    ` : ''} +
    `); + } + if (conclusion.capital_release) { + const cr = conclusion.capital_release; + sections.push(`
    +
    资金释放机会
    + ${cr.stagnant_releasable ? `

    呆滞件可释放: ${cr.stagnant_releasable}

    ` : ''} + ${cr.low_freq_releasable ? `

    低频件可释放: ${cr.low_freq_releasable}

    ` : ''} + ${cr.action_plan ? `

    ${cr.action_plan}

    ` : ''} +
    `); + } + if (conclusion.priority_actions && conclusion.priority_actions.length > 0) { + const actHtml = conclusion.priority_actions.map(a => { + if (typeof a === 'object') { + return `
  • ${a.action || ''}${a.reason ? ` - ${a.reason}` : ''}${a.expected_effect ? `
    预期效果: ${a.expected_effect}` : ''}
  • `; + } + return `
  • ${a}
  • `; + }).join(''); + sections.push(`
    +
    改善优先级
    +
      ${actHtml}
    +
    `); + } + + // 推理过程(可折叠) + let processHtml = ''; + if (process) { + processHtml = this._renderAnalysisProcess(process, 'inventory-health'); + } + + analysisHtml = sections.length > 0 ? `
    ${sections.join('')}${processHtml}
    ` : ''; + } + + container.innerHTML = ` +
    + + 库存构成健康度 +
    +
    +
    +
    缺货件
    +
    ${shortage.count || 0}
    +
    ${Components.formatNumber(shortage.count_pct)}% · ${Components.formatAmount(shortage.amount)}
    -
    -
    - ${type} - ${level.toUpperCase()} -
    -
    ${desc}
    - ${action ? `
    ${action}
    ` : ''} +
    +
    呆滞件
    +
    ${stagnant.count || 0}
    +
    ${Components.formatNumber(stagnant.count_pct)}% · ${Components.formatAmount(stagnant.amount)}
    -
    `; - }; +
    +
    低频件
    +
    ${low_freq.count || 0}
    +
    ${Components.formatNumber(low_freq.count_pct)}% · ${Components.formatAmount(low_freq.amount)}
    +
    +
    +
    正常件
    +
    ${normal.count || 0}
    +
    ${Components.formatNumber(normal.count_pct)}% · ${Components.formatAmount(normal.amount)}
    +
    +
    +
    +
    +
    数量占比
    + +
    +
    +
    金额占比
    + +
    +
    + ${analysisHtml} + `; + + // 渲染 Chart.js 环形图 + this._renderHealthCharts(chartData); - // 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 - )); + // 绑定折叠事件 + this._bindProcessToggle(container); + }, + + /** + * 渲染健康度环形图 + */ + _renderHealthCharts(chartData) { + if (!chartData || !chartData.labels) return; + if (typeof Chart === 'undefined') return; + + const colors = ['#ef4444', '#f59e0b', '#3b82f6', '#10b981']; + const borderColors = ['#dc2626', '#d97706', '#2563eb', '#059669']; + + const chartOptions = { + responsive: true, + maintainAspectRatio: true, + plugins: { + legend: { + position: 'bottom', + labels: { + color: '#94a3b8', + padding: 16, + usePointStyle: true, + pointStyleWidth: 10, + font: { size: 12 } + } + }, + tooltip: { + backgroundColor: '#1e293b', + titleColor: '#f8fafc', + bodyColor: '#94a3b8', + borderColor: 'rgba(148,163,184,0.2)', + borderWidth: 1 + } + }, + cutout: '60%' + }; + + // 数量占比图 + const countCtx = document.getElementById('health-count-chart'); + if (countCtx) { + new Chart(countCtx, { + type: 'doughnut', + data: { + labels: chartData.labels, + datasets: [{ + data: chartData.count_values, + backgroundColor: colors, + borderColor: borderColors, + borderWidth: 2 + }] + }, + options: chartOptions + }); } - - // Capital Risks - if (risks.capital_risks) { - const data = risks.capital_risks; - addRiskItem('medium', '资金风险', data.cash_flow_pressure, data.recommendation); + + // 金额占比图 + const amountCtx = document.getElementById('health-amount-chart'); + if (amountCtx) { + new Chart(amountCtx, { + type: 'doughnut', + data: { + labels: chartData.labels, + datasets: [{ + data: chartData.amount_values, + backgroundColor: colors, + borderColor: borderColors, + borderWidth: 2 + }] + }, + options: { + ...chartOptions, + plugins: { + ...chartOptions.plugins, + tooltip: { + ...chartOptions.plugins.tooltip, + callbacks: { + label: function(context) { + const value = context.parsed; + return ` ${context.label}: ¥${Number(value).toLocaleString('zh-CN', {minimumFractionDigits: 2})}`; + } + } + } + } + } + }); } + }, - // Market Risks - if (risks.market_risks && Array.isArray(risks.market_risks)) { - risks.market_risks.forEach(r => addRiskItem('medium', '市场风险', r.risk_description, r.recommendation)); + /** + * 渲染补货建议板块 + */ + renderReplenishmentSummary(container, data) { + if (!data) { + container.innerHTML = ''; + return; } + const stats = data.stats || {}; + const analysis = data.llm_analysis || {}; - // 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)); + // 兼容新旧数据结构 + const conclusion = analysis.conclusion || analysis; + const process = analysis.analysis_process || null; + + const urgent = stats.urgent || {}; + const suggested = stats.suggested || {}; + const optional = stats.optional || {}; + + // LLM 分析文本 + let analysisHtml = ''; + if (analysis.error) { + analysisHtml = `
    分析生成失败: ${analysis.error}
    `; + } else { + const sections = []; + + // 季节信息(如果有) + if (process && process.seasonal_analysis) { + const sa = process.seasonal_analysis; + sections.push(`
    +
    季节性分析 ${sa.current_season || ''}
    + ${sa.seasonal_priority_items ? `

    ${sa.seasonal_priority_items}

    ` : ''} + ${sa.timeline_adjustment ? `

    ${sa.timeline_adjustment}

    ` : ''} + ${sa.next_season_preparation ? `

    下季准备: ${sa.next_season_preparation}

    ` : ''} +
    `); + } + + if (conclusion.urgency_assessment) { + const ua = conclusion.urgency_assessment; + const riskTag = ua.risk_level === 'high' ? '高风险' : ua.risk_level === 'low' ? '低风险' : '中风险'; + sections.push(`
    +
    紧迫度评估 ${riskTag}
    +

    ${ua.urgent_ratio_evaluation || ''}

    + ${ua.immediate_action_needed ? '

    需要立即采取行动

    ' : ''} +
    `); + } + if (conclusion.budget_allocation) { + const ba = conclusion.budget_allocation; + sections.push(`
    +
    资金分配建议
    +

    ${ba.recommended_order || ''}

    + ${ba.urgent_budget ? `

    急需补货预算: ${ba.urgent_budget}

    ` : ''} + ${ba.suggested_budget ? `

    建议补货预算: ${ba.suggested_budget}

    ` : ''} + ${ba.optional_budget ? `

    可选补货预算: ${ba.optional_budget}

    ` : ''} +
    `); + } + if (conclusion.execution_plan) { + const ep = conclusion.execution_plan; + sections.push(`
    +
    执行节奏建议
    + ${ep.urgent_timeline ? `

    急需: ${ep.urgent_timeline}

    ` : ''} + ${ep.suggested_timeline ? `

    建议: ${ep.suggested_timeline}

    ` : ''} + ${ep.optional_timeline ? `

    可选: ${ep.optional_timeline}

    ` : ''} +
    `); + } + if (conclusion.risk_warnings && conclusion.risk_warnings.length > 0) { + const warnHtml = conclusion.risk_warnings.map(w => { + if (typeof w === 'object') { + return `
  • ${w.risk_type || ''}: ${w.description || ''}${w.mitigation ? `
    应对: ${w.mitigation}` : ''}
  • `; + } + return `
  • ${w}
  • `; + }).join(''); + sections.push(`
    +
    风险提示
    +
      ${warnHtml}
    +
    `); + } + + // 推理过程(可折叠) + let processHtml = ''; + if (process) { + processHtml = this._renderAnalysisProcess(process, 'replenishment-summary'); + } + + analysisHtml = sections.length > 0 ? `
    ${sections.join('')}${processHtml}
    ` : ''; } + + container.innerHTML = ` +
    + + 补货建议生成情况 +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    优先级配件种类数建议补货金额
    急需补货${urgent.count || 0}${Components.formatAmount(urgent.amount)}
    建议补货${suggested.count || 0}${Components.formatAmount(suggested.amount)}
    可选补货${optional.count || 0}${Components.formatAmount(optional.amount)}
    合计${stats.total_count || 0}${Components.formatAmount(stats.total_amount)}
    +
    + ${analysisHtml} + `; - feedHtml += '
    '; - return feedHtml; + // 绑定折叠事件 + this._bindProcessToggle(container); }, - renderStrategy(strategy) { - if (!strategy) return ''; + /** + * 渲染推理过程(可折叠) + */ + _renderAnalysisProcess(process, moduleId) { + if (!process) return ''; - let html = '
    '; + const sections = []; - const addStep = (num, title, items) => { - const listItems = Array.isArray(items) ? items : [items]; - const listHtml = listItems.map(i => `
  • ${i}
  • `).join(''); - html += ` -
    -
    0${num}
    -
    ${title}
    -
      ${listHtml}
    -
    `; - }; + // 计算指标 + if (process.calculated_metrics) { + const items = Object.entries(process.calculated_metrics) + .filter(([k, v]) => v && v !== '') + .map(([k, v]) => `
    ${this._formatProcessKey(k)}${v}
    `) + .join(''); + if (items) { + sections.push(`
    计算指标
    ${items}
    `); + } + } - // 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}` - ]); + // 库销比诊断 + if (process.ratio_diagnosis) { + const rd = process.ratio_diagnosis; + const items = []; + if (rd.current_value) items.push(`
    当前值${rd.current_value}
    `); + if (rd.level) items.push(`
    判断等级${rd.level}
    `); + if (rd.reasoning) items.push(`
    判断依据${rd.reasoning}
    `); + if (rd.benchmark_comparison) items.push(`
    行业对比${rd.benchmark_comparison}
    `); + if (items.length > 0) { + sections.push(`
    库销比诊断
    ${items.join('')}
    `); + } } - // 2. Phased - if (strategy.phased_procurement) { - addStep(2, '分批节奏', [ - `节奏: ${strategy.phased_procurement.suggested_rhythm}`, - `范围: ${strategy.phased_procurement.recommended_parts}` - ]); + // 结构分析 + if (process.structure_analysis) { + const sa = process.structure_analysis; + const items = []; + if (sa.in_stock_evaluation) items.push(`
    在库未锁${sa.in_stock_evaluation}
    `); + if (sa.on_way_evaluation) items.push(`
    在途${sa.on_way_evaluation}
    `); + if (sa.plan_evaluation) items.push(`
    计划数${sa.plan_evaluation}
    `); + if (sa.abnormal_items && sa.abnormal_items.length > 0) { + items.push(`
    异常项${sa.abnormal_items.join('; ')}
    `); + } + if (items.length > 0) { + sections.push(`
    结构分析
    ${items.join('')}
    `); + } } - - // 3. Coordination - if (strategy.supplier_coordination) { - addStep(3, '供应商协同', [ - strategy.supplier_coordination.key_communications, - `时机: ${strategy.supplier_coordination.timing_suggestions}` - ]); + + // 构成诊断(销量分析) + if (process.composition_diagnosis) { + const cd = process.composition_diagnosis; + const items = []; + if (cd.out_stock_evaluation) items.push(`
    90天出库${cd.out_stock_evaluation}
    `); + if (cd.locked_evaluation) items.push(`
    未关单已锁${cd.locked_evaluation}
    `); + if (cd.ongoing_evaluation) items.push(`
    未关单出库${cd.ongoing_evaluation}
    `); + if (cd.buy_evaluation) items.push(`
    订件${cd.buy_evaluation}
    `); + if (items.length > 0) { + sections.push(`
    构成诊断
    ${items.join('')}
    `); + } } - html += '
    '; - return html; - }, - - renderExpectedImpact(impact) { - if (!impact) return ''; + // 活跃度诊断 + if (process.activity_diagnosis) { + const ad = process.activity_diagnosis; + const items = []; + if (ad.current_rate) items.push(`
    当前活跃率${ad.current_rate}
    `); + if (ad.level) items.push(`
    判断等级${ad.level}
    `); + if (ad.reasoning) items.push(`
    判断依据${ad.reasoning}
    `); + if (items.length > 0) { + sections.push(`
    活跃度诊断
    ${items.join('')}
    `); + } + } - let html = '
    '; + // 趋势诊断 + if (process.trend_diagnosis) { + const td = process.trend_diagnosis; + const items = []; + if (td.signals && td.signals.length > 0) items.push(`
    趋势信号${td.signals.join('; ')}
    `); + if (td.reasoning) items.push(`
    判断依据${td.reasoning}
    `); + if (items.length > 0) { + sections.push(`
    趋势诊断
    ${items.join('')}
    `); + } + } - // Inventory - if (impact.inventory_health) { - html += ` -
    -
    库存健康度
    -
    ${Components.formatAmount(impact.inventory_health.shortage_reduction || 0)}
    -
    ${impact.inventory_health.structure_improvement}
    -
    `; + // 健康度诊断 + if (process.health_score_diagnosis) { + const hsd = process.health_score_diagnosis; + const items = []; + if (hsd.normal_ratio) items.push(`
    正常件占比${hsd.normal_ratio}
    `); + if (hsd.score) items.push(`
    健康度评分${hsd.score}
    `); + if (hsd.reasoning) items.push(`
    判断依据${hsd.reasoning}
    `); + if (items.length > 0) { + sections.push(`
    健康度诊断
    ${items.join('')}
    `); + } } - // Efficiency - if (impact.capital_efficiency) { - html += ` -
    -
    资金效率
    -
    ${Components.formatAmount(impact.capital_efficiency.investment_amount)}
    -
    ${impact.capital_efficiency.expected_return}
    -
    `; + // 问题诊断(健康度) + if (process.problem_diagnosis) { + const pd = process.problem_diagnosis; + const items = []; + ['shortage', 'stagnant', 'low_freq'].forEach(key => { + const item = pd[key]; + if (item) { + const label = key === 'shortage' ? '缺货件' : key === 'stagnant' ? '呆滞件' : '低频件'; + if (item.threshold_comparison) items.push(`
    ${label}${item.threshold_comparison}
    `); + } + }); + if (items.length > 0) { + sections.push(`
    问题诊断
    ${items.join('')}
    `); + } } - - // Next - if (impact.follow_up_actions) { - html += ` -
    -
    下一步关注
    -
    Key Actions
    -
    ${impact.follow_up_actions.next_steps}
    -
    `; + + // 资金释放计算 + if (process.capital_release_calculation) { + const crc = process.capital_release_calculation; + const items = []; + if (crc.stagnant_calculation) items.push(`
    呆滞件${crc.stagnant_calculation}
    `); + if (crc.low_freq_calculation) items.push(`
    低频件${crc.low_freq_calculation}
    `); + if (crc.total_releasable) items.push(`
    总可释放${crc.total_releasable}
    `); + if (items.length > 0) { + sections.push(`
    资金释放计算
    ${items.join('')}
    `); + } } - html += '
    '; - return html; + // 紧迫度诊断(补货建议) + if (process.urgency_diagnosis) { + const ud = process.urgency_diagnosis; + const items = []; + if (ud.urgent_ratio) items.push(`
    急需占比${ud.urgent_ratio}
    `); + if (ud.level) items.push(`
    紧迫等级${ud.level}
    `); + if (ud.reasoning) items.push(`
    判断依据${ud.reasoning}
    `); + if (items.length > 0) { + sections.push(`
    紧迫度诊断
    ${items.join('')}
    `); + } + } + + // 预算分析 + if (process.budget_analysis) { + const ba = process.budget_analysis; + const items = []; + if (ba.current_distribution) items.push(`
    当前分布${ba.current_distribution}
    `); + if (ba.comparison_with_standard) items.push(`
    标准对比${ba.comparison_with_standard}
    `); + if (ba.adjustment_needed) items.push(`
    调整建议${ba.adjustment_needed}
    `); + if (items.length > 0) { + sections.push(`
    预算分析
    ${items.join('')}
    `); + } + } + + // 风险识别 + if (process.risk_identification) { + const ri = process.risk_identification; + const items = []; + if (ri.capital_pressure_check) items.push(`
    资金压力${ri.capital_pressure_check}
    `); + if (ri.over_replenishment_check) items.push(`
    过度补货${ri.over_replenishment_check}
    `); + if (ri.identified_risks && ri.identified_risks.length > 0) { + items.push(`
    识别风险${ri.identified_risks.join('; ')}
    `); + } + if (items.length > 0) { + sections.push(`
    风险识别
    ${items.join('')}
    `); + } + } + + if (sections.length === 0) return ''; + + return ` +
    + + 查看分析推理过程 +
    +
    + ${sections.join('')} +
    + `; + }, + + /** + * 格式化推理过程的key名称 + */ + _formatProcessKey(key) { + const keyMap = { + 'in_stock_ratio': '在库未锁占比', + 'on_way_ratio': '在途占比', + 'plan_ratio': '计划数占比', + 'avg_cost': '平均成本', + 'out_stock_ratio': '90天出库占比', + 'locked_ratio': '未关单已锁占比', + 'ongoing_ratio': '未关单出库占比', + 'buy_ratio': '订件占比', + 'sku_active_rate': 'SKU活跃率', + 'avg_sales_price': '平均销售金额', + 'urgent_count_ratio': '急需数量占比', + 'urgent_amount_ratio': '急需金额占比', + 'suggested_count_ratio': '建议数量占比', + 'suggested_amount_ratio': '建议金额占比', + 'optional_count_ratio': '可选数量占比', + 'optional_amount_ratio': '可选金额占比', + }; + return keyMap[key] || key; + }, + + /** + * 绑定推理过程折叠事件 + */ + _bindProcessToggle(container) { + const toggles = container.querySelectorAll('.analysis-process-toggle'); + toggles.forEach(toggle => { + toggle.addEventListener('click', () => { + const targetId = toggle.dataset.target; + const content = document.getElementById(targetId); + if (content) { + toggle.classList.toggle('expanded'); + content.classList.toggle('expanded'); + lucide.createIcons(); + } + }); + }); }, - - // 辅助方法:renderReportCard, renderRiskCard, renderImpactCard 已被新的独立渲染逻辑取代,保留为空或删除 - renderReportCard(title, data) { return ''; }, - renderRiskCard(title, data, level) { return ''; }, - renderImpactCard(title, data) { return ''; }, /** * 初始化应用