Commit 25a8b07f6d8eb2c73251e4862df9503833215e8e
1 parent
f52f1031
feat: 重构分析报告为库存总览、销售分析、库存健康和补货总结四个主要板块。
Showing
23 changed files
with
4788 additions
and
1096 deletions
.kiro/specs/refactor-analysis-report/design.md
0 → 100644
| 1 | +# 设计文档:重构分析报告功能 | ||
| 2 | + | ||
| 3 | +## 概述 | ||
| 4 | + | ||
| 5 | +重构 AI 补货建议系统的分析报告功能,将现有的四模块宏观决策报告(整体态势研判/风险预警/采购策略/效果预期)替换为四大数据驱动板块(库存总体概览/销量分析/库存构成健康度/补货建议生成情况)。每个板块包含精确的统计数据和 LLM 生成的分析文本。 | ||
| 6 | + | ||
| 7 | +核心设计变更: | ||
| 8 | +- 从"LLM 主导分析"转变为"数据统计 + LLM 辅助分析"模式 | ||
| 9 | +- 使用 LangGraph 动态节点并发生成四个板块的 LLM 分析 | ||
| 10 | +- 前端新增 Chart.js 图表支持库存健康度可视化 | ||
| 11 | +- 数据库表结构完全重建,四个 JSON 字段分别存储各板块数据 | ||
| 12 | + | ||
| 13 | +## 架构 | ||
| 14 | + | ||
| 15 | +### 整体数据流 | ||
| 16 | + | ||
| 17 | +```mermaid | ||
| 18 | +graph TD | ||
| 19 | + A[allocate_budget 节点完成] --> B[generate_analysis_report 节点] | ||
| 20 | + B --> C[统计计算阶段] | ||
| 21 | + C --> C1[库存概览统计] | ||
| 22 | + C --> C2[销量分析统计] | ||
| 23 | + C --> C3[健康度统计] | ||
| 24 | + C --> C4[补货建议统计] | ||
| 25 | + C1 & C2 & C3 & C4 --> D[LangGraph 并发 LLM 分析] | ||
| 26 | + D --> D1[库存概览 LLM 节点] | ||
| 27 | + D --> D2[销量分析 LLM 节点] | ||
| 28 | + D --> D3[健康度 LLM 节点] | ||
| 29 | + D --> D4[补货建议 LLM 节点] | ||
| 30 | + D1 & D2 & D3 & D4 --> E[汇总报告] | ||
| 31 | + E --> F[Result_Writer 写入数据库] | ||
| 32 | + F --> G[API 返回前端] | ||
| 33 | + G --> H[Report_UI 渲染] | ||
| 34 | +``` | ||
| 35 | + | ||
| 36 | +### LangGraph 并发子图设计 | ||
| 37 | + | ||
| 38 | +在现有工作流 `allocate_budget → generate_analysis_report → END` 中,`generate_analysis_report` 节点内部使用一个 LangGraph 子图实现并发: | ||
| 39 | + | ||
| 40 | +```mermaid | ||
| 41 | +graph TD | ||
| 42 | + START[统计计算完成] --> FORK{并发分发} | ||
| 43 | + FORK --> N1[inventory_overview_llm] | ||
| 44 | + FORK --> N2[sales_analysis_llm] | ||
| 45 | + FORK --> N3[inventory_health_llm] | ||
| 46 | + FORK --> N4[replenishment_summary_llm] | ||
| 47 | + N1 & N2 & N3 & N4 --> JOIN[汇总合并] | ||
| 48 | + JOIN --> END_NODE[返回完整报告] | ||
| 49 | +``` | ||
| 50 | + | ||
| 51 | +实现方式:使用 `langgraph.graph.StateGraph` 构建子图,通过 `add_node` 添加四个并发 LLM 节点,使用 fan-out/fan-in 模式(从 START 分发到四个节点,四个节点汇聚到 END)。每个节点独立调用 LLM,失败不影响其他节点。 | ||
| 52 | + | ||
| 53 | +## 组件与接口 | ||
| 54 | + | ||
| 55 | +### 1. 统计计算模块 (analysis_report_node.py) | ||
| 56 | + | ||
| 57 | +#### `calculate_inventory_overview(part_ratios: list[dict]) -> dict` | ||
| 58 | +计算库存总体概览统计数据。有效库存使用 `valid_storage_cnt`(三项公式:in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt)。 | ||
| 59 | + | ||
| 60 | +输入:PartRatio 字典列表 | ||
| 61 | +输出: | ||
| 62 | +```python | ||
| 63 | +{ | ||
| 64 | + "total_valid_storage_cnt": Decimal, # 有效库存总数量(五项之和) | ||
| 65 | + "total_valid_storage_amount": Decimal, # 有效库存总金额(资金占用) | ||
| 66 | + "total_in_stock_unlocked_cnt": Decimal, # 在库未锁总数量 | ||
| 67 | + "total_in_stock_unlocked_amount": Decimal, | ||
| 68 | + "total_on_the_way_cnt": Decimal, # 在途总数量 | ||
| 69 | + "total_on_the_way_amount": Decimal, | ||
| 70 | + "total_has_plan_cnt": Decimal, # 计划数总数量 | ||
| 71 | + "total_has_plan_amount": Decimal, | ||
| 72 | + "total_transfer_cnt": Decimal, # 主动调拨在途总数量 | ||
| 73 | + "total_transfer_amount": Decimal, | ||
| 74 | + "total_gen_transfer_cnt": Decimal, # 自动调拨在途总数量 | ||
| 75 | + "total_gen_transfer_amount": Decimal, | ||
| 76 | + "total_avg_sales_cnt": Decimal, # 月均销量总数量 | ||
| 77 | + "overall_ratio": Decimal, # 整体库销比 | ||
| 78 | + "part_count": int, # 配件总种类数 | ||
| 79 | +} | ||
| 80 | +``` | ||
| 81 | + | ||
| 82 | +#### `calculate_sales_analysis(part_ratios: list[dict]) -> dict` | ||
| 83 | +计算销量分析统计数据。 | ||
| 84 | + | ||
| 85 | +输入:PartRatio 字典列表 | ||
| 86 | +输出: | ||
| 87 | +```python | ||
| 88 | +{ | ||
| 89 | + "total_avg_sales_cnt": Decimal, # 月均销量总数量 | ||
| 90 | + "total_avg_sales_amount": Decimal, # 月均销量总金额 | ||
| 91 | + "total_out_stock_cnt": Decimal, # 90天出库数总量 | ||
| 92 | + "total_storage_locked_cnt": Decimal, # 未关单已锁总量 | ||
| 93 | + "total_out_stock_ongoing_cnt": Decimal, # 未关单出库总量 | ||
| 94 | + "total_buy_cnt": int, # 订件总量 | ||
| 95 | + "has_sales_part_count": int, # 有销量配件数 | ||
| 96 | + "no_sales_part_count": int, # 无销量配件数 | ||
| 97 | +} | ||
| 98 | +``` | ||
| 99 | + | ||
| 100 | +#### `calculate_inventory_health(part_ratios: list[dict]) -> dict` | ||
| 101 | +计算库存构成健康度统计数据。 | ||
| 102 | + | ||
| 103 | +输入:PartRatio 字典列表 | ||
| 104 | +输出: | ||
| 105 | +```python | ||
| 106 | +{ | ||
| 107 | + "shortage": {"count": int, "amount": Decimal, "count_pct": float, "amount_pct": float}, | ||
| 108 | + "stagnant": {"count": int, "amount": Decimal, "count_pct": float, "amount_pct": float}, | ||
| 109 | + "low_freq": {"count": int, "amount": Decimal, "count_pct": float, "amount_pct": float}, | ||
| 110 | + "normal": {"count": int, "amount": Decimal, "count_pct": float, "amount_pct": float}, | ||
| 111 | + "total_count": int, | ||
| 112 | + "total_amount": Decimal, | ||
| 113 | +} | ||
| 114 | +``` | ||
| 115 | + | ||
| 116 | +#### `calculate_replenishment_summary(part_results: list) -> dict` | ||
| 117 | +计算补货建议生成情况统计数据。 | ||
| 118 | + | ||
| 119 | +输入:part_results 列表(配件汇总结果) | ||
| 120 | +输出: | ||
| 121 | +```python | ||
| 122 | +{ | ||
| 123 | + "urgent": {"count": int, "amount": Decimal}, # 急需补货 (priority=1) | ||
| 124 | + "suggested": {"count": int, "amount": Decimal}, # 建议补货 (priority=2) | ||
| 125 | + "optional": {"count": int, "amount": Decimal}, # 可选补货 (priority=3) | ||
| 126 | + "total_count": int, | ||
| 127 | + "total_amount": Decimal, | ||
| 128 | +} | ||
| 129 | +``` | ||
| 130 | + | ||
| 131 | +### 2. LLM 分析节点 | ||
| 132 | + | ||
| 133 | +四个独立的 LLM 分析函数,每个接收对应板块的统计数据,调用 LLM 生成分析文本: | ||
| 134 | + | ||
| 135 | +- `llm_analyze_inventory_overview(stats: dict) -> str` — 返回 JSON 字符串 | ||
| 136 | +- `llm_analyze_sales(stats: dict) -> str` — 返回 JSON 字符串 | ||
| 137 | +- `llm_analyze_inventory_health(stats: dict) -> str` — 返回 JSON 字符串 | ||
| 138 | +- `llm_analyze_replenishment_summary(stats: dict) -> str` — 返回 JSON 字符串 | ||
| 139 | + | ||
| 140 | +每个函数加载对应的提示词模板(统一放在 `prompts/analysis_report.md` 中,按板块分段),填充统计数据,调用 LLM,解析 JSON 响应。 | ||
| 141 | + | ||
| 142 | +### 3. 提示词文件 | ||
| 143 | + | ||
| 144 | +拆分为四个独立提示词文件,每个板块一个,确保分析专业且有实际决策价值: | ||
| 145 | + | ||
| 146 | +#### prompts/report_inventory_overview.md — 库存概览分析 | ||
| 147 | + | ||
| 148 | +角色:资深汽车配件库存管理专家。输入统计数据后,要求 LLM 分析: | ||
| 149 | +- **资金占用评估**:总库存金额是否合理,各构成部分(在库未锁/在途/计划数/主动调拨在途/自动调拨在途)的资金分配比例是否健康 | ||
| 150 | +- **库销比诊断**:当前整体库销比处于什么水平(<1 库存不足,1-2 合理,2-3 偏高,>3 严重积压),对比行业经验给出判断 | ||
| 151 | +- **库存结构建议**:基于五项构成的比例,给出具体的库存结构优化方向 | ||
| 152 | + | ||
| 153 | +输出 JSON 格式: | ||
| 154 | +```json | ||
| 155 | +{ | ||
| 156 | + "capital_assessment": { | ||
| 157 | + "total_evaluation": "总资金占用评估", | ||
| 158 | + "structure_ratio": "各构成部分的资金比例分析", | ||
| 159 | + "risk_level": "high/medium/low" | ||
| 160 | + }, | ||
| 161 | + "ratio_diagnosis": { | ||
| 162 | + "level": "不足/合理/偏高/严重积压", | ||
| 163 | + "analysis": "库销比具体分析", | ||
| 164 | + "benchmark": "行业参考值对比" | ||
| 165 | + }, | ||
| 166 | + "recommendations": ["具体建议1", "具体建议2"] | ||
| 167 | +} | ||
| 168 | +``` | ||
| 169 | + | ||
| 170 | +#### prompts/report_sales_analysis.md — 销量分析 | ||
| 171 | + | ||
| 172 | +角色:汽车配件销售数据分析师。输入统计数据后,要求 LLM 分析: | ||
| 173 | +- **销量构成解读**:90天出库数占比最大说明正常销售为主,未关单已锁/出库占比高说明有大量待处理订单,订件占比高说明客户预订需求旺盛 | ||
| 174 | +- **销售活跃度**:有销量 vs 无销量配件的比例反映 SKU 活跃度,无销量占比过高说明 SKU 管理需要优化 | ||
| 175 | +- **需求趋势判断**:基于各组成部分的比例关系,判断当前需求是稳定、上升还是下降趋势 | ||
| 176 | + | ||
| 177 | +输出 JSON 格式: | ||
| 178 | +```json | ||
| 179 | +{ | ||
| 180 | + "composition_analysis": { | ||
| 181 | + "main_driver": "主要销量来源分析", | ||
| 182 | + "pending_orders_impact": "未关单对销量的影响", | ||
| 183 | + "booking_trend": "订件趋势分析" | ||
| 184 | + }, | ||
| 185 | + "activity_assessment": { | ||
| 186 | + "active_ratio": "活跃SKU占比评估", | ||
| 187 | + "optimization_suggestion": "SKU优化建议" | ||
| 188 | + }, | ||
| 189 | + "demand_trend": { | ||
| 190 | + "direction": "上升/稳定/下降", | ||
| 191 | + "evidence": "判断依据", | ||
| 192 | + "forecast": "短期需求预测" | ||
| 193 | + } | ||
| 194 | +} | ||
| 195 | +``` | ||
| 196 | + | ||
| 197 | +#### prompts/report_inventory_health.md — 库存健康度分析 | ||
| 198 | + | ||
| 199 | +角色:汽车配件库存健康度诊断专家。输入统计数据后,要求 LLM 分析: | ||
| 200 | +- **健康度评分**:基于正常件占比给出整体健康度评分(正常件>70%为健康,50-70%为亚健康,<50%为不健康) | ||
| 201 | +- **问题诊断**:呆滞件占比高说明采购决策需要优化,缺货件占比高说明补货不及时,低频件占比高说明 SKU 精简空间大 | ||
| 202 | +- **资金释放机会**:呆滞件和低频件占用的资金可以通过促销、退货等方式释放,给出具体金额估算 | ||
| 203 | +- **改善优先级**:按影响程度排序,给出最应优先处理的问题类型 | ||
| 204 | + | ||
| 205 | +输出 JSON 格式: | ||
| 206 | +```json | ||
| 207 | +{ | ||
| 208 | + "health_score": { | ||
| 209 | + "score": "健康/亚健康/不健康", | ||
| 210 | + "normal_ratio_evaluation": "正常件占比评估" | ||
| 211 | + }, | ||
| 212 | + "problem_diagnosis": { | ||
| 213 | + "stagnant_analysis": "呆滞件问题分析及原因", | ||
| 214 | + "shortage_analysis": "缺货件问题分析及影响", | ||
| 215 | + "low_freq_analysis": "低频件问题分析及建议" | ||
| 216 | + }, | ||
| 217 | + "capital_release": { | ||
| 218 | + "stagnant_releasable": "呆滞件可释放资金估算", | ||
| 219 | + "low_freq_releasable": "低频件可释放资金估算", | ||
| 220 | + "action_plan": "资金释放行动方案" | ||
| 221 | + }, | ||
| 222 | + "priority_actions": ["最优先处理事项1", "最优先处理事项2"] | ||
| 223 | +} | ||
| 224 | +``` | ||
| 225 | + | ||
| 226 | +#### prompts/report_replenishment_summary.md — 补货建议分析 | ||
| 227 | + | ||
| 228 | +角色:汽车配件采购策略顾问。输入统计数据后,要求 LLM 分析: | ||
| 229 | +- **紧迫度评估**:急需补货占比反映当前缺货风险程度,急需占比>30%说明库存管理存在较大问题 | ||
| 230 | +- **资金分配建议**:基于各优先级的金额分布,给出资金分配的先后顺序和比例建议 | ||
| 231 | +- **执行节奏建议**:急需补货应立即执行,建议补货可在1-2周内完成,可选补货可根据资金情况灵活安排 | ||
| 232 | +- **风险提示**:如果可选补货金额占比过高,提示可能存在过度补货风险 | ||
| 233 | + | ||
| 234 | +输出 JSON 格式: | ||
| 235 | +```json | ||
| 236 | +{ | ||
| 237 | + "urgency_assessment": { | ||
| 238 | + "urgent_ratio_evaluation": "急需补货占比评估", | ||
| 239 | + "risk_level": "high/medium/low", | ||
| 240 | + "immediate_action_needed": true/false | ||
| 241 | + }, | ||
| 242 | + "budget_allocation": { | ||
| 243 | + "recommended_order": "建议资金分配顺序", | ||
| 244 | + "urgent_budget": "急需补货建议预算", | ||
| 245 | + "suggested_budget": "建议补货建议预算", | ||
| 246 | + "optional_budget": "可选补货建议预算" | ||
| 247 | + }, | ||
| 248 | + "execution_plan": { | ||
| 249 | + "urgent_timeline": "急需补货执行时间建议", | ||
| 250 | + "suggested_timeline": "建议补货执行时间建议", | ||
| 251 | + "optional_timeline": "可选补货执行时间建议" | ||
| 252 | + }, | ||
| 253 | + "risk_warnings": ["风险提示1", "风险提示2"] | ||
| 254 | +} | ||
| 255 | +``` | ||
| 256 | + | ||
| 257 | +### 4. API 接口 (tasks.py) | ||
| 258 | + | ||
| 259 | +更新 `GET /api/tasks/{task_no}/analysis-report` 端点: | ||
| 260 | + | ||
| 261 | +响应模型 `AnalysisReportResponse`: | ||
| 262 | +```python | ||
| 263 | +class AnalysisReportResponse(BaseModel): | ||
| 264 | + id: int | ||
| 265 | + task_no: str | ||
| 266 | + group_id: int | ||
| 267 | + dealer_grouping_id: int | ||
| 268 | + dealer_grouping_name: Optional[str] | ||
| 269 | + report_type: str | ||
| 270 | + | ||
| 271 | + inventory_overview: Optional[Dict[str, Any]] # 库存概览(统计+分析) | ||
| 272 | + sales_analysis: Optional[Dict[str, Any]] # 销量分析(统计+分析) | ||
| 273 | + inventory_health: Optional[Dict[str, Any]] # 健康度(统计+分析+图表数据) | ||
| 274 | + replenishment_summary: Optional[Dict[str, Any]] # 补货建议(统计+分析) | ||
| 275 | + | ||
| 276 | + llm_provider: Optional[str] | ||
| 277 | + llm_model: Optional[str] | ||
| 278 | + llm_tokens: int | ||
| 279 | + execution_time_ms: int | ||
| 280 | + statistics_date: Optional[str] | ||
| 281 | + create_time: Optional[str] | ||
| 282 | +``` | ||
| 283 | + | ||
| 284 | +### 5. 前端渲染 (app.js) | ||
| 285 | + | ||
| 286 | +`renderReportTab()` 重写,渲染四个板块: | ||
| 287 | + | ||
| 288 | +1. **库存概览板块**: 统计卡片(总数量、总金额、库销比)+ 五项构成明细表(在库未锁/在途/计划数/主动调拨在途/自动调拨在途)+ LLM 分析文本 | ||
| 289 | +2. **销量分析板块**: 统计卡片(月均销量、总金额)+ 构成明细表 + LLM 分析文本 | ||
| 290 | +3. **健康度板块**: 统计卡片 + Chart.js 环形图(数量占比 + 金额占比)+ LLM 分析文本 | ||
| 291 | +4. **补货建议板块**: 优先级统计表 + LLM 分析文本 | ||
| 292 | + | ||
| 293 | +### 6. Chart.js 集成 | ||
| 294 | + | ||
| 295 | +在 `ui/index.html` 中通过 CDN 引入 Chart.js: | ||
| 296 | +```html | ||
| 297 | +<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script> | ||
| 298 | +``` | ||
| 299 | + | ||
| 300 | +健康度板块使用两个环形图(Doughnut Chart): | ||
| 301 | +- 数量占比图:缺货/呆滞/低频/正常 四类的数量百分比 | ||
| 302 | +- 金额占比图:缺货/呆滞/低频/正常 四类的金额百分比 | ||
| 303 | + | ||
| 304 | +## 数据模型 | ||
| 305 | + | ||
| 306 | +### 数据库表 (ai_analysis_report) | ||
| 307 | + | ||
| 308 | +```sql | ||
| 309 | +DROP TABLE IF EXISTS ai_analysis_report; | ||
| 310 | +CREATE TABLE ai_analysis_report ( | ||
| 311 | + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', | ||
| 312 | + task_no VARCHAR(32) NOT NULL COMMENT '任务编号', | ||
| 313 | + group_id BIGINT NOT NULL COMMENT '集团ID', | ||
| 314 | + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID', | ||
| 315 | + dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称', | ||
| 316 | + brand_grouping_id BIGINT COMMENT '品牌组合ID', | ||
| 317 | + report_type VARCHAR(32) DEFAULT 'replenishment' COMMENT '报告类型', | ||
| 318 | + | ||
| 319 | + -- 四大板块 (JSON 结构化存储,每个字段包含 stats + llm_analysis) | ||
| 320 | + inventory_overview JSON COMMENT '库存总体概览(统计数据+LLM分析)', | ||
| 321 | + sales_analysis JSON COMMENT '销量分析(统计数据+LLM分析)', | ||
| 322 | + inventory_health JSON COMMENT '库存构成健康度(统计数据+图表数据+LLM分析)', | ||
| 323 | + replenishment_summary JSON COMMENT '补货建议生成情况(统计数据+LLM分析)', | ||
| 324 | + | ||
| 325 | + -- LLM 元数据 | ||
| 326 | + llm_provider VARCHAR(32) COMMENT 'LLM提供商', | ||
| 327 | + llm_model VARCHAR(64) COMMENT 'LLM模型名称', | ||
| 328 | + llm_tokens INT DEFAULT 0 COMMENT 'LLM Token消耗', | ||
| 329 | + execution_time_ms INT DEFAULT 0 COMMENT '执行耗时(毫秒)', | ||
| 330 | + | ||
| 331 | + statistics_date VARCHAR(16) COMMENT '统计日期', | ||
| 332 | + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', | ||
| 333 | + | ||
| 334 | + INDEX idx_task_no (task_no), | ||
| 335 | + INDEX idx_group_date (group_id, statistics_date), | ||
| 336 | + INDEX idx_dealer_grouping (dealer_grouping_id) | ||
| 337 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议分析报告表-重构版'; | ||
| 338 | +``` | ||
| 339 | + | ||
| 340 | +### Python 数据模型 (AnalysisReport) | ||
| 341 | + | ||
| 342 | +```python | ||
| 343 | +@dataclass | ||
| 344 | +class AnalysisReport: | ||
| 345 | + task_no: str | ||
| 346 | + group_id: int | ||
| 347 | + dealer_grouping_id: int | ||
| 348 | + | ||
| 349 | + id: Optional[int] = None | ||
| 350 | + dealer_grouping_name: Optional[str] = None | ||
| 351 | + brand_grouping_id: Optional[int] = None | ||
| 352 | + report_type: str = "replenishment" | ||
| 353 | + | ||
| 354 | + # 四大板块 | ||
| 355 | + inventory_overview: Optional[Dict[str, Any]] = None | ||
| 356 | + sales_analysis: Optional[Dict[str, Any]] = None | ||
| 357 | + inventory_health: Optional[Dict[str, Any]] = None | ||
| 358 | + replenishment_summary: Optional[Dict[str, Any]] = None | ||
| 359 | + | ||
| 360 | + # LLM 元数据 | ||
| 361 | + llm_provider: str = "" | ||
| 362 | + llm_model: str = "" | ||
| 363 | + llm_tokens: int = 0 | ||
| 364 | + execution_time_ms: int = 0 | ||
| 365 | + | ||
| 366 | + statistics_date: str = "" | ||
| 367 | + create_time: Optional[datetime] = None | ||
| 368 | +``` | ||
| 369 | + | ||
| 370 | +### 每个板块的 JSON 数据结构 | ||
| 371 | + | ||
| 372 | +每个板块的 JSON 包含 `stats`(统计数据)和 `llm_analysis`(LLM 分析文本)两部分: | ||
| 373 | + | ||
| 374 | +```python | ||
| 375 | +# inventory_overview 示例 | ||
| 376 | +{ | ||
| 377 | + "stats": { | ||
| 378 | + "total_valid_storage_cnt": 12500, | ||
| 379 | + "total_valid_storage_amount": 3250000.00, | ||
| 380 | + "total_in_stock_unlocked_cnt": 8000, | ||
| 381 | + "total_in_stock_unlocked_amount": 2080000.00, | ||
| 382 | + "total_on_the_way_cnt": 2500, | ||
| 383 | + "total_on_the_way_amount": 650000.00, | ||
| 384 | + "total_has_plan_cnt": 1000, | ||
| 385 | + "total_has_plan_amount": 260000.00, | ||
| 386 | + "total_transfer_cnt": 600, | ||
| 387 | + "total_transfer_amount": 156000.00, | ||
| 388 | + "total_gen_transfer_cnt": 400, | ||
| 389 | + "total_gen_transfer_amount": 104000.00, | ||
| 390 | + "total_avg_sales_cnt": 5000, | ||
| 391 | + "overall_ratio": 2.5, | ||
| 392 | + "part_count": 800 | ||
| 393 | + }, | ||
| 394 | + "llm_analysis": { ... } # LLM 返回的 JSON 分析对象 | ||
| 395 | +} | ||
| 396 | + | ||
| 397 | +# inventory_health 示例(额外包含 chart_data) | ||
| 398 | +{ | ||
| 399 | + "stats": { | ||
| 400 | + "shortage": {"count": 50, "amount": 125000, "count_pct": 6.25, "amount_pct": 3.85}, | ||
| 401 | + "stagnant": {"count": 120, "amount": 480000, "count_pct": 15.0, "amount_pct": 14.77}, | ||
| 402 | + "low_freq": {"count": 200, "amount": 300000, "count_pct": 25.0, "amount_pct": 9.23}, | ||
| 403 | + "normal": {"count": 430, "amount": 2345000, "count_pct": 53.75, "amount_pct": 72.15}, | ||
| 404 | + "total_count": 800, | ||
| 405 | + "total_amount": 3250000 | ||
| 406 | + }, | ||
| 407 | + "chart_data": { | ||
| 408 | + "labels": ["缺货件", "呆滞件", "低频件", "正常件"], | ||
| 409 | + "count_values": [50, 120, 200, 430], | ||
| 410 | + "amount_values": [125000, 480000, 300000, 2345000] | ||
| 411 | + }, | ||
| 412 | + "llm_analysis": { ... } | ||
| 413 | +} | ||
| 414 | +``` | ||
| 415 | + | ||
| 416 | + | ||
| 417 | +## 正确性属性 | ||
| 418 | + | ||
| 419 | +*正确性属性是系统在所有合法执行中都应保持为真的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规范与机器可验证正确性保证之间的桥梁。* | ||
| 420 | + | ||
| 421 | +### Property 1: 库存概览统计一致性 | ||
| 422 | + | ||
| 423 | +*对于任意* PartRatio 字典列表,`calculate_inventory_overview` 的输出应满足: | ||
| 424 | +- `total_valid_storage_cnt` = 所有配件 `(in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt)` 之和 | ||
| 425 | +- `total_valid_storage_amount` = 所有配件 `(in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt) × cost_price` 之和 | ||
| 426 | +- `total_in_stock_unlocked_cnt + total_on_the_way_cnt + total_has_plan_cnt` = `total_valid_storage_cnt`(构成不变量) | ||
| 427 | +- 当 `total_avg_sales_cnt > 0` 时,`overall_ratio` = `total_valid_storage_cnt / total_avg_sales_cnt` | ||
| 428 | + | ||
| 429 | +**Validates: Requirements 2.1, 2.2, 2.3** | ||
| 430 | + | ||
| 431 | +### Property 2: 销量分析统计一致性 | ||
| 432 | + | ||
| 433 | +*对于任意* PartRatio 字典列表,`calculate_sales_analysis` 的输出应满足: | ||
| 434 | +- `total_avg_sales_cnt` = 所有配件 `(out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3` 之和 | ||
| 435 | +- `(total_out_stock_cnt + total_storage_locked_cnt + total_out_stock_ongoing_cnt + total_buy_cnt) / 3` = `total_avg_sales_cnt`(构成不变量) | ||
| 436 | +- `has_sales_part_count + no_sales_part_count` = 总配件数 | ||
| 437 | + | ||
| 438 | +**Validates: Requirements 3.1, 3.2, 3.4** | ||
| 439 | + | ||
| 440 | +### Property 3: 健康度分类完备性与一致性 | ||
| 441 | + | ||
| 442 | +*对于任意* PartRatio 字典列表,`calculate_inventory_health` 的输出应满足: | ||
| 443 | +- `shortage.count + stagnant.count + low_freq.count + normal.count` = `total_count`(分类完备) | ||
| 444 | +- `shortage.amount + stagnant.amount + low_freq.amount + normal.amount` = `total_amount`(金额守恒) | ||
| 445 | +- 每种类型的 `count_pct` = `count / total_count × 100` | ||
| 446 | +- 所有 `count_pct` 之和 ≈ 100.0(浮点精度容差内) | ||
| 447 | + | ||
| 448 | +**Validates: Requirements 4.1, 4.2** | ||
| 449 | + | ||
| 450 | +### Property 4: 补货建议统计一致性 | ||
| 451 | + | ||
| 452 | +*对于任意* part_results 列表,`calculate_replenishment_summary` 的输出应满足: | ||
| 453 | +- `urgent.count + suggested.count + optional.count` = `total_count` | ||
| 454 | +- `urgent.amount + suggested.amount + optional.amount` = `total_amount` | ||
| 455 | + | ||
| 456 | +**Validates: Requirements 5.1, 5.2** | ||
| 457 | + | ||
| 458 | +### Property 5: 报告数据模型序列化 round-trip | ||
| 459 | + | ||
| 460 | +*对于任意* 合法的 AnalysisReport 对象,调用 `to_dict()` 后再用返回的字典构造新的 AnalysisReport 对象,两个对象的核心字段应相等。 | ||
| 461 | + | ||
| 462 | +**Validates: Requirements 10.2, 10.3** | ||
| 463 | + | ||
| 464 | +## 错误处理 | ||
| 465 | + | ||
| 466 | +| 错误场景 | 处理方式 | | ||
| 467 | +|---------|---------| | ||
| 468 | +| LLM 单板块调用失败 | 该板块 `llm_analysis` 置为 `{"error": "错误信息"}`,其他板块正常生成 | | ||
| 469 | +| LLM 返回非法 JSON | 记录原始响应到日志,`llm_analysis` 置为 `{"error": "JSON解析失败", "raw": "原始文本前200字符"}` | | ||
| 470 | +| PartRatio 列表为空 | 所有统计值为 0,LLM 分析说明无数据 | | ||
| 471 | +| part_results 列表为空 | 补货建议板块统计值为 0,LLM 分析说明无补货建议 | | ||
| 472 | +| 数据库写入失败 | 记录错误日志,不中断主工作流,返回包含错误信息的报告 | | ||
| 473 | +| 提示词文件缺失 | 抛出 FileNotFoundError,由上层节点捕获并记录 | | ||
| 474 | + | ||
| 475 | +## 测试策略 | ||
| 476 | + | ||
| 477 | +### 属性测试 (Property-Based Testing) | ||
| 478 | + | ||
| 479 | +使用 `hypothesis` 库进行属性测试,每个属性至少运行 100 次迭代。 | ||
| 480 | + | ||
| 481 | +- **Property 1**: 生成随机 PartRatio 字典列表(随机数量、随机字段值),验证库存概览统计的不变量 | ||
| 482 | + - Tag: **Feature: refactor-analysis-report, Property 1: 库存概览统计一致性** | ||
| 483 | +- **Property 2**: 生成随机 PartRatio 字典列表,验证销量分析统计的不变量 | ||
| 484 | + - Tag: **Feature: refactor-analysis-report, Property 2: 销量分析统计一致性** | ||
| 485 | +- **Property 3**: 生成随机 PartRatio 字典列表,验证健康度分类的完备性和一致性 | ||
| 486 | + - Tag: **Feature: refactor-analysis-report, Property 3: 健康度分类完备性与一致性** | ||
| 487 | +- **Property 4**: 生成随机 part_results 列表(随机优先级和金额),验证补货建议统计的一致性 | ||
| 488 | + - Tag: **Feature: refactor-analysis-report, Property 4: 补货建议统计一致性** | ||
| 489 | +- **Property 5**: 生成随机 AnalysisReport 对象,验证 to_dict round-trip | ||
| 490 | + - Tag: **Feature: refactor-analysis-report, Property 5: 报告数据模型序列化 round-trip** | ||
| 491 | + | ||
| 492 | +### 单元测试 | ||
| 493 | + | ||
| 494 | +- 边界情况:空列表输入、月均销量为零、所有配件同一类型 | ||
| 495 | +- LLM 响应解析:合法 JSON、非法 JSON、空响应 | ||
| 496 | +- API 端点:有报告数据、无报告数据 | ||
| 497 | + | ||
| 498 | +### 集成测试 | ||
| 499 | + | ||
| 500 | +- 完整报告生成流程(mock LLM) | ||
| 501 | +- 数据库写入和读取一致性 | ||
| 502 | +- 前端渲染(手动验证) |
.kiro/specs/refactor-analysis-report/requirements.md
0 → 100644
| 1 | +# 需求文档:重构分析报告功能 | ||
| 2 | + | ||
| 3 | +## 简介 | ||
| 4 | + | ||
| 5 | +重构 AI 补货建议系统的分析报告功能。先清理现有分析报告相关代码(仅限分析报告模块,不涉及其他模块),再基于新结构重建。新报告涵盖库存总体概览、销量分析、库存构成健康度、补货建议生成情况四大板块。每个板块包含统计数据和 LLM 生成的分析文本。考虑使用 LangGraph 动态节点并发生成各板块统计及分析,最后汇总。涉及全栈改动:数据库表结构、数据模型、Agent 节点、LLM 提示词、API 接口、前端页面。 | ||
| 6 | + | ||
| 7 | +## 术语表 | ||
| 8 | + | ||
| 9 | +- **Report_Generator**: 分析报告生成节点(analysis_report_node),负责统计计算和调用 LLM 生成报告 | ||
| 10 | +- **Report_API**: 分析报告 API 接口,负责从数据库读取报告并返回给前端 | ||
| 11 | +- **Report_UI**: 前端报告渲染模块,负责展示报告数据和图表 | ||
| 12 | +- **PartRatio**: 配件库销比数据,包含库存、销量、成本等原始数据 | ||
| 13 | +- **有效库存**: valid_storage_cnt = in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt(在库未锁 + 在途 + 计划数) | ||
| 14 | +- **月均销量**: (90天出库数 + 未关单已锁 + 未关单出库 + 订件) / 3 | ||
| 15 | +- **库销比**: 有效库存(valid_storage_cnt) / 月均销量 | ||
| 16 | +- **呆滞件**: 有效库存(valid_storage_cnt) > 0 且 90天出库数 = 0 的配件 | ||
| 17 | +- **低频件**: 月均销量 < 1 或 出库次数 < 3 或 出库间隔 >= 30天 的配件 | ||
| 18 | +- **缺货件**: 有效库存(valid_storage_cnt) = 0 且 月均销量 >= 1 的配件 | ||
| 19 | +- **正常件**: 不属于呆滞件、低频件、缺货件的配件 | ||
| 20 | +- **急需补货**: 优先级 1,库销比 < 0.5 且月均销量 >= 1 | ||
| 21 | +- **建议补货**: 优先级 2,库销比 0.5-1.0 且月均销量 >= 1 | ||
| 22 | +- **可选补货**: 优先级 3,库销比 1.0-目标值 且月均销量 >= 1 | ||
| 23 | +- **Result_Writer**: 数据库写入服务,负责将报告持久化到 MySQL | ||
| 24 | + | ||
| 25 | +## 需求 | ||
| 26 | + | ||
| 27 | +### 需求 1:清理现有分析报告代码 | ||
| 28 | + | ||
| 29 | +**用户故事:** 作为开发者,我希望先清理现有分析报告相关代码,以便在干净的基础上重构新报告功能。 | ||
| 30 | + | ||
| 31 | +#### 验收标准 | ||
| 32 | + | ||
| 33 | +1. WHEN 开始重构, THE 开发者 SHALL 清理 analysis_report_node.py 中的现有报告生成逻辑 | ||
| 34 | +2. WHEN 开始重构, THE 开发者 SHALL 清理 analysis_report.py 中的现有数据模型 | ||
| 35 | +3. WHEN 开始重构, THE 开发者 SHALL 清理 prompts/analysis_report.md 中的现有提示词 | ||
| 36 | +4. WHEN 开始重构, THE 开发者 SHALL 清理前端 app.js 中的现有报告渲染代码(renderReportTab、renderOverallAssessment、renderRiskAlerts、renderStrategy、renderExpectedImpact) | ||
| 37 | +5. WHEN 清理代码, THE 开发者 SHALL 仅清理分析报告相关代码,保持其他模块代码不变 | ||
| 38 | + | ||
| 39 | +### 需求 2:库存总体概览统计 | ||
| 40 | + | ||
| 41 | +**用户故事:** 作为采购决策者,我希望看到库存总体概览(总数量、总金额/资金占用、库销比及库存构成明细),以便快速了解当前库存状况和资金占用情况。 | ||
| 42 | + | ||
| 43 | +#### 验收标准 | ||
| 44 | + | ||
| 45 | +1. WHEN Report_Generator 接收到 PartRatio 列表, THE Report_Generator SHALL 使用 valid_storage_cnt(in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt)计算所有配件的有效库存总数量和总金额(有效库存 × 成本价之和) | ||
| 46 | +2. WHEN Report_Generator 计算库存构成, THE Report_Generator SHALL 分别统计在库未锁(in_stock_unlocked_cnt)、在途(on_the_way_cnt)、计划数(has_plan_cnt)的总数量/总金额 | ||
| 47 | +3. WHEN Report_Generator 计算库销比, THE Report_Generator SHALL 使用有效库存总数量除以月均销量总数量得到整体库销比 | ||
| 48 | +4. WHEN 库存概览统计完成, THE Report_Generator SHALL 将统计数据作为上下文传递给 LLM 生成库存概览分析文本 | ||
| 49 | +5. IF 月均销量总数量为零, THEN THE Report_Generator SHALL 将库销比标记为特殊值并在分析中说明无销量数据 | ||
| 50 | + | ||
| 51 | +### 需求 3:销量分析统计 | ||
| 52 | + | ||
| 53 | +**用户故事:** 作为采购决策者,我希望看到销量分析(月均销量及各组成部分明细),以便了解销售趋势和需求分布。 | ||
| 54 | + | ||
| 55 | +#### 验收标准 | ||
| 56 | + | ||
| 57 | +1. WHEN Report_Generator 接收到 PartRatio 列表, THE Report_Generator SHALL 计算所有配件的月均销量总数量和总金额(月均销量 × 成本价之和) | ||
| 58 | +2. WHEN Report_Generator 计算销量构成, THE Report_Generator SHALL 分别统计90天出库数(out_stock_cnt)总量、未关单已锁(storage_locked_cnt)总量、未关单出库(out_stock_ongoing_cnt)总量、订件(buy_cnt)总量 | ||
| 59 | +3. WHEN 销量统计完成, THE Report_Generator SHALL 将统计数据作为上下文传递给 LLM 生成销量分析文本 | ||
| 60 | +4. WHEN Report_Generator 计算销量分布, THE Report_Generator SHALL 统计有销量配件数(月均销量 > 0)和无销量配件数(月均销量 = 0) | ||
| 61 | + | ||
| 62 | +### 需求 4:库存构成健康度统计 | ||
| 63 | + | ||
| 64 | +**用户故事:** 作为采购决策者,我希望看到库存健康度分析(缺货/呆滞/低频/正常各类型的数量和金额占比),以便识别库存结构问题。 | ||
| 65 | + | ||
| 66 | +#### 验收标准 | ||
| 67 | + | ||
| 68 | +1. WHEN Report_Generator 分类配件, THE Report_Generator SHALL 将每个配件归类为缺货件、呆滞件、低频件或正常件中的一种 | ||
| 69 | +2. WHEN Report_Generator 统计各类型, THE Report_Generator SHALL 计算每种类型的配件数量、占总数量的百分比、涉及金额(有效库存 × 成本价)、占总金额的百分比 | ||
| 70 | +3. WHEN 健康度统计完成, THE Report_Generator SHALL 将统计数据作为上下文传递给 LLM 生成健康度分析文本 | ||
| 71 | +4. WHEN Report_API 返回健康度数据, THE Report_API SHALL 返回包含各类型数量和金额的结构化数据,供前端生成图表 | ||
| 72 | +5. WHEN Report_UI 展示健康度数据, THE Report_UI SHALL 使用图表展示各类型配件的数量占比和金额占比 | ||
| 73 | + | ||
| 74 | +### 需求 5:补货建议生成情况统计 | ||
| 75 | + | ||
| 76 | +**用户故事:** 作为采购决策者,我希望看到本次补货建议的生成情况(急需/建议/可选各优先级的配件数和金额),以便了解补货的紧迫程度和资金需求。 | ||
| 77 | + | ||
| 78 | +#### 验收标准 | ||
| 79 | + | ||
| 80 | +1. WHEN Report_Generator 统计补货建议, THE Report_Generator SHALL 按优先级(急需补货/建议补货/可选补货)分别统计配件种类数和建议补货总金额 | ||
| 81 | +2. WHEN Report_Generator 统计补货总量, THE Report_Generator SHALL 计算所有优先级的配件总种类数和总金额 | ||
| 82 | +3. WHEN 补货统计完成, THE Report_Generator SHALL 将统计数据作为上下文传递给 LLM 生成补货建议分析文本 | ||
| 83 | + | ||
| 84 | +### 需求 6:数据库表结构重构 | ||
| 85 | + | ||
| 86 | +**用户故事:** 作为开发者,我希望数据库表结构能够存储新的报告模块数据,以便支持四大板块的数据持久化。 | ||
| 87 | + | ||
| 88 | +#### 验收标准 | ||
| 89 | + | ||
| 90 | +1. THE ai_analysis_report 表 SHALL 包含库存概览模块的 JSON 字段(inventory_overview)存储统计数据和 LLM 分析 | ||
| 91 | +2. THE ai_analysis_report 表 SHALL 包含销量分析模块的 JSON 字段(sales_analysis)存储统计数据和 LLM 分析 | ||
| 92 | +3. THE ai_analysis_report 表 SHALL 包含健康度模块的 JSON 字段(inventory_health)存储统计数据和 LLM 分析 | ||
| 93 | +4. THE ai_analysis_report 表 SHALL 包含补货建议模块的 JSON 字段(replenishment_summary)存储统计数据和 LLM 分析 | ||
| 94 | +5. THE ai_analysis_report 表 SHALL 保留 task_no、group_id、dealer_grouping_id 等基础字段和 LLM 元数据字段 | ||
| 95 | + | ||
| 96 | +### 需求 7:LLM 提示词重构与并发生成 | ||
| 97 | + | ||
| 98 | +**用户故事:** 作为开发者,我希望使用 LangGraph 动态节点并发生成各板块的 LLM 分析,提高报告生成效率。 | ||
| 99 | + | ||
| 100 | +#### 验收标准 | ||
| 101 | + | ||
| 102 | +1. WHEN Report_Generator 生成报告, THE Report_Generator SHALL 使用 LangGraph 动态节点并发调用 LLM 生成四个板块的分析文本 | ||
| 103 | +2. WHEN 每个板块节点调用 LLM, THE 节点 SHALL 在提示词中仅包含该板块相关的统计数据 | ||
| 104 | +3. WHEN LLM 生成分析, THE LLM 输出 SHALL 为合法的 JSON 对象 | ||
| 105 | +4. WHEN 所有板块分析完成, THE Report_Generator SHALL 汇总四个板块的统计数据和 LLM 分析结果为一个完整报告 | ||
| 106 | +5. IF 某个板块的 LLM 调用失败, THEN THE Report_Generator SHALL 记录错误日志,该板块分析文本置为错误提示,其他板块继续正常生成 | ||
| 107 | + | ||
| 108 | +### 需求 8:API 接口重构 | ||
| 109 | + | ||
| 110 | +**用户故事:** 作为前端开发者,我希望 API 返回新结构的报告数据,以便前端能够渲染四大板块。 | ||
| 111 | + | ||
| 112 | +#### 验收标准 | ||
| 113 | + | ||
| 114 | +1. WHEN 前端请求分析报告, THE Report_API SHALL 返回包含四个模块(inventory_overview、sales_analysis、inventory_health、replenishment_summary)的 JSON 响应 | ||
| 115 | +2. WHEN 报告中包含 JSON 字符串字段, THE Report_API SHALL 将其解析为 JSON 对象后返回 | ||
| 116 | +3. IF 指定 task_no 无报告数据, THEN THE Report_API SHALL 返回 null | ||
| 117 | + | ||
| 118 | +### 需求 9:前端报告页面重构 | ||
| 119 | + | ||
| 120 | +**用户故事:** 作为采购决策者,我希望在前端看到结构清晰的四大板块报告,包含统计数据卡片、图表和 LLM 分析文本。 | ||
| 121 | + | ||
| 122 | +#### 验收标准 | ||
| 123 | + | ||
| 124 | +1. WHEN Report_UI 渲染报告, THE Report_UI SHALL 按库存概览、销量分析、健康度、补货建议的顺序展示四个板块 | ||
| 125 | +2. WHEN Report_UI 渲染库存概览板块, THE Report_UI SHALL 展示总库存数量、总金额(资金占用)、库销比的统计卡片,以及三项构成明细(在库未锁/在途/计划数)和 LLM 分析文本 | ||
| 126 | +3. WHEN Report_UI 渲染销量分析板块, THE Report_UI SHALL 展示月均销量总量、销量构成明细(90天出库/未关单已锁/未关单出库/订件)和 LLM 分析文本 | ||
| 127 | +4. WHEN Report_UI 渲染健康度板块, THE Report_UI SHALL 展示各类型配件的数量/金额占比图表和 LLM 分析文本 | ||
| 128 | +5. WHEN Report_UI 渲染补货建议板块, THE Report_UI SHALL 展示各优先级的配件数/金额统计和 LLM 分析文本 | ||
| 129 | +6. WHEN Report_UI 渲染健康度图表, THE Report_UI SHALL 使用 Chart.js 生成饼图或环形图展示数量占比和金额占比 | ||
| 130 | + | ||
| 131 | +### 需求 10:数据模型与写入服务重构 | ||
| 132 | + | ||
| 133 | +**用户故事:** 作为开发者,我希望 Python 数据模型和数据库写入服务能够适配新的报告结构。 | ||
| 134 | + | ||
| 135 | +#### 验收标准 | ||
| 136 | + | ||
| 137 | +1. THE AnalysisReport 数据模型 SHALL 包含 inventory_overview、sales_analysis、inventory_health、replenishment_summary 四个字典字段 | ||
| 138 | +2. WHEN Result_Writer 保存报告, THE Result_Writer SHALL 将四个模块的字典数据序列化为 JSON 字符串写入对应数据库字段 | ||
| 139 | +3. THE AnalysisReport 数据模型 SHALL 提供 to_dict 方法将报告转换为可序列化的字典 |
.kiro/specs/refactor-analysis-report/tasks.md
0 → 100644
| 1 | +# 实现计划:重构分析报告功能 | ||
| 2 | + | ||
| 3 | +## 概述 | ||
| 4 | + | ||
| 5 | +全栈重构分析报告功能,从清理现有代码开始,依次重建数据库表、数据模型、统计计算、LLM 提示词、并发节点、API 接口、前端页面。使用 Python + LangGraph + FastAPI + Chart.js 技术栈。 | ||
| 6 | + | ||
| 7 | +## 任务 | ||
| 8 | + | ||
| 9 | +- [x] 1. 清理现有分析报告专属文件 | ||
| 10 | + - [x] 1.1 清空 `src/fw_pms_ai/models/analysis_report.py`,仅保留文件头注释和必要 import,移除现有 AnalysisReport dataclass 的所有字段和方法 | ||
| 11 | + - [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` 等所有函数 | ||
| 12 | + - [x] 1.3 清空 `prompts/analysis_report.md` 的内容 | ||
| 13 | + - 注意:此步骤仅清理分析报告专属文件,不修改 `tasks.py`、`app.js`、`result_writer.py` 等共享文件(这些文件中的报告相关代码将在后续重建步骤中以替换方式更新) | ||
| 14 | + - _Requirements: 1.1, 1.2, 1.3, 1.5_ | ||
| 15 | + | ||
| 16 | +- [x] 2. 重建数据库表和数据模型 | ||
| 17 | + - [x] 2.1 重写 `sql/migrate_analysis_report.sql`,创建新表结构(inventory_overview、sales_analysis、inventory_health、replenishment_summary 四个 JSON 字段 + LLM 元数据字段) | ||
| 18 | + - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5_ | ||
| 19 | + - [x] 2.2 在已清空的 `src/fw_pms_ai/models/analysis_report.py` 中编写新的 AnalysisReport dataclass(四个 Dict 字段 + to_dict 方法) | ||
| 20 | + - _Requirements: 10.1, 10.3_ | ||
| 21 | + - [x] 2.3 替换 `src/fw_pms_ai/services/result_writer.py` 中的 `save_analysis_report` 方法为新版本,适配新表结构(四个 JSON 字段序列化写入),不修改该文件中的其他方法 | ||
| 22 | + - _Requirements: 10.2_ | ||
| 23 | + - [ ]* 2.4 编写属性测试:报告数据模型序列化 round-trip | ||
| 24 | + - **Property 5: 报告数据模型序列化 round-trip** | ||
| 25 | + - **Validates: Requirements 10.2, 10.3** | ||
| 26 | + | ||
| 27 | +- [x] 3. 实现四大板块统计计算函数 | ||
| 28 | + - [x] 3.1 在已清空的 `src/fw_pms_ai/agent/analysis_report_node.py` 中实现 `calculate_inventory_overview(part_ratios)` 函数 | ||
| 29 | + - 计算有效库存(valid_storage_cnt = in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt)总数量/总金额、三项构成明细、库销比 | ||
| 30 | + - _Requirements: 2.1, 2.2, 2.3, 2.5_ | ||
| 31 | + - [x] 3.2 实现 `calculate_sales_analysis(part_ratios)` 函数 | ||
| 32 | + - 计算月均销量总数量/总金额、各组成部分(out_stock_cnt/storage_locked_cnt/out_stock_ongoing_cnt/buy_cnt)总量、有销量/无销量配件数 | ||
| 33 | + - _Requirements: 3.1, 3.2, 3.4_ | ||
| 34 | + - [x] 3.3 实现 `calculate_inventory_health(part_ratios)` 函数 | ||
| 35 | + - 将配件分类为缺货/呆滞/低频/正常,计算各类型数量/金额/百分比,生成 chart_data | ||
| 36 | + - _Requirements: 4.1, 4.2_ | ||
| 37 | + - [x] 3.4 实现 `calculate_replenishment_summary(part_results)` 函数 | ||
| 38 | + - 按优先级(1=急需/2=建议/3=可选)统计配件种类数和金额 | ||
| 39 | + - _Requirements: 5.1, 5.2_ | ||
| 40 | + - [ ]* 3.5 编写属性测试:库存概览统计一致性 | ||
| 41 | + - **Property 1: 库存概览统计一致性** | ||
| 42 | + - **Validates: Requirements 2.1, 2.2, 2.3** | ||
| 43 | + - [ ]* 3.6 编写属性测试:销量分析统计一致性 | ||
| 44 | + - **Property 2: 销量分析统计一致性** | ||
| 45 | + - **Validates: Requirements 3.1, 3.2, 3.4** | ||
| 46 | + - [ ]* 3.7 编写属性测试:健康度分类完备性与一致性 | ||
| 47 | + - **Property 3: 健康度分类完备性与一致性** | ||
| 48 | + - **Validates: Requirements 4.1, 4.2** | ||
| 49 | + - [ ]* 3.8 编写属性测试:补货建议统计一致性 | ||
| 50 | + - **Property 4: 补货建议统计一致性** | ||
| 51 | + - **Validates: Requirements 5.1, 5.2** | ||
| 52 | + | ||
| 53 | +- [ ] 4. Checkpoint - 确保统计计算函数和属性测试通过 | ||
| 54 | + - 确保所有测试通过,如有问题请向用户确认。 | ||
| 55 | + | ||
| 56 | +- [x] 5. 创建 LLM 提示词文件 | ||
| 57 | + - [x] 5.1 创建 `prompts/report_inventory_overview.md`,包含库存概览分析提示词(资金占用评估、库销比诊断、库存结构建议),确保分析专业且有实际决策价值 | ||
| 58 | + - _Requirements: 7.2_ | ||
| 59 | + - [x] 5.2 创建 `prompts/report_sales_analysis.md`,包含销量分析提示词(销量构成解读、销售活跃度、需求趋势判断),确保分析专业且有实际决策价值 | ||
| 60 | + - _Requirements: 7.2_ | ||
| 61 | + - [x] 5.3 创建 `prompts/report_inventory_health.md`,包含健康度分析提示词(健康度评分、问题诊断、资金释放机会、改善优先级),确保分析专业且有实际决策价值 | ||
| 62 | + - _Requirements: 7.2_ | ||
| 63 | + - [x] 5.4 创建 `prompts/report_replenishment_summary.md`,包含补货建议分析提示词(紧迫度评估、资金分配建议、执行节奏、风险提示),确保分析专业且有实际决策价值 | ||
| 64 | + - _Requirements: 7.2_ | ||
| 65 | + - [x] 5.5 删除旧的 `prompts/analysis_report.md` 文件(已在步骤1.3清空,此处正式删除) | ||
| 66 | + - _Requirements: 1.3_ | ||
| 67 | + | ||
| 68 | +- [x] 6. 实现 LangGraph 并发 LLM 分析节点 | ||
| 69 | + - [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 响应 | ||
| 70 | + - _Requirements: 7.2, 7.3_ | ||
| 71 | + - [x] 6.2 使用 LangGraph StateGraph 构建并发子图,四个 LLM 节点从 START fan-out 并发执行,结果 fan-in 汇总 | ||
| 72 | + - _Requirements: 7.1, 7.4_ | ||
| 73 | + - [x] 6.3 实现新的 `generate_analysis_report_node(state)` 主函数,串联统计计算 → 并发 LLM 分析 → 汇总报告 → 写入数据库,单板块 LLM 失败不影响其他板块 | ||
| 74 | + - _Requirements: 7.4, 7.5_ | ||
| 75 | + - [x] 6.4 确认 `src/fw_pms_ai/agent/replenishment.py` 中的工作流引用无需修改(`generate_analysis_report_node` 函数签名保持不变) | ||
| 76 | + - _Requirements: 7.1_ | ||
| 77 | + | ||
| 78 | +- [ ] 7. Checkpoint - 确保后端报告生成流程完整 | ||
| 79 | + - 确保所有测试通过,如有问题请向用户确认。 | ||
| 80 | + | ||
| 81 | +- [x] 8. 重建 API 接口 | ||
| 82 | + - [x] 8.1 替换 `src/fw_pms_ai/api/routes/tasks.py` 中的 `AnalysisReportResponse` 模型为新版本(inventory_overview、sales_analysis、inventory_health、replenishment_summary 四个 Dict 字段),不修改该文件中的其他模型和端点 | ||
| 83 | + - _Requirements: 8.1_ | ||
| 84 | + - [x] 8.2 替换 `get_analysis_report` 端点实现,从新表读取数据并解析 JSON 字段,不修改该文件中的其他端点 | ||
| 85 | + - _Requirements: 8.1, 8.2, 8.3_ | ||
| 86 | + | ||
| 87 | +- [x] 9. 重建前端报告页面 | ||
| 88 | + - [x] 9.1 在 `ui/index.html` 中引入 Chart.js CDN | ||
| 89 | + - _Requirements: 9.6_ | ||
| 90 | + - [x] 9.2 替换 `ui/js/app.js` 中的 `renderReportTab` 方法为新版本,渲染四大板块框架,同时移除旧的 `renderOverallAssessment`、`renderRiskAlerts`、`renderStrategy`、`renderExpectedImpact` 方法,不修改该文件中的其他方法 | ||
| 91 | + - _Requirements: 9.1, 1.4_ | ||
| 92 | + - [x] 9.3 实现 `renderInventoryOverview` 方法,渲染库存概览板块(统计卡片 + 五项构成明细 + LLM 分析文本) | ||
| 93 | + - _Requirements: 9.2_ | ||
| 94 | + - [x] 9.4 实现 `renderSalesAnalysis` 方法,渲染销量分析板块(统计卡片 + 构成明细 + LLM 分析文本) | ||
| 95 | + - _Requirements: 9.3_ | ||
| 96 | + - [x] 9.5 实现 `renderInventoryHealth` 方法,渲染健康度板块(统计卡片 + Chart.js 环形图 + LLM 分析文本) | ||
| 97 | + - _Requirements: 9.4, 9.6_ | ||
| 98 | + - [x] 9.6 实现 `renderReplenishmentSummary` 方法,渲染补货建议板块(优先级统计表 + LLM 分析文本) | ||
| 99 | + - _Requirements: 9.5_ | ||
| 100 | + - [x] 9.7 在 `ui/css/style.css` 中添加新报告板块的样式(统计卡片、图表容器、分析文本区域),不修改现有样式 | ||
| 101 | + - _Requirements: 9.1_ | ||
| 102 | + | ||
| 103 | +- [ ] 10. Final Checkpoint - 全栈集成验证 | ||
| 104 | + - 确保所有测试通过,如有问题请向用户确认。 | ||
| 105 | + | ||
| 106 | +## 安全约束:不影响补货建议功能 | ||
| 107 | + | ||
| 108 | +本次重构严格限定在分析报告模块范围内,以下补货建议相关代码禁止修改: | ||
| 109 | + | ||
| 110 | +- `src/fw_pms_ai/agent/nodes.py` — 补货建议核心节点(fetch_part_ratio、sql_agent、allocate_budget) | ||
| 111 | +- `src/fw_pms_ai/agent/replenishment.py` — 补货建议工作流(仅确认无需修改,不做任何改动) | ||
| 112 | +- `src/fw_pms_ai/agent/sql_agent/` — SQL Agent 目录 | ||
| 113 | +- `src/fw_pms_ai/models/part_ratio.py` — 配件库销比模型 | ||
| 114 | +- `src/fw_pms_ai/models/replenishment_*.py` — 补货建议相关模型 | ||
| 115 | +- `result_writer.py` 中的 `save_task`、`update_task`、`save_details`、`save_part_summaries`、`save_execution_log` 等方法 | ||
| 116 | +- `tasks.py` 中的任务列表、任务详情、配件明细、配件汇总、执行日志等端点 | ||
| 117 | +- `app.js` 中的任务列表、任务详情、配件明细等渲染方法 | ||
| 118 | +- `prompts/` 中的 `part_shop_analysis*.md`、`suggestion*.md`、`sql_agent.md` 等提示词文件 | ||
| 119 | + | ||
| 120 | +## 备注 | ||
| 121 | + | ||
| 122 | +- 标记 `*` 的任务为可选任务,可跳过以加快 MVP 进度 | ||
| 123 | +- 每个任务引用了具体的需求编号以保证可追溯性 | ||
| 124 | +- 属性测试使用 `hypothesis` 库,每个属性至少 100 次迭代 | ||
| 125 | +- Checkpoint 用于阶段性验证,确保增量正确 | ||
| 126 | +- 共享文件(tasks.py、app.js、result_writer.py、style.css)中的修改均采用"替换特定函数/类"方式,明确不修改其他部分 |
README.md
| 1 | # fw-pms-ai | 1 | # fw-pms-ai |
| 2 | 2 | ||
| 3 | -AI 配件系统 - 基于 Python + LangChain + LangGraph | 3 | +fw-pms 配件管理系统 AI 扩展平台 — 基于 Python + LangChain + LangGraph |
| 4 | 4 | ||
| 5 | ## 项目简介 | 5 | ## 项目简介 |
| 6 | 6 | ||
| 7 | -本项目是 `fw-pms` 的 AI 扩展模块,使用大语言模型 (LLM) 和 Agent 技术,为配件管理系统提供智能化的补货建议能力。 | 7 | +本项目是 `fw-pms` 配件管理系统的 **AI 能力扩展平台**,使用大语言模型 (LLM) 和 Agent 技术,为配件业务提供多种智能化功能。 |
| 8 | 8 | ||
| 9 | -## 核心技术 | 9 | +### 功能模块 |
| 10 | 10 | ||
| 11 | -### LangChain + LangGraph | ||
| 12 | - | ||
| 13 | -| 技术 | 作用 | | ||
| 14 | -|------|------| | ||
| 15 | -| **LangChain** | LLM 框架,提供模型抽象、Prompt 管理、消息格式化 | | ||
| 16 | -| **LangGraph** | Agent 工作流编排,管理状态机、定义节点和边、支持条件分支 | | ||
| 17 | -| **SQL Agent** | 自定义 Text-to-SQL 实现,支持错误重试和 LLM 数据分析 | | 11 | +| 状态 | 模块 | 说明 | |
| 12 | +|------|------|------| | ||
| 13 | +| ✅ 已实现 | **智能补货建议** | 分析库销比数据,LLM 逐配件分析各门店补货需求,生成结构化建议 | | ||
| 14 | +| ✅ 已实现 | **分析报告** | 四大板块(库存概览/销量分析/健康度/补货建议)并发 LLM 分析 | | ||
| 15 | +| 🚧 规划中 | **需求预测** | 基于历史销量预测未来配件需求 | | ||
| 16 | +| 🚧 规划中 | **异常检测** | 识别库存和销量数据异常 | | ||
| 17 | +| 🚧 规划中 | **智能定价** | AI 辅助配件定价建议 | | ||
| 18 | + | ||
| 19 | +## 技术栈 | ||
| 20 | + | ||
| 21 | +| 组件 | 技术 | 版本要求 | | ||
| 22 | +|------|------|---------| | ||
| 23 | +| 编程语言 | Python | ≥ 3.11 | | ||
| 24 | +| Agent 框架 | LangChain + LangGraph | LangChain ≥ 0.3, LangGraph ≥ 0.2 | | ||
| 25 | +| LLM 集成 | 智谱 GLM / 豆包 / OpenAI 兼容 / Anthropic 兼容 | — | | ||
| 26 | +| Web API | FastAPI + Uvicorn | FastAPI ≥ 0.109 | | ||
| 27 | +| 数据库 | MySQL (mysql-connector-python + SQLAlchemy) | SQLAlchemy ≥ 2.0 | | ||
| 28 | +| 任务调度 | APScheduler | ≥ 3.10 | | ||
| 29 | +| 配置管理 | Pydantic Settings + python-dotenv | Pydantic ≥ 2.0 | | ||
| 30 | +| HTTP 客户端 | httpx | ≥ 0.25 | | ||
| 31 | +| 重试机制 | tenacity | ≥ 8.0 | | ||
| 32 | + | ||
| 33 | +## 系统架构 | ||
| 18 | 34 | ||
| 19 | ```mermaid | 35 | ```mermaid |
| 20 | -graph LR | ||
| 21 | - A[用户请求] --> B[LangGraph Agent] | ||
| 22 | - B --> C[FetchPartRatio] | ||
| 23 | - C --> D[SQLAgent<br/>LLM分析] | ||
| 24 | - D --> E[AllocateBudget] | ||
| 25 | - E --> F[AnalysisReport] | ||
| 26 | - F --> G[SaveResult] | 36 | +flowchart TB |
| 37 | + subgraph Scheduler ["定时调度 (APScheduler)"] | ||
| 38 | + S[每日凌晨触发] | ||
| 39 | + end | ||
| 40 | + | ||
| 41 | + subgraph API ["FastAPI API 层"] | ||
| 42 | + A[/tasks, details, logs, reports/] | ||
| 43 | + end | ||
| 44 | + | ||
| 45 | + subgraph Agent ["LangGraph 工作流"] | ||
| 46 | + direction TB | ||
| 47 | + B[1. fetch_part_ratio] --> C[2. sql_agent] | ||
| 48 | + C --> D{需要重试?} | ||
| 49 | + D -->|是| C | ||
| 50 | + D -->|否| E[3. allocate_budget] | ||
| 51 | + E --> F[4. generate_analysis_report] | ||
| 52 | + F --> G[END] | ||
| 53 | + end | ||
| 54 | + | ||
| 55 | + subgraph ReportSubgraph ["分析报告并发子图"] | ||
| 56 | + direction LR | ||
| 57 | + R1[库存概览 LLM] & R2[销量分析 LLM] & R3[健康度 LLM] & R4[补货建议 LLM] | ||
| 58 | + end | ||
| 59 | + | ||
| 60 | + subgraph Services ["业务服务层"] | ||
| 61 | + DS[DataService] | ||
| 62 | + RW[ResultWriter] | ||
| 63 | + RP[Repository] | ||
| 64 | + end | ||
| 65 | + | ||
| 66 | + subgraph LLM ["LLM 适配层"] | ||
| 67 | + L1[GLMClient] | ||
| 68 | + L2[DoubaoClient] | ||
| 69 | + L3[OpenAICompatClient] | ||
| 70 | + L4[AnthropicCompatClient] | ||
| 71 | + end | ||
| 72 | + | ||
| 73 | + subgraph DB ["数据存储"] | ||
| 74 | + MySQL[(MySQL)] | ||
| 75 | + end | ||
| 76 | + | ||
| 77 | + S --> Agent | ||
| 78 | + A --> Services | ||
| 79 | + B --> DS | ||
| 80 | + C --> LLM | ||
| 81 | + F --> ReportSubgraph | ||
| 82 | + ReportSubgraph --> LLM | ||
| 83 | + E --> RW | ||
| 84 | + DS --> MySQL | ||
| 85 | + RW --> MySQL | ||
| 86 | + RP --> MySQL | ||
| 27 | ``` | 87 | ``` |
| 28 | 88 | ||
| 29 | 详细架构图见 [docs/architecture.md](docs/architecture.md) | 89 | 详细架构图见 [docs/architecture.md](docs/architecture.md) |
| 30 | 90 | ||
| 31 | -## 功能模块 | 91 | +> 平台采用模块化设计,新增 AI 功能模块只需添加对应的 Agent 工作流节点和提示词文件。 |
| 32 | 92 | ||
| 33 | -### ✅ 已实现 | 93 | +## 补货建议工作流 |
| 34 | 94 | ||
| 35 | -| 模块 | 功能 | 说明 | | ||
| 36 | -|------|------|------| | ||
| 37 | -| **SQL Agent** | LLM 分析 | 直接分析 part_ratio 数据生成补货建议 | | ||
| 38 | -| **补货分配** | Replenishment | 转换 LLM 建议为补货明细 | | 95 | +补货建议模块的核心是一个 **4 节点 LangGraph 工作流**,按顺序执行: |
| 39 | 96 | ||
| 40 | -### 🚧 计划中 | 97 | +| 序号 | 节点 | 功能 | 说明 | |
| 98 | +|------|------|------|------| | ||
| 99 | +| 1 | `fetch_part_ratio` | 获取库销比数据 | 通过 dealer_grouping_id 从 part_ratio 表查询配件数据 | | ||
| 100 | +| 2 | `sql_agent` | LLM 分析 + 建议生成 | 按 part_code 分组,逐配件分析各门店补货需求,支持错误重试 | | ||
| 101 | +| 3 | `allocate_budget` | 转换补货明细 | 将 LLM 建议转换为结构化的补货明细记录 | | ||
| 102 | +| 4 | `generate_analysis_report` | 生成分析报告 | 统计计算 + 4 路并发 LLM 分析生成结构化报告 | | ||
| 41 | 103 | ||
| 42 | -| 模块 | 功能 | | ||
| 43 | -|------|------| | ||
| 44 | -| 预测引擎 | 基于历史销量预测未来需求 | | ||
| 45 | -| 异常检测 | 识别数据异常 | | 104 | +### 分析报告四大板块 |
| 105 | + | ||
| 106 | +| 板块 | 统计计算 | LLM 分析 | | ||
| 107 | +|------|---------|---------| | ||
| 108 | +| **库存概览** | 有效库存、资金占用、配件总数 | 库存状况综合评价 | | ||
| 109 | +| **销量分析** | 月均销量、出库频次、销售趋势 | 销售趋势洞察 | | ||
| 110 | +| **库存健康度** | 缺货/呆滞/低频/正常分类统计 | 健康度风险提示 | | ||
| 111 | +| **补货建议汇总** | 按优先级分类统计补货数量和金额 | 补货策略建议 | | ||
| 112 | + | ||
| 113 | +> 四个 LLM 分析节点使用 LangGraph 子图 **并发执行**,单板块失败不影响其他板块。 | ||
| 114 | + | ||
| 115 | +## 业务术语 | ||
| 116 | + | ||
| 117 | +| 术语 | 定义 | 处理方式 | | ||
| 118 | +|------|------|---------| | ||
| 119 | +| **呆滞件** | 有效库存 > 0,90天出库数 = 0 | 不做计划 | | ||
| 120 | +| **低频件** | 月均销量 < 1 或 出库次数 < 3 或 出库间隔 ≥ 30天 | 不做计划 | | ||
| 121 | +| **缺货件** | 有效库存 = 0,月均销量 ≥ 1 | 需要补货 | | ||
| 122 | +| **正常件** | 不属于以上三类 | 按需补货 | | ||
| 46 | 123 | ||
| 47 | ## 项目结构 | 124 | ## 项目结构 |
| 48 | 125 | ||
| 49 | ``` | 126 | ``` |
| 50 | fw-pms-ai/ | 127 | fw-pms-ai/ |
| 51 | ├── src/fw_pms_ai/ | 128 | ├── src/fw_pms_ai/ |
| 52 | -│ ├── agent/ # LangGraph Agent | ||
| 53 | -│ │ ├── state.py # Agent 状态定义 | ||
| 54 | -│ │ ├── nodes.py # 工作流节点 | ||
| 55 | -│ │ ├── sql_agent.py # SQL Agent(Text-to-SQL + 建议生成) | ||
| 56 | -│ │ └── replenishment.py | ||
| 57 | -│ ├── api/ # FastAPI 接口 | ||
| 58 | -│ │ ├── app.py # 应用入口 | ||
| 59 | -│ │ └── routes/ # 路由模块 | ||
| 60 | -│ ├── config/ # 配置管理 | ||
| 61 | -│ ├── llm/ # LLM 集成 | ||
| 62 | -│ │ ├── base.py # 抽象基类 | ||
| 63 | -│ │ ├── glm.py # 智谱 GLM | ||
| 64 | -│ │ ├── doubao.py # 豆包 | ||
| 65 | -│ │ ├── openai_compat.py | ||
| 66 | -│ │ └── anthropic_compat.py | ||
| 67 | -│ ├── models/ # 数据模型 | ||
| 68 | -│ │ ├── task.py # 任务和明细模型 | ||
| 69 | -│ │ ├── execution_log.py # 执行日志模型 | ||
| 70 | -│ │ ├── part_ratio.py # 库销比模型 | ||
| 71 | -│ │ ├── part_summary.py # 配件汇总模型 | ||
| 72 | -│ │ ├── sql_result.py # SQL执行结果模型 | ||
| 73 | -│ │ └── suggestion.py # 补货建议模型 | ||
| 74 | -│ ├── services/ # 业务服务 | ||
| 75 | -│ │ ├── db.py # 数据库连接 | ||
| 76 | -│ │ ├── data_service.py # 数据查询服务 | ||
| 77 | -│ │ └── result_writer.py # 结果写入服务 | ||
| 78 | -│ ├── scheduler/ # 定时任务 | ||
| 79 | -│ └── main.py | ||
| 80 | -├── prompts/ # AI Prompt 文件 | ||
| 81 | -│ ├── sql_agent.md # SQL Agent 系统提示词 | ||
| 82 | -│ ├── suggestion.md # 补货建议提示词 | ||
| 83 | -│ ├── suggestion_system.md | ||
| 84 | -│ ├── part_shop_analysis.md | ||
| 85 | -│ └── part_shop_analysis_system.md | ||
| 86 | -├── ui/ # 前端静态文件 | ||
| 87 | -├── sql/ # 数据库迁移脚本 | ||
| 88 | -├── pyproject.toml | 129 | +│ ├── main.py # 应用入口 |
| 130 | +│ ├── agent/ # LangGraph Agent 工作流 | ||
| 131 | +│ │ ├── state.py # Agent 状态定义 (TypedDict + Annotated reducer) | ||
| 132 | +│ │ ├── nodes.py # 工作流节点 (fetch/sql_agent/allocate) | ||
| 133 | +│ │ ├── analysis_report_node.py # 分析报告节点 (统计计算 + 并发 LLM 子图) | ||
| 134 | +│ │ ├── replenishment.py # ReplenishmentAgent 主类 (构建图 + 运行) | ||
| 135 | +│ │ └── sql_agent/ # SQL Agent 子包 | ||
| 136 | +│ │ ├── agent.py # SQLAgent 主类 | ||
| 137 | +│ │ ├── executor.py # SQL 执行器 | ||
| 138 | +│ │ ├── analyzer.py # 配件分析器 (逐配件 LLM 分析) | ||
| 139 | +│ │ └── prompts.py # 提示词加载 | ||
| 140 | +│ ├── api/ # FastAPI REST API | ||
| 141 | +│ │ ├── app.py # FastAPI 应用 (CORS + 静态文件 + 路由) | ||
| 142 | +│ │ └── routes/ | ||
| 143 | +│ │ └── tasks.py # 任务/明细/日志/汇总/报告 API | ||
| 144 | +│ ├── config/ | ||
| 145 | +│ │ └── settings.py # Pydantic Settings 配置管理 | ||
| 146 | +│ ├── llm/ # LLM 适配层 | ||
| 147 | +│ │ ├── base.py # BaseLLMClient 抽象基类 | ||
| 148 | +│ │ ├── glm.py # 智谱 GLM 客户端 | ||
| 149 | +│ │ ├── doubao.py # 豆包客户端 | ||
| 150 | +│ │ ├── openai_compat.py # OpenAI 兼容客户端 (火山引擎等) | ||
| 151 | +│ │ └── anthropic_compat.py # Anthropic 兼容客户端 | ||
| 152 | +│ ├── models/ # 数据模型 (Pydantic) | ||
| 153 | +│ │ ├── task.py # 任务模型 + 状态枚举 | ||
| 154 | +│ │ ├── suggestion.py # 补货建议模型 | ||
| 155 | +│ │ ├── part_ratio.py # 配件库销比模型 | ||
| 156 | +│ │ ├── part_summary.py # 配件汇总模型 | ||
| 157 | +│ │ ├── analysis_report.py # 分析报告模型 | ||
| 158 | +│ │ ├── execution_log.py # 执行日志模型 | ||
| 159 | +│ │ └── sql_result.py # SQL 执行结果模型 | ||
| 160 | +│ ├── services/ # 业务服务层 | ||
| 161 | +│ │ ├── db.py # 数据库连接管理 | ||
| 162 | +│ │ ├── data_service.py # 数据查询服务 | ||
| 163 | +│ │ ├── result_writer.py # 结果写入服务 | ||
| 164 | +│ │ └── repository/ # 仓库模式 | ||
| 165 | +│ │ ├── task_repo.py # 任务仓库 | ||
| 166 | +│ │ ├── detail_repo.py # 明细仓库 | ||
| 167 | +│ │ └── log_repo.py # 日志仓库 | ||
| 168 | +│ └── scheduler/ | ||
| 169 | +│ └── tasks.py # APScheduler 定时任务 + CLI 参数解析 | ||
| 170 | +├── prompts/ # AI 提示词文件 | ||
| 171 | +│ ├── sql_agent.md # SQL Agent 系统提示词 | ||
| 172 | +│ ├── suggestion.md # 补货建议生成提示词 | ||
| 173 | +│ ├── suggestion_system.md # 补货建议系统提示词 | ||
| 174 | +│ ├── part_shop_analysis.md # 配件门店分析提示词 | ||
| 175 | +│ ├── part_shop_analysis_system.md | ||
| 176 | +│ ├── report_inventory_overview.md # 分析报告-库存概览提示词 | ||
| 177 | +│ ├── report_sales_analysis.md # 分析报告-销量分析提示词 | ||
| 178 | +│ ├── report_inventory_health.md # 分析报告-库存健康度提示词 | ||
| 179 | +│ └── report_replenishment_summary.md # 分析报告-补货建议提示词 | ||
| 180 | +├── ui/ # 前端静态文件 | ||
| 181 | +│ ├── index.html # 主页面 | ||
| 182 | +│ ├── css/ # 样式 | ||
| 183 | +│ ├── js/ # JavaScript | ||
| 184 | +│ └── merchant-report/ # 商家组合报告页面 | ||
| 185 | +├── sql/ # 数据库脚本 | ||
| 186 | +│ ├── init.sql # 初始化建表 (4张表) | ||
| 187 | +│ └── migrate_analysis_report.sql # 分析报告表迁移 | ||
| 188 | +├── docs/ # 文档 | ||
| 189 | +│ ├── architecture.md # 系统架构文档 | ||
| 190 | +│ └── 商家组合维度分析需求设计.md | ||
| 191 | +├── pyproject.toml # 项目配置 (hatchling 构建) | ||
| 192 | +├── .env # 环境变量 | ||
| 89 | └── README.md | 193 | └── README.md |
| 90 | ``` | 194 | ``` |
| 91 | 195 | ||
| 196 | +## 数据表说明 | ||
| 92 | 197 | ||
| 93 | -## 工作流程 | 198 | +| 表名 | 说明 | SQL 文件 | |
| 199 | +|------|------|---------| | ||
| 200 | +| `part_ratio` | 配件库销比数据(来源表,只读) | — | | ||
| 201 | +| `ai_replenishment_task` | 任务记录 | init.sql | | ||
| 202 | +| `ai_replenishment_detail` | 配件级别补货建议明细 | init.sql | | ||
| 203 | +| `ai_replenishment_part_summary` | 配件汇总表(按配件编码聚合) | init.sql | | ||
| 204 | +| `ai_task_execution_log` | 任务执行日志(每步骤详情) | init.sql | | ||
| 205 | +| `ai_analysis_report` | 结构化分析报告(四大板块 JSON) | migrate_analysis_report.sql | | ||
| 94 | 206 | ||
| 95 | -``` | ||
| 96 | -1. FetchPartRatio - 从 part_ratio 表获取库销比数据 | ||
| 97 | -2. SQLAgent - LLM 分析数据,生成补货建议 | ||
| 98 | -3. AllocateBudget - 转换建议为补货明细 | ||
| 99 | -4. AnalysisReport - 生成分析报告(风险评估、行动方案) | ||
| 100 | -5. SaveResult - 写入数据库 | ||
| 101 | -``` | 207 | +## API 接口 |
| 102 | 208 | ||
| 103 | -### 业务术语 | 209 | +基于 FastAPI 提供 REST API,支持前端 UI 对接。 |
| 104 | 210 | ||
| 105 | -| 术语 | 定义 | 处理 | | 211 | +| 方法 | 路径 | 说明 | |
| 106 | |------|------|------| | 212 | |------|------|------| |
| 107 | -| **呆滞件** | 有库存,90天无销量 | 不做计划 | | ||
| 108 | -| **低频件** | 无库存,月均销量<1 | 不做计划 | | ||
| 109 | -| **缺货件** | 无库存,月均销量≥1 | 需要补货 | | 213 | +| GET | `/api/tasks` | 任务列表(分页、状态/组合/日期筛选) | |
| 214 | +| GET | `/api/tasks/{task_no}` | 任务详情 | | ||
| 215 | +| GET | `/api/tasks/{task_no}/details` | 配件建议明细(分页、排序、搜索) | | ||
| 216 | +| GET | `/api/tasks/{task_no}/logs` | 执行日志 | | ||
| 217 | +| GET | `/api/tasks/{task_no}/part-summaries` | 配件汇总列表(分页、优先级筛选) | | ||
| 218 | +| GET | `/api/tasks/{task_no}/parts/{part_code}/shops` | 指定配件的门店明细 | | ||
| 219 | +| GET | `/api/tasks/{task_no}/analysis-report` | 分析报告 | | ||
| 220 | +| GET | `/health` | 健康检查 | | ||
| 221 | +| GET | `/` | 主页面(静态文件) | | ||
| 110 | 222 | ||
| 111 | -## 数据表说明 | 223 | +运行后访问 `/docs` 查看 Swagger 文档。 |
| 112 | 224 | ||
| 113 | -| 表名 | 说明 | | ||
| 114 | -|------|------| | ||
| 115 | -| `part_ratio` | 配件库销比数据(来源表) | | ||
| 116 | -| `ai_replenishment_task` | 任务记录 | | ||
| 117 | -| `ai_replenishment_detail` | 配件级别补货建议 | | ||
| 118 | -| `ai_replenishment_part_summary` | 配件汇总表 | | ||
| 119 | -| `ai_task_execution_log` | 任务执行日志 | | 225 | +## LLM 集成 |
| 226 | + | ||
| 227 | +支持 4 种 LLM 客户端,通过环境变量自动选择: | ||
| 228 | + | ||
| 229 | +| 客户端 | 环境变量 | 说明 | | ||
| 230 | +|--------|---------|------| | ||
| 231 | +| `OpenAICompatClient` | `OPENAI_COMPAT_API_KEY` | OpenAI 兼容接口(火山引擎、智谱等) | | ||
| 232 | +| `AnthropicCompatClient` | `ANTHROPIC_API_KEY` | Anthropic 兼容接口 | | ||
| 233 | +| `GLMClient` | `GLM_API_KEY` | 智谱 GLM 原生 SDK | | ||
| 234 | +| `DoubaoClient` | `DOUBAO_API_KEY` | 豆包 | | ||
| 235 | + | ||
| 236 | +优先级:`OpenAI Compat` > `Anthropic Compat` > `GLM` > `Doubao` | ||
| 120 | 237 | ||
| 121 | ## 快速开始 | 238 | ## 快速开始 |
| 122 | 239 | ||
| @@ -131,39 +248,53 @@ pip install -e . | @@ -131,39 +248,53 @@ pip install -e . | ||
| 131 | 248 | ||
| 132 | ```bash | 249 | ```bash |
| 133 | cp .env.example .env | 250 | cp .env.example .env |
| 251 | +# 编辑 .env 填写以下配置 | ||
| 134 | ``` | 252 | ``` |
| 135 | 253 | ||
| 136 | 必填配置项: | 254 | 必填配置项: |
| 137 | -- `GLM_API_KEY` / `ANTHROPIC_API_KEY` - LLM API Key | ||
| 138 | -- `MYSQL_HOST`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE` | 255 | + |
| 256 | +```env | ||
| 257 | +# LLM (至少配置一种) | ||
| 258 | +OPENAI_COMPAT_API_KEY=your-key | ||
| 259 | +OPENAI_COMPAT_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 | ||
| 260 | +OPENAI_COMPAT_MODEL=glm-4-7-251222 | ||
| 261 | + | ||
| 262 | +# 数据库 | ||
| 263 | +MYSQL_HOST=localhost | ||
| 264 | +MYSQL_PORT=3306 | ||
| 265 | +MYSQL_USER=root | ||
| 266 | +MYSQL_PASSWORD=your-password | ||
| 267 | +MYSQL_DATABASE=fw_pms | ||
| 268 | +``` | ||
| 139 | 269 | ||
| 140 | ### 3. 初始化数据库 | 270 | ### 3. 初始化数据库 |
| 141 | 271 | ||
| 142 | ```bash | 272 | ```bash |
| 273 | +# 基础表结构 | ||
| 143 | mysql -u root -p fw_pms < sql/init.sql | 274 | mysql -u root -p fw_pms < sql/init.sql |
| 275 | + | ||
| 276 | +# 分析报告表 | ||
| 277 | +mysql -u root -p fw_pms < sql/migrate_analysis_report.sql | ||
| 144 | ``` | 278 | ``` |
| 145 | 279 | ||
| 146 | ### 4. 运行 | 280 | ### 4. 运行 |
| 147 | 281 | ||
| 148 | ```bash | 282 | ```bash |
| 149 | -# 启动定时任务调度器 | 283 | +# 启动定时任务调度器(默认每日 02:00 执行) |
| 150 | fw-pms-ai | 284 | fw-pms-ai |
| 151 | 285 | ||
| 152 | -# 立即执行一次 | 286 | +# 立即执行一次(所有商家组合) |
| 153 | fw-pms-ai --run-once | 287 | fw-pms-ai --run-once |
| 154 | 288 | ||
| 155 | -# 指定参数 | ||
| 156 | -fw-pms-ai --run-once --group-id 2 | 289 | +# 指定集团和商家组合 |
| 290 | +fw-pms-ai --run-once --group-id 2 --dealer-grouping-id 100 | ||
| 157 | ``` | 291 | ``` |
| 158 | 292 | ||
| 159 | -## AI Prompt 文件 | 293 | +### 5. 启动 API 服务 |
| 160 | 294 | ||
| 161 | -Prompt 文件存放在 `prompts/` 目录: | ||
| 162 | - | ||
| 163 | -| 文件 | 用途 | | ||
| 164 | -|------|------| | ||
| 165 | -| `suggestion.md` | 补货建议生成(含业务术语定义) | | ||
| 166 | -| `analyze_inventory.md` | 库存分析 | | 295 | +```bash |
| 296 | +uvicorn fw_pms_ai.api.app:app --host 0.0.0.0 --port 8000 --reload | ||
| 297 | +``` | ||
| 167 | 298 | ||
| 168 | ## 开发 | 299 | ## 开发 |
| 169 | 300 | ||
| @@ -171,6 +302,26 @@ Prompt 文件存放在 `prompts/` 目录: | @@ -171,6 +302,26 @@ Prompt 文件存放在 `prompts/` 目录: | ||
| 171 | # 安装开发依赖 | 302 | # 安装开发依赖 |
| 172 | pip install -e ".[dev]" | 303 | pip install -e ".[dev]" |
| 173 | 304 | ||
| 305 | +# 代码格式化 | ||
| 306 | +black src/ | ||
| 307 | +ruff check src/ | ||
| 308 | + | ||
| 174 | # 运行测试 | 309 | # 运行测试 |
| 175 | pytest tests/ -v | 310 | pytest tests/ -v |
| 176 | ``` | 311 | ``` |
| 312 | + | ||
| 313 | +## 提示词文件 | ||
| 314 | + | ||
| 315 | +提示词文件存放在 `prompts/` 目录,供 LLM 调用时使用: | ||
| 316 | + | ||
| 317 | +| 文件 | 用途 | | ||
| 318 | +|------|------| | ||
| 319 | +| `sql_agent.md` | SQL Agent 生成 SQL 查询的系统提示词 | | ||
| 320 | +| `suggestion.md` | 配件补货建议生成 | | ||
| 321 | +| `suggestion_system.md` | 补货建议系统角色提示词 | | ||
| 322 | +| `part_shop_analysis.md` | 配件门店级别分析 | | ||
| 323 | +| `part_shop_analysis_system.md` | 门店分析系统角色提示词 | | ||
| 324 | +| `report_inventory_overview.md` | 分析报告 — 库存概览板块 | | ||
| 325 | +| `report_sales_analysis.md` | 分析报告 — 销量分析板块 | | ||
| 326 | +| `report_inventory_health.md` | 分析报告 — 库存健康度板块 | | ||
| 327 | +| `report_replenishment_summary.md` | 分析报告 — 补货建议板块 | |
docs/architecture.md deleted
| 1 | -# fw-pms-ai 系统架构 | ||
| 2 | - | ||
| 3 | -## 技术栈 | ||
| 4 | - | ||
| 5 | -| 组件 | 技术 | | ||
| 6 | -|------|------| | ||
| 7 | -| 编程语言 | Python 3.11+ | | ||
| 8 | -| Agent 框架 | LangChain + LangGraph | | ||
| 9 | -| LLM | 智谱 GLM / 豆包 / OpenAI 兼容接口 | | ||
| 10 | -| 数据库 | MySQL | | ||
| 11 | -| API 框架 | FastAPI | | ||
| 12 | -| 任务调度 | APScheduler | | ||
| 13 | - | ||
| 14 | ---- | ||
| 15 | - | ||
| 16 | -## 系统架构图 | ||
| 17 | - | ||
| 18 | -```mermaid | ||
| 19 | -flowchart TB | ||
| 20 | - subgraph API ["FastAPI API 层"] | ||
| 21 | - A[/tasks endpoint/] | ||
| 22 | - end | ||
| 23 | - | ||
| 24 | - subgraph Agent ["LangGraph Agent"] | ||
| 25 | - direction TB | ||
| 26 | - B[fetch_part_ratio] --> C[sql_agent] | ||
| 27 | - C --> D{需要重试?} | ||
| 28 | - D -->|是| C | ||
| 29 | - D -->|否| E[allocate_budget] | ||
| 30 | - E --> E2[generate_analysis_report] | ||
| 31 | - E2 --> F[END] | ||
| 32 | - end | ||
| 33 | - | ||
| 34 | - subgraph Services ["业务服务层"] | ||
| 35 | - G[DataService] | ||
| 36 | - H[ResultWriter] | ||
| 37 | - end | ||
| 38 | - | ||
| 39 | - subgraph LLM ["LLM 集成"] | ||
| 40 | - I[GLM] | ||
| 41 | - J[Doubao] | ||
| 42 | - K[OpenAI Compat] | ||
| 43 | - end | ||
| 44 | - | ||
| 45 | - subgraph DB ["数据存储"] | ||
| 46 | - L[(MySQL)] | ||
| 47 | - end | ||
| 48 | - | ||
| 49 | - A --> Agent | ||
| 50 | - B --> G | ||
| 51 | - C --> LLM | ||
| 52 | - E --> H | ||
| 53 | - G --> L | ||
| 54 | - H --> L | ||
| 55 | -``` | ||
| 56 | - | ||
| 57 | ---- | ||
| 58 | - | ||
| 59 | -## 工作流节点说明 | ||
| 60 | - | ||
| 61 | -| 节点 | 职责 | 输入 | 输出 | | ||
| 62 | -|------|------|------|------| | ||
| 63 | -| `fetch_part_ratio` | 获取商家组合的配件库销比数据 | dealer_grouping_id | part_ratios[] | | ||
| 64 | -| `sql_agent` | LLM 分析配件数据,生成补货建议 | part_ratios[] | llm_suggestions[], part_results[] | | ||
| 65 | -| `allocate_budget` | 转换 LLM 建议为补货明细 | llm_suggestions[] | details[] | | ||
| 66 | -| `generate_analysis_report` | 生成分析报告 | part_ratios[], details[] | analysis_report | | ||
| 67 | - | ||
| 68 | ---- | ||
| 69 | - | ||
| 70 | -## 核心数据流 | ||
| 71 | - | ||
| 72 | -```mermaid | ||
| 73 | -sequenceDiagram | ||
| 74 | - participant API | ||
| 75 | - participant Agent | ||
| 76 | - participant SQLAgent | ||
| 77 | - participant LLM | ||
| 78 | - participant DB | ||
| 79 | - | ||
| 80 | - API->>Agent: 创建任务 | ||
| 81 | - Agent->>DB: 保存任务记录 | ||
| 82 | - Agent->>DB: 查询 part_ratio | ||
| 83 | - Agent->>SQLAgent: 分析配件数据 | ||
| 84 | - | ||
| 85 | - loop 每个配件 | ||
| 86 | - SQLAgent->>LLM: 发送分析请求 | ||
| 87 | - LLM-->>SQLAgent: 返回补货建议 | ||
| 88 | - end | ||
| 89 | - | ||
| 90 | - SQLAgent-->>Agent: 汇总建议 | ||
| 91 | - Agent->>DB: 保存补货明细 | ||
| 92 | - Agent->>DB: 更新任务状态 | ||
| 93 | - Agent-->>API: 返回结果 | ||
| 94 | -``` | ||
| 95 | - | ||
| 96 | ---- | ||
| 97 | - | ||
| 98 | -## 目录结构 | ||
| 99 | - | ||
| 100 | -``` | ||
| 101 | -src/fw_pms_ai/ | ||
| 102 | -├── agent/ # LangGraph 工作流 | ||
| 103 | -│ ├── state.py # 状态定义 (TypedDict) | ||
| 104 | -│ ├── nodes.py # 工作流节点 | ||
| 105 | -│ ├── sql_agent.py # SQL Agent 实现 | ||
| 106 | -│ └── replenishment.py # 主入口 | ||
| 107 | -├── api/ # REST API | ||
| 108 | -├── config/ # 配置管理 | ||
| 109 | -├── llm/ # LLM 适配器 | ||
| 110 | -├── models/ # 数据模型 | ||
| 111 | -├── services/ # 业务服务 | ||
| 112 | -└── scheduler/ # 定时任务 | ||
| 113 | -``` | ||
| 114 | - | ||
| 115 | ---- | ||
| 116 | - | ||
| 117 | -## 数据库表结构 | ||
| 118 | - | ||
| 119 | -| 表名 | 用途 | | ||
| 120 | -|------|------| | ||
| 121 | -| `part_ratio` | 配件库销比数据(只读) | | ||
| 122 | -| `ai_replenishment_task` | 任务记录 | | ||
| 123 | -| `ai_replenishment_detail` | 补货明细 | | ||
| 124 | -| `ai_replenishment_part_summary` | 配件级汇总 | | ||
| 125 | -| `ai_task_execution_log` | 执行日志 | | ||
| 126 | -| `ai_analysis_report` | 分析报告(JSON结构化) | |
docs/商家组合维度分析需求设计.md
0 → 100644
| 1 | +# 商家组合维度分析报告 - 需求分析与设计文档 | ||
| 2 | + | ||
| 3 | +> **版本**: 2.0.0 | ||
| 4 | +> **日期**: 2026-02-12 | ||
| 5 | +> **项目**: fw-pms-ai(AI配件补货建议系统) | ||
| 6 | + | ||
| 7 | +--- | ||
| 8 | + | ||
| 9 | +## 1. 业务背景与目标 | ||
| 10 | + | ||
| 11 | +### 1.1 业务痛点 | ||
| 12 | + | ||
| 13 | +汽车配件管理面临以下核心挑战: | ||
| 14 | + | ||
| 15 | +| 痛点 | 描述 | 影响 | | ||
| 16 | +|------|------|------| | ||
| 17 | +| **库存失衡** | 部分配件长期缺货,部分配件严重呆滞 | 缺货导致客户流失,呆滞占用资金 | | ||
| 18 | +| **人工决策低效** | 传统补货依赖采购员经验判断 | 效率低、易出错、难以规模化 | | ||
| 19 | +| **多门店协调困难** | 同一商家组合下的多门店库存无法统一调配 | 资源利用率低,部分门店过剩而另一部分缺货 | | ||
| 20 | +| **数据利用不足** | 丰富的销售数据未能有效转化为决策依据 | 补货缺乏数据支撑,决策质量参差不齐 | | ||
| 21 | + | ||
| 22 | +### 1.2 项目目标 | ||
| 23 | + | ||
| 24 | +```mermaid | ||
| 25 | +mindmap | ||
| 26 | + root((商家组合维度分析)) | ||
| 27 | + 智能补货建议 | ||
| 28 | + LLM驱动分析 | ||
| 29 | + 配件级决策 | ||
| 30 | + 门店级分配 | ||
| 31 | + 风险识别与预警 | ||
| 32 | + 呆滞件识别 | ||
| 33 | + 低频件过滤 | ||
| 34 | + 缺货预警 | ||
| 35 | + 数据驱动分析报告 | ||
| 36 | + 库存概览 | ||
| 37 | + 销量分析 | ||
| 38 | + 库存健康度 | ||
| 39 | + 补货建议汇总 | ||
| 40 | + 可视化展示 | ||
| 41 | + 分析报告 | ||
| 42 | + 配件明细 | ||
| 43 | + 执行日志 | ||
| 44 | +``` | ||
| 45 | + | ||
| 46 | +**核心价值主张**: | ||
| 47 | +1. **智能化**:通过 LLM 自动分析库销比数据,生成专业补货建议 | ||
| 48 | +2. **精细化**:从商家组合维度统一分析,再下钻到配件级、门店级 | ||
| 49 | +3. **专业化**:输出的分析理由贴合采购人员专业度,包含具体数据指标 | ||
| 50 | + | ||
| 51 | +--- | ||
| 52 | + | ||
| 53 | +## 2. 功能模块设计 | ||
| 54 | + | ||
| 55 | +### 2.1 功能架构 | ||
| 56 | + | ||
| 57 | +```mermaid | ||
| 58 | +flowchart TB | ||
| 59 | + subgraph 用户层["🖥️ 用户层"] | ||
| 60 | + UI[Web管理界面] | ||
| 61 | + end | ||
| 62 | + | ||
| 63 | + subgraph 应用层["⚙️ 应用层"] | ||
| 64 | + API[FastAPI 接口层] | ||
| 65 | + | ||
| 66 | + subgraph Agent["🤖 LangGraph Agent"] | ||
| 67 | + N1[获取配件库销比<br/>fetch_part_ratio] | ||
| 68 | + N2[LLM分析生成建议<br/>sql_agent] | ||
| 69 | + N3[转换补货明细<br/>allocate_budget] | ||
| 70 | + N4[生成分析报告<br/>generate_analysis_report] | ||
| 71 | + end | ||
| 72 | + | ||
| 73 | + subgraph ReportSubgraph["📊 分析报告并发子图"] | ||
| 74 | + R1[库存概览 LLM] | ||
| 75 | + R2[销量分析 LLM] | ||
| 76 | + R3[健康度 LLM] | ||
| 77 | + R4[补货建议 LLM] | ||
| 78 | + end | ||
| 79 | + end | ||
| 80 | + | ||
| 81 | + subgraph 基础设施["🔧 基础设施"] | ||
| 82 | + LLM[LLM服务<br/>智谱GLM/豆包/OpenAI/Anthropic] | ||
| 83 | + DB[(MySQL数据库)] | ||
| 84 | + end | ||
| 85 | + | ||
| 86 | + UI --> API | ||
| 87 | + API --> Agent | ||
| 88 | + N1 --> N2 --> N3 --> N4 | ||
| 89 | + N4 --> ReportSubgraph | ||
| 90 | + N1 -.-> DB | ||
| 91 | + N2 -.-> LLM | ||
| 92 | + N3 -.-> DB | ||
| 93 | + R1 & R2 & R3 & R4 -.-> LLM | ||
| 94 | +``` | ||
| 95 | + | ||
| 96 | +### 2.2 功能模块清单 | ||
| 97 | + | ||
| 98 | +| 模块 | 功能 | 输入 | 输出 | | ||
| 99 | +|------|------|------|------| | ||
| 100 | +| **数据获取** | 获取商家组合内所有配件的库销比数据 | dealer_grouping_id | part_ratios[] | | ||
| 101 | +| **LLM分析** | 按配件分组分析,生成补货建议和决策理由 | part_ratios, base_ratio | llm_suggestions[] | | ||
| 102 | +| **建议转换** | 将LLM建议转换为结构化的补货明细 | llm_suggestions[] | details[], part_summaries[] | | ||
| 103 | +| **分析报告** | 四大板块统计计算 + 并发LLM分析 | part_ratios, part_results | analysis_report | | ||
| 104 | + | ||
| 105 | +--- | ||
| 106 | + | ||
| 107 | +## 3. 系统架构设计 | ||
| 108 | + | ||
| 109 | +### 3.1 整体架构 | ||
| 110 | + | ||
| 111 | +```mermaid | ||
| 112 | +C4Component | ||
| 113 | + title 商家组合维度分析系统 - 组件架构 | ||
| 114 | + | ||
| 115 | + Container_Boundary(web, "Web层") { | ||
| 116 | + Component(ui, "前端UI", "HTML/CSS/JS", "任务管理、结果展示") | ||
| 117 | + } | ||
| 118 | + | ||
| 119 | + Container_Boundary(api, "API层") { | ||
| 120 | + Component(routes, "路由模块", "FastAPI", "REST API接口") | ||
| 121 | + Component(scheduler, "定时调度", "APScheduler", "任务调度") | ||
| 122 | + } | ||
| 123 | + | ||
| 124 | + Container_Boundary(agent, "Agent层") { | ||
| 125 | + Component(workflow, "工作流引擎", "LangGraph", "状态机编排") | ||
| 126 | + Component(nodes, "节点实现", "Python", "业务逻辑") | ||
| 127 | + Component(report_subgraph, "报告子图", "LangGraph 并发子图", "4路并发LLM分析") | ||
| 128 | + Component(prompts, "提示词", "Markdown", "LLM指令") | ||
| 129 | + } | ||
| 130 | + | ||
| 131 | + Container_Boundary(service, "服务层") { | ||
| 132 | + Component(data, "数据服务", "Python", "数据查询") | ||
| 133 | + Component(writer, "写入服务", "Python", "结果持久化") | ||
| 134 | + } | ||
| 135 | + | ||
| 136 | + Container_Boundary(infra, "基础设施") { | ||
| 137 | + ComponentDb(mysql, "MySQL", "数据库", "业务数据存储") | ||
| 138 | + Component(llm, "LLM", "GLM/Doubao/OpenAI/Anthropic", "大语言模型") | ||
| 139 | + } | ||
| 140 | + | ||
| 141 | + Rel(ui, routes, "HTTP请求") | ||
| 142 | + Rel(routes, workflow, "触发任务") | ||
| 143 | + Rel(workflow, nodes, "执行") | ||
| 144 | + Rel(nodes, report_subgraph, "fan-out/fan-in") | ||
| 145 | + Rel(nodes, prompts, "加载") | ||
| 146 | + Rel(nodes, llm, "调用") | ||
| 147 | + Rel(nodes, data, "查询") | ||
| 148 | + Rel(nodes, writer, "写入") | ||
| 149 | + Rel(data, mysql, "SQL") | ||
| 150 | + Rel(writer, mysql, "SQL") | ||
| 151 | +``` | ||
| 152 | + | ||
| 153 | +### 3.2 工作流状态机 | ||
| 154 | + | ||
| 155 | +```mermaid | ||
| 156 | +stateDiagram-v2 | ||
| 157 | + [*] --> FetchPartRatio: 启动任务 | ||
| 158 | + | ||
| 159 | + FetchPartRatio --> SQLAgent: 获取库销比数据 | ||
| 160 | + FetchPartRatio --> [*]: 无数据 | ||
| 161 | + | ||
| 162 | + SQLAgent --> SQLAgent: 重试(错误 & 次数<3) | ||
| 163 | + SQLAgent --> AllocateBudget: 分析完成 | ||
| 164 | + SQLAgent --> [*]: 重试失败 | ||
| 165 | + | ||
| 166 | + AllocateBudget --> GenerateReport: 转换完成 | ||
| 167 | + | ||
| 168 | + GenerateReport --> [*]: 生成报告 | ||
| 169 | + | ||
| 170 | + state GenerateReport { | ||
| 171 | + [*] --> 统计计算 | ||
| 172 | + 统计计算 --> 并发LLM子图 | ||
| 173 | + | ||
| 174 | + state 并发LLM子图 { | ||
| 175 | + [*] --> 库存概览LLM | ||
| 176 | + [*] --> 销量分析LLM | ||
| 177 | + [*] --> 健康度LLM | ||
| 178 | + [*] --> 补货建议LLM | ||
| 179 | + 库存概览LLM --> [*] | ||
| 180 | + 销量分析LLM --> [*] | ||
| 181 | + 健康度LLM --> [*] | ||
| 182 | + 补货建议LLM --> [*] | ||
| 183 | + } | ||
| 184 | + | ||
| 185 | + 并发LLM子图 --> 汇总写入 | ||
| 186 | + 汇总写入 --> [*] | ||
| 187 | + } | ||
| 188 | +``` | ||
| 189 | + | ||
| 190 | +--- | ||
| 191 | + | ||
| 192 | +## 4. 核心算法说明 | ||
| 193 | + | ||
| 194 | +### 4.1 三层决策逻辑 | ||
| 195 | + | ||
| 196 | +```mermaid | ||
| 197 | +flowchart LR | ||
| 198 | + subgraph L1["第一层: 配件级判断"] | ||
| 199 | + A1[汇总商家组合内<br/>所有门店数据] | ||
| 200 | + A2[计算整体库销比] | ||
| 201 | + A3{是否需要补货?} | ||
| 202 | + A4[生成配件级理由] | ||
| 203 | + end | ||
| 204 | + | ||
| 205 | + subgraph L2["第二层: 门店级分配"] | ||
| 206 | + B1[按库销比从低到高排序] | ||
| 207 | + B2[计算各门店缺口] | ||
| 208 | + B3[分配补货数量] | ||
| 209 | + end | ||
| 210 | + | ||
| 211 | + subgraph L3["第三层: 决策理由生成"] | ||
| 212 | + C1[状态判定标签] | ||
| 213 | + C2[关键指标数据] | ||
| 214 | + C3[缺口分析] | ||
| 215 | + C4[天数说明] | ||
| 216 | + end | ||
| 217 | + | ||
| 218 | + A1 --> A2 --> A3 | ||
| 219 | + A3 -->|是| L2 | ||
| 220 | + A3 -->|否| A4 | ||
| 221 | + B1 --> B2 --> B3 | ||
| 222 | + B3 --> L3 | ||
| 223 | + C1 --> C2 --> C3 --> C4 | ||
| 224 | +``` | ||
| 225 | + | ||
| 226 | +### 4.2 补货数量计算公式 | ||
| 227 | + | ||
| 228 | +``` | ||
| 229 | +suggest_cnt = ceil(目标库销比 × 月均销量 - 当前库存) | ||
| 230 | +``` | ||
| 231 | + | ||
| 232 | +其中: | ||
| 233 | +- **有效库存** = `in_stock_unlocked_cnt` + `on_the_way_cnt` + `has_plan_cnt` | ||
| 234 | +- **月均销量** = (`out_stock_cnt` + `storage_locked_cnt` + `out_stock_ongoing_cnt` + `buy_cnt`) / 3 | ||
| 235 | +- **资金占用** = (`in_stock_unlocked_cnt` + `on_the_way_cnt`) × `cost_price` | ||
| 236 | + | ||
| 237 | +### 4.3 配件分类与处理规则 | ||
| 238 | + | ||
| 239 | +| 分类 | 判定条件 | 处理策略 | | ||
| 240 | +|------|----------|----------| | ||
| 241 | +| **缺货件** | 有效库存 = 0 且 月均销量 ≥ 1 | 优先补货 | | ||
| 242 | +| **呆滞件** | 有效库存 > 0 且 90天出库数 = 0 | 不补货,建议清理 | | ||
| 243 | +| **低频件** | 月均销量 < 1 或 出库次数 < 3 或 出库间隔 ≥ 30天 | 不补货 | | ||
| 244 | +| **正常件** | 不属于以上三类 | 按缺口补货 | | ||
| 245 | + | ||
| 246 | +> 分类优先级:缺货件 > 呆滞件 > 低频件 > 正常件(按顺序判断,命中即止) | ||
| 247 | + | ||
| 248 | +### 4.4 优先级判定标准 | ||
| 249 | + | ||
| 250 | +```mermaid | ||
| 251 | +flowchart TD | ||
| 252 | + A{库存状态} -->|库存=0 且 销量活跃| H[高优先级<br/>急需补货] | ||
| 253 | + A -->|库销比<0.5| M[中优先级<br/>建议补货] | ||
| 254 | + A -->|0.5≤库销比<目标值| L[低优先级<br/>可选补货] | ||
| 255 | + A -->|库销比≥目标值| N[无需补货<br/>库存充足] | ||
| 256 | + | ||
| 257 | + style H fill:#ff6b6b | ||
| 258 | + style M fill:#feca57 | ||
| 259 | + style L fill:#48dbfb | ||
| 260 | + style N fill:#2ecc71 | ||
| 261 | +``` | ||
| 262 | + | ||
| 263 | +--- | ||
| 264 | + | ||
| 265 | +## 5. 数据模型设计 | ||
| 266 | + | ||
| 267 | +### 5.1 ER图 | ||
| 268 | + | ||
| 269 | +```mermaid | ||
| 270 | +erDiagram | ||
| 271 | + AI_REPLENISHMENT_TASK ||--o{ AI_REPLENISHMENT_DETAIL : contains | ||
| 272 | + AI_REPLENISHMENT_TASK ||--o{ AI_REPLENISHMENT_PART_SUMMARY : contains | ||
| 273 | + AI_REPLENISHMENT_TASK ||--o{ AI_TASK_EXECUTION_LOG : logs | ||
| 274 | + AI_REPLENISHMENT_TASK ||--o| AI_ANALYSIS_REPORT : generates | ||
| 275 | + PART_RATIO }o--|| AI_REPLENISHMENT_DETAIL : references | ||
| 276 | + | ||
| 277 | + AI_REPLENISHMENT_TASK { | ||
| 278 | + bigint id PK | ||
| 279 | + varchar task_no UK "AI-开头" | ||
| 280 | + bigint group_id "集团ID" | ||
| 281 | + bigint dealer_grouping_id | ||
| 282 | + varchar dealer_grouping_name | ||
| 283 | + bigint brand_grouping_id "品牌组合ID" | ||
| 284 | + decimal plan_amount "计划采购金额" | ||
| 285 | + decimal actual_amount "实际分配金额" | ||
| 286 | + int part_count "配件数量" | ||
| 287 | + decimal base_ratio "基准库销比" | ||
| 288 | + tinyint status "0运行/1成功/2失败" | ||
| 289 | + varchar llm_provider | ||
| 290 | + varchar llm_model | ||
| 291 | + int llm_total_tokens | ||
| 292 | + varchar statistics_date | ||
| 293 | + datetime start_time | ||
| 294 | + datetime end_time | ||
| 295 | + } | ||
| 296 | + | ||
| 297 | + AI_REPLENISHMENT_DETAIL { | ||
| 298 | + bigint id PK | ||
| 299 | + varchar task_no FK | ||
| 300 | + bigint group_id | ||
| 301 | + bigint dealer_grouping_id | ||
| 302 | + bigint brand_grouping_id | ||
| 303 | + bigint shop_id "库房ID" | ||
| 304 | + varchar shop_name | ||
| 305 | + varchar part_code "配件编码" | ||
| 306 | + varchar part_name | ||
| 307 | + varchar unit | ||
| 308 | + decimal cost_price | ||
| 309 | + decimal current_ratio "当前库销比" | ||
| 310 | + decimal base_ratio "基准库销比" | ||
| 311 | + decimal post_plan_ratio "计划后库销比" | ||
| 312 | + decimal valid_storage_cnt "有效库存" | ||
| 313 | + decimal avg_sales_cnt "月均销量" | ||
| 314 | + int suggest_cnt "建议数量" | ||
| 315 | + decimal suggest_amount "建议金额" | ||
| 316 | + text suggestion_reason "决策理由" | ||
| 317 | + int priority "1高/2中/3低" | ||
| 318 | + float llm_confidence "LLM置信度" | ||
| 319 | + } | ||
| 320 | + | ||
| 321 | + AI_REPLENISHMENT_PART_SUMMARY { | ||
| 322 | + bigint id PK | ||
| 323 | + varchar task_no FK | ||
| 324 | + bigint group_id | ||
| 325 | + bigint dealer_grouping_id | ||
| 326 | + varchar part_code "配件编码" | ||
| 327 | + varchar part_name | ||
| 328 | + varchar unit | ||
| 329 | + decimal cost_price | ||
| 330 | + decimal total_storage_cnt "总库存" | ||
| 331 | + decimal total_avg_sales_cnt "总月均销量" | ||
| 332 | + decimal group_current_ratio "商家组合库销比" | ||
| 333 | + int total_suggest_cnt "总建议数量" | ||
| 334 | + decimal total_suggest_amount "总建议金额" | ||
| 335 | + int shop_count "涉及门店数" | ||
| 336 | + int need_replenishment_shop_count "需补货门店数" | ||
| 337 | + text part_decision_reason "配件级理由" | ||
| 338 | + int priority "1高/2中/3低" | ||
| 339 | + float llm_confidence | ||
| 340 | + } | ||
| 341 | + | ||
| 342 | + AI_TASK_EXECUTION_LOG { | ||
| 343 | + bigint id PK | ||
| 344 | + varchar task_no FK | ||
| 345 | + bigint group_id | ||
| 346 | + bigint brand_grouping_id | ||
| 347 | + varchar brand_grouping_name | ||
| 348 | + bigint dealer_grouping_id | ||
| 349 | + varchar dealer_grouping_name | ||
| 350 | + varchar step_name "步骤名称" | ||
| 351 | + int step_order "步骤顺序" | ||
| 352 | + tinyint status "0进行/1成功/2失败/3跳过" | ||
| 353 | + text input_data "输入JSON" | ||
| 354 | + text output_data "输出JSON" | ||
| 355 | + text error_message | ||
| 356 | + int retry_count | ||
| 357 | + text sql_query | ||
| 358 | + text llm_prompt | ||
| 359 | + text llm_response | ||
| 360 | + int llm_tokens "Token消耗" | ||
| 361 | + int execution_time_ms "耗时" | ||
| 362 | + } | ||
| 363 | + | ||
| 364 | + AI_ANALYSIS_REPORT { | ||
| 365 | + bigint id PK | ||
| 366 | + varchar task_no FK | ||
| 367 | + bigint group_id | ||
| 368 | + bigint dealer_grouping_id | ||
| 369 | + varchar dealer_grouping_name | ||
| 370 | + bigint brand_grouping_id | ||
| 371 | + varchar report_type "默认replenishment" | ||
| 372 | + json inventory_overview "库存概览" | ||
| 373 | + json sales_analysis "销量分析" | ||
| 374 | + json inventory_health "健康度" | ||
| 375 | + json replenishment_summary "补货建议" | ||
| 376 | + varchar llm_provider | ||
| 377 | + varchar llm_model | ||
| 378 | + int llm_tokens | ||
| 379 | + int execution_time_ms | ||
| 380 | + } | ||
| 381 | + | ||
| 382 | + PART_RATIO { | ||
| 383 | + bigint id PK | ||
| 384 | + bigint shop_id | ||
| 385 | + varchar part_code | ||
| 386 | + decimal in_stock_unlocked_cnt "在库未锁定" | ||
| 387 | + decimal on_the_way_cnt "在途" | ||
| 388 | + decimal has_plan_cnt "已有计划" | ||
| 389 | + decimal out_stock_cnt "出库数" | ||
| 390 | + decimal storage_locked_cnt "库存锁定" | ||
| 391 | + decimal out_stock_ongoing_cnt "出库在途" | ||
| 392 | + decimal buy_cnt "采购数" | ||
| 393 | + decimal cost_price "成本价" | ||
| 394 | + int out_times "出库次数" | ||
| 395 | + int out_duration "平均出库间隔" | ||
| 396 | + } | ||
| 397 | +``` | ||
| 398 | + | ||
| 399 | +### 5.2 核心表结构 | ||
| 400 | + | ||
| 401 | +#### ai_replenishment_task(任务主表) | ||
| 402 | +| 字段 | 类型 | 说明 | | ||
| 403 | +|------|------|------| | ||
| 404 | +| task_no | VARCHAR(32) | 任务编号,AI-开头,唯一 | | ||
| 405 | +| group_id | BIGINT | 集团ID | | ||
| 406 | +| dealer_grouping_id | BIGINT | 商家组合ID | | ||
| 407 | +| dealer_grouping_name | VARCHAR(128) | 商家组合名称 | | ||
| 408 | +| brand_grouping_id | BIGINT | 品牌组合ID | | ||
| 409 | +| plan_amount | DECIMAL(14,2) | 计划采购金额(预算) | | ||
| 410 | +| actual_amount | DECIMAL(14,2) | 实际分配金额 | | ||
| 411 | +| part_count | INT | 配件数量 | | ||
| 412 | +| base_ratio | DECIMAL(10,4) | 基准库销比 | | ||
| 413 | +| status | TINYINT | 状态: 0运行中/1成功/2失败 | | ||
| 414 | +| llm_provider | VARCHAR(32) | LLM提供商 | | ||
| 415 | +| llm_model | VARCHAR(64) | LLM模型名称 | | ||
| 416 | +| statistics_date | VARCHAR(16) | 统计日期 | | ||
| 417 | +| start_time / end_time | DATETIME | 任务执行起止时间 | | ||
| 418 | + | ||
| 419 | +#### ai_replenishment_part_summary(配件汇总表) | ||
| 420 | +| 字段 | 类型 | 说明 | | ||
| 421 | +|------|------|------| | ||
| 422 | +| task_no | VARCHAR(32) | 任务编号 | | ||
| 423 | +| group_id | BIGINT | 集团ID | | ||
| 424 | +| dealer_grouping_id | BIGINT | 商家组合ID | | ||
| 425 | +| part_code | VARCHAR(64) | 配件编码 | | ||
| 426 | +| part_name | VARCHAR(256) | 配件名称 | | ||
| 427 | +| cost_price | DECIMAL(14,2) | 成本价 | | ||
| 428 | +| total_storage_cnt | DECIMAL(14,2) | 商家组合内总库存 | | ||
| 429 | +| total_avg_sales_cnt | DECIMAL(14,2) | 总月均销量 | | ||
| 430 | +| group_current_ratio | DECIMAL(10,4) | 商家组合级库销比 | | ||
| 431 | +| total_suggest_cnt | INT | 总建议数量 | | ||
| 432 | +| total_suggest_amount | DECIMAL(14,2) | 总建议金额 | | ||
| 433 | +| shop_count | INT | 涉及门店数 | | ||
| 434 | +| need_replenishment_shop_count | INT | 需补货门店数 | | ||
| 435 | +| part_decision_reason | TEXT | 配件级补货理由 | | ||
| 436 | +| priority | INT | 优先级: 1高/2中/3低 | | ||
| 437 | + | ||
| 438 | +#### ai_analysis_report(分析报告表) | ||
| 439 | +| 字段 | 类型 | 说明 | | ||
| 440 | +|------|------|------| | ||
| 441 | +| task_no | VARCHAR(32) | 任务编号 | | ||
| 442 | +| group_id | BIGINT | 集团ID | | ||
| 443 | +| dealer_grouping_id | BIGINT | 商家组合ID | | ||
| 444 | +| report_type | VARCHAR(32) | 报告类型(默认 replenishment) | | ||
| 445 | +| inventory_overview | JSON | 库存总体概览(stats + llm_analysis) | | ||
| 446 | +| sales_analysis | JSON | 销量分析(stats + llm_analysis) | | ||
| 447 | +| inventory_health | JSON | 库存健康度(stats + chart_data + llm_analysis) | | ||
| 448 | +| replenishment_summary | JSON | 补货建议(stats + llm_analysis) | | ||
| 449 | +| llm_provider | VARCHAR(32) | LLM提供商 | | ||
| 450 | +| llm_model | VARCHAR(64) | LLM模型名称 | | ||
| 451 | +| llm_tokens | INT | LLM Token总消耗 | | ||
| 452 | +| execution_time_ms | INT | 执行耗时(毫秒) | | ||
| 453 | + | ||
| 454 | +--- | ||
| 455 | + | ||
| 456 | +## 6. API 接口设计 | ||
| 457 | + | ||
| 458 | +### 6.1 接口总览 | ||
| 459 | + | ||
| 460 | +```mermaid | ||
| 461 | +flowchart LR | ||
| 462 | + subgraph Tasks["任务管理"] | ||
| 463 | + T1["GET /api/tasks"] | ||
| 464 | + T2["GET /api/tasks/:task_no"] | ||
| 465 | + end | ||
| 466 | + | ||
| 467 | + subgraph Details["明细查询"] | ||
| 468 | + D1["GET /api/tasks/:task_no/details"] | ||
| 469 | + D2["GET /api/tasks/:task_no/part-summaries"] | ||
| 470 | + D3["GET /api/tasks/:task_no/parts/:part_code/shops"] | ||
| 471 | + end | ||
| 472 | + | ||
| 473 | + subgraph Reports["报告模块"] | ||
| 474 | + R1["GET /api/tasks/:task_no/analysis-report"] | ||
| 475 | + R2["GET /api/tasks/:task_no/logs"] | ||
| 476 | + end | ||
| 477 | + | ||
| 478 | + subgraph Health["健康检查"] | ||
| 479 | + H1["GET /health"] | ||
| 480 | + end | ||
| 481 | +``` | ||
| 482 | + | ||
| 483 | +### 6.2 核心接口定义 | ||
| 484 | + | ||
| 485 | +#### 1. 获取任务列表 | ||
| 486 | +``` | ||
| 487 | +GET /api/tasks?page=1&page_size=20&status=1&dealer_grouping_id=100&statistics_date=20260212 | ||
| 488 | +``` | ||
| 489 | + | ||
| 490 | +**响应示例**: | ||
| 491 | +```json | ||
| 492 | +{ | ||
| 493 | + "items": [ | ||
| 494 | + { | ||
| 495 | + "id": 1, | ||
| 496 | + "task_no": "AI-ABC12345", | ||
| 497 | + "group_id": 2, | ||
| 498 | + "dealer_grouping_id": 100, | ||
| 499 | + "dealer_grouping_name": "华东区商家组合", | ||
| 500 | + "brand_grouping_id": 50, | ||
| 501 | + "plan_amount": 100000.00, | ||
| 502 | + "actual_amount": 89520.50, | ||
| 503 | + "part_count": 156, | ||
| 504 | + "base_ratio": 1.5000, | ||
| 505 | + "status": 1, | ||
| 506 | + "status_text": "成功", | ||
| 507 | + "llm_provider": "openai_compat", | ||
| 508 | + "llm_model": "glm-4-7-251222", | ||
| 509 | + "llm_total_tokens": 8500, | ||
| 510 | + "statistics_date": "20260212", | ||
| 511 | + "start_time": "2026-02-12 02:00:00", | ||
| 512 | + "end_time": "2026-02-12 02:05:30", | ||
| 513 | + "duration_seconds": 330, | ||
| 514 | + "create_time": "2026-02-12 02:00:00" | ||
| 515 | + } | ||
| 516 | + ], | ||
| 517 | + "total": 100, | ||
| 518 | + "page": 1, | ||
| 519 | + "page_size": 20 | ||
| 520 | +} | ||
| 521 | +``` | ||
| 522 | + | ||
| 523 | +#### 2. 获取配件汇总(商家组合维度) | ||
| 524 | +``` | ||
| 525 | +GET /api/tasks/{task_no}/part-summaries?sort_by=total_suggest_amount&sort_order=desc&priority=1 | ||
| 526 | +``` | ||
| 527 | + | ||
| 528 | +**响应示例**: | ||
| 529 | +```json | ||
| 530 | +{ | ||
| 531 | + "items": [ | ||
| 532 | + { | ||
| 533 | + "id": 1, | ||
| 534 | + "task_no": "AI-ABC12345", | ||
| 535 | + "part_code": "C211F280503", | ||
| 536 | + "part_name": "机油滤芯", | ||
| 537 | + "unit": "个", | ||
| 538 | + "cost_price": 140.00, | ||
| 539 | + "total_storage_cnt": 25, | ||
| 540 | + "total_avg_sales_cnt": 18.5, | ||
| 541 | + "group_current_ratio": 1.35, | ||
| 542 | + "group_post_plan_ratio": 2.0, | ||
| 543 | + "total_suggest_cnt": 12, | ||
| 544 | + "total_suggest_amount": 1680.00, | ||
| 545 | + "shop_count": 5, | ||
| 546 | + "need_replenishment_shop_count": 3, | ||
| 547 | + "part_decision_reason": "【配件决策】该配件在商家组合内总库存25件...", | ||
| 548 | + "priority": 1, | ||
| 549 | + "llm_confidence": 0.85 | ||
| 550 | + } | ||
| 551 | + ], | ||
| 552 | + "total": 50, | ||
| 553 | + "page": 1, | ||
| 554 | + "page_size": 50 | ||
| 555 | +} | ||
| 556 | +``` | ||
| 557 | + | ||
| 558 | +#### 3. 获取门店级明细 | ||
| 559 | +``` | ||
| 560 | +GET /api/tasks/{task_no}/parts/{part_code}/shops | ||
| 561 | +``` | ||
| 562 | + | ||
| 563 | +**响应示例**: | ||
| 564 | +```json | ||
| 565 | +{ | ||
| 566 | + "total": 3, | ||
| 567 | + "items": [ | ||
| 568 | + { | ||
| 569 | + "id": 101, | ||
| 570 | + "task_no": "AI-ABC12345", | ||
| 571 | + "shop_id": 1001, | ||
| 572 | + "shop_name": "杭州西湖店", | ||
| 573 | + "part_code": "C211F280503", | ||
| 574 | + "part_name": "机油滤芯", | ||
| 575 | + "cost_price": 140.00, | ||
| 576 | + "valid_storage_cnt": 5, | ||
| 577 | + "avg_sales_cnt": 6.2, | ||
| 578 | + "current_ratio": 0.81, | ||
| 579 | + "post_plan_ratio": 1.61, | ||
| 580 | + "suggest_cnt": 5, | ||
| 581 | + "suggest_amount": 700.00, | ||
| 582 | + "suggestion_reason": "「建议补货」当前库存5件,月均销量6.2件...", | ||
| 583 | + "priority": 1 | ||
| 584 | + } | ||
| 585 | + ] | ||
| 586 | +} | ||
| 587 | +``` | ||
| 588 | + | ||
| 589 | +#### 4. 获取配件建议明细 | ||
| 590 | +``` | ||
| 591 | +GET /api/tasks/{task_no}/details?page=1&page_size=50&sort_by=suggest_amount&sort_order=desc&part_code=C211 | ||
| 592 | +``` | ||
| 593 | + | ||
| 594 | +#### 5. 获取分析报告 | ||
| 595 | +``` | ||
| 596 | +GET /api/tasks/{task_no}/analysis-report | ||
| 597 | +``` | ||
| 598 | + | ||
| 599 | +**响应示例**: | ||
| 600 | +```json | ||
| 601 | +{ | ||
| 602 | + "id": 1, | ||
| 603 | + "task_no": "AI-ABC12345", | ||
| 604 | + "group_id": 2, | ||
| 605 | + "dealer_grouping_id": 100, | ||
| 606 | + "report_type": "replenishment", | ||
| 607 | + "inventory_overview": { | ||
| 608 | + "stats": { | ||
| 609 | + "total_valid_storage_cnt": 2500, | ||
| 610 | + "total_valid_storage_amount": 350000.0, | ||
| 611 | + "total_capital_occupation": 280000.0, | ||
| 612 | + "overall_ratio": 1.35, | ||
| 613 | + "part_count": 156 | ||
| 614 | + }, | ||
| 615 | + "llm_analysis": { "..." : "LLM生成的分析结论" } | ||
| 616 | + }, | ||
| 617 | + "sales_analysis": { | ||
| 618 | + "stats": { "total_avg_sales_cnt": 1850, "..." : "..." }, | ||
| 619 | + "llm_analysis": { "..." : "..." } | ||
| 620 | + }, | ||
| 621 | + "inventory_health": { | ||
| 622 | + "stats": { "shortage": { "count": 12, "amount": 5000 }, "..." : "..." }, | ||
| 623 | + "chart_data": { "labels": ["缺货件","呆滞件","低频件","正常件"], "..." : "..." }, | ||
| 624 | + "llm_analysis": { "..." : "..." } | ||
| 625 | + }, | ||
| 626 | + "replenishment_summary": { | ||
| 627 | + "stats": { "urgent": { "count": 15, "amount": 25000 }, "..." : "..." }, | ||
| 628 | + "llm_analysis": { "..." : "..." } | ||
| 629 | + }, | ||
| 630 | + "llm_tokens": 3200, | ||
| 631 | + "execution_time_ms": 12000 | ||
| 632 | +} | ||
| 633 | +``` | ||
| 634 | + | ||
| 635 | +#### 6. 获取执行日志 | ||
| 636 | +``` | ||
| 637 | +GET /api/tasks/{task_no}/logs | ||
| 638 | +``` | ||
| 639 | + | ||
| 640 | +--- | ||
| 641 | + | ||
| 642 | +## 7. 前端交互设计 | ||
| 643 | + | ||
| 644 | +### 7.1 页面结构 | ||
| 645 | + | ||
| 646 | +```mermaid | ||
| 647 | +flowchart TB | ||
| 648 | + subgraph Dashboard["仪表盘"] | ||
| 649 | + S1[统计卡片] | ||
| 650 | + S2[最近任务列表] | ||
| 651 | + end | ||
| 652 | + | ||
| 653 | + subgraph TaskList["任务列表页"] | ||
| 654 | + L1[筛选条件] | ||
| 655 | + L2[任务表格] | ||
| 656 | + L3[分页控件] | ||
| 657 | + end | ||
| 658 | + | ||
| 659 | + subgraph TaskDetail["任务详情页"] | ||
| 660 | + D1[任务头部信息] | ||
| 661 | + D2[统计卡片] | ||
| 662 | + | ||
| 663 | + subgraph Tabs["标签页"] | ||
| 664 | + T1[配件明细] | ||
| 665 | + T2[分析报告] | ||
| 666 | + T3[执行日志] | ||
| 667 | + T4[任务信息] | ||
| 668 | + end | ||
| 669 | + end | ||
| 670 | + | ||
| 671 | + Dashboard --> TaskList --> TaskDetail | ||
| 672 | +``` | ||
| 673 | + | ||
| 674 | +### 7.2 配件明细交互 | ||
| 675 | + | ||
| 676 | +```mermaid | ||
| 677 | +sequenceDiagram | ||
| 678 | + participant U as 用户 | ||
| 679 | + participant UI as 前端UI | ||
| 680 | + participant API as 后端API | ||
| 681 | + | ||
| 682 | + U->>UI: 点击任务详情 | ||
| 683 | + UI->>API: GET /api/tasks/{task_no}/part-summaries | ||
| 684 | + API-->>UI: 返回配件汇总列表 | ||
| 685 | + UI->>UI: 渲染配件表格(可排序/筛选/优先级) | ||
| 686 | + | ||
| 687 | + U->>UI: 点击展开某配件 | ||
| 688 | + UI->>API: GET /api/tasks/{task_no}/parts/{part_code}/shops | ||
| 689 | + API-->>UI: 返回门店级明细 | ||
| 690 | + UI->>UI: 展开子表格显示门店数据 | ||
| 691 | + | ||
| 692 | + Note over UI: 门店数据包含:<br/>库存、销量、库销比<br/>建议数量、建议理由<br/>计划后库销比 | ||
| 693 | +``` | ||
| 694 | + | ||
| 695 | +### 7.3 关键UI组件 | ||
| 696 | + | ||
| 697 | +| 组件 | 功能 | 交互方式 | | ||
| 698 | +|------|------|----------| | ||
| 699 | +| **配件汇总表格** | 展示商家组合维度的配件建议 | 支持排序、筛选、分页、优先级筛选 | | ||
| 700 | +| **可展开行** | 展示配件下的门店明细 | 点击行展开/收起 | | ||
| 701 | +| **配件决策卡片** | 显示LLM生成的配件级理由 | 展开配件时显示 | | ||
| 702 | +| **库销比指示器** | 直观显示库销比健康度 | 颜色渐变(红/黄/绿) | | ||
| 703 | +| **分析报告面板** | 四大板块数据驱动展示 | 统计数据 + LLM 分析 + 图表 | | ||
| 704 | + | ||
| 705 | +--- | ||
| 706 | + | ||
| 707 | +## 8. 分析报告设计 | ||
| 708 | + | ||
| 709 | +### 8.1 报告模块结构 | ||
| 710 | + | ||
| 711 | +分析报告由 **统计计算** + **4路并发 LLM 分析** 的 LangGraph 子图生成。每个板块包含 `stats`(统计数据)和 `llm_analysis`(LLM 分析结论)。 | ||
| 712 | + | ||
| 713 | +```mermaid | ||
| 714 | +flowchart TB | ||
| 715 | + subgraph Report["分析报告四大板块"] | ||
| 716 | + M1["板块1: 库存总体概览<br/>inventory_overview"] | ||
| 717 | + M2["板块2: 销量分析<br/>sales_analysis"] | ||
| 718 | + M3["板块3: 库存构成健康度<br/>inventory_health"] | ||
| 719 | + M4["板块4: 补货建议生成情况<br/>replenishment_summary"] | ||
| 720 | + end | ||
| 721 | + | ||
| 722 | + M1 --> S1[有效库存/资金占用] | ||
| 723 | + M1 --> S2[在库/在途/已有计划] | ||
| 724 | + M1 --> S3[整体库销比] | ||
| 725 | + | ||
| 726 | + M2 --> R1[月均销量/销售金额] | ||
| 727 | + M2 --> R2[有销量/无销量配件数] | ||
| 728 | + M2 --> R3[出库/锁定/采购统计] | ||
| 729 | + | ||
| 730 | + M3 --> P1[缺货件统计] | ||
| 731 | + M3 --> P2[呆滞件统计] | ||
| 732 | + M3 --> P3[低频件统计] | ||
| 733 | + M3 --> P4[正常件统计] | ||
| 734 | + M3 --> P5[chart_data图表数据] | ||
| 735 | + | ||
| 736 | + M4 --> E1[急需补货统计] | ||
| 737 | + M4 --> E2[建议补货统计] | ||
| 738 | + M4 --> E3[可选补货统计] | ||
| 739 | +``` | ||
| 740 | + | ||
| 741 | +### 8.2 各板块统计计算与LLM分析 | ||
| 742 | + | ||
| 743 | +| 板块 | 统计计算 | LLM 分析 | 提示词文件 | | ||
| 744 | +|------|---------|---------|-----------| | ||
| 745 | +| **库存概览** | 有效库存、资金占用、配件总数、整体库销比 | 库存状况综合评价 | `report_inventory_overview.md` | | ||
| 746 | +| **销量分析** | 月均销量、出库频次、有/无销量配件数 | 销售趋势洞察 | `report_sales_analysis.md` | | ||
| 747 | +| **库存健康度** | 缺货/呆滞/低频/正常分类统计(数量/金额/占比) | 健康度风险提示 | `report_inventory_health.md` | | ||
| 748 | +| **补货建议汇总** | 按优先级(急需/建议/可选)分类统计 | 补货策略建议 | `report_replenishment_summary.md` | | ||
| 749 | + | ||
| 750 | +> 四个 LLM 分析节点使用 LangGraph 子图 **并发执行**(fan-out / fan-in),单板块失败不影响其他板块。 | ||
| 751 | + | ||
| 752 | +### 8.3 并发子图实现 | ||
| 753 | + | ||
| 754 | +```mermaid | ||
| 755 | +flowchart LR | ||
| 756 | + START --> A[库存概览LLM] --> END2[END] | ||
| 757 | + START --> B[销量分析LLM] --> END2 | ||
| 758 | + START --> C[健康度LLM] --> END2 | ||
| 759 | + START --> D[补货建议LLM] --> END2 | ||
| 760 | +``` | ||
| 761 | + | ||
| 762 | +子图采用 `ReportLLMState` TypedDict 定义状态,使用 `Annotated` reducer 合并并发结果: | ||
| 763 | +- 分析结果:`_merge_dict`(保留非 None) | ||
| 764 | +- Token 用量:`_sum_int`(累加) | ||
| 765 | + | ||
| 766 | +--- | ||
| 767 | + | ||
| 768 | +## 9. 技术选型 | ||
| 769 | + | ||
| 770 | +| 组件 | 技术 | 选型理由 | | ||
| 771 | +|------|------|----------| | ||
| 772 | +| **编程语言** | Python 3.11+ | 丰富的AI/ML生态 | | ||
| 773 | +| **Agent框架** | LangChain + LangGraph | 成熟的LLM编排框架,支持并发子图 | | ||
| 774 | +| **API框架** | FastAPI | 高性能、自动文档 | | ||
| 775 | +| **数据库** | MySQL | 与主系统保持一致 | | ||
| 776 | +| **LLM** | 智谱GLM / 豆包 / OpenAI兼容 / Anthropic兼容 | 多模型支持,优先级自动选择 | | ||
| 777 | +| **前端** | 原生HTML+CSS+JS | 轻量级,无构建依赖 | | ||
| 778 | + | ||
| 779 | +### LLM 客户端优先级 | ||
| 780 | + | ||
| 781 | +| 优先级 | 客户端 | 触发条件 | | ||
| 782 | +|--------|--------|----------| | ||
| 783 | +| 1 | `OpenAICompatClient` | `OPENAI_COMPAT_API_KEY` 已配置 | | ||
| 784 | +| 2 | `AnthropicCompatClient` | `ANTHROPIC_API_KEY` 已配置 | | ||
| 785 | +| 3 | `GLMClient` | `GLM_API_KEY` 已配置 | | ||
| 786 | +| 4 | `DoubaoClient` | `DOUBAO_API_KEY` 已配置 | | ||
| 787 | + | ||
| 788 | +--- | ||
| 789 | + | ||
| 790 | +## 10. 部署与运维 | ||
| 791 | + | ||
| 792 | +### 10.1 部署架构 | ||
| 793 | + | ||
| 794 | +```mermaid | ||
| 795 | +flowchart LR | ||
| 796 | + subgraph Client["客户端"] | ||
| 797 | + Browser[浏览器] | ||
| 798 | + end | ||
| 799 | + | ||
| 800 | + subgraph Server["服务器"] | ||
| 801 | + Nginx[Nginx<br/>静态资源/反向代理] | ||
| 802 | + API[FastAPI<br/>API服务] | ||
| 803 | + Scheduler[APScheduler<br/>定时任务] | ||
| 804 | + end | ||
| 805 | + | ||
| 806 | + subgraph External["外部服务"] | ||
| 807 | + LLM[LLM API] | ||
| 808 | + DB[(MySQL)] | ||
| 809 | + end | ||
| 810 | + | ||
| 811 | + Browser --> Nginx | ||
| 812 | + Nginx --> API | ||
| 813 | + API --> LLM | ||
| 814 | + API --> DB | ||
| 815 | + Scheduler --> API | ||
| 816 | +``` | ||
| 817 | + | ||
| 818 | +### 10.2 关键监控指标 | ||
| 819 | + | ||
| 820 | +| 指标 | 阈值 | 告警方式 | | ||
| 821 | +|------|------|----------| | ||
| 822 | +| 任务成功率 | < 95% | 邮件 | | ||
| 823 | +| LLM响应时间 | > 30s | 日志 | | ||
| 824 | +| Token消耗 | > 10000/任务 | 日志 | | ||
| 825 | +| API响应时间 | > 2s | 监控 | | ||
| 826 | + | ||
| 827 | +--- | ||
| 828 | + | ||
| 829 | +## 附录 | ||
| 830 | + | ||
| 831 | +### A. 术语表 | ||
| 832 | + | ||
| 833 | +| 术语 | 定义 | | ||
| 834 | +|------|------| | ||
| 835 | +| 商家组合 | 多个经销商/门店的逻辑分组 | | ||
| 836 | +| 库销比 | 库存数量 / 月均销量,衡量库存健康度 | | ||
| 837 | +| 呆滞件 | 有库存但90天无出库数的配件 | | ||
| 838 | +| 低频件 | 月均销量<1 或 出库次数<3 或 出库间隔≥30天的配件 | | ||
| 839 | +| 有效库存 | 在库未锁定 + 在途 + 已有计划 | | ||
| 840 | +| 资金占用 | (在库未锁定 + 在途) × 成本价 | | ||
| 841 | + | ||
| 842 | +### B. 参考文档 | ||
| 843 | + | ||
| 844 | +- [docs/architecture.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/docs/architecture.md) - 系统架构文档 | ||
| 845 | +- [prompts/part_shop_analysis.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/part_shop_analysis.md) - 配件分析提示词 | ||
| 846 | +- [prompts/report_inventory_overview.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/report_inventory_overview.md) - 库存概览提示词 | ||
| 847 | +- [prompts/report_sales_analysis.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/report_sales_analysis.md) - 销量分析提示词 | ||
| 848 | +- [prompts/report_inventory_health.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/report_inventory_health.md) - 库存健康度提示词 | ||
| 849 | +- [prompts/report_replenishment_summary.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/report_replenishment_summary.md) - 补货建议提示词 | ||
| 850 | + | ||
| 851 | +### C. 版本变更记录 | ||
| 852 | + | ||
| 853 | +| 版本 | 日期 | 变更说明 | | ||
| 854 | +|------|------|----------| | ||
| 855 | +| 1.0.0 | 2026-02-09 | 初始版本 | | ||
| 856 | +| 2.0.0 | 2026-02-12 | 根据实际实现更新:分析报告重构为四大数据驱动板块、ER图更新、API路径和字段对齐、新增LLM客户端等 | |
prompts/analysis_report.md deleted
| 1 | -# 智能补货建议分析报告 | ||
| 2 | - | ||
| 3 | -你是一位资深汽车配件采购顾问。AI系统已经基于全量数据生成了补货建议的**多维统计分布数据**,现在需要你站在更宏观的视角,为采购决策者提供**整体性分析**。 | ||
| 4 | - | ||
| 5 | -> **核心定位**: 统计数据揭示了补货的宏观特征与分布情况,本报告聚焦于"整体策略、风险预警、资金规划"等**决策层面**的洞察。 | ||
| 6 | - | ||
| 7 | ---- | ||
| 8 | - | ||
| 9 | -## 商家组合信息 | ||
| 10 | - | ||
| 11 | -| 项目 | 数值 | | ||
| 12 | -|------|------| | ||
| 13 | -| 商家组合ID | {dealer_grouping_id} | | ||
| 14 | -| 商家组合名称 | {dealer_grouping_name} | | ||
| 15 | -| 报告生成日期 | {statistics_date} | | ||
| 16 | - | ||
| 17 | ---- | ||
| 18 | - | ||
| 19 | -## 本期补货建议概览 (统计摘要) | ||
| 20 | - | ||
| 21 | -{suggestion_summary} | ||
| 22 | - | ||
| 23 | ---- | ||
| 24 | - | ||
| 25 | -## 库存健康度参考 | ||
| 26 | - | ||
| 27 | -| 状态分类 | 配件数量 | 涉及金额 | | ||
| 28 | -|----------|----------|----------| | ||
| 29 | -| 缺货件 | {shortage_cnt} | {shortage_amount} | | ||
| 30 | -| 呆滞件 | {stagnant_cnt} | {stagnant_amount} | | ||
| 31 | -| 低频件 | {low_freq_cnt} | {low_freq_amount} | | ||
| 32 | - | ||
| 33 | ---- | ||
| 34 | - | ||
| 35 | -## 分析任务 | ||
| 36 | - | ||
| 37 | -请严格按以下4个模块输出 **JSON格式** 的整体分析报告。 | ||
| 38 | - | ||
| 39 | -**注意**: 分析应基于提供的统计分布数据(如优先级、价格区间、周转频次等),按以下**宏观维度**展开。 | ||
| 40 | - | ||
| 41 | -### 模块1: 整体态势研判 (overall_assessment) | ||
| 42 | - | ||
| 43 | -从全局视角评估本次补货建议: | ||
| 44 | - | ||
| 45 | -1. **补货规模评估**: 结合涉及配件数和总金额,评估本期补货的力度和资金需求规模。 | ||
| 46 | -2. **结构特征分析**: 基于价格区间和周转频次分布,分析补货建议的结构特征(如是否偏向高频低价件,或存在大量低频高价件)。 | ||
| 47 | -3. **时机判断**: 当前是否处于补货的有利时机?需要考虑哪些时间因素(如节假日、促销季、供应商备货周期)? | ||
| 48 | - | ||
| 49 | -### 模块2: 风险预警与应对 (risk_alerts) | ||
| 50 | - | ||
| 51 | -识别本次补货可能面临的风险并给出应对建议: | ||
| 52 | - | ||
| 53 | -1. **供应风险**: 高频或高优先级配件的供应保障是否关键? | ||
| 54 | -2. **资金风险**: 大额补货配件(≥5000元)的占比是否过高?是否构成资金压力? | ||
| 55 | -3. **库存结构风险**: 低频配件的补货比例是否合理?是否存在积压风险? | ||
| 56 | -4. **执行重点**: 针对高优先级或大额补货的配件,建议采取什么复核策略? | ||
| 57 | - | ||
| 58 | -### 模块3: 采购策略建议 (procurement_strategy) | ||
| 59 | - | ||
| 60 | -提供整体性的采购执行策略: | ||
| 61 | - | ||
| 62 | -1. **优先级排序原则**: 结合优先级分布数据,给出资金分配和采购执行的先后顺序建议。 | ||
| 63 | -2. **批量采购机会**: 对于低价高频的配件,是否建议采用批量采购策略以优化成本? | ||
| 64 | -3. **分批采购建议**: 对于大额或低频配件,是否建议分批次补货以控制风险? | ||
| 65 | -4. **供应商协调要点**: 针对本期补货的结构特征(如大额占比高或高频占比高),与供应商沟通的侧重点是什么? | ||
| 66 | - | ||
| 67 | -### 模块4: 效果预期与建议 (expected_impact) | ||
| 68 | - | ||
| 69 | -预估按建议执行后的整体效果: | ||
| 70 | - | ||
| 71 | -1. **库存健康度改善**: 补货后整体库存结构预计如何变化?缺货率预计下降多少? | ||
| 72 | -2. **资金效率预估**: 本期补货的预计投入产出如何?资金周转是否会改善? | ||
| 73 | -3. **后续关注点**: 补货完成后需要持续关注哪些指标或配件?下一步建议行动是什么? | ||
| 74 | - | ||
| 75 | ---- | ||
| 76 | - | ||
| 77 | -## 输出格式 | ||
| 78 | - | ||
| 79 | -直接输出JSON对象,**不要**包含 ```json 标记: | ||
| 80 | - | ||
| 81 | -{{ | ||
| 82 | - "overall_assessment": {{ | ||
| 83 | - "scale_evaluation": {{ | ||
| 84 | - "current_vs_historical": "与历史同期对比结论", | ||
| 85 | - "possible_reasons": "规模变化的可能原因" | ||
| 86 | - }}, | ||
| 87 | - "structure_analysis": {{ | ||
| 88 | - "category_distribution": "品类分布特征", | ||
| 89 | - "price_range_distribution": "价格区间分布特征", | ||
| 90 | - "turnover_distribution": "周转频次分布特征", | ||
| 91 | - "imbalance_warning": "是否存在失衡及说明" | ||
| 92 | - }}, | ||
| 93 | - "timing_judgment": {{ | ||
| 94 | - "is_favorable": true或false, | ||
| 95 | - "timing_factors": "需要考虑的时间因素", | ||
| 96 | - "recommendation": "时机相关建议" | ||
| 97 | - }} | ||
| 98 | - }}, | ||
| 99 | - "risk_alerts": {{ | ||
| 100 | - "supply_risks": [ | ||
| 101 | - {{ | ||
| 102 | - "risk_type": "风险类型(缺货/涨价/交期延长等)", | ||
| 103 | - "affected_scope": "影响范围描述", | ||
| 104 | - "likelihood": "可能性评估(高/中/低)", | ||
| 105 | - "mitigation": "应对建议" | ||
| 106 | - }} | ||
| 107 | - ], | ||
| 108 | - "capital_risks": {{ | ||
| 109 | - "cash_flow_pressure": "资金压力评估", | ||
| 110 | - "stagnation_warning": "呆滞风险提示", | ||
| 111 | - "recommendation": "资金风险应对建议" | ||
| 112 | - }}, | ||
| 113 | - "market_risks": [ | ||
| 114 | - {{ | ||
| 115 | - "risk_description": "市场风险描述", | ||
| 116 | - "affected_parts": "影响配件范围", | ||
| 117 | - "recommendation": "应对建议" | ||
| 118 | - }} | ||
| 119 | - ], | ||
| 120 | - "execution_anomalies": [ | ||
| 121 | - {{ | ||
| 122 | - "anomaly_type": "异常类型", | ||
| 123 | - "description": "异常描述", | ||
| 124 | - "review_suggestion": "复核建议" | ||
| 125 | - }} | ||
| 126 | - ] | ||
| 127 | - }}, | ||
| 128 | - "procurement_strategy": {{ | ||
| 129 | - "priority_principle": {{ | ||
| 130 | - "tier1_criteria": "第一优先级标准及说明", | ||
| 131 | - "tier2_criteria": "第二优先级标准及说明", | ||
| 132 | - "tier3_criteria": "可延后采购的标准及说明" | ||
| 133 | - }}, | ||
| 134 | - "batch_opportunities": {{ | ||
| 135 | - "potential_savings": "潜在节省金额或比例", | ||
| 136 | - "applicable_categories": "适用品类或供应商", | ||
| 137 | - "execution_suggestion": "具体操作建议" | ||
| 138 | - }}, | ||
| 139 | - "phased_procurement": {{ | ||
| 140 | - "recommended_parts": "建议分批采购的配件范围", | ||
| 141 | - "suggested_rhythm": "建议的采购节奏" | ||
| 142 | - }}, | ||
| 143 | - "supplier_coordination": {{ | ||
| 144 | - "key_communications": "关键沟通事项", | ||
| 145 | - "timing_suggestions": "沟通时机建议" | ||
| 146 | - }} | ||
| 147 | - }}, | ||
| 148 | - "expected_impact": {{ | ||
| 149 | - "inventory_health": {{ | ||
| 150 | - "structure_improvement": "库存结构改善预期", | ||
| 151 | - "shortage_reduction": "缺货率预计下降幅度" | ||
| 152 | - }}, | ||
| 153 | - "capital_efficiency": {{ | ||
| 154 | - "investment_amount": 本期补货投入金额, | ||
| 155 | - "expected_return": "预期收益描述", | ||
| 156 | - "turnover_improvement": "周转改善预期" | ||
| 157 | - }}, | ||
| 158 | - "follow_up_actions": {{ | ||
| 159 | - "key_metrics_to_watch": "需持续关注的指标", | ||
| 160 | - "next_steps": "下一步建议行动" | ||
| 161 | - }} | ||
| 162 | - }} | ||
| 163 | -}} | ||
| 164 | - | ||
| 165 | ---- | ||
| 166 | - | ||
| 167 | -## 重要约束 | ||
| 168 | - | ||
| 169 | -1. **输出必须是合法的JSON对象** | ||
| 170 | -2. **所有金额单位为元,保留2位小数** | ||
| 171 | -3. **聚焦宏观分析,不要重复明细中已有的配件级别信息** | ||
| 172 | -4. **风险和效果预估尽量量化** | ||
| 173 | -5. **策略建议要具体可执行,避免空泛描述** | ||
| 174 | -6. **分析基于提供的汇总数据,保持客观理性** |
prompts/report_inventory_health.md
0 → 100644
| 1 | +# 库存健康度分析提示词 | ||
| 2 | + | ||
| 3 | +你是一位汽车配件库存健康度诊断专家,擅长从库存结构数据中识别问题并提出改善方案。请基于以下健康度统计数据,进行专业的库存健康度诊断。 | ||
| 4 | + | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +## 统计数据 | ||
| 8 | + | ||
| 9 | +| 指标 | 数值 | | ||
| 10 | +|------|------| | ||
| 11 | +| 配件总种类数 | {total_count} | | ||
| 12 | +| 库存总金额 | {total_amount} 元 | | ||
| 13 | + | ||
| 14 | +### 各类型配件统计 | ||
| 15 | + | ||
| 16 | +| 类型 | 数量 | 数量占比 | 金额(元) | 金额占比 | | ||
| 17 | +|------|------|----------|------------|----------| | ||
| 18 | +| 缺货件 | {shortage_count} | {shortage_count_pct}% | {shortage_amount} | {shortage_amount_pct}% | | ||
| 19 | +| 呆滞件 | {stagnant_count} | {stagnant_count_pct}% | {stagnant_amount} | {stagnant_amount_pct}% | | ||
| 20 | +| 低频件 | {low_freq_count} | {low_freq_count_pct}% | {low_freq_amount} | {low_freq_amount_pct}% | | ||
| 21 | +| 正常件 | {normal_count} | {normal_count_pct}% | {normal_amount} | {normal_amount_pct}% | | ||
| 22 | + | ||
| 23 | +--- | ||
| 24 | + | ||
| 25 | +## 术语说明 | ||
| 26 | + | ||
| 27 | +- **缺货件**: 有效库存 = 0 且月均销量 >= 1,有需求但无库存 | ||
| 28 | +- **呆滞件**: 有效库存 > 0 且90天出库数 = 0,有库存但无销售 | ||
| 29 | +- **低频件**: 月均销量 < 1 或出库次数 < 3 或出库间隔 >= 30天 | ||
| 30 | +- **正常件**: 不属于以上三类的配件 | ||
| 31 | + | ||
| 32 | +--- | ||
| 33 | + | ||
| 34 | +## 当前季节信息 | ||
| 35 | + | ||
| 36 | +- **当前季节**: {current_season} | ||
| 37 | +- **统计日期**: {statistics_date} | ||
| 38 | + | ||
| 39 | +--- | ||
| 40 | + | ||
| 41 | +## 季节性因素参考 | ||
| 42 | + | ||
| 43 | +| 季节 | 健康度评估调整 | 特别关注 | | ||
| 44 | +|------|--------------|---------| | ||
| 45 | +| 春季(3-5月) | 呆滞件中可能包含冬季配件,属正常现象 | 关注冬季配件是否及时清理 | | ||
| 46 | +| 夏季(6-8月) | 制冷配件缺货风险高,需重点关注 | 空调、冷却系统配件缺货影响大 | | ||
| 47 | +| 秋季(9-11月) | 夏季配件可能转为低频,需提前处理 | 关注夏季配件库存消化 | | ||
| 48 | +| 冬季(12-2月) | 电瓶、暖风配件缺货影响大 | 春节前缺货损失更大,需提前备货 | | ||
| 49 | + | ||
| 50 | +--- | ||
| 51 | + | ||
| 52 | +## 分析框架与判断标准 | ||
| 53 | + | ||
| 54 | +### 健康度评分标准 | ||
| 55 | +| 正常件数量占比 | 健康度等级 | 说明 | | ||
| 56 | +|---------------|-----------|------| | ||
| 57 | +| > 70% | 健康 | 库存结构良好,继续保持 | | ||
| 58 | +| 50% - 70% | 亚健康 | 存在优化空间,需关注问题件 | | ||
| 59 | +| < 50% | 不健康 | 库存结构严重失衡,需立即改善 | | ||
| 60 | + | ||
| 61 | +### 各类型问题件风险评估标准 | ||
| 62 | +| 类型 | 数量占比阈值 | 金额占比阈值 | 风险等级 | | ||
| 63 | +|------|-------------|-------------|---------| | ||
| 64 | +| 缺货件 | > 10% | - | 高风险(影响销售) | | ||
| 65 | +| 呆滞件 | > 15% | > 20% | 高风险(资金占用) | | ||
| 66 | +| 低频件 | > 25% | > 30% | 中风险(周转效率) | | ||
| 67 | + | ||
| 68 | +### 资金释放潜力评估 | ||
| 69 | +| 类型 | 可释放比例 | 释放方式 | | ||
| 70 | +|------|-----------|---------| | ||
| 71 | +| 呆滞件 | 60%-80% | 促销清仓、退货供应商、调拨其他门店 | | ||
| 72 | +| 低频件 | 30%-50% | 降价促销、减少补货、逐步淘汰 | | ||
| 73 | + | ||
| 74 | +--- | ||
| 75 | + | ||
| 76 | +## 分析任务 | ||
| 77 | + | ||
| 78 | +请严格按照以下步骤进行分析,每一步都要展示推理过程: | ||
| 79 | + | ||
| 80 | +### 步骤1:健康度评分 | ||
| 81 | +- 读取正常件数量占比 | ||
| 82 | +- 对照健康度评分标准,确定健康度等级 | ||
| 83 | +- 说明判断依据 | ||
| 84 | + | ||
| 85 | +### 步骤2:问题件诊断 | ||
| 86 | +对每类问题件进行分析: | ||
| 87 | + | ||
| 88 | +**缺货件分析:** | ||
| 89 | +- 对照风险阈值(数量占比>10%),判断风险等级 | ||
| 90 | +- 分析缺货对业务的影响(销售损失、客户流失) | ||
| 91 | +- 推断可能原因(补货不及时、需求预测不准、供应链问题) | ||
| 92 | + | ||
| 93 | +**呆滞件分析:** | ||
| 94 | +- 对照风险阈值(数量占比>15%或金额占比>20%),判断风险等级 | ||
| 95 | +- 分析呆滞对资金的影响 | ||
| 96 | +- 推断可能原因(采购决策失误、市场变化、产品更新换代) | ||
| 97 | + | ||
| 98 | +**低频件分析:** | ||
| 99 | +- 对照风险阈值(数量占比>25%或金额占比>30%),判断风险等级 | ||
| 100 | +- 分析低频件对SKU效率的影响 | ||
| 101 | +- 推断可能原因(长尾需求、季节性产品、新品导入) | ||
| 102 | + | ||
| 103 | +### 步骤3:资金释放机会评估 | ||
| 104 | +- 计算呆滞件可释放资金 = 呆滞件金额 × 可释放比例(60%-80%) | ||
| 105 | +- 计算低频件可释放资金 = 低频件金额 × 可释放比例(30%-50%) | ||
| 106 | +- 给出具体的资金释放行动方案 | ||
| 107 | + | ||
| 108 | +### 步骤4:改善优先级排序 | ||
| 109 | +- 根据风险等级和影响程度,排序问题类型 | ||
| 110 | +- 给出2-3条优先级最高的改善行动 | ||
| 111 | + | ||
| 112 | +--- | ||
| 113 | + | ||
| 114 | +## 输出格式 | ||
| 115 | + | ||
| 116 | +直接输出JSON对象,**不要**包含 ```json 标记: | ||
| 117 | + | ||
| 118 | +{{ | ||
| 119 | + "analysis_process": {{ | ||
| 120 | + "health_score_diagnosis": {{ | ||
| 121 | + "normal_ratio": "正常件数量占比(直接读取:{normal_count_pct}%)", | ||
| 122 | + "score": "健康/亚健康/不健康", | ||
| 123 | + "reasoning": "判断依据:对照标准xxx,当前正常件占比为xxx%,因此判断为xxx" | ||
| 124 | + }}, | ||
| 125 | + "problem_diagnosis": {{ | ||
| 126 | + "shortage": {{ | ||
| 127 | + "risk_level": "高/中/低", | ||
| 128 | + "threshold_comparison": "对照阈值>10%,当前{shortage_count_pct}%,结论", | ||
| 129 | + "business_impact": "对业务的具体影响分析", | ||
| 130 | + "possible_causes": ["可能原因1", "可能原因2"] | ||
| 131 | + }}, | ||
| 132 | + "stagnant": {{ | ||
| 133 | + "risk_level": "高/中/低", | ||
| 134 | + "threshold_comparison": "对照阈值(数量>15%或金额>20%),当前数量{stagnant_count_pct}%/金额{stagnant_amount_pct}%,结论", | ||
| 135 | + "capital_impact": "对资金的具体影响分析", | ||
| 136 | + "possible_causes": ["可能原因1", "可能原因2"] | ||
| 137 | + }}, | ||
| 138 | + "low_freq": {{ | ||
| 139 | + "risk_level": "高/中/低", | ||
| 140 | + "threshold_comparison": "对照阈值(数量>25%或金额>30%),当前数量{low_freq_count_pct}%/金额{low_freq_amount_pct}%,结论", | ||
| 141 | + "efficiency_impact": "对SKU效率的具体影响分析", | ||
| 142 | + "possible_causes": ["可能原因1", "可能原因2"] | ||
| 143 | + }} | ||
| 144 | + }}, | ||
| 145 | + "capital_release_calculation": {{ | ||
| 146 | + "stagnant_calculation": "呆滞件可释放资金 = {stagnant_amount} × 70% = xxx元(取中间值70%)", | ||
| 147 | + "low_freq_calculation": "低频件可释放资金 = {low_freq_amount} × 40% = xxx元(取中间值40%)", | ||
| 148 | + "total_releasable": "总可释放资金 = xxx元" | ||
| 149 | + }}, | ||
| 150 | + "seasonal_analysis": {{ | ||
| 151 | + "current_season": "当前季节", | ||
| 152 | + "seasonal_stagnant_items": "呆滞件中是否包含季节性配件(如冬季的空调配件)", | ||
| 153 | + "seasonal_shortage_risk": "当季高需求配件的缺货风险评估", | ||
| 154 | + "upcoming_season_alert": "下一季节需要关注的配件类型" | ||
| 155 | + }} | ||
| 156 | + }}, | ||
| 157 | + "conclusion": {{ | ||
| 158 | + "health_score": {{ | ||
| 159 | + "score": "健康/亚健康/不健康", | ||
| 160 | + "normal_ratio_evaluation": "正常件占比评估结论(基于分析得出)" | ||
| 161 | + }}, | ||
| 162 | + "problem_diagnosis": {{ | ||
| 163 | + "stagnant_analysis": "呆滞件问题分析及原因(基于分析得出)", | ||
| 164 | + "shortage_analysis": "缺货件问题分析及影响(基于分析得出)", | ||
| 165 | + "low_freq_analysis": "低频件问题分析及建议(基于分析得出)" | ||
| 166 | + }}, | ||
| 167 | + "capital_release": {{ | ||
| 168 | + "stagnant_releasable": "呆滞件可释放资金估算(基于计算得出)", | ||
| 169 | + "low_freq_releasable": "低频件可释放资金估算(基于计算得出)", | ||
| 170 | + "action_plan": "资金释放行动方案(具体步骤)" | ||
| 171 | + }}, | ||
| 172 | + "priority_actions": [ | ||
| 173 | + {{ | ||
| 174 | + "priority": 1, | ||
| 175 | + "action": "最优先处理事项", | ||
| 176 | + "reason": "优先原因", | ||
| 177 | + "expected_effect": "预期效果" | ||
| 178 | + }}, | ||
| 179 | + {{ | ||
| 180 | + "priority": 2, | ||
| 181 | + "action": "次优先处理事项", | ||
| 182 | + "reason": "优先原因", | ||
| 183 | + "expected_effect": "预期效果" | ||
| 184 | + }} | ||
| 185 | + ] | ||
| 186 | + }} | ||
| 187 | +}} | ||
| 188 | + | ||
| 189 | +--- | ||
| 190 | + | ||
| 191 | +## 重要约束 | ||
| 192 | + | ||
| 193 | +1. **输出必须是合法的JSON对象** | ||
| 194 | +2. **分析必须基于提供的数据,不要编造数据** | ||
| 195 | +3. **每个结论都必须有明确的推理依据和数据支撑** | ||
| 196 | +4. **资金释放估算应基于实际数据和给定的释放比例范围** | ||
| 197 | +5. **score 只能是"健康"、"亚健康"、"不健康"三个值之一** | ||
| 198 | +6. **priority_actions 数组至少包含2条,最多3条** | ||
| 199 | +7. **如果数据全为零,请在分析中说明无有效数据,并给出相应建议** | ||
| 200 | +8. **所有金额计算结果保留两位小数** |
prompts/report_inventory_overview.md
0 → 100644
| 1 | +# 库存概览分析提示词 | ||
| 2 | + | ||
| 3 | +你是一位资深汽车配件库存管理专家,拥有20年以上的汽车后市场库存管理经验。请基于以下库存概览统计数据,进行专业的库存分析。 | ||
| 4 | + | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +## 统计数据 | ||
| 8 | + | ||
| 9 | +| 指标 | 数值 | | ||
| 10 | +|------|------| | ||
| 11 | +| 配件总种类数 | {part_count} | | ||
| 12 | +| 有效库存总数量 | {total_valid_storage_cnt} | | ||
| 13 | +| 有效库存总金额(资金占用) | {total_valid_storage_amount} 元 | | ||
| 14 | +| 月均销量总数量 | {total_avg_sales_cnt} | | ||
| 15 | +| 整体库销比 | {overall_ratio} | | ||
| 16 | + | ||
| 17 | +### 库存三项构成明细 | ||
| 18 | + | ||
| 19 | +| 构成项 | 数量 | 金额(元) | 数量占比 | 金额占比 | | ||
| 20 | +|--------|------|------------|----------|----------| | ||
| 21 | +| 在库未锁 | {total_in_stock_unlocked_cnt} | {total_in_stock_unlocked_amount} | - | - | | ||
| 22 | +| 在途 | {total_on_the_way_cnt} | {total_on_the_way_amount} | - | - | | ||
| 23 | +| 计划数 | {total_has_plan_cnt} | {total_has_plan_amount} | - | - | | ||
| 24 | + | ||
| 25 | +--- | ||
| 26 | + | ||
| 27 | +## 术语说明 | ||
| 28 | + | ||
| 29 | +- **有效库存**: 在库未锁 + 在途 + 计划数 | ||
| 30 | +- **月均销量**: (90天出库数 + 未关单已锁 + 未关单出库 + 订件) / 3 | ||
| 31 | +- **库销比**: 有效库存总数量 / 月均销量总数量,反映库存周转效率 | ||
| 32 | + | ||
| 33 | +--- | ||
| 34 | + | ||
| 35 | +## 当前季节信息 | ||
| 36 | + | ||
| 37 | +- **当前季节**: {current_season} | ||
| 38 | +- **统计日期**: {statistics_date} | ||
| 39 | + | ||
| 40 | +--- | ||
| 41 | + | ||
| 42 | +## 季节性因素参考 | ||
| 43 | + | ||
| 44 | +| 季节 | 需求特征 | 库存策略建议 | | ||
| 45 | +|------|---------|-------------| | ||
| 46 | +| 春季(3-5月) | 需求回暖,维修保养高峰前期 | 适当增加库存,为旺季做准备 | | ||
| 47 | +| 夏季(6-8月) | 空调、冷却系统配件需求旺盛 | 重点备货制冷相关配件,库销比可适当放宽至2.5 | | ||
| 48 | +| 秋季(9-11月) | 需求平稳,换季保养需求 | 保持正常库存水平,关注轮胎、刹车片等 | | ||
| 49 | +| 冬季(12-2月) | 电瓶、暖风系统需求增加,春节前备货期 | 提前备货,库销比可适当放宽至2.5-3.0 | | ||
| 50 | + | ||
| 51 | +--- | ||
| 52 | + | ||
| 53 | +## 分析框架与判断标准 | ||
| 54 | + | ||
| 55 | +### 库销比判断标准 | ||
| 56 | +| 库销比范围 | 判断等级 | 含义 | | ||
| 57 | +|-----------|---------|------| | ||
| 58 | +| < 1.0 | 库存不足 | 可能面临缺货风险,需要加快补货 | | ||
| 59 | +| 1.0 - 2.0 | 合理 | 库存水平健康,周转效率良好 | | ||
| 60 | +| 2.0 - 3.0 | 偏高 | 库存积压风险,需关注周转 | | ||
| 61 | +| > 3.0 | 严重积压 | 资金占用过高,需立即优化 | | ||
| 62 | +| = 999 | 无销量 | 月均销量为零,需特别关注 | | ||
| 63 | + | ||
| 64 | +### 库存结构健康标准 | ||
| 65 | +| 构成项 | 健康占比范围 | 风险提示 | | ||
| 66 | +|--------|-------------|---------| | ||
| 67 | +| 在库未锁 | 60%-80% | 过高说明周转慢,过低说明库存不足 | | ||
| 68 | +| 在途 | 10%-25% | 过高说明到货延迟风险,过低说明补货不及时 | | ||
| 69 | +| 计划数 | 5%-15% | 过高说明计划执行滞后 | | ||
| 70 | + | ||
| 71 | +### 资金占用风险等级 | ||
| 72 | +| 条件 | 风险等级 | | ||
| 73 | +|------|---------| | ||
| 74 | +| 库销比 > 3.0 或 在库未锁占比 > 85% | high | | ||
| 75 | +| 库销比 2.0-3.0 或 在库未锁占比 80%-85% | medium | | ||
| 76 | +| 库销比 < 2.0 且 结构合理 | low | | ||
| 77 | + | ||
| 78 | +--- | ||
| 79 | + | ||
| 80 | +## 分析任务 | ||
| 81 | + | ||
| 82 | +请严格按照以下步骤进行分析,每一步都要展示推理过程: | ||
| 83 | + | ||
| 84 | +### 步骤1:计算关键指标 | ||
| 85 | +首先计算以下指标(请在分析中展示计算过程): | ||
| 86 | +- 各构成项的数量占比 = 构成项数量 / 有效库存总数量 × 100% | ||
| 87 | +- 各构成项的金额占比 = 构成项金额 / 有效库存总金额 × 100% | ||
| 88 | +- 单件平均成本 = 有效库存总金额 / 有效库存总数量 | ||
| 89 | + | ||
| 90 | +### 步骤2:库销比诊断 | ||
| 91 | +- 对照判断标准,确定当前库销比所处等级 | ||
| 92 | +- 说明该等级的业务含义 | ||
| 93 | +- 与行业经验值(1.5-2.5)进行对比 | ||
| 94 | + | ||
| 95 | +### 步骤3:库存结构分析 | ||
| 96 | +- 对照健康标准,评估各构成项占比是否合理 | ||
| 97 | +- 识别偏离健康范围的构成项 | ||
| 98 | +- 分析偏离的可能原因 | ||
| 99 | + | ||
| 100 | +### 步骤4:风险评估 | ||
| 101 | +- 根据风险等级判断条件,确定当前风险等级 | ||
| 102 | +- 列出具体的风险点 | ||
| 103 | + | ||
| 104 | +### 步骤5:季节性考量 | ||
| 105 | +- 结合当前季节特征,评估库存水平是否适合当前季节 | ||
| 106 | +- 考虑即将到来的季节变化,是否需要提前调整 | ||
| 107 | + | ||
| 108 | +### 步骤6:形成建议 | ||
| 109 | +- 基于以上分析,提出2-3条具体可操作的改善建议 | ||
| 110 | +- 每条建议需说明预期效果 | ||
| 111 | +- 建议需考虑季节性因素 | ||
| 112 | + | ||
| 113 | +--- | ||
| 114 | + | ||
| 115 | +## 输出格式 | ||
| 116 | + | ||
| 117 | +直接输出JSON对象,**不要**包含 ```json 标记: | ||
| 118 | + | ||
| 119 | +{{ | ||
| 120 | + "analysis_process": {{ | ||
| 121 | + "calculated_metrics": {{ | ||
| 122 | + "in_stock_ratio": "在库未锁数量占比(计算过程:xxx / xxx = xx%)", | ||
| 123 | + "on_way_ratio": "在途数量占比(计算过程)", | ||
| 124 | + "plan_ratio": "计划数占比(计算过程)", | ||
| 125 | + "avg_cost": "单件平均成本(计算过程)" | ||
| 126 | + }}, | ||
| 127 | + "ratio_diagnosis": {{ | ||
| 128 | + "current_value": "当前库销比数值", | ||
| 129 | + "level": "不足/合理/偏高/严重积压/无销量", | ||
| 130 | + "reasoning": "判断依据:对照标准xxx,当前值xxx,因此判断为xxx", | ||
| 131 | + "benchmark_comparison": "与行业经验值1.5-2.5对比的结论" | ||
| 132 | + }}, | ||
| 133 | + "structure_analysis": {{ | ||
| 134 | + "in_stock_evaluation": "在库未锁占比评估(对照标准60%-80%,当前xx%,结论)", | ||
| 135 | + "on_way_evaluation": "在途占比评估(对照标准10%-25%,当前xx%,结论)", | ||
| 136 | + "plan_evaluation": "计划数占比评估(对照标准5%-15%,当前xx%,结论)", | ||
| 137 | + "abnormal_items": ["偏离健康范围的项目及原因分析"] | ||
| 138 | + }}, | ||
| 139 | + "seasonal_analysis": {{ | ||
| 140 | + "current_season": "当前季节", | ||
| 141 | + "season_demand_feature": "当前季节需求特征", | ||
| 142 | + "inventory_fitness": "当前库存水平是否适合本季节(结合季节性因素评估)", | ||
| 143 | + "upcoming_season_preparation": "对即将到来季节的准备建议" | ||
| 144 | + }} | ||
| 145 | + }}, | ||
| 146 | + "conclusion": {{ | ||
| 147 | + "capital_assessment": {{ | ||
| 148 | + "total_evaluation": "总资金占用评估(基于以上分析得出的一句话结论)", | ||
| 149 | + "structure_ratio": "各构成部分的资金比例分析结论", | ||
| 150 | + "risk_level": "high/medium/low(基于风险等级判断条件得出)" | ||
| 151 | + }}, | ||
| 152 | + "ratio_diagnosis": {{ | ||
| 153 | + "level": "不足/合理/偏高/严重积压", | ||
| 154 | + "analysis": "库销比分析结论", | ||
| 155 | + "benchmark": "行业参考值对比结论" | ||
| 156 | + }}, | ||
| 157 | + "recommendations": [ | ||
| 158 | + {{ | ||
| 159 | + "action": "具体建议1", | ||
| 160 | + "reason": "建议依据", | ||
| 161 | + "expected_effect": "预期效果" | ||
| 162 | + }}, | ||
| 163 | + {{ | ||
| 164 | + "action": "具体建议2", | ||
| 165 | + "reason": "建议依据", | ||
| 166 | + "expected_effect": "预期效果" | ||
| 167 | + }} | ||
| 168 | + ] | ||
| 169 | + }} | ||
| 170 | +}} | ||
| 171 | + | ||
| 172 | +--- | ||
| 173 | + | ||
| 174 | +## 重要约束 | ||
| 175 | + | ||
| 176 | +1. **输出必须是合法的JSON对象** | ||
| 177 | +2. **分析必须基于提供的数据,不要编造数据** | ||
| 178 | +3. **每个结论都必须有明确的推理依据** | ||
| 179 | +4. **建议必须具体可操作,避免空泛的表述** | ||
| 180 | +5. **risk_level 只能是 high、medium、low 三个值之一** | ||
| 181 | +6. **如果数据全为零,请在分析中说明无有效数据,并给出相应建议** | ||
| 182 | +7. **所有百分比计算结果保留两位小数** |
prompts/report_replenishment_summary.md
0 → 100644
| 1 | +# 补货建议分析提示词 | ||
| 2 | + | ||
| 3 | +你是一位汽车配件采购策略顾问,擅长制定科学的补货计划和资金分配方案。请基于以下补货建议统计数据,进行专业的补货策略分析。 | ||
| 4 | + | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +## 统计数据 | ||
| 8 | + | ||
| 9 | +| 指标 | 数值 | | ||
| 10 | +|------|------| | ||
| 11 | +| 补货配件总种类数 | {total_count} | | ||
| 12 | +| 补货总金额 | {total_amount} 元 | | ||
| 13 | + | ||
| 14 | +### 各优先级统计 | ||
| 15 | + | ||
| 16 | +| 优先级 | 配件种类数 | 金额(元) | | ||
| 17 | +|--------|-----------|------------| | ||
| 18 | +| 急需补货(优先级1) | {urgent_count} | {urgent_amount} | | ||
| 19 | +| 建议补货(优先级2) | {suggested_count} | {suggested_amount} | | ||
| 20 | +| 可选补货(优先级3) | {optional_count} | {optional_amount} | | ||
| 21 | + | ||
| 22 | +--- | ||
| 23 | + | ||
| 24 | +## 术语说明 | ||
| 25 | + | ||
| 26 | +- **急需补货(优先级1)**: 库销比 < 0.5 且月均销量 >= 1,库存严重不足,面临断货风险 | ||
| 27 | +- **建议补货(优先级2)**: 库销比 0.5-1.0 且月均销量 >= 1,库存偏低,建议及时补充 | ||
| 28 | +- **可选补货(优先级3)**: 库销比 1.0-目标值 且月均销量 >= 1,库存尚可,可灵活安排 | ||
| 29 | + | ||
| 30 | +--- | ||
| 31 | + | ||
| 32 | +## 当前季节信息 | ||
| 33 | + | ||
| 34 | +- **当前季节**: {current_season} | ||
| 35 | +- **统计日期**: {statistics_date} | ||
| 36 | + | ||
| 37 | +--- | ||
| 38 | + | ||
| 39 | +## 季节性因素参考 | ||
| 40 | + | ||
| 41 | +| 季节 | 补货策略调整 | 重点补货品类 | | ||
| 42 | +|------|-------------|-------------| | ||
| 43 | +| 春季(3-5月) | 为夏季旺季提前备货 | 空调、冷却系统配件 | | ||
| 44 | +| 夏季(6-8月) | 制冷配件紧急补货优先级更高 | 空调压缩机、冷凝器、制冷剂 | | ||
| 45 | +| 秋季(9-11月) | 为冬季备货,减少夏季配件补货 | 电瓶、暖风系统、防冻液 | | ||
| 46 | +| 冬季(12-2月) | 春节前加快补货节奏 | 电瓶、启动机、暖风配件 | | ||
| 47 | + | ||
| 48 | +--- | ||
| 49 | + | ||
| 50 | +## 分析框架与判断标准 | ||
| 51 | + | ||
| 52 | +### 紧迫度评估标准 | ||
| 53 | +| 急需补货占比 | 紧迫度等级 | 风险等级 | 建议 | | ||
| 54 | +|-------------|-----------|---------|------| | ||
| 55 | +| > 30% | 非常紧迫 | high | 立即启动紧急补货流程 | | ||
| 56 | +| 15% - 30% | 较紧迫 | medium | 优先处理急需补货 | | ||
| 57 | +| < 15% | 一般 | low | 按正常流程处理 | | ||
| 58 | + | ||
| 59 | +### 资金分配优先级原则 | ||
| 60 | +| 优先级 | 建议预算占比 | 执行时间 | | ||
| 61 | +|--------|-------------|---------| | ||
| 62 | +| 急需补货 | 50%-70% | 1-3天内 | | ||
| 63 | +| 建议补货 | 20%-35% | 1-2周内 | | ||
| 64 | +| 可选补货 | 10%-15% | 2-4周内 | | ||
| 65 | + | ||
| 66 | +### 风险预警阈值 | ||
| 67 | +| 风险类型 | 触发条件 | 预警等级 | | ||
| 68 | +|---------|---------|---------| | ||
| 69 | +| 资金压力 | 急需补货金额占比 > 60% | 高 | | ||
| 70 | +| 过度补货 | 可选补货金额占比 > 40% | 中 | | ||
| 71 | + | ||
| 72 | +--- | ||
| 73 | + | ||
| 74 | +## 分析任务 | ||
| 75 | + | ||
| 76 | +请严格按照以下步骤进行分析,展示推理过程: | ||
| 77 | + | ||
| 78 | +### 步骤1:计算关键指标 | ||
| 79 | +- 各优先级数量占比 = 数量 / 总数量 × 100% | ||
| 80 | +- 各优先级金额占比 = 金额 / 总金额 × 100% | ||
| 81 | + | ||
| 82 | +### 步骤2:紧迫度评估 | ||
| 83 | +- 对照标准确定紧迫度等级和风险等级 | ||
| 84 | +- 判断是否需要立即行动 | ||
| 85 | + | ||
| 86 | +### 步骤3:资金分配建议 | ||
| 87 | +- 对照建议预算占比,判断当前分布是否合理 | ||
| 88 | +- 给出具体资金分配建议 | ||
| 89 | + | ||
| 90 | +### 步骤4:执行节奏规划 | ||
| 91 | +- 规划各类补货的执行时间 | ||
| 92 | + | ||
| 93 | +### 步骤5:风险识别 | ||
| 94 | +- 对照风险预警阈值,识别潜在风险 | ||
| 95 | + | ||
| 96 | +--- | ||
| 97 | + | ||
| 98 | +## 输出格式 | ||
| 99 | + | ||
| 100 | +直接输出JSON对象,**不要**包含 ```json 标记: | ||
| 101 | + | ||
| 102 | +{{ | ||
| 103 | + "analysis_process": {{ | ||
| 104 | + "calculated_metrics": {{ | ||
| 105 | + "urgent_count_ratio": "急需补货数量占比(计算:xxx / xxx = xx%)", | ||
| 106 | + "urgent_amount_ratio": "急需补货金额占比(计算)", | ||
| 107 | + "suggested_count_ratio": "建议补货数量占比(计算)", | ||
| 108 | + "suggested_amount_ratio": "建议补货金额占比(计算)", | ||
| 109 | + "optional_count_ratio": "可选补货数量占比(计算)", | ||
| 110 | + "optional_amount_ratio": "可选补货金额占比(计算)" | ||
| 111 | + }}, | ||
| 112 | + "urgency_diagnosis": {{ | ||
| 113 | + "urgent_ratio": "急需补货数量占比", | ||
| 114 | + "level": "非常紧迫/较紧迫/一般", | ||
| 115 | + "reasoning": "判断依据:对照标准xxx,当前占比xxx%,因此判断为xxx" | ||
| 116 | + }}, | ||
| 117 | + "budget_analysis": {{ | ||
| 118 | + "current_distribution": "当前各优先级金额分布情况", | ||
| 119 | + "comparison_with_standard": "与建议预算占比对比分析", | ||
| 120 | + "adjustment_needed": "是否需要调整及原因" | ||
| 121 | + }}, | ||
| 122 | + "risk_identification": {{ | ||
| 123 | + "capital_pressure_check": "资金压力检查(急需占比是否>60%)", | ||
| 124 | + "over_replenishment_check": "过度补货检查(可选占比是否>40%)", | ||
| 125 | + "identified_risks": ["识别到的风险1", "识别到的风险2"] | ||
| 126 | + }}, | ||
| 127 | + "seasonal_analysis": {{ | ||
| 128 | + "current_season": "当前季节", | ||
| 129 | + "seasonal_priority_items": "当季重点补货品类是否在急需列表中", | ||
| 130 | + "timeline_adjustment": "是否需要根据季节调整补货时间(如春节前加快)", | ||
| 131 | + "next_season_preparation": "为下一季节需要提前准备的配件" | ||
| 132 | + }} | ||
| 133 | + }}, | ||
| 134 | + "conclusion": {{ | ||
| 135 | + "urgency_assessment": {{ | ||
| 136 | + "urgent_ratio_evaluation": "急需补货占比评估结论", | ||
| 137 | + "risk_level": "high/medium/low", | ||
| 138 | + "immediate_action_needed": true或false | ||
| 139 | + }}, | ||
| 140 | + "budget_allocation": {{ | ||
| 141 | + "recommended_order": "建议资金分配顺序(基于分析得出)", | ||
| 142 | + "urgent_budget": "急需补货建议预算(具体金额或比例)", | ||
| 143 | + "suggested_budget": "建议补货建议预算", | ||
| 144 | + "optional_budget": "可选补货建议预算" | ||
| 145 | + }}, | ||
| 146 | + "execution_plan": {{ | ||
| 147 | + "urgent_timeline": "急需补货执行时间(1-3天内)", | ||
| 148 | + "suggested_timeline": "建议补货执行时间(1-2周内)", | ||
| 149 | + "optional_timeline": "可选补货执行时间(2-4周内)" | ||
| 150 | + }}, | ||
| 151 | + "risk_warnings": [ | ||
| 152 | + {{ | ||
| 153 | + "risk_type": "风险类型", | ||
| 154 | + "description": "风险描述", | ||
| 155 | + "mitigation": "应对建议" | ||
| 156 | + }} | ||
| 157 | + ] | ||
| 158 | + }} | ||
| 159 | +}} | ||
| 160 | + | ||
| 161 | +--- | ||
| 162 | + | ||
| 163 | +## 重要约束 | ||
| 164 | + | ||
| 165 | +1. **输出必须是合法的JSON对象** | ||
| 166 | +2. **分析必须基于提供的数据,不要编造数据** | ||
| 167 | +3. **每个结论都必须有明确的推理依据和数据支撑** | ||
| 168 | +4. **建议必须具体可操作,包含时间和金额参考** | ||
| 169 | +5. **risk_level 只能是 high、medium、low 三个值之一** | ||
| 170 | +6. **immediate_action_needed 必须是布尔值 true 或 false** | ||
| 171 | +7. **risk_warnings 数组至少包含1条,最多3条** | ||
| 172 | +8. **如果数据全为零,请在分析中说明无补货建议数据** | ||
| 173 | +9. **所有百分比计算结果保留两位小数** |
prompts/report_sales_analysis.md
0 → 100644
| 1 | +# 销量分析提示词 | ||
| 2 | + | ||
| 3 | +你是一位汽车配件销售数据分析师,擅长从销量数据中洞察需求趋势和业务机会。请基于以下销量统计数据,进行专业的销量分析。 | ||
| 4 | + | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +## 统计数据 | ||
| 8 | + | ||
| 9 | +| 指标 | 数值 | | ||
| 10 | +|------|------| | ||
| 11 | +| 月均销量总数量 | {total_avg_sales_cnt} | | ||
| 12 | +| 月均销量总金额 | {total_avg_sales_amount} 元 | | ||
| 13 | +| 有销量配件数 | {has_sales_part_count} | | ||
| 14 | +| 无销量配件数 | {no_sales_part_count} | | ||
| 15 | + | ||
| 16 | +### 销量构成明细 | ||
| 17 | + | ||
| 18 | +| 构成项 | 数量 | 说明 | | ||
| 19 | +|--------|------|------| | ||
| 20 | +| 90天出库数 | {total_out_stock_cnt} | 近90天实际出库,反映正常销售 | | ||
| 21 | +| 未关单已锁 | {total_storage_locked_cnt} | 已锁定库存但订单未关闭,反映待处理订单 | | ||
| 22 | +| 未关单出库 | {total_out_stock_ongoing_cnt} | 已出库但订单未关闭,反映在途交付 | | ||
| 23 | +| 订件 | {total_buy_cnt} | 客户预订的配件数量,反映预订需求 | | ||
| 24 | + | ||
| 25 | +--- | ||
| 26 | + | ||
| 27 | +## 术语说明 | ||
| 28 | + | ||
| 29 | +- **月均销量**: (90天出库数 + 未关单已锁 + 未关单出库 + 订件) / 3 | ||
| 30 | +- **有销量配件**: 月均销量 > 0 的配件 | ||
| 31 | +- **无销量配件**: 月均销量 = 0 的配件 | ||
| 32 | +- **SKU活跃率**: 有销量配件数 / 总配件数 × 100% | ||
| 33 | + | ||
| 34 | +--- | ||
| 35 | + | ||
| 36 | +## 当前季节信息 | ||
| 37 | + | ||
| 38 | +- **当前季节**: {current_season} | ||
| 39 | +- **统计日期**: {statistics_date} | ||
| 40 | + | ||
| 41 | +--- | ||
| 42 | + | ||
| 43 | +## 季节性因素参考 | ||
| 44 | + | ||
| 45 | +| 季节 | 销量特征 | 关注重点 | | ||
| 46 | +|------|---------|---------| | ||
| 47 | +| 春季(3-5月) | 销量逐步回升,保养类配件需求增加 | 关注机油、滤芯等保养件销量变化 | | ||
| 48 | +| 夏季(6-8月) | 空调、冷却系统配件销量高峰 | 制冷配件销量应明显上升,否则需关注 | | ||
| 49 | +| 秋季(9-11月) | 销量平稳,换季保养需求 | 轮胎、刹车片等安全件需求增加 | | ||
| 50 | +| 冬季(12-2月) | 电瓶、暖风配件需求增加,春节前订单高峰 | 订件占比可能上升,属正常现象 | | ||
| 51 | + | ||
| 52 | +--- | ||
| 53 | + | ||
| 54 | +## 分析框架与判断标准 | ||
| 55 | + | ||
| 56 | +### 销量构成健康标准 | ||
| 57 | +| 构成项 | 健康占比范围 | 异常信号 | | ||
| 58 | +|--------|-------------|---------| | ||
| 59 | +| 90天出库数 | > 70% | 占比过低说明正常销售不足,可能存在订单积压 | | ||
| 60 | +| 未关单已锁 | < 15% | 占比过高说明订单处理效率低,需关注 | | ||
| 61 | +| 未关单出库 | < 10% | 占比过高说明交付周期长,客户体验受影响 | | ||
| 62 | +| 订件 | 5%-15% | 过高说明预订需求旺盛但库存不足,过低说明预订渠道不畅 | | ||
| 63 | + | ||
| 64 | +### SKU活跃度判断标准 | ||
| 65 | +| 活跃率范围 | 判断等级 | 建议 | | ||
| 66 | +|-----------|---------|------| | ||
| 67 | +| > 80% | 优秀 | SKU管理良好,保持现状 | | ||
| 68 | +| 70%-80% | 良好 | 可适当优化无销量SKU | | ||
| 69 | +| 50%-70% | 一般 | 需要重点关注SKU精简 | | ||
| 70 | +| < 50% | 较差 | SKU管理存在严重问题,需立即优化 | | ||
| 71 | + | ||
| 72 | +### 需求趋势判断依据 | ||
| 73 | +| 信号 | 趋势判断 | | ||
| 74 | +|------|---------| | ||
| 75 | +| 订件占比上升 + 未关单占比上升 | 上升(需求增长但供应跟不上) | | ||
| 76 | +| 90天出库占比稳定 + 各项占比均衡 | 稳定(供需平衡) | | ||
| 77 | +| 90天出库占比下降 + 订件占比下降 | 下降(需求萎缩) | | ||
| 78 | + | ||
| 79 | +--- | ||
| 80 | + | ||
| 81 | +## 分析任务 | ||
| 82 | + | ||
| 83 | +请严格按照以下步骤进行分析,每一步都要展示推理过程: | ||
| 84 | + | ||
| 85 | +### 步骤1:计算关键指标 | ||
| 86 | +首先计算以下指标(请在分析中展示计算过程): | ||
| 87 | +- 各构成项占比 = 构成项数量 / (90天出库数 + 未关单已锁 + 未关单出库 + 订件) × 100% | ||
| 88 | +- SKU活跃率 = 有销量配件数 / (有销量配件数 + 无销量配件数) × 100% | ||
| 89 | +- 单件平均销售金额 = 月均销量总金额 / 月均销量总数量 | ||
| 90 | + | ||
| 91 | +### 步骤2:销量构成分析 | ||
| 92 | +- 对照健康标准,评估各构成项占比是否合理 | ||
| 93 | +- 识别主要销量来源 | ||
| 94 | +- 分析未关单(已锁+出库)对整体销量的影响 | ||
| 95 | + | ||
| 96 | +### 步骤3:SKU活跃度评估 | ||
| 97 | +- 对照活跃度标准,确定当前活跃率等级 | ||
| 98 | +- 分析无销量配件占比的业务影响 | ||
| 99 | +- 提出SKU优化方向 | ||
| 100 | + | ||
| 101 | +### 步骤4:季节性分析 | ||
| 102 | +- 结合当前季节特征,评估销量表现是否符合季节预期 | ||
| 103 | +- 分析季节性配件的销量是否正常 | ||
| 104 | + | ||
| 105 | +### 步骤5:需求趋势判断 | ||
| 106 | +- 根据各构成项的占比关系,判断需求趋势 | ||
| 107 | +- 结合季节因素,说明判断依据 | ||
| 108 | +- 给出短期需求预测(考虑季节变化) | ||
| 109 | + | ||
| 110 | +--- | ||
| 111 | + | ||
| 112 | +## 输出格式 | ||
| 113 | + | ||
| 114 | +直接输出JSON对象,**不要**包含 ```json 标记: | ||
| 115 | + | ||
| 116 | +{{ | ||
| 117 | + "analysis_process": {{ | ||
| 118 | + "calculated_metrics": {{ | ||
| 119 | + "out_stock_ratio": "90天出库占比(计算过程:xxx / xxx = xx%)", | ||
| 120 | + "locked_ratio": "未关单已锁占比(计算过程)", | ||
| 121 | + "ongoing_ratio": "未关单出库占比(计算过程)", | ||
| 122 | + "buy_ratio": "订件占比(计算过程)", | ||
| 123 | + "sku_active_rate": "SKU活跃率(计算过程:xxx / xxx = xx%)", | ||
| 124 | + "avg_sales_price": "单件平均销售金额(计算过程)" | ||
| 125 | + }}, | ||
| 126 | + "composition_diagnosis": {{ | ||
| 127 | + "out_stock_evaluation": "90天出库占比评估(对照标准>70%,当前xx%,结论)", | ||
| 128 | + "locked_evaluation": "未关单已锁占比评估(对照标准<15%,当前xx%,结论)", | ||
| 129 | + "ongoing_evaluation": "未关单出库占比评估(对照标准<10%,当前xx%,结论)", | ||
| 130 | + "buy_evaluation": "订件占比评估(对照标准5%-15%,当前xx%,结论)", | ||
| 131 | + "abnormal_items": ["偏离健康范围的项目及原因分析"] | ||
| 132 | + }}, | ||
| 133 | + "activity_diagnosis": {{ | ||
| 134 | + "current_rate": "当前SKU活跃率", | ||
| 135 | + "level": "优秀/良好/一般/较差", | ||
| 136 | + "reasoning": "判断依据:对照标准xxx,当前值xxx,因此判断为xxx" | ||
| 137 | + }}, | ||
| 138 | + "trend_diagnosis": {{ | ||
| 139 | + "signals": ["观察到的趋势信号1", "观察到的趋势信号2"], | ||
| 140 | + "reasoning": "基于以上信号,判断需求趋势为xxx,因为xxx" | ||
| 141 | + }}, | ||
| 142 | + "seasonal_analysis": {{ | ||
| 143 | + "current_season": "当前季节", | ||
| 144 | + "expected_performance": "本季节预期销量特征", | ||
| 145 | + "actual_vs_expected": "实际表现与季节预期对比", | ||
| 146 | + "seasonal_items_status": "季节性配件销量状态评估" | ||
| 147 | + }} | ||
| 148 | + }}, | ||
| 149 | + "conclusion": {{ | ||
| 150 | + "composition_analysis": {{ | ||
| 151 | + "main_driver": "主要销量来源分析(基于占比计算得出)", | ||
| 152 | + "pending_orders_impact": "未关单对销量的影响(基于占比计算得出)", | ||
| 153 | + "booking_trend": "订件趋势分析(基于占比计算得出)" | ||
| 154 | + }}, | ||
| 155 | + "activity_assessment": {{ | ||
| 156 | + "active_ratio": "活跃SKU占比评估结论", | ||
| 157 | + "optimization_suggestion": "SKU优化建议(基于活跃度等级给出)" | ||
| 158 | + }}, | ||
| 159 | + "demand_trend": {{ | ||
| 160 | + "direction": "上升/稳定/下降", | ||
| 161 | + "evidence": "判断依据(列出具体数据支撑)", | ||
| 162 | + "seasonal_factor": "季节因素对趋势的影响", | ||
| 163 | + "forecast": "短期需求预测(考虑季节变化)" | ||
| 164 | + }} | ||
| 165 | + }} | ||
| 166 | +}} | ||
| 167 | + | ||
| 168 | +--- | ||
| 169 | + | ||
| 170 | +## 重要约束 | ||
| 171 | + | ||
| 172 | +1. **输出必须是合法的JSON对象** | ||
| 173 | +2. **分析必须基于提供的数据,不要编造数据** | ||
| 174 | +3. **每个结论都必须有明确的推理依据和数据支撑** | ||
| 175 | +4. **建议必须具体可操作,避免空泛的表述** | ||
| 176 | +5. **direction 只能是"上升"、"稳定"、"下降"三个值之一** | ||
| 177 | +6. **如果数据全为零,请在分析中说明无有效数据,并给出相应建议** | ||
| 178 | +7. **所有百分比计算结果保留两位小数** |
prompts/sql_agent.md
| @@ -60,7 +60,7 @@ CREATE TABLE part_ratio ( | @@ -60,7 +60,7 @@ CREATE TABLE part_ratio ( | ||
| 60 | 60 | ||
| 61 | | 指标 | 公式 | 说明 | | 61 | | 指标 | 公式 | 说明 | |
| 62 | |------|------|------| | 62 | |------|------|------| |
| 63 | -| 有效库存 | `in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt + transfer_cnt + gen_transfer_cnt` | 可用库存总量 | | 63 | +| 有效库存 | `in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt` | 可用库存总量 | |
| 64 | | 月均销量 | `(out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3` | 基于90天数据计算 | | 64 | | 月均销量 | `(out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3` | 基于90天数据计算 | |
| 65 | | 库销比 | `有效库存 / 月均销量` | 当月均销量 > 0 时有效 | | 65 | | 库销比 | `有效库存 / 月均销量` | 当月均销量 > 0 时有效 | |
| 66 | 66 |
sql/migrate_analysis_report.sql
| 1 | -- ============================================================================ | 1 | -- ============================================================================ |
| 2 | -- AI 补货建议分析报告表 | 2 | -- AI 补货建议分析报告表 |
| 3 | -- ============================================================================ | 3 | -- ============================================================================ |
| 4 | --- 版本: 2.0.0 | ||
| 5 | --- 更新日期: 2026-02-05 | ||
| 6 | --- 变更说明: 重构报告模块,聚焦补货决策支持(区别于传统库销分析) | 4 | +-- 版本: 3.0.0 |
| 5 | +-- 更新日期: 2026-02-10 | ||
| 6 | +-- 变更说明: 重构为四大数据驱动板块(库存概览/销量分析/健康度/补货建议) | ||
| 7 | -- ============================================================================ | 7 | -- ============================================================================ |
| 8 | 8 | ||
| 9 | DROP TABLE IF EXISTS ai_analysis_report; | 9 | DROP TABLE IF EXISTS ai_analysis_report; |
| @@ -15,33 +15,23 @@ CREATE TABLE ai_analysis_report ( | @@ -15,33 +15,23 @@ CREATE TABLE ai_analysis_report ( | ||
| 15 | dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称', | 15 | dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称', |
| 16 | brand_grouping_id BIGINT COMMENT '品牌组合ID', | 16 | brand_grouping_id BIGINT COMMENT '品牌组合ID', |
| 17 | report_type VARCHAR(32) DEFAULT 'replenishment' COMMENT '报告类型', | 17 | report_type VARCHAR(32) DEFAULT 'replenishment' COMMENT '报告类型', |
| 18 | - | ||
| 19 | - -- 报告各模块 (JSON 结构化存储) - 宏观决策分析 | ||
| 20 | - -- 注:字段名保持兼容,实际存储内容已更新为新模块 | ||
| 21 | - replenishment_insights JSON COMMENT '整体态势研判(规模评估/结构分析/时机判断) - 原overall_assessment', | ||
| 22 | - urgency_assessment JSON COMMENT '风险预警与应对(供应/资金/市场/执行风险) - 原risk_alerts', | ||
| 23 | - strategy_recommendations JSON COMMENT '采购策略建议(优先级/批量机会/分批/供应商协调) - 原procurement_strategy', | ||
| 24 | - execution_guide JSON COMMENT '已废弃,置为NULL', | ||
| 25 | - expected_outcomes JSON COMMENT '效果预期与建议(库存健康/资金效率/后续行动) - 原expected_impact', | ||
| 26 | - | ||
| 27 | - -- 统计信息 | ||
| 28 | - total_suggest_cnt INT DEFAULT 0 COMMENT '总建议数量', | ||
| 29 | - total_suggest_amount DECIMAL(14,2) DEFAULT 0 COMMENT '总建议金额', | ||
| 30 | - shortage_risk_cnt INT DEFAULT 0 COMMENT '缺货风险配件数', | ||
| 31 | - excess_risk_cnt INT DEFAULT 0 COMMENT '过剩风险配件数', | ||
| 32 | - stagnant_cnt INT DEFAULT 0 COMMENT '呆滞件数量', | ||
| 33 | - low_freq_cnt INT DEFAULT 0 COMMENT '低频件数量', | ||
| 34 | - | 18 | + |
| 19 | + -- 四大板块 (JSON 结构化存储,每个字段包含 stats + llm_analysis) | ||
| 20 | + inventory_overview JSON COMMENT '库存总体概览(统计数据+LLM分析)', | ||
| 21 | + sales_analysis JSON COMMENT '销量分析(统计数据+LLM分析)', | ||
| 22 | + inventory_health JSON COMMENT '库存构成健康度(统计数据+图表数据+LLM分析)', | ||
| 23 | + replenishment_summary JSON COMMENT '补货建议生成情况(统计数据+LLM分析)', | ||
| 24 | + | ||
| 35 | -- LLM 元数据 | 25 | -- LLM 元数据 |
| 36 | llm_provider VARCHAR(32) COMMENT 'LLM提供商', | 26 | llm_provider VARCHAR(32) COMMENT 'LLM提供商', |
| 37 | llm_model VARCHAR(64) COMMENT 'LLM模型名称', | 27 | llm_model VARCHAR(64) COMMENT 'LLM模型名称', |
| 38 | llm_tokens INT DEFAULT 0 COMMENT 'LLM Token消耗', | 28 | llm_tokens INT DEFAULT 0 COMMENT 'LLM Token消耗', |
| 39 | execution_time_ms INT DEFAULT 0 COMMENT '执行耗时(毫秒)', | 29 | execution_time_ms INT DEFAULT 0 COMMENT '执行耗时(毫秒)', |
| 40 | - | 30 | + |
| 41 | statistics_date VARCHAR(16) COMMENT '统计日期', | 31 | statistics_date VARCHAR(16) COMMENT '统计日期', |
| 42 | create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', | 32 | create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', |
| 43 | - | 33 | + |
| 44 | INDEX idx_task_no (task_no), | 34 | INDEX idx_task_no (task_no), |
| 45 | INDEX idx_group_date (group_id, statistics_date), | 35 | INDEX idx_group_date (group_id, statistics_date), |
| 46 | INDEX idx_dealer_grouping (dealer_grouping_id) | 36 | INDEX idx_dealer_grouping (dealer_grouping_id) |
| 47 | -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议分析报告表-结构化补货决策支持报告'; | 37 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议分析报告表-重构版'; |
src/fw_pms_ai/agent/analysis_report_node.py
| 1 | """ | 1 | """ |
| 2 | 分析报告生成节点 | 2 | 分析报告生成节点 |
| 3 | 3 | ||
| 4 | -在补货建议工作流的最后一个节点执行,生成结构化分析报告 | 4 | +在补货建议工作流的最后一个节点执行,生成结构化分析报告。 |
| 5 | +包含四大板块的统计计算函数:库存概览、销量分析、库存健康度、补货建议。 | ||
| 5 | """ | 6 | """ |
| 6 | 7 | ||
| 7 | import logging | 8 | import logging |
| 8 | -import time | ||
| 9 | -import json | ||
| 10 | -import os | ||
| 11 | -from typing import Dict, Any | ||
| 12 | -from decimal import Decimal | ||
| 13 | -from datetime import datetime | 9 | +from decimal import Decimal, ROUND_HALF_UP |
| 14 | 10 | ||
| 15 | -from langchain_core.messages import HumanMessage | 11 | +logger = logging.getLogger(__name__) |
| 16 | 12 | ||
| 17 | -from ..llm import get_llm_client | ||
| 18 | -from ..models import AnalysisReport | ||
| 19 | -from ..services.result_writer import ResultWriter | ||
| 20 | 13 | ||
| 21 | -logger = logging.getLogger(__name__) | 14 | +def _to_decimal(value) -> Decimal: |
| 15 | + """安全转换为 Decimal""" | ||
| 16 | + if value is None: | ||
| 17 | + return Decimal("0") | ||
| 18 | + return Decimal(str(value)) | ||
| 22 | 19 | ||
| 23 | 20 | ||
| 24 | -def _load_prompt(filename: str) -> str: | ||
| 25 | - """从prompts目录加载提示词文件""" | ||
| 26 | - prompts_dir = os.path.join( | ||
| 27 | - os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))), | ||
| 28 | - "prompts" | 21 | +def calculate_inventory_overview(part_ratios: list[dict]) -> dict: |
| 22 | + """ | ||
| 23 | + 计算库存总体概览统计数据 | ||
| 24 | + | ||
| 25 | + 有效库存 = in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt | ||
| 26 | + 资金占用 = in_stock_unlocked_cnt + on_the_way_cnt(仅计算实际占用资金的库存) | ||
| 27 | + | ||
| 28 | + Args: | ||
| 29 | + part_ratios: PartRatio 字典列表 | ||
| 30 | + | ||
| 31 | + Returns: | ||
| 32 | + 库存概览统计字典 | ||
| 33 | + """ | ||
| 34 | + total_in_stock_unlocked_cnt = Decimal("0") | ||
| 35 | + total_in_stock_unlocked_amount = Decimal("0") | ||
| 36 | + total_on_the_way_cnt = Decimal("0") | ||
| 37 | + total_on_the_way_amount = Decimal("0") | ||
| 38 | + total_has_plan_cnt = Decimal("0") | ||
| 39 | + total_has_plan_amount = Decimal("0") | ||
| 40 | + total_avg_sales_cnt = Decimal("0") | ||
| 41 | + # 资金占用合计 = (在库未锁 + 在途) * 成本价 | ||
| 42 | + total_capital_occupation = Decimal("0") | ||
| 43 | + | ||
| 44 | + for p in part_ratios: | ||
| 45 | + cost_price = _to_decimal(p.get("cost_price", 0)) | ||
| 46 | + | ||
| 47 | + in_stock = _to_decimal(p.get("in_stock_unlocked_cnt", 0)) | ||
| 48 | + on_way = _to_decimal(p.get("on_the_way_cnt", 0)) | ||
| 49 | + has_plan = _to_decimal(p.get("has_plan_cnt", 0)) | ||
| 50 | + | ||
| 51 | + total_in_stock_unlocked_cnt += in_stock | ||
| 52 | + total_in_stock_unlocked_amount += in_stock * cost_price | ||
| 53 | + total_on_the_way_cnt += on_way | ||
| 54 | + total_on_the_way_amount += on_way * cost_price | ||
| 55 | + total_has_plan_cnt += has_plan | ||
| 56 | + total_has_plan_amount += has_plan * cost_price | ||
| 57 | + | ||
| 58 | + # 资金占用 = 在库未锁 + 在途 | ||
| 59 | + total_capital_occupation += (in_stock + on_way) * cost_price | ||
| 60 | + | ||
| 61 | + # 月均销量 | ||
| 62 | + out_stock = _to_decimal(p.get("out_stock_cnt", 0)) | ||
| 63 | + locked = _to_decimal(p.get("storage_locked_cnt", 0)) | ||
| 64 | + ongoing = _to_decimal(p.get("out_stock_ongoing_cnt", 0)) | ||
| 65 | + buy = _to_decimal(p.get("buy_cnt", 0)) | ||
| 66 | + avg_sales = (out_stock + locked + ongoing + buy) / Decimal("3") | ||
| 67 | + total_avg_sales_cnt += avg_sales | ||
| 68 | + | ||
| 69 | + total_valid_storage_cnt = ( | ||
| 70 | + total_in_stock_unlocked_cnt | ||
| 71 | + + total_on_the_way_cnt | ||
| 72 | + + total_has_plan_cnt | ||
| 29 | ) | 73 | ) |
| 30 | - filepath = os.path.join(prompts_dir, filename) | ||
| 31 | - | ||
| 32 | - if not os.path.exists(filepath): | ||
| 33 | - raise FileNotFoundError(f"Prompt文件未找到: {filepath}") | ||
| 34 | - | ||
| 35 | - with open(filepath, "r", encoding="utf-8") as f: | ||
| 36 | - return f.read() | 74 | + total_valid_storage_amount = ( |
| 75 | + total_in_stock_unlocked_amount | ||
| 76 | + + total_on_the_way_amount | ||
| 77 | + + total_has_plan_amount | ||
| 78 | + ) | ||
| 79 | + | ||
| 80 | + # 库销比:月均销量为零时标记为特殊值 | ||
| 81 | + if total_avg_sales_cnt > 0: | ||
| 82 | + overall_ratio = total_valid_storage_cnt / total_avg_sales_cnt | ||
| 83 | + else: | ||
| 84 | + overall_ratio = Decimal("999") | ||
| 37 | 85 | ||
| 86 | + return { | ||
| 87 | + "total_valid_storage_cnt": total_valid_storage_cnt, | ||
| 88 | + "total_valid_storage_amount": total_valid_storage_amount, | ||
| 89 | + "total_capital_occupation": total_capital_occupation, | ||
| 90 | + "total_in_stock_unlocked_cnt": total_in_stock_unlocked_cnt, | ||
| 91 | + "total_in_stock_unlocked_amount": total_in_stock_unlocked_amount, | ||
| 92 | + "total_on_the_way_cnt": total_on_the_way_cnt, | ||
| 93 | + "total_on_the_way_amount": total_on_the_way_amount, | ||
| 94 | + "total_has_plan_cnt": total_has_plan_cnt, | ||
| 95 | + "total_has_plan_amount": total_has_plan_amount, | ||
| 96 | + "total_avg_sales_cnt": total_avg_sales_cnt, | ||
| 97 | + "overall_ratio": overall_ratio, | ||
| 98 | + "part_count": len(part_ratios), | ||
| 99 | + } | ||
| 38 | 100 | ||
| 39 | -def _calculate_suggestion_stats(part_results: list) -> dict: | 101 | + |
| 102 | +def calculate_sales_analysis(part_ratios: list[dict]) -> dict: | ||
| 40 | """ | 103 | """ |
| 41 | - 基于完整数据计算补货建议统计 | ||
| 42 | - | ||
| 43 | - 统计维度: | ||
| 44 | - 1. 总体统计:总数量、总金额 | ||
| 45 | - 2. 优先级分布:高/中/低优先级配件数及金额 | ||
| 46 | - 3. 价格区间分布:低价/中价/高价配件分布 | ||
| 47 | - 4. 周转频次分布:高频/中频/低频配件分布 | ||
| 48 | - 5. 补货规模分布:大额/中额/小额补货配件分布 | 104 | + 计算销量分析统计数据 |
| 105 | + | ||
| 106 | + 月均销量 = (out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3 | ||
| 107 | + | ||
| 108 | + Args: | ||
| 109 | + part_ratios: PartRatio 字典列表 | ||
| 110 | + | ||
| 111 | + Returns: | ||
| 112 | + 销量分析统计字典 | ||
| 49 | """ | 113 | """ |
| 50 | - stats = { | ||
| 51 | - # 总体统计 | ||
| 52 | - "total_parts_cnt": 0, | ||
| 53 | - "total_suggest_cnt": 0, | ||
| 54 | - "total_suggest_amount": Decimal("0"), | ||
| 55 | - | ||
| 56 | - # 优先级分布: 1=高, 2=中, 3=低 | ||
| 57 | - "priority_high_cnt": 0, | ||
| 58 | - "priority_high_amount": Decimal("0"), | ||
| 59 | - "priority_medium_cnt": 0, | ||
| 60 | - "priority_medium_amount": Decimal("0"), | ||
| 61 | - "priority_low_cnt": 0, | ||
| 62 | - "priority_low_amount": Decimal("0"), | ||
| 63 | - | ||
| 64 | - # 价格区间分布 (成本价) | ||
| 65 | - "price_low_cnt": 0, | ||
| 66 | - "price_low_amount": Decimal("0"), | ||
| 67 | - "price_medium_cnt": 0, | ||
| 68 | - "price_medium_amount": Decimal("0"), | ||
| 69 | - "price_high_cnt": 0, | ||
| 70 | - "price_high_amount": Decimal("0"), | ||
| 71 | - | ||
| 72 | - # 周转频次分布 (月均销量) | ||
| 73 | - "turnover_high_cnt": 0, | ||
| 74 | - "turnover_high_amount": Decimal("0"), | ||
| 75 | - "turnover_medium_cnt": 0, | ||
| 76 | - "turnover_medium_amount": Decimal("0"), | ||
| 77 | - "turnover_low_cnt": 0, | ||
| 78 | - "turnover_low_amount": Decimal("0"), | ||
| 79 | - | ||
| 80 | - # 补货金额分布 | ||
| 81 | - "replenish_large_cnt": 0, | ||
| 82 | - "replenish_large_amount": Decimal("0"), | ||
| 83 | - "replenish_medium_cnt": 0, | ||
| 84 | - "replenish_medium_amount": Decimal("0"), | ||
| 85 | - "replenish_small_cnt": 0, | ||
| 86 | - "replenish_small_amount": Decimal("0"), | 114 | + total_out_stock_cnt = Decimal("0") |
| 115 | + total_storage_locked_cnt = Decimal("0") | ||
| 116 | + total_out_stock_ongoing_cnt = Decimal("0") | ||
| 117 | + total_buy_cnt = Decimal("0") | ||
| 118 | + total_avg_sales_amount = Decimal("0") | ||
| 119 | + has_sales_part_count = 0 | ||
| 120 | + no_sales_part_count = 0 | ||
| 121 | + | ||
| 122 | + for p in part_ratios: | ||
| 123 | + cost_price = _to_decimal(p.get("cost_price", 0)) | ||
| 124 | + | ||
| 125 | + out_stock = _to_decimal(p.get("out_stock_cnt", 0)) | ||
| 126 | + locked = _to_decimal(p.get("storage_locked_cnt", 0)) | ||
| 127 | + ongoing = _to_decimal(p.get("out_stock_ongoing_cnt", 0)) | ||
| 128 | + buy = _to_decimal(p.get("buy_cnt", 0)) | ||
| 129 | + | ||
| 130 | + total_out_stock_cnt += out_stock | ||
| 131 | + total_storage_locked_cnt += locked | ||
| 132 | + total_out_stock_ongoing_cnt += ongoing | ||
| 133 | + total_buy_cnt += buy | ||
| 134 | + | ||
| 135 | + avg_sales = (out_stock + locked + ongoing + buy) / Decimal("3") | ||
| 136 | + total_avg_sales_amount += avg_sales * cost_price | ||
| 137 | + | ||
| 138 | + if avg_sales > 0: | ||
| 139 | + has_sales_part_count += 1 | ||
| 140 | + else: | ||
| 141 | + no_sales_part_count += 1 | ||
| 142 | + | ||
| 143 | + total_avg_sales_cnt = ( | ||
| 144 | + total_out_stock_cnt + total_storage_locked_cnt + total_out_stock_ongoing_cnt + total_buy_cnt | ||
| 145 | + ) / Decimal("3") | ||
| 146 | + | ||
| 147 | + return { | ||
| 148 | + "total_avg_sales_cnt": total_avg_sales_cnt, | ||
| 149 | + "total_avg_sales_amount": total_avg_sales_amount, | ||
| 150 | + "total_out_stock_cnt": total_out_stock_cnt, | ||
| 151 | + "total_storage_locked_cnt": total_storage_locked_cnt, | ||
| 152 | + "total_out_stock_ongoing_cnt": total_out_stock_ongoing_cnt, | ||
| 153 | + "total_buy_cnt": total_buy_cnt, | ||
| 154 | + "has_sales_part_count": has_sales_part_count, | ||
| 155 | + "no_sales_part_count": no_sales_part_count, | ||
| 87 | } | 156 | } |
| 88 | - | ||
| 89 | - if not part_results: | ||
| 90 | - return stats | ||
| 91 | - | ||
| 92 | - for pr in part_results: | ||
| 93 | - # 兼容对象和字典两种形式 | ||
| 94 | - if hasattr(pr, "total_suggest_cnt"): | ||
| 95 | - suggest_cnt = pr.total_suggest_cnt | ||
| 96 | - suggest_amount = pr.total_suggest_amount | ||
| 97 | - cost_price = pr.cost_price | ||
| 98 | - avg_sales = pr.total_avg_sales_cnt | ||
| 99 | - priority = pr.priority | 157 | + |
| 158 | + | ||
| 159 | +def _classify_part(p: dict) -> str: | ||
| 160 | + """ | ||
| 161 | + 将配件分类为缺货/呆滞/低频/正常 | ||
| 162 | + | ||
| 163 | + 分类规则(按优先级顺序判断): | ||
| 164 | + - 缺货件: 有效库存 = 0 且 月均销量 >= 1 | ||
| 165 | + - 呆滞件: 有效库存 > 0 且 90天出库数 = 0 | ||
| 166 | + - 低频件: 月均销量 < 1 或 出库次数 < 3 或 出库间隔 >= 30天 | ||
| 167 | + - 正常件: 不属于以上三类 | ||
| 168 | + """ | ||
| 169 | + in_stock = _to_decimal(p.get("in_stock_unlocked_cnt", 0)) | ||
| 170 | + on_way = _to_decimal(p.get("on_the_way_cnt", 0)) | ||
| 171 | + has_plan = _to_decimal(p.get("has_plan_cnt", 0)) | ||
| 172 | + valid_storage = in_stock + on_way + has_plan | ||
| 173 | + | ||
| 174 | + out_stock = _to_decimal(p.get("out_stock_cnt", 0)) | ||
| 175 | + locked = _to_decimal(p.get("storage_locked_cnt", 0)) | ||
| 176 | + ongoing = _to_decimal(p.get("out_stock_ongoing_cnt", 0)) | ||
| 177 | + buy = _to_decimal(p.get("buy_cnt", 0)) | ||
| 178 | + avg_sales = (out_stock + locked + ongoing + buy) / Decimal("3") | ||
| 179 | + | ||
| 180 | + out_times = int(p.get("out_times", 0) or 0) | ||
| 181 | + out_duration = int(p.get("out_duration", 0) or 0) | ||
| 182 | + | ||
| 183 | + # 缺货件 | ||
| 184 | + if valid_storage == 0 and avg_sales >= 1: | ||
| 185 | + return "shortage" | ||
| 186 | + | ||
| 187 | + # 呆滞件 | ||
| 188 | + if valid_storage > 0 and out_stock == 0: | ||
| 189 | + return "stagnant" | ||
| 190 | + | ||
| 191 | + # 低频件 | ||
| 192 | + if avg_sales < 1 or out_times < 3 or out_duration >= 30: | ||
| 193 | + return "low_freq" | ||
| 194 | + | ||
| 195 | + return "normal" | ||
| 196 | + | ||
| 197 | + | ||
| 198 | +def calculate_inventory_health(part_ratios: list[dict]) -> dict: | ||
| 199 | + """ | ||
| 200 | + 计算库存构成健康度统计数据 | ||
| 201 | + | ||
| 202 | + 将每个配件归类为缺货件/呆滞件/低频件/正常件,统计各类型数量/金额/百分比, | ||
| 203 | + 并生成 chart_data 供前端图表使用。 | ||
| 204 | + | ||
| 205 | + Args: | ||
| 206 | + part_ratios: PartRatio 字典列表 | ||
| 207 | + | ||
| 208 | + Returns: | ||
| 209 | + 健康度统计字典(含 chart_data) | ||
| 210 | + """ | ||
| 211 | + categories = { | ||
| 212 | + "shortage": {"count": 0, "amount": Decimal("0")}, | ||
| 213 | + "stagnant": {"count": 0, "amount": Decimal("0")}, | ||
| 214 | + "low_freq": {"count": 0, "amount": Decimal("0")}, | ||
| 215 | + "normal": {"count": 0, "amount": Decimal("0")}, | ||
| 216 | + } | ||
| 217 | + | ||
| 218 | + for p in part_ratios: | ||
| 219 | + cat = _classify_part(p) | ||
| 220 | + cost_price = _to_decimal(p.get("cost_price", 0)) | ||
| 221 | + | ||
| 222 | + # 有效库存金额 | ||
| 223 | + in_stock = _to_decimal(p.get("in_stock_unlocked_cnt", 0)) | ||
| 224 | + on_way = _to_decimal(p.get("on_the_way_cnt", 0)) | ||
| 225 | + has_plan = _to_decimal(p.get("has_plan_cnt", 0)) | ||
| 226 | + valid_storage = in_stock + on_way + has_plan | ||
| 227 | + amount = valid_storage * cost_price | ||
| 228 | + | ||
| 229 | + categories[cat]["count"] += 1 | ||
| 230 | + categories[cat]["amount"] += amount | ||
| 231 | + | ||
| 232 | + total_count = len(part_ratios) | ||
| 233 | + total_amount = sum(c["amount"] for c in categories.values()) | ||
| 234 | + | ||
| 235 | + # 计算百分比 | ||
| 236 | + result = {} | ||
| 237 | + for cat_name, data in categories.items(): | ||
| 238 | + count_pct = (data["count"] / total_count * 100) if total_count > 0 else 0.0 | ||
| 239 | + amount_pct = (float(data["amount"]) / float(total_amount) * 100) if total_amount > 0 else 0.0 | ||
| 240 | + result[cat_name] = { | ||
| 241 | + "count": data["count"], | ||
| 242 | + "amount": data["amount"], | ||
| 243 | + "count_pct": round(count_pct, 2), | ||
| 244 | + "amount_pct": round(amount_pct, 2), | ||
| 245 | + } | ||
| 246 | + | ||
| 247 | + result["total_count"] = total_count | ||
| 248 | + result["total_amount"] = total_amount | ||
| 249 | + | ||
| 250 | + # chart_data 供前端 Chart.js 使用 | ||
| 251 | + labels = ["缺货件", "呆滞件", "低频件", "正常件"] | ||
| 252 | + cat_keys = ["shortage", "stagnant", "low_freq", "normal"] | ||
| 253 | + result["chart_data"] = { | ||
| 254 | + "labels": labels, | ||
| 255 | + "count_values": [categories[k]["count"] for k in cat_keys], | ||
| 256 | + "amount_values": [float(categories[k]["amount"]) for k in cat_keys], | ||
| 257 | + } | ||
| 258 | + | ||
| 259 | + return result | ||
| 260 | + | ||
| 261 | + | ||
| 262 | +def calculate_replenishment_summary(part_results: list) -> dict: | ||
| 263 | + """ | ||
| 264 | + 计算补货建议生成情况统计数据 | ||
| 265 | + | ||
| 266 | + 按优先级分类统计: | ||
| 267 | + - priority=1: 急需补货 | ||
| 268 | + - priority=2: 建议补货 | ||
| 269 | + - priority=3: 可选补货 | ||
| 270 | + | ||
| 271 | + Args: | ||
| 272 | + part_results: 配件汇总结果列表(字典或 ReplenishmentPartSummary 对象) | ||
| 273 | + | ||
| 274 | + Returns: | ||
| 275 | + 补货建议统计字典 | ||
| 276 | + """ | ||
| 277 | + urgent = {"count": 0, "amount": Decimal("0")} | ||
| 278 | + suggested = {"count": 0, "amount": Decimal("0")} | ||
| 279 | + optional = {"count": 0, "amount": Decimal("0")} | ||
| 280 | + | ||
| 281 | + for item in part_results: | ||
| 282 | + # 兼容字典和对象两种形式 | ||
| 283 | + if isinstance(item, dict): | ||
| 284 | + priority = int(item.get("priority", 0)) | ||
| 285 | + amount = _to_decimal(item.get("total_suggest_amount", 0)) | ||
| 100 | else: | 286 | else: |
| 101 | - suggest_cnt = int(pr.get("total_suggest_cnt", 0)) | ||
| 102 | - suggest_amount = Decimal(str(pr.get("total_suggest_amount", 0))) | ||
| 103 | - cost_price = Decimal(str(pr.get("cost_price", 0))) | ||
| 104 | - avg_sales = Decimal(str(pr.get("total_avg_sales_cnt", 0))) | ||
| 105 | - priority = int(pr.get("priority", 2)) | ||
| 106 | - | ||
| 107 | - # 总体统计 | ||
| 108 | - stats["total_parts_cnt"] += 1 | ||
| 109 | - stats["total_suggest_cnt"] += suggest_cnt | ||
| 110 | - stats["total_suggest_amount"] += suggest_amount | ||
| 111 | - | ||
| 112 | - # 优先级分布 | 287 | + priority = getattr(item, "priority", 0) |
| 288 | + amount = _to_decimal(getattr(item, "total_suggest_amount", 0)) | ||
| 289 | + | ||
| 113 | if priority == 1: | 290 | if priority == 1: |
| 114 | - stats["priority_high_cnt"] += 1 | ||
| 115 | - stats["priority_high_amount"] += suggest_amount | 291 | + urgent["count"] += 1 |
| 292 | + urgent["amount"] += amount | ||
| 116 | elif priority == 2: | 293 | elif priority == 2: |
| 117 | - stats["priority_medium_cnt"] += 1 | ||
| 118 | - stats["priority_medium_amount"] += suggest_amount | ||
| 119 | - else: | ||
| 120 | - stats["priority_low_cnt"] += 1 | ||
| 121 | - stats["priority_low_amount"] += suggest_amount | ||
| 122 | - | ||
| 123 | - # 价格区间分布: <50低价, 50-200中价, >200高价 | ||
| 124 | - if cost_price < 50: | ||
| 125 | - stats["price_low_cnt"] += 1 | ||
| 126 | - stats["price_low_amount"] += suggest_amount | ||
| 127 | - elif cost_price <= 200: | ||
| 128 | - stats["price_medium_cnt"] += 1 | ||
| 129 | - stats["price_medium_amount"] += suggest_amount | ||
| 130 | - else: | ||
| 131 | - stats["price_high_cnt"] += 1 | ||
| 132 | - stats["price_high_amount"] += suggest_amount | ||
| 133 | - | ||
| 134 | - # 周转频次分布: 月均销量 >=5高频, 1-5中频, <1低频 | ||
| 135 | - if avg_sales >= 5: | ||
| 136 | - stats["turnover_high_cnt"] += 1 | ||
| 137 | - stats["turnover_high_amount"] += suggest_amount | ||
| 138 | - elif avg_sales >= 1: | ||
| 139 | - stats["turnover_medium_cnt"] += 1 | ||
| 140 | - stats["turnover_medium_amount"] += suggest_amount | ||
| 141 | - else: | ||
| 142 | - stats["turnover_low_cnt"] += 1 | ||
| 143 | - stats["turnover_low_amount"] += suggest_amount | ||
| 144 | - | ||
| 145 | - # 补货金额分布: >=5000大额, 1000-5000中额, <1000小额 | ||
| 146 | - if suggest_amount >= 5000: | ||
| 147 | - stats["replenish_large_cnt"] += 1 | ||
| 148 | - stats["replenish_large_amount"] += suggest_amount | ||
| 149 | - elif suggest_amount >= 1000: | ||
| 150 | - stats["replenish_medium_cnt"] += 1 | ||
| 151 | - stats["replenish_medium_amount"] += suggest_amount | 294 | + suggested["count"] += 1 |
| 295 | + suggested["amount"] += amount | ||
| 296 | + elif priority == 3: | ||
| 297 | + optional["count"] += 1 | ||
| 298 | + optional["amount"] += amount | ||
| 299 | + | ||
| 300 | + total_count = urgent["count"] + suggested["count"] + optional["count"] | ||
| 301 | + total_amount = urgent["amount"] + suggested["amount"] + optional["amount"] | ||
| 302 | + | ||
| 303 | + return { | ||
| 304 | + "urgent": urgent, | ||
| 305 | + "suggested": suggested, | ||
| 306 | + "optional": optional, | ||
| 307 | + "total_count": total_count, | ||
| 308 | + "total_amount": total_amount, | ||
| 309 | + } | ||
| 310 | + | ||
| 311 | + | ||
| 312 | +# ============================================================ | ||
| 313 | +# LLM 分析函数 | ||
| 314 | +# ============================================================ | ||
| 315 | + | ||
| 316 | +import os | ||
| 317 | +import json | ||
| 318 | +import time | ||
| 319 | +from langchain_core.messages import SystemMessage, HumanMessage | ||
| 320 | + | ||
| 321 | + | ||
| 322 | +def _load_prompt(filename: str) -> str: | ||
| 323 | + """从 prompts 目录加载提示词文件""" | ||
| 324 | + prompt_path = os.path.join( | ||
| 325 | + os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))), | ||
| 326 | + "prompts", | ||
| 327 | + filename, | ||
| 328 | + ) | ||
| 329 | + with open(prompt_path, "r", encoding="utf-8") as f: | ||
| 330 | + return f.read() | ||
| 331 | + | ||
| 332 | + | ||
| 333 | +def _format_decimal(value) -> str: | ||
| 334 | + """将 Decimal 格式化为字符串,用于填充提示词""" | ||
| 335 | + if value is None: | ||
| 336 | + return "0" | ||
| 337 | + return str(round(float(value), 2)) | ||
| 338 | + | ||
| 339 | + | ||
| 340 | +def _get_season_from_date(date_str: str) -> str: | ||
| 341 | + """ | ||
| 342 | + 根据日期字符串获取季节 | ||
| 343 | + | ||
| 344 | + Args: | ||
| 345 | + date_str: 日期字符串,格式如 "2024-01-15" 或 "20240115" | ||
| 346 | + | ||
| 347 | + Returns: | ||
| 348 | + 季节名称:春季/夏季/秋季/冬季 | ||
| 349 | + """ | ||
| 350 | + from datetime import datetime | ||
| 351 | + | ||
| 352 | + try: | ||
| 353 | + # 尝试解析不同格式的日期 | ||
| 354 | + if "-" in date_str: | ||
| 355 | + dt = datetime.strptime(date_str[:10], "%Y-%m-%d") | ||
| 152 | else: | 356 | else: |
| 153 | - stats["replenish_small_cnt"] += 1 | ||
| 154 | - stats["replenish_small_amount"] += suggest_amount | ||
| 155 | - | ||
| 156 | - return stats | ||
| 157 | - | ||
| 158 | - | ||
| 159 | -def _calculate_risk_stats(part_ratios: list) -> dict: | ||
| 160 | - """计算风险统计数据""" | ||
| 161 | - stats = { | ||
| 162 | - "shortage_cnt": 0, | ||
| 163 | - "shortage_amount": Decimal("0"), | ||
| 164 | - "stagnant_cnt": 0, | ||
| 165 | - "stagnant_amount": Decimal("0"), | ||
| 166 | - "low_freq_cnt": 0, | ||
| 167 | - "low_freq_amount": Decimal("0"), | 357 | + dt = datetime.strptime(date_str[:8], "%Y%m%d") |
| 358 | + month = dt.month | ||
| 359 | + except (ValueError, TypeError): | ||
| 360 | + # 解析失败时使用当前月份 | ||
| 361 | + month = datetime.now().month | ||
| 362 | + | ||
| 363 | + if month in (3, 4, 5): | ||
| 364 | + return "春季(3-5月)" | ||
| 365 | + elif month in (6, 7, 8): | ||
| 366 | + return "夏季(6-8月)" | ||
| 367 | + elif month in (9, 10, 11): | ||
| 368 | + return "秋季(9-11月)" | ||
| 369 | + else: | ||
| 370 | + return "冬季(12-2月)" | ||
| 371 | + | ||
| 372 | + | ||
| 373 | +def _parse_llm_json(content: str) -> dict: | ||
| 374 | + """ | ||
| 375 | + 解析 LLM 返回的 JSON 内容 | ||
| 376 | + | ||
| 377 | + 尝试直接解析,如果失败则尝试提取 ```json 代码块中的内容。 | ||
| 378 | + """ | ||
| 379 | + text = content.strip() | ||
| 380 | + | ||
| 381 | + # 尝试直接解析 | ||
| 382 | + try: | ||
| 383 | + return json.loads(text) | ||
| 384 | + except json.JSONDecodeError: | ||
| 385 | + pass | ||
| 386 | + | ||
| 387 | + # 尝试提取 ```json ... ``` 代码块 | ||
| 388 | + import re | ||
| 389 | + match = re.search(r"```json\s*(.*?)\s*```", text, re.DOTALL) | ||
| 390 | + if match: | ||
| 391 | + try: | ||
| 392 | + return json.loads(match.group(1)) | ||
| 393 | + except json.JSONDecodeError: | ||
| 394 | + pass | ||
| 395 | + | ||
| 396 | + # 尝试提取 { ... } 块 | ||
| 397 | + start = text.find("{") | ||
| 398 | + end = text.rfind("}") | ||
| 399 | + if start != -1 and end != -1 and end > start: | ||
| 400 | + try: | ||
| 401 | + return json.loads(text[start : end + 1]) | ||
| 402 | + except json.JSONDecodeError: | ||
| 403 | + pass | ||
| 404 | + | ||
| 405 | + # 解析失败 | ||
| 406 | + raise json.JSONDecodeError("无法从 LLM 响应中解析 JSON", text, 0) | ||
| 407 | + | ||
| 408 | + | ||
| 409 | +def llm_analyze_inventory_overview(stats: dict, statistics_date: str = "", llm_client=None) -> tuple[dict, dict]: | ||
| 410 | + """ | ||
| 411 | + LLM 分析库存概览 | ||
| 412 | + | ||
| 413 | + Args: | ||
| 414 | + stats: calculate_inventory_overview 的输出 | ||
| 415 | + statistics_date: 统计日期 | ||
| 416 | + llm_client: LLM 客户端实例,为 None 时自动获取 | ||
| 417 | + | ||
| 418 | + Returns: | ||
| 419 | + (llm_analysis_dict, usage_dict) | ||
| 420 | + """ | ||
| 421 | + from ..llm import get_llm_client | ||
| 422 | + | ||
| 423 | + if llm_client is None: | ||
| 424 | + llm_client = get_llm_client() | ||
| 425 | + | ||
| 426 | + current_season = _get_season_from_date(statistics_date) | ||
| 427 | + | ||
| 428 | + prompt_template = _load_prompt("report_inventory_overview.md") | ||
| 429 | + prompt = prompt_template.format( | ||
| 430 | + part_count=stats.get("part_count", 0), | ||
| 431 | + total_valid_storage_cnt=_format_decimal(stats.get("total_valid_storage_cnt")), | ||
| 432 | + total_valid_storage_amount=_format_decimal(stats.get("total_valid_storage_amount")), | ||
| 433 | + total_avg_sales_cnt=_format_decimal(stats.get("total_avg_sales_cnt")), | ||
| 434 | + overall_ratio=_format_decimal(stats.get("overall_ratio")), | ||
| 435 | + total_in_stock_unlocked_cnt=_format_decimal(stats.get("total_in_stock_unlocked_cnt")), | ||
| 436 | + total_in_stock_unlocked_amount=_format_decimal(stats.get("total_in_stock_unlocked_amount")), | ||
| 437 | + total_on_the_way_cnt=_format_decimal(stats.get("total_on_the_way_cnt")), | ||
| 438 | + total_on_the_way_amount=_format_decimal(stats.get("total_on_the_way_amount")), | ||
| 439 | + total_has_plan_cnt=_format_decimal(stats.get("total_has_plan_cnt")), | ||
| 440 | + total_has_plan_amount=_format_decimal(stats.get("total_has_plan_amount")), | ||
| 441 | + current_season=current_season, | ||
| 442 | + statistics_date=statistics_date or "未知", | ||
| 443 | + ) | ||
| 444 | + | ||
| 445 | + messages = [HumanMessage(content=prompt)] | ||
| 446 | + response = llm_client.invoke(messages) | ||
| 447 | + | ||
| 448 | + try: | ||
| 449 | + analysis = _parse_llm_json(response.content) | ||
| 450 | + except json.JSONDecodeError: | ||
| 451 | + logger.warning(f"库存概览 LLM JSON 解析失败,原始响应: {response.content[:200]}") | ||
| 452 | + analysis = {"error": "JSON解析失败", "raw": response.content[:200]} | ||
| 453 | + | ||
| 454 | + usage = { | ||
| 455 | + "provider": response.usage.provider, | ||
| 456 | + "model": response.usage.model, | ||
| 457 | + "prompt_tokens": response.usage.prompt_tokens, | ||
| 458 | + "completion_tokens": response.usage.completion_tokens, | ||
| 168 | } | 459 | } |
| 169 | - | ||
| 170 | - for pr in part_ratios: | ||
| 171 | - valid_storage = Decimal(str(pr.get("valid_storage_cnt", 0) or 0)) | ||
| 172 | - avg_sales = Decimal(str(pr.get("avg_sales_cnt", 0) or 0)) | ||
| 173 | - out_stock = Decimal(str(pr.get("out_stock_cnt", 0) or 0)) | ||
| 174 | - cost_price = Decimal(str(pr.get("cost_price", 0) or 0)) | ||
| 175 | - | ||
| 176 | - # 呆滞件: 有库存但90天无出库 | ||
| 177 | - if valid_storage > 0 and out_stock == 0: | ||
| 178 | - stats["stagnant_cnt"] += 1 | ||
| 179 | - stats["stagnant_amount"] += valid_storage * cost_price | ||
| 180 | - | ||
| 181 | - # 低频件: 无库存且月均销量<1 | ||
| 182 | - elif valid_storage == 0 and avg_sales < 1: | ||
| 183 | - stats["low_freq_cnt"] += 1 | ||
| 184 | - | ||
| 185 | - # 缺货件: 无库存且月均销量>=1 | ||
| 186 | - elif valid_storage == 0 and avg_sales >= 1: | ||
| 187 | - stats["shortage_cnt"] += 1 | ||
| 188 | - # 缺货损失估算:月均销量 * 成本价 | ||
| 189 | - stats["shortage_amount"] += avg_sales * cost_price | ||
| 190 | - | ||
| 191 | - return stats | 460 | + return analysis, usage |
| 192 | 461 | ||
| 193 | 462 | ||
| 194 | -def _build_suggestion_summary(suggestion_stats: dict) -> str: | 463 | +def llm_analyze_sales(stats: dict, statistics_date: str = "", llm_client=None) -> tuple[dict, dict]: |
| 195 | """ | 464 | """ |
| 196 | - 基于预计算的统计数据构建结构化补货建议摘要 | ||
| 197 | - | ||
| 198 | - 摘要包含: | ||
| 199 | - - 补货总体规模 | ||
| 200 | - - 优先级分布 | ||
| 201 | - - 价格区间分布 | ||
| 202 | - - 周转频次分布 | ||
| 203 | - - 补货金额分布 | 465 | + LLM 分析销量 |
| 466 | + | ||
| 467 | + Args: | ||
| 468 | + stats: calculate_sales_analysis 的输出 | ||
| 469 | + statistics_date: 统计日期 | ||
| 470 | + llm_client: LLM 客户端实例 | ||
| 471 | + | ||
| 472 | + Returns: | ||
| 473 | + (llm_analysis_dict, usage_dict) | ||
| 204 | """ | 474 | """ |
| 205 | - if suggestion_stats["total_parts_cnt"] == 0: | ||
| 206 | - return "暂无补货建议" | ||
| 207 | - | ||
| 208 | - lines = [] | ||
| 209 | - | ||
| 210 | - # 总体规模 | ||
| 211 | - lines.append(f"### 补货总体规模") | ||
| 212 | - lines.append(f"- 涉及配件种类: {suggestion_stats['total_parts_cnt']}种") | ||
| 213 | - lines.append(f"- 建议补货总数量: {suggestion_stats['total_suggest_cnt']}件") | ||
| 214 | - lines.append(f"- 建议补货总金额: {suggestion_stats['total_suggest_amount']:.2f}元") | ||
| 215 | - lines.append("") | ||
| 216 | - | ||
| 217 | - # 优先级分布 | ||
| 218 | - lines.append(f"### 优先级分布") | ||
| 219 | - lines.append(f"| 优先级 | 配件数 | 金额(元) | 占比 |") | ||
| 220 | - lines.append(f"|--------|--------|----------|------|") | ||
| 221 | - total_amount = suggestion_stats['total_suggest_amount'] or Decimal("1") | ||
| 222 | - | ||
| 223 | - if suggestion_stats['priority_high_cnt'] > 0: | ||
| 224 | - pct = suggestion_stats['priority_high_amount'] / total_amount * 100 | ||
| 225 | - lines.append(f"| 高优先级 | {suggestion_stats['priority_high_cnt']} | {suggestion_stats['priority_high_amount']:.2f} | {pct:.1f}% |") | ||
| 226 | - if suggestion_stats['priority_medium_cnt'] > 0: | ||
| 227 | - pct = suggestion_stats['priority_medium_amount'] / total_amount * 100 | ||
| 228 | - lines.append(f"| 中优先级 | {suggestion_stats['priority_medium_cnt']} | {suggestion_stats['priority_medium_amount']:.2f} | {pct:.1f}% |") | ||
| 229 | - if suggestion_stats['priority_low_cnt'] > 0: | ||
| 230 | - pct = suggestion_stats['priority_low_amount'] / total_amount * 100 | ||
| 231 | - lines.append(f"| 低优先级 | {suggestion_stats['priority_low_cnt']} | {suggestion_stats['priority_low_amount']:.2f} | {pct:.1f}% |") | ||
| 232 | - lines.append("") | ||
| 233 | - | ||
| 234 | - # 价格区间分布 | ||
| 235 | - lines.append(f"### 价格区间分布 (按成本价)") | ||
| 236 | - lines.append(f"| 价格区间 | 配件数 | 金额(元) | 占比 |") | ||
| 237 | - lines.append(f"|----------|--------|----------|------|") | ||
| 238 | - if suggestion_stats['price_low_cnt'] > 0: | ||
| 239 | - pct = suggestion_stats['price_low_amount'] / total_amount * 100 | ||
| 240 | - lines.append(f"| 低价(<50元) | {suggestion_stats['price_low_cnt']} | {suggestion_stats['price_low_amount']:.2f} | {pct:.1f}% |") | ||
| 241 | - if suggestion_stats['price_medium_cnt'] > 0: | ||
| 242 | - pct = suggestion_stats['price_medium_amount'] / total_amount * 100 | ||
| 243 | - lines.append(f"| 中价(50-200元) | {suggestion_stats['price_medium_cnt']} | {suggestion_stats['price_medium_amount']:.2f} | {pct:.1f}% |") | ||
| 244 | - if suggestion_stats['price_high_cnt'] > 0: | ||
| 245 | - pct = suggestion_stats['price_high_amount'] / total_amount * 100 | ||
| 246 | - lines.append(f"| 高价(>200元) | {suggestion_stats['price_high_cnt']} | {suggestion_stats['price_high_amount']:.2f} | {pct:.1f}% |") | ||
| 247 | - lines.append("") | ||
| 248 | - | ||
| 249 | - # 周转频次分布 | ||
| 250 | - lines.append(f"### 周转频次分布 (按月均销量)") | ||
| 251 | - lines.append(f"| 周转频次 | 配件数 | 金额(元) | 占比 |") | ||
| 252 | - lines.append(f"|----------|--------|----------|------|") | ||
| 253 | - if suggestion_stats['turnover_high_cnt'] > 0: | ||
| 254 | - pct = suggestion_stats['turnover_high_amount'] / total_amount * 100 | ||
| 255 | - lines.append(f"| 高频(≥5件/月) | {suggestion_stats['turnover_high_cnt']} | {suggestion_stats['turnover_high_amount']:.2f} | {pct:.1f}% |") | ||
| 256 | - if suggestion_stats['turnover_medium_cnt'] > 0: | ||
| 257 | - pct = suggestion_stats['turnover_medium_amount'] / total_amount * 100 | ||
| 258 | - lines.append(f"| 中频(1-5件/月) | {suggestion_stats['turnover_medium_cnt']} | {suggestion_stats['turnover_medium_amount']:.2f} | {pct:.1f}% |") | ||
| 259 | - if suggestion_stats['turnover_low_cnt'] > 0: | ||
| 260 | - pct = suggestion_stats['turnover_low_amount'] / total_amount * 100 | ||
| 261 | - lines.append(f"| 低频(<1件/月) | {suggestion_stats['turnover_low_cnt']} | {suggestion_stats['turnover_low_amount']:.2f} | {pct:.1f}% |") | ||
| 262 | - lines.append("") | ||
| 263 | - | ||
| 264 | - # 补货金额分布 | ||
| 265 | - lines.append(f"### 单配件补货金额分布") | ||
| 266 | - lines.append(f"| 补货规模 | 配件数 | 金额(元) | 占比 |") | ||
| 267 | - lines.append(f"|----------|--------|----------|------|") | ||
| 268 | - if suggestion_stats['replenish_large_cnt'] > 0: | ||
| 269 | - pct = suggestion_stats['replenish_large_amount'] / total_amount * 100 | ||
| 270 | - lines.append(f"| 大额(≥5000元) | {suggestion_stats['replenish_large_cnt']} | {suggestion_stats['replenish_large_amount']:.2f} | {pct:.1f}% |") | ||
| 271 | - if suggestion_stats['replenish_medium_cnt'] > 0: | ||
| 272 | - pct = suggestion_stats['replenish_medium_amount'] / total_amount * 100 | ||
| 273 | - lines.append(f"| 中额(1000-5000元) | {suggestion_stats['replenish_medium_cnt']} | {suggestion_stats['replenish_medium_amount']:.2f} | {pct:.1f}% |") | ||
| 274 | - if suggestion_stats['replenish_small_cnt'] > 0: | ||
| 275 | - pct = suggestion_stats['replenish_small_amount'] / total_amount * 100 | ||
| 276 | - lines.append(f"| 小额(<1000元) | {suggestion_stats['replenish_small_cnt']} | {suggestion_stats['replenish_small_amount']:.2f} | {pct:.1f}% |") | ||
| 277 | - | ||
| 278 | - return "\n".join(lines) | 475 | + from ..llm import get_llm_client |
| 279 | 476 | ||
| 477 | + if llm_client is None: | ||
| 478 | + llm_client = get_llm_client() | ||
| 280 | 479 | ||
| 281 | -def generate_analysis_report_node(state: dict) -> dict: | 480 | + current_season = _get_season_from_date(statistics_date) |
| 481 | + | ||
| 482 | + prompt_template = _load_prompt("report_sales_analysis.md") | ||
| 483 | + prompt = prompt_template.format( | ||
| 484 | + total_avg_sales_cnt=_format_decimal(stats.get("total_avg_sales_cnt")), | ||
| 485 | + total_avg_sales_amount=_format_decimal(stats.get("total_avg_sales_amount")), | ||
| 486 | + has_sales_part_count=stats.get("has_sales_part_count", 0), | ||
| 487 | + no_sales_part_count=stats.get("no_sales_part_count", 0), | ||
| 488 | + total_out_stock_cnt=_format_decimal(stats.get("total_out_stock_cnt")), | ||
| 489 | + total_storage_locked_cnt=_format_decimal(stats.get("total_storage_locked_cnt")), | ||
| 490 | + total_out_stock_ongoing_cnt=_format_decimal(stats.get("total_out_stock_ongoing_cnt")), | ||
| 491 | + total_buy_cnt=_format_decimal(stats.get("total_buy_cnt")), | ||
| 492 | + current_season=current_season, | ||
| 493 | + statistics_date=statistics_date or "未知", | ||
| 494 | + ) | ||
| 495 | + | ||
| 496 | + messages = [HumanMessage(content=prompt)] | ||
| 497 | + response = llm_client.invoke(messages) | ||
| 498 | + | ||
| 499 | + try: | ||
| 500 | + analysis = _parse_llm_json(response.content) | ||
| 501 | + except json.JSONDecodeError: | ||
| 502 | + logger.warning(f"销量分析 LLM JSON 解析失败,原始响应: {response.content[:200]}") | ||
| 503 | + analysis = {"error": "JSON解析失败", "raw": response.content[:200]} | ||
| 504 | + | ||
| 505 | + usage = { | ||
| 506 | + "provider": response.usage.provider, | ||
| 507 | + "model": response.usage.model, | ||
| 508 | + "prompt_tokens": response.usage.prompt_tokens, | ||
| 509 | + "completion_tokens": response.usage.completion_tokens, | ||
| 510 | + } | ||
| 511 | + return analysis, usage | ||
| 512 | + | ||
| 513 | + | ||
| 514 | +def llm_analyze_inventory_health(stats: dict, statistics_date: str = "", llm_client=None) -> tuple[dict, dict]: | ||
| 282 | """ | 515 | """ |
| 283 | - 生成分析报告节点 | ||
| 284 | - | ||
| 285 | - 输入: part_ratios, llm_suggestions, allocated_details, part_results | ||
| 286 | - 输出: analysis_report | 516 | + LLM 分析库存健康度 |
| 517 | + | ||
| 518 | + Args: | ||
| 519 | + stats: calculate_inventory_health 的输出 | ||
| 520 | + statistics_date: 统计日期 | ||
| 521 | + llm_client: LLM 客户端实例 | ||
| 522 | + | ||
| 523 | + Returns: | ||
| 524 | + (llm_analysis_dict, usage_dict) | ||
| 287 | """ | 525 | """ |
| 288 | - start_time = time.time() | ||
| 289 | - | ||
| 290 | - task_no = state.get("task_no", "") | ||
| 291 | - group_id = state.get("group_id", 0) | ||
| 292 | - dealer_grouping_id = state.get("dealer_grouping_id", 0) | ||
| 293 | - dealer_grouping_name = state.get("dealer_grouping_name", "") | ||
| 294 | - brand_grouping_id = state.get("brand_grouping_id") | ||
| 295 | - statistics_date = state.get("statistics_date", "") | ||
| 296 | - | ||
| 297 | - part_ratios = state.get("part_ratios", []) | ||
| 298 | - part_results = state.get("part_results", []) | ||
| 299 | - allocated_details = state.get("allocated_details", []) | ||
| 300 | - | ||
| 301 | - logger.info(f"[{task_no}] 开始生成分析报告: dealer={dealer_grouping_name}") | ||
| 302 | - | 526 | + from ..llm import get_llm_client |
| 527 | + | ||
| 528 | + if llm_client is None: | ||
| 529 | + llm_client = get_llm_client() | ||
| 530 | + | ||
| 531 | + current_season = _get_season_from_date(statistics_date) | ||
| 532 | + | ||
| 533 | + prompt_template = _load_prompt("report_inventory_health.md") | ||
| 534 | + prompt = prompt_template.format( | ||
| 535 | + total_count=stats.get("total_count", 0), | ||
| 536 | + total_amount=_format_decimal(stats.get("total_amount")), | ||
| 537 | + shortage_count=stats.get("shortage", {}).get("count", 0), | ||
| 538 | + shortage_count_pct=stats.get("shortage", {}).get("count_pct", 0), | ||
| 539 | + shortage_amount=_format_decimal(stats.get("shortage", {}).get("amount")), | ||
| 540 | + shortage_amount_pct=stats.get("shortage", {}).get("amount_pct", 0), | ||
| 541 | + stagnant_count=stats.get("stagnant", {}).get("count", 0), | ||
| 542 | + stagnant_count_pct=stats.get("stagnant", {}).get("count_pct", 0), | ||
| 543 | + stagnant_amount=_format_decimal(stats.get("stagnant", {}).get("amount")), | ||
| 544 | + stagnant_amount_pct=stats.get("stagnant", {}).get("amount_pct", 0), | ||
| 545 | + low_freq_count=stats.get("low_freq", {}).get("count", 0), | ||
| 546 | + low_freq_count_pct=stats.get("low_freq", {}).get("count_pct", 0), | ||
| 547 | + low_freq_amount=_format_decimal(stats.get("low_freq", {}).get("amount")), | ||
| 548 | + low_freq_amount_pct=stats.get("low_freq", {}).get("amount_pct", 0), | ||
| 549 | + normal_count=stats.get("normal", {}).get("count", 0), | ||
| 550 | + normal_count_pct=stats.get("normal", {}).get("count_pct", 0), | ||
| 551 | + normal_amount=_format_decimal(stats.get("normal", {}).get("amount")), | ||
| 552 | + normal_amount_pct=stats.get("normal", {}).get("amount_pct", 0), | ||
| 553 | + current_season=current_season, | ||
| 554 | + statistics_date=statistics_date or "未知", | ||
| 555 | + ) | ||
| 556 | + | ||
| 557 | + messages = [HumanMessage(content=prompt)] | ||
| 558 | + response = llm_client.invoke(messages) | ||
| 559 | + | ||
| 303 | try: | 560 | try: |
| 304 | - # 计算风险统计 | ||
| 305 | - risk_stats = _calculate_risk_stats(part_ratios) | ||
| 306 | - | ||
| 307 | - # 计算补货建议统计 (基于完整数据) | ||
| 308 | - suggestion_stats = _calculate_suggestion_stats(part_results) | ||
| 309 | - | ||
| 310 | - # 构建结构化建议汇总 | ||
| 311 | - suggestion_summary = _build_suggestion_summary(suggestion_stats) | ||
| 312 | - | ||
| 313 | - # 加载 Prompt | ||
| 314 | - prompt_template = _load_prompt("analysis_report.md") | ||
| 315 | - | ||
| 316 | - # 填充 Prompt 变量 | ||
| 317 | - prompt = prompt_template.format( | ||
| 318 | - dealer_grouping_id=dealer_grouping_id, | ||
| 319 | - dealer_grouping_name=dealer_grouping_name, | ||
| 320 | - statistics_date=statistics_date, | ||
| 321 | - suggestion_summary=suggestion_summary, | ||
| 322 | - shortage_cnt=risk_stats["shortage_cnt"], | ||
| 323 | - shortage_amount=f"{risk_stats['shortage_amount']:.2f}", | ||
| 324 | - stagnant_cnt=risk_stats["stagnant_cnt"], | ||
| 325 | - stagnant_amount=f"{risk_stats['stagnant_amount']:.2f}", | ||
| 326 | - low_freq_cnt=risk_stats["low_freq_cnt"], | ||
| 327 | - low_freq_amount="0.00", # 低频件无库存 | ||
| 328 | - ) | ||
| 329 | - | ||
| 330 | - # 调用 LLM | 561 | + analysis = _parse_llm_json(response.content) |
| 562 | + except json.JSONDecodeError: | ||
| 563 | + logger.warning(f"健康度 LLM JSON 解析失败,原始响应: {response.content[:200]}") | ||
| 564 | + analysis = {"error": "JSON解析失败", "raw": response.content[:200]} | ||
| 565 | + | ||
| 566 | + usage = { | ||
| 567 | + "provider": response.usage.provider, | ||
| 568 | + "model": response.usage.model, | ||
| 569 | + "prompt_tokens": response.usage.prompt_tokens, | ||
| 570 | + "completion_tokens": response.usage.completion_tokens, | ||
| 571 | + } | ||
| 572 | + return analysis, usage | ||
| 573 | + | ||
| 574 | + | ||
| 575 | +def llm_analyze_replenishment_summary(stats: dict, statistics_date: str = "", llm_client=None) -> tuple[dict, dict]: | ||
| 576 | + """ | ||
| 577 | + LLM 分析补货建议 | ||
| 578 | + | ||
| 579 | + Args: | ||
| 580 | + stats: calculate_replenishment_summary 的输出 | ||
| 581 | + statistics_date: 统计日期 | ||
| 582 | + llm_client: LLM 客户端实例 | ||
| 583 | + | ||
| 584 | + Returns: | ||
| 585 | + (llm_analysis_dict, usage_dict) | ||
| 586 | + """ | ||
| 587 | + from ..llm import get_llm_client | ||
| 588 | + | ||
| 589 | + if llm_client is None: | ||
| 331 | llm_client = get_llm_client() | 590 | llm_client = get_llm_client() |
| 332 | - response = llm_client.invoke( | ||
| 333 | - messages=[HumanMessage(content=prompt)], | ||
| 334 | - ) | ||
| 335 | - | ||
| 336 | - # 解析 JSON 响应 | ||
| 337 | - response_text = response.content.strip() | ||
| 338 | - # 移除可能的 markdown 代码块 | ||
| 339 | - if response_text.startswith("```"): | ||
| 340 | - lines = response_text.split("\n") | ||
| 341 | - response_text = "\n".join(lines[1:-1]) | ||
| 342 | - | ||
| 343 | - report_data = json.loads(response_text) | ||
| 344 | - | ||
| 345 | - # 复用已计算的统计数据 | ||
| 346 | - total_suggest_cnt = suggestion_stats["total_suggest_cnt"] | ||
| 347 | - total_suggest_amount = suggestion_stats["total_suggest_amount"] | ||
| 348 | - | ||
| 349 | - execution_time_ms = int((time.time() - start_time) * 1000) | ||
| 350 | - | ||
| 351 | - # 创建报告对象 | ||
| 352 | - # 新 prompt 字段名映射到现有数据库字段: | ||
| 353 | - # overall_assessment -> replenishment_insights | ||
| 354 | - # risk_alerts -> urgency_assessment | ||
| 355 | - # procurement_strategy -> strategy_recommendations | ||
| 356 | - # expected_impact -> expected_outcomes | ||
| 357 | - # execution_guide 已移除,置为 None | ||
| 358 | - report = AnalysisReport( | ||
| 359 | - task_no=task_no, | ||
| 360 | - group_id=group_id, | ||
| 361 | - dealer_grouping_id=dealer_grouping_id, | ||
| 362 | - dealer_grouping_name=dealer_grouping_name, | ||
| 363 | - brand_grouping_id=brand_grouping_id, | ||
| 364 | - report_type="replenishment", | ||
| 365 | - replenishment_insights=report_data.get("overall_assessment"), | ||
| 366 | - urgency_assessment=report_data.get("risk_alerts"), | ||
| 367 | - strategy_recommendations=report_data.get("procurement_strategy"), | ||
| 368 | - execution_guide=None, | ||
| 369 | - expected_outcomes=report_data.get("expected_impact"), | ||
| 370 | - total_suggest_cnt=total_suggest_cnt, | ||
| 371 | - total_suggest_amount=total_suggest_amount, | ||
| 372 | - shortage_risk_cnt=risk_stats["shortage_cnt"], | ||
| 373 | - excess_risk_cnt=risk_stats["stagnant_cnt"], | ||
| 374 | - stagnant_cnt=risk_stats["stagnant_cnt"], | ||
| 375 | - low_freq_cnt=risk_stats["low_freq_cnt"], | ||
| 376 | - llm_provider=getattr(llm_client, "provider", ""), | ||
| 377 | - llm_model=getattr(llm_client, "model", ""), | ||
| 378 | - llm_tokens=response.usage.total_tokens, | ||
| 379 | - execution_time_ms=execution_time_ms, | ||
| 380 | - statistics_date=statistics_date, | ||
| 381 | - ) | ||
| 382 | - | ||
| 383 | - # 保存到数据库 | ||
| 384 | - result_writer = ResultWriter() | ||
| 385 | - try: | ||
| 386 | - result_writer.save_analysis_report(report) | ||
| 387 | - finally: | ||
| 388 | - result_writer.close() | ||
| 389 | - | ||
| 390 | - logger.info( | ||
| 391 | - f"[{task_no}] 分析报告生成完成: " | ||
| 392 | - f"shortage={risk_stats['shortage_cnt']}, " | ||
| 393 | - f"stagnant={risk_stats['stagnant_cnt']}, " | ||
| 394 | - f"time={execution_time_ms}ms" | ||
| 395 | - ) | ||
| 396 | - | 591 | + |
| 592 | + current_season = _get_season_from_date(statistics_date) | ||
| 593 | + | ||
| 594 | + prompt_template = _load_prompt("report_replenishment_summary.md") | ||
| 595 | + prompt = prompt_template.format( | ||
| 596 | + total_count=stats.get("total_count", 0), | ||
| 597 | + total_amount=_format_decimal(stats.get("total_amount")), | ||
| 598 | + urgent_count=stats.get("urgent", {}).get("count", 0), | ||
| 599 | + urgent_amount=_format_decimal(stats.get("urgent", {}).get("amount")), | ||
| 600 | + suggested_count=stats.get("suggested", {}).get("count", 0), | ||
| 601 | + suggested_amount=_format_decimal(stats.get("suggested", {}).get("amount")), | ||
| 602 | + optional_count=stats.get("optional", {}).get("count", 0), | ||
| 603 | + optional_amount=_format_decimal(stats.get("optional", {}).get("amount")), | ||
| 604 | + current_season=current_season, | ||
| 605 | + statistics_date=statistics_date or "未知", | ||
| 606 | + ) | ||
| 607 | + | ||
| 608 | + messages = [HumanMessage(content=prompt)] | ||
| 609 | + response = llm_client.invoke(messages) | ||
| 610 | + | ||
| 611 | + try: | ||
| 612 | + analysis = _parse_llm_json(response.content) | ||
| 613 | + except json.JSONDecodeError: | ||
| 614 | + logger.warning(f"补货建议 LLM JSON 解析失败,原始响应: {response.content[:200]}") | ||
| 615 | + analysis = {"error": "JSON解析失败", "raw": response.content[:200]} | ||
| 616 | + | ||
| 617 | + usage = { | ||
| 618 | + "provider": response.usage.provider, | ||
| 619 | + "model": response.usage.model, | ||
| 620 | + "prompt_tokens": response.usage.prompt_tokens, | ||
| 621 | + "completion_tokens": response.usage.completion_tokens, | ||
| 622 | + } | ||
| 623 | + return analysis, usage | ||
| 624 | + | ||
| 625 | + | ||
| 626 | +# ============================================================ | ||
| 627 | +# LangGraph 并发子图 | ||
| 628 | +# ============================================================ | ||
| 629 | + | ||
| 630 | +from typing import TypedDict, Optional, Any, Annotated, Dict | ||
| 631 | + | ||
| 632 | +from langgraph.graph import StateGraph, START, END | ||
| 633 | + | ||
| 634 | + | ||
| 635 | +def _merge_dict(left: Optional[dict], right: Optional[dict]) -> Optional[dict]: | ||
| 636 | + """合并字典,保留非 None 的值""" | ||
| 637 | + if right is not None: | ||
| 638 | + return right | ||
| 639 | + return left | ||
| 640 | + | ||
| 641 | + | ||
| 642 | +def _sum_int(left: int, right: int) -> int: | ||
| 643 | + """累加整数""" | ||
| 644 | + return (left or 0) + (right or 0) | ||
| 645 | + | ||
| 646 | + | ||
| 647 | +def _merge_str(left: Optional[str], right: Optional[str]) -> Optional[str]: | ||
| 648 | + """合并字符串,保留非 None 的值""" | ||
| 649 | + if right is not None: | ||
| 650 | + return right | ||
| 651 | + return left | ||
| 652 | + | ||
| 653 | + | ||
| 654 | +class ReportLLMState(TypedDict, total=False): | ||
| 655 | + """并发 LLM 分析子图的状态""" | ||
| 656 | + | ||
| 657 | + # 输入:四大板块的统计数据(只读,由主函数写入) | ||
| 658 | + inventory_overview_stats: Annotated[Optional[dict], _merge_dict] | ||
| 659 | + sales_analysis_stats: Annotated[Optional[dict], _merge_dict] | ||
| 660 | + inventory_health_stats: Annotated[Optional[dict], _merge_dict] | ||
| 661 | + replenishment_summary_stats: Annotated[Optional[dict], _merge_dict] | ||
| 662 | + | ||
| 663 | + # 输入:统计日期(用于季节判断) | ||
| 664 | + statistics_date: Annotated[Optional[str], _merge_str] | ||
| 665 | + | ||
| 666 | + # 输出:四大板块的 LLM 分析结果(各节点独立写入) | ||
| 667 | + inventory_overview_analysis: Annotated[Optional[dict], _merge_dict] | ||
| 668 | + sales_analysis_analysis: Annotated[Optional[dict], _merge_dict] | ||
| 669 | + inventory_health_analysis: Annotated[Optional[dict], _merge_dict] | ||
| 670 | + replenishment_summary_analysis: Annotated[Optional[dict], _merge_dict] | ||
| 671 | + | ||
| 672 | + # LLM 使用量(累加) | ||
| 673 | + total_prompt_tokens: Annotated[int, _sum_int] | ||
| 674 | + total_completion_tokens: Annotated[int, _sum_int] | ||
| 675 | + llm_provider: Annotated[Optional[str], _merge_dict] | ||
| 676 | + llm_model: Annotated[Optional[str], _merge_dict] | ||
| 677 | + | ||
| 678 | + | ||
| 679 | +def _node_inventory_overview(state: ReportLLMState) -> ReportLLMState: | ||
| 680 | + """并发节点:库存概览 LLM 分析""" | ||
| 681 | + stats = state.get("inventory_overview_stats") | ||
| 682 | + statistics_date = state.get("statistics_date", "") | ||
| 683 | + if not stats: | ||
| 684 | + return {"inventory_overview_analysis": {"error": "无统计数据"}} | ||
| 685 | + | ||
| 686 | + try: | ||
| 687 | + analysis, usage = llm_analyze_inventory_overview(stats, statistics_date) | ||
| 397 | return { | 688 | return { |
| 398 | - "analysis_report": report.to_dict(), | ||
| 399 | - "end_time": time.time(), | 689 | + "inventory_overview_analysis": analysis, |
| 690 | + "total_prompt_tokens": usage.get("prompt_tokens", 0), | ||
| 691 | + "total_completion_tokens": usage.get("completion_tokens", 0), | ||
| 692 | + "llm_provider": usage.get("provider", ""), | ||
| 693 | + "llm_model": usage.get("model", ""), | ||
| 400 | } | 694 | } |
| 401 | - | ||
| 402 | except Exception as e: | 695 | except Exception as e: |
| 403 | - logger.error(f"[{task_no}] 分析报告生成失败: {e}", exc_info=True) | ||
| 404 | - | ||
| 405 | - # 返回空报告,不中断整个流程 | 696 | + logger.error(f"库存概览 LLM 分析失败: {e}") |
| 697 | + return {"inventory_overview_analysis": {"error": str(e)}} | ||
| 698 | + | ||
| 699 | + | ||
| 700 | +def _node_sales_analysis(state: ReportLLMState) -> ReportLLMState: | ||
| 701 | + """并发节点:销量分析 LLM 分析""" | ||
| 702 | + stats = state.get("sales_analysis_stats") | ||
| 703 | + statistics_date = state.get("statistics_date", "") | ||
| 704 | + if not stats: | ||
| 705 | + return {"sales_analysis_analysis": {"error": "无统计数据"}} | ||
| 706 | + | ||
| 707 | + try: | ||
| 708 | + analysis, usage = llm_analyze_sales(stats, statistics_date) | ||
| 709 | + return { | ||
| 710 | + "sales_analysis_analysis": analysis, | ||
| 711 | + "total_prompt_tokens": usage.get("prompt_tokens", 0), | ||
| 712 | + "total_completion_tokens": usage.get("completion_tokens", 0), | ||
| 713 | + "llm_provider": usage.get("provider", ""), | ||
| 714 | + "llm_model": usage.get("model", ""), | ||
| 715 | + } | ||
| 716 | + except Exception as e: | ||
| 717 | + logger.error(f"销量分析 LLM 分析失败: {e}") | ||
| 718 | + return {"sales_analysis_analysis": {"error": str(e)}} | ||
| 719 | + | ||
| 720 | + | ||
| 721 | +def _node_inventory_health(state: ReportLLMState) -> ReportLLMState: | ||
| 722 | + """并发节点:健康度 LLM 分析""" | ||
| 723 | + stats = state.get("inventory_health_stats") | ||
| 724 | + statistics_date = state.get("statistics_date", "") | ||
| 725 | + if not stats: | ||
| 726 | + return {"inventory_health_analysis": {"error": "无统计数据"}} | ||
| 727 | + | ||
| 728 | + try: | ||
| 729 | + analysis, usage = llm_analyze_inventory_health(stats, statistics_date) | ||
| 730 | + return { | ||
| 731 | + "inventory_health_analysis": analysis, | ||
| 732 | + "total_prompt_tokens": usage.get("prompt_tokens", 0), | ||
| 733 | + "total_completion_tokens": usage.get("completion_tokens", 0), | ||
| 734 | + "llm_provider": usage.get("provider", ""), | ||
| 735 | + "llm_model": usage.get("model", ""), | ||
| 736 | + } | ||
| 737 | + except Exception as e: | ||
| 738 | + logger.error(f"健康度 LLM 分析失败: {e}") | ||
| 739 | + return {"inventory_health_analysis": {"error": str(e)}} | ||
| 740 | + | ||
| 741 | + | ||
| 742 | +def _node_replenishment_summary(state: ReportLLMState) -> ReportLLMState: | ||
| 743 | + """并发节点:补货建议 LLM 分析""" | ||
| 744 | + stats = state.get("replenishment_summary_stats") | ||
| 745 | + statistics_date = state.get("statistics_date", "") | ||
| 746 | + if not stats: | ||
| 747 | + return {"replenishment_summary_analysis": {"error": "无统计数据"}} | ||
| 748 | + | ||
| 749 | + try: | ||
| 750 | + analysis, usage = llm_analyze_replenishment_summary(stats, statistics_date) | ||
| 406 | return { | 751 | return { |
| 407 | - "analysis_report": { | ||
| 408 | - "error": str(e), | ||
| 409 | - "task_no": task_no, | ||
| 410 | - }, | ||
| 411 | - "end_time": time.time(), | 752 | + "replenishment_summary_analysis": analysis, |
| 753 | + "total_prompt_tokens": usage.get("prompt_tokens", 0), | ||
| 754 | + "total_completion_tokens": usage.get("completion_tokens", 0), | ||
| 755 | + "llm_provider": usage.get("provider", ""), | ||
| 756 | + "llm_model": usage.get("model", ""), | ||
| 412 | } | 757 | } |
| 758 | + except Exception as e: | ||
| 759 | + logger.error(f"补货建议 LLM 分析失败: {e}") | ||
| 760 | + return {"replenishment_summary_analysis": {"error": str(e)}} | ||
| 761 | + | ||
| 762 | + | ||
| 763 | +def build_report_llm_subgraph() -> StateGraph: | ||
| 764 | + """ | ||
| 765 | + 构建并发 LLM 分析子图 | ||
| 766 | + | ||
| 767 | + 四个 LLM 节点从 START fan-out 并发执行,结果 fan-in 汇总到 END。 | ||
| 768 | + """ | ||
| 769 | + graph = StateGraph(ReportLLMState) | ||
| 770 | + | ||
| 771 | + # 添加四个并发节点 | ||
| 772 | + graph.add_node("inventory_overview_llm", _node_inventory_overview) | ||
| 773 | + graph.add_node("sales_analysis_llm", _node_sales_analysis) | ||
| 774 | + graph.add_node("inventory_health_llm", _node_inventory_health) | ||
| 775 | + graph.add_node("replenishment_summary_llm", _node_replenishment_summary) | ||
| 776 | + | ||
| 777 | + # fan-out: START → 四个节点 | ||
| 778 | + graph.add_edge(START, "inventory_overview_llm") | ||
| 779 | + graph.add_edge(START, "sales_analysis_llm") | ||
| 780 | + graph.add_edge(START, "inventory_health_llm") | ||
| 781 | + graph.add_edge(START, "replenishment_summary_llm") | ||
| 782 | + | ||
| 783 | + # fan-in: 四个节点 → END | ||
| 784 | + graph.add_edge("inventory_overview_llm", END) | ||
| 785 | + graph.add_edge("sales_analysis_llm", END) | ||
| 786 | + graph.add_edge("inventory_health_llm", END) | ||
| 787 | + graph.add_edge("replenishment_summary_llm", END) | ||
| 788 | + | ||
| 789 | + return graph.compile() | ||
| 790 | + | ||
| 791 | + | ||
| 792 | +# ============================================================ | ||
| 793 | +# 主节点函数 | ||
| 794 | +# ============================================================ | ||
| 795 | + | ||
| 796 | + | ||
| 797 | +def _serialize_stats(stats: dict) -> dict: | ||
| 798 | + """将统计数据中的 Decimal 转换为 float,以便 JSON 序列化""" | ||
| 799 | + result = {} | ||
| 800 | + for k, v in stats.items(): | ||
| 801 | + if isinstance(v, Decimal): | ||
| 802 | + result[k] = float(v) | ||
| 803 | + elif isinstance(v, dict): | ||
| 804 | + result[k] = _serialize_stats(v) | ||
| 805 | + elif isinstance(v, list): | ||
| 806 | + result[k] = [ | ||
| 807 | + _serialize_stats(item) if isinstance(item, dict) else (float(item) if isinstance(item, Decimal) else item) | ||
| 808 | + for item in v | ||
| 809 | + ] | ||
| 810 | + else: | ||
| 811 | + result[k] = v | ||
| 812 | + return result | ||
| 813 | + | ||
| 814 | + | ||
| 815 | +def generate_analysis_report_node(state: dict) -> dict: | ||
| 816 | + """ | ||
| 817 | + 分析报告生成主节点 | ||
| 818 | + | ||
| 819 | + 串联流程: | ||
| 820 | + 1. 统计计算(四大板块) | ||
| 821 | + 2. 并发 LLM 分析(LangGraph 子图) | ||
| 822 | + 3. 汇总报告 | ||
| 823 | + 4. 写入数据库 | ||
| 824 | + | ||
| 825 | + 单板块 LLM 失败不影响其他板块。 | ||
| 826 | + | ||
| 827 | + Args: | ||
| 828 | + state: AgentState 字典 | ||
| 829 | + | ||
| 830 | + Returns: | ||
| 831 | + 更新后的 state 字典 | ||
| 832 | + """ | ||
| 833 | + from .state import AgentState | ||
| 834 | + from ..models import AnalysisReport | ||
| 835 | + from ..services.result_writer import ResultWriter | ||
| 836 | + | ||
| 837 | + logger.info("[AnalysisReport] ========== 开始生成分析报告 ==========") | ||
| 838 | + start_time = time.time() | ||
| 839 | + | ||
| 840 | + part_ratios = state.get("part_ratios", []) | ||
| 841 | + part_results = state.get("part_results", []) | ||
| 842 | + | ||
| 843 | + # ---- 1. 统计计算 ---- | ||
| 844 | + logger.info(f"[AnalysisReport] 统计计算: part_ratios={len(part_ratios)}, part_results={len(part_results)}") | ||
| 845 | + | ||
| 846 | + inventory_overview_stats = calculate_inventory_overview(part_ratios) | ||
| 847 | + sales_analysis_stats = calculate_sales_analysis(part_ratios) | ||
| 848 | + inventory_health_stats = calculate_inventory_health(part_ratios) | ||
| 849 | + replenishment_summary_stats = calculate_replenishment_summary(part_results) | ||
| 850 | + | ||
| 851 | + # 序列化统计数据(Decimal → float) | ||
| 852 | + io_stats_serialized = _serialize_stats(inventory_overview_stats) | ||
| 853 | + sa_stats_serialized = _serialize_stats(sales_analysis_stats) | ||
| 854 | + ih_stats_serialized = _serialize_stats(inventory_health_stats) | ||
| 855 | + rs_stats_serialized = _serialize_stats(replenishment_summary_stats) | ||
| 856 | + | ||
| 857 | + # ---- 2. 并发 LLM 分析 ---- | ||
| 858 | + logger.info("[AnalysisReport] 启动并发 LLM 分析子图") | ||
| 859 | + | ||
| 860 | + statistics_date = state.get("statistics_date", "") | ||
| 861 | + | ||
| 862 | + subgraph = build_report_llm_subgraph() | ||
| 863 | + llm_state: ReportLLMState = { | ||
| 864 | + "inventory_overview_stats": io_stats_serialized, | ||
| 865 | + "sales_analysis_stats": sa_stats_serialized, | ||
| 866 | + "inventory_health_stats": ih_stats_serialized, | ||
| 867 | + "replenishment_summary_stats": rs_stats_serialized, | ||
| 868 | + "statistics_date": statistics_date, | ||
| 869 | + "inventory_overview_analysis": None, | ||
| 870 | + "sales_analysis_analysis": None, | ||
| 871 | + "inventory_health_analysis": None, | ||
| 872 | + "replenishment_summary_analysis": None, | ||
| 873 | + "total_prompt_tokens": 0, | ||
| 874 | + "total_completion_tokens": 0, | ||
| 875 | + "llm_provider": None, | ||
| 876 | + "llm_model": None, | ||
| 877 | + } | ||
| 878 | + | ||
| 879 | + try: | ||
| 880 | + llm_result = subgraph.invoke(llm_state) | ||
| 881 | + except Exception as e: | ||
| 882 | + logger.error(f"[AnalysisReport] LLM 子图执行异常: {e}") | ||
| 883 | + llm_result = llm_state # 使用初始状态(所有分析为 None) | ||
| 884 | + | ||
| 885 | + # ---- 3. 汇总报告 ---- | ||
| 886 | + inventory_overview_data = { | ||
| 887 | + "stats": io_stats_serialized, | ||
| 888 | + "llm_analysis": llm_result.get("inventory_overview_analysis") or {"error": "未生成"}, | ||
| 889 | + } | ||
| 890 | + sales_analysis_data = { | ||
| 891 | + "stats": sa_stats_serialized, | ||
| 892 | + "llm_analysis": llm_result.get("sales_analysis_analysis") or {"error": "未生成"}, | ||
| 893 | + } | ||
| 894 | + inventory_health_data = { | ||
| 895 | + "stats": ih_stats_serialized, | ||
| 896 | + "chart_data": ih_stats_serialized.get("chart_data"), | ||
| 897 | + "llm_analysis": llm_result.get("inventory_health_analysis") or {"error": "未生成"}, | ||
| 898 | + } | ||
| 899 | + replenishment_summary_data = { | ||
| 900 | + "stats": rs_stats_serialized, | ||
| 901 | + "llm_analysis": llm_result.get("replenishment_summary_analysis") or {"error": "未生成"}, | ||
| 902 | + } | ||
| 903 | + | ||
| 904 | + total_tokens = ( | ||
| 905 | + (llm_result.get("total_prompt_tokens") or 0) | ||
| 906 | + + (llm_result.get("total_completion_tokens") or 0) | ||
| 907 | + ) | ||
| 908 | + execution_time_ms = int((time.time() - start_time) * 1000) | ||
| 909 | + | ||
| 910 | + # ---- 4. 写入数据库 ---- | ||
| 911 | + report = AnalysisReport( | ||
| 912 | + task_no=state.get("task_no", ""), | ||
| 913 | + group_id=state.get("group_id", 0), | ||
| 914 | + dealer_grouping_id=state.get("dealer_grouping_id", 0), | ||
| 915 | + dealer_grouping_name=state.get("dealer_grouping_name"), | ||
| 916 | + brand_grouping_id=state.get("brand_grouping_id"), | ||
| 917 | + inventory_overview=inventory_overview_data, | ||
| 918 | + sales_analysis=sales_analysis_data, | ||
| 919 | + inventory_health=inventory_health_data, | ||
| 920 | + replenishment_summary=replenishment_summary_data, | ||
| 921 | + llm_provider=llm_result.get("llm_provider") or "", | ||
| 922 | + llm_model=llm_result.get("llm_model") or "", | ||
| 923 | + llm_tokens=total_tokens, | ||
| 924 | + execution_time_ms=execution_time_ms, | ||
| 925 | + statistics_date=state.get("statistics_date", ""), | ||
| 926 | + ) | ||
| 927 | + | ||
| 928 | + try: | ||
| 929 | + writer = ResultWriter() | ||
| 930 | + report_id = writer.save_analysis_report(report) | ||
| 931 | + writer.close() | ||
| 932 | + logger.info(f"[AnalysisReport] 报告已保存: id={report_id}, tokens={total_tokens}, 耗时={execution_time_ms}ms") | ||
| 933 | + except Exception as e: | ||
| 934 | + logger.error(f"[AnalysisReport] 报告写入数据库失败: {e}") | ||
| 935 | + | ||
| 936 | + # 返回更新后的状态 | ||
| 937 | + return { | ||
| 938 | + "analysis_report": report.to_dict(), | ||
| 939 | + "llm_provider": llm_result.get("llm_provider") or state.get("llm_provider", ""), | ||
| 940 | + "llm_model": llm_result.get("llm_model") or state.get("llm_model", ""), | ||
| 941 | + "llm_prompt_tokens": llm_result.get("total_prompt_tokens") or 0, | ||
| 942 | + "llm_completion_tokens": llm_result.get("total_completion_tokens") or 0, | ||
| 943 | + "current_node": "generate_analysis_report", | ||
| 944 | + "next_node": "end", | ||
| 945 | + } |
src/fw_pms_ai/agent/sql_agent/agent.py
| @@ -140,7 +140,7 @@ class SQLAgent: | @@ -140,7 +140,7 @@ class SQLAgent: | ||
| 140 | out_stock_ongoing_cnt, stock_age, out_times, out_duration, | 140 | out_stock_ongoing_cnt, stock_age, out_times, out_duration, |
| 141 | transfer_cnt, gen_transfer_cnt, | 141 | transfer_cnt, gen_transfer_cnt, |
| 142 | part_biz_type, statistics_date, | 142 | part_biz_type, statistics_date, |
| 143 | - (in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt + transfer_cnt + gen_transfer_cnt) as valid_storage_cnt, | 143 | + (in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt) as valid_storage_cnt, |
| 144 | ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) as avg_sales_cnt | 144 | ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) as avg_sales_cnt |
| 145 | FROM part_ratio | 145 | FROM part_ratio |
| 146 | WHERE group_id = %s | 146 | WHERE group_id = %s |
| @@ -159,7 +159,7 @@ class SQLAgent: | @@ -159,7 +159,7 @@ class SQLAgent: | ||
| 159 | # 优先处理有销量的配件 | 159 | # 优先处理有销量的配件 |
| 160 | sql += """ ORDER BY | 160 | sql += """ ORDER BY |
| 161 | CASE WHEN ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) > 0 THEN 0 ELSE 1 END, | 161 | CASE WHEN ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) > 0 THEN 0 ELSE 1 END, |
| 162 | - (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, | 162 | + (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, |
| 163 | ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) DESC | 163 | ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) DESC |
| 164 | """ | 164 | """ |
| 165 | 165 |
src/fw_pms_ai/agent/state.py
| @@ -53,8 +53,8 @@ class AgentState(TypedDict, total=False): | @@ -53,8 +53,8 @@ class AgentState(TypedDict, total=False): | ||
| 53 | dealer_grouping_name: Annotated[str, keep_last] | 53 | dealer_grouping_name: Annotated[str, keep_last] |
| 54 | statistics_date: Annotated[str, keep_last] | 54 | statistics_date: Annotated[str, keep_last] |
| 55 | 55 | ||
| 56 | - # part_ratio 原始数据 | ||
| 57 | - part_ratios: Annotated[List[dict], merge_dicts] | 56 | + # part_ratio 原始数据(使用 keep_last,因为只在 fetch_part_ratio 节点写入一次) |
| 57 | + part_ratios: Annotated[List[dict], keep_last] | ||
| 58 | 58 | ||
| 59 | # SQL Agent 相关 | 59 | # SQL Agent 相关 |
| 60 | sql_queries: Annotated[List[str], merge_lists] | 60 | sql_queries: Annotated[List[str], merge_lists] |
src/fw_pms_ai/api/routes/tasks.py
| @@ -619,23 +619,14 @@ class AnalysisReportResponse(BaseModel): | @@ -619,23 +619,14 @@ class AnalysisReportResponse(BaseModel): | ||
| 619 | group_id: int | 619 | group_id: int |
| 620 | dealer_grouping_id: int | 620 | dealer_grouping_id: int |
| 621 | dealer_grouping_name: Optional[str] = None | 621 | dealer_grouping_name: Optional[str] = None |
| 622 | - brand_grouping_id: Optional[int] = None | ||
| 623 | report_type: str | 622 | report_type: str |
| 624 | - | ||
| 625 | - # JSON 字段,使用 Any 或 Dict 来接收解析后的对象 | ||
| 626 | - replenishment_insights: Optional[Dict[str, Any]] = None | ||
| 627 | - urgency_assessment: Optional[Dict[str, Any]] = None | ||
| 628 | - strategy_recommendations: Optional[Dict[str, Any]] = None | ||
| 629 | - execution_guide: Optional[Dict[str, Any]] = None | ||
| 630 | - expected_outcomes: Optional[Dict[str, Any]] = None | ||
| 631 | - | ||
| 632 | - total_suggest_cnt: int = 0 | ||
| 633 | - total_suggest_amount: float = 0 | ||
| 634 | - shortage_risk_cnt: int = 0 | ||
| 635 | - excess_risk_cnt: int = 0 | ||
| 636 | - stagnant_cnt: int = 0 | ||
| 637 | - low_freq_cnt: int = 0 | ||
| 638 | - | 623 | + |
| 624 | + # 四大板块(统计数据 + LLM 分析) | ||
| 625 | + inventory_overview: Optional[Dict[str, Any]] = None | ||
| 626 | + sales_analysis: Optional[Dict[str, Any]] = None | ||
| 627 | + inventory_health: Optional[Dict[str, Any]] = None | ||
| 628 | + replenishment_summary: Optional[Dict[str, Any]] = None | ||
| 629 | + | ||
| 639 | llm_provider: Optional[str] = None | 630 | llm_provider: Optional[str] = None |
| 640 | llm_model: Optional[str] = None | 631 | llm_model: Optional[str] = None |
| 641 | llm_tokens: int = 0 | 632 | llm_tokens: int = 0 |
| @@ -664,17 +655,19 @@ async def get_analysis_report(task_no: str): | @@ -664,17 +655,19 @@ async def get_analysis_report(task_no: str): | ||
| 664 | 655 | ||
| 665 | if not row: | 656 | if not row: |
| 666 | return None | 657 | return None |
| 667 | - | ||
| 668 | - # 辅助函数:解析 JSON 字符串 | 658 | + |
| 659 | + # 解析 JSON 字段 | ||
| 669 | def parse_json(value): | 660 | def parse_json(value): |
| 670 | - if not value: | 661 | + if value is None: |
| 671 | return None | 662 | return None |
| 663 | + if isinstance(value, dict): | ||
| 664 | + return value | ||
| 672 | if isinstance(value, str): | 665 | if isinstance(value, str): |
| 673 | try: | 666 | try: |
| 674 | return json.loads(value) | 667 | return json.loads(value) |
| 675 | - except json.JSONDecodeError: | 668 | + except (json.JSONDecodeError, TypeError): |
| 676 | return None | 669 | return None |
| 677 | - return value | 670 | + return None |
| 678 | 671 | ||
| 679 | return AnalysisReportResponse( | 672 | return AnalysisReportResponse( |
| 680 | id=row["id"], | 673 | id=row["id"], |
| @@ -682,22 +675,11 @@ async def get_analysis_report(task_no: str): | @@ -682,22 +675,11 @@ async def get_analysis_report(task_no: str): | ||
| 682 | group_id=row["group_id"], | 675 | group_id=row["group_id"], |
| 683 | dealer_grouping_id=row["dealer_grouping_id"], | 676 | dealer_grouping_id=row["dealer_grouping_id"], |
| 684 | dealer_grouping_name=row.get("dealer_grouping_name"), | 677 | dealer_grouping_name=row.get("dealer_grouping_name"), |
| 685 | - brand_grouping_id=row.get("brand_grouping_id"), | ||
| 686 | report_type=row.get("report_type", "replenishment"), | 678 | report_type=row.get("report_type", "replenishment"), |
| 687 | - | ||
| 688 | - replenishment_insights=parse_json(row.get("replenishment_insights")), | ||
| 689 | - urgency_assessment=parse_json(row.get("urgency_assessment")), | ||
| 690 | - strategy_recommendations=parse_json(row.get("strategy_recommendations")), | ||
| 691 | - execution_guide=parse_json(row.get("execution_guide")), | ||
| 692 | - expected_outcomes=parse_json(row.get("expected_outcomes")), | ||
| 693 | - | ||
| 694 | - total_suggest_cnt=row.get("total_suggest_cnt") or 0, | ||
| 695 | - total_suggest_amount=float(row.get("total_suggest_amount") or 0), | ||
| 696 | - shortage_risk_cnt=row.get("shortage_risk_cnt") or 0, | ||
| 697 | - excess_risk_cnt=row.get("excess_risk_cnt") or 0, | ||
| 698 | - stagnant_cnt=row.get("stagnant_cnt") or 0, | ||
| 699 | - low_freq_cnt=row.get("low_freq_cnt") or 0, | ||
| 700 | - | 679 | + inventory_overview=parse_json(row.get("inventory_overview")), |
| 680 | + sales_analysis=parse_json(row.get("sales_analysis")), | ||
| 681 | + inventory_health=parse_json(row.get("inventory_health")), | ||
| 682 | + replenishment_summary=parse_json(row.get("replenishment_summary")), | ||
| 701 | llm_provider=row.get("llm_provider"), | 683 | llm_provider=row.get("llm_provider"), |
| 702 | llm_model=row.get("llm_model"), | 684 | llm_model=row.get("llm_model"), |
| 703 | llm_tokens=row.get("llm_tokens") or 0, | 685 | llm_tokens=row.get("llm_tokens") or 0, |
src/fw_pms_ai/models/analysis_report.py
| 1 | """ | 1 | """ |
| 2 | 数据模型 - 分析报告 | 2 | 数据模型 - 分析报告 |
| 3 | +四大板块:库存概览、销量分析、库存健康度、补货建议 | ||
| 3 | """ | 4 | """ |
| 4 | 5 | ||
| 5 | from dataclasses import dataclass, field | 6 | from dataclasses import dataclass, field |
| 6 | -from decimal import Decimal | ||
| 7 | from datetime import datetime | 7 | from datetime import datetime |
| 8 | -from typing import Optional, Dict, Any | 8 | +from typing import Any, Dict, Optional |
| 9 | 9 | ||
| 10 | 10 | ||
| 11 | @dataclass | 11 | @dataclass |
| 12 | class AnalysisReport: | 12 | class AnalysisReport: |
| 13 | - """AI补货建议分析报告""" | ||
| 14 | - | 13 | + """分析报告数据模型""" |
| 14 | + | ||
| 15 | task_no: str | 15 | task_no: str |
| 16 | group_id: int | 16 | group_id: int |
| 17 | dealer_grouping_id: int | 17 | dealer_grouping_id: int |
| 18 | - | 18 | + |
| 19 | id: Optional[int] = None | 19 | id: Optional[int] = None |
| 20 | dealer_grouping_name: Optional[str] = None | 20 | dealer_grouping_name: Optional[str] = None |
| 21 | brand_grouping_id: Optional[int] = None | 21 | brand_grouping_id: Optional[int] = None |
| 22 | report_type: str = "replenishment" | 22 | report_type: str = "replenishment" |
| 23 | - | ||
| 24 | - # 报告各模块 (字典结构) | ||
| 25 | - replenishment_insights: Optional[Dict[str, Any]] = None | ||
| 26 | - urgency_assessment: Optional[Dict[str, Any]] = None | ||
| 27 | - strategy_recommendations: Optional[Dict[str, Any]] = None | ||
| 28 | - execution_guide: Optional[Dict[str, Any]] = None | ||
| 29 | - expected_outcomes: Optional[Dict[str, Any]] = None | ||
| 30 | - | ||
| 31 | - # 统计信息 | ||
| 32 | - total_suggest_cnt: int = 0 | ||
| 33 | - total_suggest_amount: Decimal = Decimal("0") | ||
| 34 | - shortage_risk_cnt: int = 0 | ||
| 35 | - excess_risk_cnt: int = 0 | ||
| 36 | - stagnant_cnt: int = 0 | ||
| 37 | - low_freq_cnt: int = 0 | ||
| 38 | - | 23 | + |
| 24 | + # 四大板块 | ||
| 25 | + inventory_overview: Optional[Dict[str, Any]] = field(default=None) | ||
| 26 | + sales_analysis: Optional[Dict[str, Any]] = field(default=None) | ||
| 27 | + inventory_health: Optional[Dict[str, Any]] = field(default=None) | ||
| 28 | + replenishment_summary: Optional[Dict[str, Any]] = field(default=None) | ||
| 29 | + | ||
| 39 | # LLM 元数据 | 30 | # LLM 元数据 |
| 40 | llm_provider: str = "" | 31 | llm_provider: str = "" |
| 41 | llm_model: str = "" | 32 | llm_model: str = "" |
| 42 | llm_tokens: int = 0 | 33 | llm_tokens: int = 0 |
| 43 | execution_time_ms: int = 0 | 34 | execution_time_ms: int = 0 |
| 44 | - | 35 | + |
| 45 | statistics_date: str = "" | 36 | statistics_date: str = "" |
| 46 | create_time: Optional[datetime] = None | 37 | create_time: Optional[datetime] = None |
| 47 | 38 | ||
| 48 | - def to_dict(self) -> dict: | ||
| 49 | - """转换为字典""" | 39 | + def to_dict(self) -> Dict[str, Any]: |
| 40 | + """将报告转换为可序列化的字典""" | ||
| 50 | return { | 41 | return { |
| 42 | + "id": self.id, | ||
| 51 | "task_no": self.task_no, | 43 | "task_no": self.task_no, |
| 52 | "group_id": self.group_id, | 44 | "group_id": self.group_id, |
| 53 | "dealer_grouping_id": self.dealer_grouping_id, | 45 | "dealer_grouping_id": self.dealer_grouping_id, |
| 54 | "dealer_grouping_name": self.dealer_grouping_name, | 46 | "dealer_grouping_name": self.dealer_grouping_name, |
| 55 | "brand_grouping_id": self.brand_grouping_id, | 47 | "brand_grouping_id": self.brand_grouping_id, |
| 56 | "report_type": self.report_type, | 48 | "report_type": self.report_type, |
| 57 | - "replenishment_insights": self.replenishment_insights, | ||
| 58 | - "urgency_assessment": self.urgency_assessment, | ||
| 59 | - "strategy_recommendations": self.strategy_recommendations, | ||
| 60 | - "execution_guide": self.execution_guide, | ||
| 61 | - "expected_outcomes": self.expected_outcomes, | ||
| 62 | - "total_suggest_cnt": self.total_suggest_cnt, | ||
| 63 | - "total_suggest_amount": float(self.total_suggest_amount), | ||
| 64 | - "shortage_risk_cnt": self.shortage_risk_cnt, | ||
| 65 | - "excess_risk_cnt": self.excess_risk_cnt, | ||
| 66 | - "stagnant_cnt": self.stagnant_cnt, | ||
| 67 | - "low_freq_cnt": self.low_freq_cnt, | 49 | + "inventory_overview": self.inventory_overview, |
| 50 | + "sales_analysis": self.sales_analysis, | ||
| 51 | + "inventory_health": self.inventory_health, | ||
| 52 | + "replenishment_summary": self.replenishment_summary, | ||
| 68 | "llm_provider": self.llm_provider, | 53 | "llm_provider": self.llm_provider, |
| 69 | "llm_model": self.llm_model, | 54 | "llm_model": self.llm_model, |
| 70 | "llm_tokens": self.llm_tokens, | 55 | "llm_tokens": self.llm_tokens, |
| 71 | "execution_time_ms": self.execution_time_ms, | 56 | "execution_time_ms": self.execution_time_ms, |
| 72 | "statistics_date": self.statistics_date, | 57 | "statistics_date": self.statistics_date, |
| 58 | + "create_time": self.create_time.isoformat() if self.create_time else None, | ||
| 73 | } | 59 | } |
src/fw_pms_ai/models/part_ratio.py
| @@ -46,9 +46,8 @@ class PartRatio: | @@ -46,9 +46,8 @@ class PartRatio: | ||
| 46 | 46 | ||
| 47 | @property | 47 | @property |
| 48 | def valid_storage_cnt(self) -> Decimal: | 48 | def valid_storage_cnt(self) -> Decimal: |
| 49 | - """有效库存数量 = 在库未锁 + 在途 + 计划数 + 主动调拨在途 + 自动调拨在途""" | ||
| 50 | - return (self.in_stock_unlocked_cnt + self.on_the_way_cnt + self.has_plan_cnt + | ||
| 51 | - Decimal(str(self.transfer_cnt)) + Decimal(str(self.gen_transfer_cnt))) | 49 | + """有效库存数量 = 在库未锁 + 在途 + 计划数""" |
| 50 | + return self.in_stock_unlocked_cnt + self.on_the_way_cnt + self.has_plan_cnt | ||
| 52 | 51 | ||
| 53 | @property | 52 | @property |
| 54 | def valid_storage_amount(self) -> Decimal: | 53 | def valid_storage_amount(self) -> Decimal: |
src/fw_pms_ai/services/result_writer.py
| @@ -325,8 +325,8 @@ class ResultWriter: | @@ -325,8 +325,8 @@ class ResultWriter: | ||
| 325 | 325 | ||
| 326 | def save_analysis_report(self, report: AnalysisReport) -> int: | 326 | def save_analysis_report(self, report: AnalysisReport) -> int: |
| 327 | """ | 327 | """ |
| 328 | - 保存分析报告 | ||
| 329 | - | 328 | + 保存分析报告(四大板块 JSON 结构) |
| 329 | + | ||
| 330 | Returns: | 330 | Returns: |
| 331 | 插入的报告ID | 331 | 插入的报告ID |
| 332 | """ | 332 | """ |
| @@ -338,20 +338,18 @@ class ResultWriter: | @@ -338,20 +338,18 @@ class ResultWriter: | ||
| 338 | INSERT INTO ai_analysis_report ( | 338 | INSERT INTO ai_analysis_report ( |
| 339 | task_no, group_id, dealer_grouping_id, dealer_grouping_name, | 339 | task_no, group_id, dealer_grouping_id, dealer_grouping_name, |
| 340 | brand_grouping_id, report_type, | 340 | brand_grouping_id, report_type, |
| 341 | - replenishment_insights, urgency_assessment, strategy_recommendations, | ||
| 342 | - execution_guide, expected_outcomes, | ||
| 343 | - total_suggest_cnt, total_suggest_amount, shortage_risk_cnt, | ||
| 344 | - excess_risk_cnt, stagnant_cnt, low_freq_cnt, | 341 | + inventory_overview, sales_analysis, |
| 342 | + inventory_health, replenishment_summary, | ||
| 345 | llm_provider, llm_model, llm_tokens, execution_time_ms, | 343 | llm_provider, llm_model, llm_tokens, execution_time_ms, |
| 346 | statistics_date, create_time | 344 | statistics_date, create_time |
| 347 | ) VALUES ( | 345 | ) VALUES ( |
| 348 | %s, %s, %s, %s, %s, %s, | 346 | %s, %s, %s, %s, %s, %s, |
| 349 | - %s, %s, %s, %s, %s, | ||
| 350 | - %s, %s, %s, %s, %s, %s, | ||
| 351 | - %s, %s, %s, %s, %s, NOW() | 347 | + %s, %s, %s, %s, |
| 348 | + %s, %s, %s, %s, | ||
| 349 | + %s, NOW() | ||
| 352 | ) | 350 | ) |
| 353 | """ | 351 | """ |
| 354 | - | 352 | + |
| 355 | values = ( | 353 | values = ( |
| 356 | report.task_no, | 354 | report.task_no, |
| 357 | report.group_id, | 355 | report.group_id, |
| @@ -359,27 +357,20 @@ class ResultWriter: | @@ -359,27 +357,20 @@ class ResultWriter: | ||
| 359 | report.dealer_grouping_name, | 357 | report.dealer_grouping_name, |
| 360 | report.brand_grouping_id, | 358 | report.brand_grouping_id, |
| 361 | report.report_type, | 359 | report.report_type, |
| 362 | - json.dumps(report.replenishment_insights, ensure_ascii=False) if report.replenishment_insights else None, | ||
| 363 | - json.dumps(report.urgency_assessment, ensure_ascii=False) if report.urgency_assessment else None, | ||
| 364 | - json.dumps(report.strategy_recommendations, ensure_ascii=False) if report.strategy_recommendations else None, | ||
| 365 | - json.dumps(report.execution_guide, ensure_ascii=False) if report.execution_guide else None, | ||
| 366 | - json.dumps(report.expected_outcomes, ensure_ascii=False) if report.expected_outcomes else None, | ||
| 367 | - report.total_suggest_cnt, | ||
| 368 | - float(report.total_suggest_amount), | ||
| 369 | - report.shortage_risk_cnt, | ||
| 370 | - report.excess_risk_cnt, | ||
| 371 | - report.stagnant_cnt, | ||
| 372 | - report.low_freq_cnt, | 360 | + json.dumps(report.inventory_overview, ensure_ascii=False) if report.inventory_overview else None, |
| 361 | + json.dumps(report.sales_analysis, ensure_ascii=False) if report.sales_analysis else None, | ||
| 362 | + json.dumps(report.inventory_health, ensure_ascii=False) if report.inventory_health else None, | ||
| 363 | + json.dumps(report.replenishment_summary, ensure_ascii=False) if report.replenishment_summary else None, | ||
| 373 | report.llm_provider, | 364 | report.llm_provider, |
| 374 | report.llm_model, | 365 | report.llm_model, |
| 375 | report.llm_tokens, | 366 | report.llm_tokens, |
| 376 | report.execution_time_ms, | 367 | report.execution_time_ms, |
| 377 | report.statistics_date, | 368 | report.statistics_date, |
| 378 | ) | 369 | ) |
| 379 | - | 370 | + |
| 380 | cursor.execute(sql, values) | 371 | cursor.execute(sql, values) |
| 381 | conn.commit() | 372 | conn.commit() |
| 382 | - | 373 | + |
| 383 | report_id = cursor.lastrowid | 374 | report_id = cursor.lastrowid |
| 384 | logger.info(f"保存分析报告: task_no={report.task_no}, id={report_id}") | 375 | logger.info(f"保存分析报告: task_no={report.task_no}, id={report_id}") |
| 385 | return report_id | 376 | return report_id |
ui/css/style.css
| @@ -2362,3 +2362,390 @@ tbody tr:last-child td { | @@ -2362,3 +2362,390 @@ tbody tr:last-child td { | ||
| 2362 | font-size: 0.75rem; | 2362 | font-size: 0.75rem; |
| 2363 | color: var(--text-muted); | 2363 | color: var(--text-muted); |
| 2364 | } | 2364 | } |
| 2365 | + | ||
| 2366 | + | ||
| 2367 | +/* ===================== | ||
| 2368 | + 新报告板块样式 | ||
| 2369 | + ===================== */ | ||
| 2370 | + | ||
| 2371 | +/* 报告统计卡片网格 */ | ||
| 2372 | +.report-stat-cards { | ||
| 2373 | + display: grid; | ||
| 2374 | + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | ||
| 2375 | + gap: var(--spacing-md); | ||
| 2376 | + margin-bottom: var(--spacing-xl); | ||
| 2377 | +} | ||
| 2378 | + | ||
| 2379 | +.report-stat-cards-4 { | ||
| 2380 | + grid-template-columns: repeat(4, 1fr); | ||
| 2381 | +} | ||
| 2382 | + | ||
| 2383 | +@media (max-width: 768px) { | ||
| 2384 | + .report-stat-cards-4 { | ||
| 2385 | + grid-template-columns: repeat(2, 1fr); | ||
| 2386 | + } | ||
| 2387 | +} | ||
| 2388 | + | ||
| 2389 | +/* 报告统计卡片 */ | ||
| 2390 | +.report-stat-card { | ||
| 2391 | + background: rgba(30, 41, 59, 0.6); | ||
| 2392 | + border: 1px solid var(--glass-border); | ||
| 2393 | + border-radius: var(--radius-lg); | ||
| 2394 | + padding: var(--spacing-lg); | ||
| 2395 | + transition: all var(--transition-base); | ||
| 2396 | +} | ||
| 2397 | + | ||
| 2398 | +.report-stat-card:hover { | ||
| 2399 | + transform: translateY(-2px); | ||
| 2400 | + box-shadow: var(--shadow-md); | ||
| 2401 | + border-color: var(--border-color-light); | ||
| 2402 | +} | ||
| 2403 | + | ||
| 2404 | +.report-stat-label { | ||
| 2405 | + font-size: 0.8rem; | ||
| 2406 | + color: var(--text-secondary); | ||
| 2407 | + margin-bottom: var(--spacing-xs); | ||
| 2408 | + text-transform: uppercase; | ||
| 2409 | + letter-spacing: 0.05em; | ||
| 2410 | +} | ||
| 2411 | + | ||
| 2412 | +.report-stat-value { | ||
| 2413 | + font-size: 1.5rem; | ||
| 2414 | + font-weight: 700; | ||
| 2415 | + color: var(--text-primary); | ||
| 2416 | + line-height: 1.3; | ||
| 2417 | +} | ||
| 2418 | + | ||
| 2419 | +.report-stat-sub { | ||
| 2420 | + font-size: 0.85rem; | ||
| 2421 | + font-weight: 400; | ||
| 2422 | + color: var(--text-muted); | ||
| 2423 | +} | ||
| 2424 | + | ||
| 2425 | +.report-stat-pct { | ||
| 2426 | + font-size: 0.8rem; | ||
| 2427 | + color: var(--text-secondary); | ||
| 2428 | + margin-top: var(--spacing-xs); | ||
| 2429 | +} | ||
| 2430 | + | ||
| 2431 | +/* 健康度卡片颜色变体 */ | ||
| 2432 | +.report-stat-card-danger { | ||
| 2433 | + border-left: 3px solid var(--color-danger); | ||
| 2434 | +} | ||
| 2435 | +.report-stat-card-danger .report-stat-value { | ||
| 2436 | + color: var(--color-danger-light); | ||
| 2437 | +} | ||
| 2438 | + | ||
| 2439 | +.report-stat-card-warning { | ||
| 2440 | + border-left: 3px solid var(--color-warning); | ||
| 2441 | +} | ||
| 2442 | +.report-stat-card-warning .report-stat-value { | ||
| 2443 | + color: var(--color-warning-light); | ||
| 2444 | +} | ||
| 2445 | + | ||
| 2446 | +.report-stat-card-info { | ||
| 2447 | + border-left: 3px solid var(--color-info); | ||
| 2448 | +} | ||
| 2449 | +.report-stat-card-info .report-stat-value { | ||
| 2450 | + color: var(--color-info-light); | ||
| 2451 | +} | ||
| 2452 | + | ||
| 2453 | +.report-stat-card-success { | ||
| 2454 | + border-left: 3px solid var(--color-success); | ||
| 2455 | +} | ||
| 2456 | +.report-stat-card-success .report-stat-value { | ||
| 2457 | + color: var(--color-success-light); | ||
| 2458 | +} | ||
| 2459 | + | ||
| 2460 | +/* 报告明细表格 */ | ||
| 2461 | +.report-detail-table { | ||
| 2462 | + background: rgba(30, 41, 59, 0.4); | ||
| 2463 | + border: 1px solid var(--glass-border); | ||
| 2464 | + border-radius: var(--radius-lg); | ||
| 2465 | + overflow: hidden; | ||
| 2466 | + margin-bottom: var(--spacing-xl); | ||
| 2467 | +} | ||
| 2468 | + | ||
| 2469 | +.report-detail-table table { | ||
| 2470 | + width: 100%; | ||
| 2471 | + border-collapse: collapse; | ||
| 2472 | +} | ||
| 2473 | + | ||
| 2474 | +.report-detail-table th { | ||
| 2475 | + background: var(--bg-surface); | ||
| 2476 | + padding: var(--spacing-md) var(--spacing-lg); | ||
| 2477 | + text-align: left; | ||
| 2478 | + font-weight: 600; | ||
| 2479 | + font-size: 0.8rem; | ||
| 2480 | + text-transform: uppercase; | ||
| 2481 | + letter-spacing: 0.05em; | ||
| 2482 | + color: var(--text-secondary); | ||
| 2483 | + border-bottom: 1px solid var(--border-color); | ||
| 2484 | +} | ||
| 2485 | + | ||
| 2486 | +.report-detail-table td { | ||
| 2487 | + padding: var(--spacing-md) var(--spacing-lg); | ||
| 2488 | + border-bottom: 1px solid var(--border-color); | ||
| 2489 | + color: var(--text-primary); | ||
| 2490 | +} | ||
| 2491 | + | ||
| 2492 | +.report-detail-table tbody tr:last-child td { | ||
| 2493 | + border-bottom: none; | ||
| 2494 | +} | ||
| 2495 | + | ||
| 2496 | +.report-detail-table tbody tr:hover { | ||
| 2497 | + background: rgba(255, 255, 255, 0.02); | ||
| 2498 | +} | ||
| 2499 | + | ||
| 2500 | +/* 图表容器 */ | ||
| 2501 | +.report-charts-row { | ||
| 2502 | + display: grid; | ||
| 2503 | + grid-template-columns: 1fr 1fr; | ||
| 2504 | + gap: var(--spacing-xl); | ||
| 2505 | + margin-bottom: var(--spacing-xl); | ||
| 2506 | +} | ||
| 2507 | + | ||
| 2508 | +@media (max-width: 768px) { | ||
| 2509 | + .report-charts-row { | ||
| 2510 | + grid-template-columns: 1fr; | ||
| 2511 | + } | ||
| 2512 | +} | ||
| 2513 | + | ||
| 2514 | +.report-chart-container { | ||
| 2515 | + background: rgba(30, 41, 59, 0.4); | ||
| 2516 | + border: 1px solid var(--glass-border); | ||
| 2517 | + border-radius: var(--radius-lg); | ||
| 2518 | + padding: var(--spacing-lg); | ||
| 2519 | + display: flex; | ||
| 2520 | + flex-direction: column; | ||
| 2521 | + align-items: center; | ||
| 2522 | +} | ||
| 2523 | + | ||
| 2524 | +.report-chart-title { | ||
| 2525 | + font-size: 0.9rem; | ||
| 2526 | + font-weight: 600; | ||
| 2527 | + color: var(--text-secondary); | ||
| 2528 | + margin-bottom: var(--spacing-md); | ||
| 2529 | + text-align: center; | ||
| 2530 | +} | ||
| 2531 | + | ||
| 2532 | +.report-chart-container canvas { | ||
| 2533 | + max-width: 300px; | ||
| 2534 | + max-height: 300px; | ||
| 2535 | +} | ||
| 2536 | + | ||
| 2537 | +/* LLM 分析文本区域 */ | ||
| 2538 | +.report-analysis-text { | ||
| 2539 | + background: rgba(30, 41, 59, 0.3); | ||
| 2540 | + border: 1px solid var(--glass-border); | ||
| 2541 | + border-radius: var(--radius-lg); | ||
| 2542 | + padding: var(--spacing-xl); | ||
| 2543 | +} | ||
| 2544 | + | ||
| 2545 | +.analysis-block { | ||
| 2546 | + margin-bottom: var(--spacing-lg); | ||
| 2547 | +} | ||
| 2548 | + | ||
| 2549 | +.analysis-block:last-child { | ||
| 2550 | + margin-bottom: 0; | ||
| 2551 | +} | ||
| 2552 | + | ||
| 2553 | +.analysis-block-title { | ||
| 2554 | + font-size: 1rem; | ||
| 2555 | + font-weight: 600; | ||
| 2556 | + color: var(--text-primary); | ||
| 2557 | + margin-bottom: var(--spacing-sm); | ||
| 2558 | + display: flex; | ||
| 2559 | + align-items: center; | ||
| 2560 | + gap: var(--spacing-sm); | ||
| 2561 | +} | ||
| 2562 | + | ||
| 2563 | +.analysis-block-title svg { | ||
| 2564 | + width: 18px; | ||
| 2565 | + height: 18px; | ||
| 2566 | + color: var(--color-primary); | ||
| 2567 | +} | ||
| 2568 | + | ||
| 2569 | +.analysis-block p { | ||
| 2570 | + color: var(--text-secondary); | ||
| 2571 | + line-height: 1.7; | ||
| 2572 | + margin-bottom: var(--spacing-xs); | ||
| 2573 | + font-size: 0.9rem; | ||
| 2574 | +} | ||
| 2575 | + | ||
| 2576 | +.analysis-block p:empty { | ||
| 2577 | + display: none; | ||
| 2578 | +} | ||
| 2579 | + | ||
| 2580 | +.analysis-benchmark { | ||
| 2581 | + font-style: italic; | ||
| 2582 | + opacity: 0.8; | ||
| 2583 | +} | ||
| 2584 | + | ||
| 2585 | +.analysis-rec-list { | ||
| 2586 | + list-style: none; | ||
| 2587 | + padding: 0; | ||
| 2588 | + margin: 0; | ||
| 2589 | +} | ||
| 2590 | + | ||
| 2591 | +.analysis-rec-list li { | ||
| 2592 | + position: relative; | ||
| 2593 | + padding-left: 20px; | ||
| 2594 | + margin-bottom: var(--spacing-sm); | ||
| 2595 | + color: var(--text-secondary); | ||
| 2596 | + font-size: 0.9rem; | ||
| 2597 | + line-height: 1.6; | ||
| 2598 | +} | ||
| 2599 | + | ||
| 2600 | +.analysis-rec-list li::before { | ||
| 2601 | + content: '→'; | ||
| 2602 | + position: absolute; | ||
| 2603 | + left: 0; | ||
| 2604 | + color: var(--color-primary); | ||
| 2605 | +} | ||
| 2606 | + | ||
| 2607 | +ol.analysis-rec-list { | ||
| 2608 | + counter-reset: rec-counter; | ||
| 2609 | +} | ||
| 2610 | + | ||
| 2611 | +ol.analysis-rec-list li { | ||
| 2612 | + counter-increment: rec-counter; | ||
| 2613 | +} | ||
| 2614 | + | ||
| 2615 | +ol.analysis-rec-list li::before { | ||
| 2616 | + content: counter(rec-counter) '.'; | ||
| 2617 | + font-weight: 600; | ||
| 2618 | +} | ||
| 2619 | + | ||
| 2620 | +/* 风险标签 */ | ||
| 2621 | +.risk-tag { | ||
| 2622 | + display: inline-flex; | ||
| 2623 | + align-items: center; | ||
| 2624 | + padding: 2px 8px; | ||
| 2625 | + border-radius: var(--radius-full); | ||
| 2626 | + font-size: 0.7rem; | ||
| 2627 | + font-weight: 600; | ||
| 2628 | + margin-left: var(--spacing-sm); | ||
| 2629 | +} | ||
| 2630 | + | ||
| 2631 | +.risk-tag-high { | ||
| 2632 | + background: rgba(239, 68, 68, 0.15); | ||
| 2633 | + color: var(--color-danger-light); | ||
| 2634 | +} | ||
| 2635 | + | ||
| 2636 | +.risk-tag-medium { | ||
| 2637 | + background: rgba(245, 158, 11, 0.15); | ||
| 2638 | + color: var(--color-warning-light); | ||
| 2639 | +} | ||
| 2640 | + | ||
| 2641 | +.risk-tag-low { | ||
| 2642 | + background: rgba(16, 185, 129, 0.15); | ||
| 2643 | + color: var(--color-success-light); | ||
| 2644 | +} | ||
| 2645 | + | ||
| 2646 | +/* 可折叠推理过程 */ | ||
| 2647 | +.analysis-process-toggle { | ||
| 2648 | + display: flex; | ||
| 2649 | + align-items: center; | ||
| 2650 | + gap: var(--spacing-sm); | ||
| 2651 | + padding: var(--spacing-sm) var(--spacing-md); | ||
| 2652 | + margin-top: var(--spacing-md); | ||
| 2653 | + background: rgba(99, 102, 241, 0.08); | ||
| 2654 | + border: 1px solid rgba(99, 102, 241, 0.2); | ||
| 2655 | + border-radius: var(--radius-md); | ||
| 2656 | + cursor: pointer; | ||
| 2657 | + transition: all var(--transition-fast); | ||
| 2658 | + color: var(--color-primary-light); | ||
| 2659 | + font-size: 0.85rem; | ||
| 2660 | + font-weight: 500; | ||
| 2661 | +} | ||
| 2662 | + | ||
| 2663 | +.analysis-process-toggle:hover { | ||
| 2664 | + background: rgba(99, 102, 241, 0.15); | ||
| 2665 | +} | ||
| 2666 | + | ||
| 2667 | +.analysis-process-toggle svg { | ||
| 2668 | + width: 16px; | ||
| 2669 | + height: 16px; | ||
| 2670 | + transition: transform var(--transition-fast); | ||
| 2671 | +} | ||
| 2672 | + | ||
| 2673 | +.analysis-process-toggle.expanded svg { | ||
| 2674 | + transform: rotate(180deg); | ||
| 2675 | +} | ||
| 2676 | + | ||
| 2677 | +.analysis-process-content { | ||
| 2678 | + display: none; | ||
| 2679 | + margin-top: var(--spacing-md); | ||
| 2680 | + padding: var(--spacing-md); | ||
| 2681 | + background: rgba(30, 41, 59, 0.5); | ||
| 2682 | + border: 1px solid var(--border-color); | ||
| 2683 | + border-radius: var(--radius-md); | ||
| 2684 | + font-size: 0.85rem; | ||
| 2685 | +} | ||
| 2686 | + | ||
| 2687 | +.analysis-process-content.expanded { | ||
| 2688 | + display: block; | ||
| 2689 | +} | ||
| 2690 | + | ||
| 2691 | +.process-section { | ||
| 2692 | + margin-bottom: var(--spacing-md); | ||
| 2693 | + padding-bottom: var(--spacing-md); | ||
| 2694 | + border-bottom: 1px solid var(--border-color); | ||
| 2695 | +} | ||
| 2696 | + | ||
| 2697 | +.process-section:last-child { | ||
| 2698 | + margin-bottom: 0; | ||
| 2699 | + padding-bottom: 0; | ||
| 2700 | + border-bottom: none; | ||
| 2701 | +} | ||
| 2702 | + | ||
| 2703 | +.process-section-title { | ||
| 2704 | + font-size: 0.8rem; | ||
| 2705 | + font-weight: 600; | ||
| 2706 | + color: var(--text-muted); | ||
| 2707 | + text-transform: uppercase; | ||
| 2708 | + letter-spacing: 0.5px; | ||
| 2709 | + margin-bottom: var(--spacing-sm); | ||
| 2710 | +} | ||
| 2711 | + | ||
| 2712 | +.process-item { | ||
| 2713 | + display: flex; | ||
| 2714 | + margin-bottom: var(--spacing-xs); | ||
| 2715 | + line-height: 1.5; | ||
| 2716 | +} | ||
| 2717 | + | ||
| 2718 | +.process-item-label { | ||
| 2719 | + color: var(--text-muted); | ||
| 2720 | + min-width: 120px; | ||
| 2721 | + flex-shrink: 0; | ||
| 2722 | +} | ||
| 2723 | + | ||
| 2724 | +.process-item-value { | ||
| 2725 | + color: var(--text-secondary); | ||
| 2726 | + word-break: break-word; | ||
| 2727 | +} | ||
| 2728 | + | ||
| 2729 | +.process-item-value.highlight { | ||
| 2730 | + color: var(--color-primary-light); | ||
| 2731 | + font-weight: 500; | ||
| 2732 | +} | ||
| 2733 | + | ||
| 2734 | +/* 季节标签 */ | ||
| 2735 | +.season-tag { | ||
| 2736 | + display: inline-flex; | ||
| 2737 | + align-items: center; | ||
| 2738 | + gap: 4px; | ||
| 2739 | + padding: 2px 10px; | ||
| 2740 | + border-radius: var(--radius-full); | ||
| 2741 | + font-size: 0.75rem; | ||
| 2742 | + font-weight: 500; | ||
| 2743 | + background: rgba(59, 130, 246, 0.15); | ||
| 2744 | + color: var(--color-info-light); | ||
| 2745 | + margin-left: var(--spacing-sm); | ||
| 2746 | +} | ||
| 2747 | + | ||
| 2748 | +.season-tag svg { | ||
| 2749 | + width: 12px; | ||
| 2750 | + height: 12px; | ||
| 2751 | +} |
ui/index.html
| @@ -17,6 +17,9 @@ | @@ -17,6 +17,9 @@ | ||
| 17 | <!-- Markdown Parser --> | 17 | <!-- Markdown Parser --> |
| 18 | <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> | 18 | <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> |
| 19 | 19 | ||
| 20 | + <!-- Chart.js --> | ||
| 21 | + <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script> | ||
| 22 | + | ||
| 20 | <!-- Styles --> | 23 | <!-- Styles --> |
| 21 | <link rel="stylesheet" href="/css/style.css"> | 24 | <link rel="stylesheet" href="/css/style.css"> |
| 22 | </head> | 25 | </head> |
ui/js/app.js
| @@ -98,7 +98,7 @@ const App = { | @@ -98,7 +98,7 @@ const App = { | ||
| 98 | 98 | ||
| 99 | 99 | ||
| 100 | /** | 100 | /** |
| 101 | - * 渲染分析报告标签页 | 101 | + * 渲染分析报告标签页(四大板块:库存概览/销量分析/健康度/补货建议) |
| 102 | */ | 102 | */ |
| 103 | async renderReportTab(container, taskNo) { | 103 | async renderReportTab(container, taskNo) { |
| 104 | container.innerHTML = '<div class="loading-shops">加载分析报告...</div>'; | 104 | container.innerHTML = '<div class="loading-shops">加载分析报告...</div>'; |
| @@ -116,46 +116,28 @@ const App = { | @@ -116,46 +116,28 @@ const App = { | ||
| 116 | } | 116 | } |
| 117 | 117 | ||
| 118 | container.innerHTML = ` | 118 | container.innerHTML = ` |
| 119 | - <div class="report-module"> | ||
| 120 | - <div class="report-section-title"> | ||
| 121 | - <i data-lucide="layout-dashboard"></i> | ||
| 122 | - 核心经营综述 | ||
| 123 | - </div> | ||
| 124 | - <div class="report-grid"> | ||
| 125 | - ${this.renderOverallAssessment(report.replenishment_insights)} | ||
| 126 | - </div> | ||
| 127 | - </div> | ||
| 128 | - | ||
| 129 | - <div class="report-module"> | ||
| 130 | - <div class="report-section-title"> | ||
| 131 | - <i data-lucide="alert-triangle"></i> | ||
| 132 | - 风险管控预警 | ||
| 133 | - </div> | ||
| 134 | - <div class="report-grid"> | ||
| 135 | - ${this.renderRiskAlerts(report.urgency_assessment)} | ||
| 136 | - </div> | ||
| 137 | - </div> | ||
| 138 | - | ||
| 139 | - <div class="report-module"> | ||
| 140 | - <div class="report-section-title"> | ||
| 141 | - <i data-lucide="target"></i> | ||
| 142 | - 补货策略建议 | ||
| 143 | - </div> | ||
| 144 | - <div class="report-grid"> | ||
| 145 | - ${this.renderStrategy(report.strategy_recommendations)} | ||
| 146 | - </div> | ||
| 147 | - </div> | ||
| 148 | - | ||
| 149 | - <div class="report-module"> | ||
| 150 | - <div class="report-section-title"> | ||
| 151 | - <i data-lucide="trending-up"></i> | ||
| 152 | - 效果预期与建议 | ||
| 153 | - </div> | ||
| 154 | - <div class="report-grid"> | ||
| 155 | - ${this.renderExpectedImpact(report.expected_outcomes)} | ||
| 156 | - </div> | ||
| 157 | - </div> | 119 | + <div id="report-inventory-overview" class="report-module"></div> |
| 120 | + <div id="report-sales-analysis" class="report-module"></div> | ||
| 121 | + <div id="report-inventory-health" class="report-module"></div> | ||
| 122 | + <div id="report-replenishment-summary" class="report-module"></div> | ||
| 158 | `; | 123 | `; |
| 124 | + | ||
| 125 | + this.renderInventoryOverview( | ||
| 126 | + document.getElementById('report-inventory-overview'), | ||
| 127 | + report.inventory_overview | ||
| 128 | + ); | ||
| 129 | + this.renderSalesAnalysis( | ||
| 130 | + document.getElementById('report-sales-analysis'), | ||
| 131 | + report.sales_analysis | ||
| 132 | + ); | ||
| 133 | + this.renderInventoryHealth( | ||
| 134 | + document.getElementById('report-inventory-health'), | ||
| 135 | + report.inventory_health | ||
| 136 | + ); | ||
| 137 | + this.renderReplenishmentSummary( | ||
| 138 | + document.getElementById('report-replenishment-summary'), | ||
| 139 | + report.replenishment_summary | ||
| 140 | + ); | ||
| 159 | 141 | ||
| 160 | lucide.createIcons(); | 142 | lucide.createIcons(); |
| 161 | } catch (error) { | 143 | } catch (error) { |
| @@ -169,203 +151,835 @@ const App = { | @@ -169,203 +151,835 @@ const App = { | ||
| 169 | } | 151 | } |
| 170 | }, | 152 | }, |
| 171 | 153 | ||
| 172 | - renderOverallAssessment(insights) { | ||
| 173 | - if (!insights) return ''; | ||
| 174 | - | ||
| 175 | - let heroHtml = ''; | 154 | + /** |
| 155 | + * 渲染库存概览板块 | ||
| 156 | + */ | ||
| 157 | + renderInventoryOverview(container, data) { | ||
| 158 | + if (!data) { | ||
| 159 | + container.innerHTML = ''; | ||
| 160 | + return; | ||
| 161 | + } | ||
| 162 | + const stats = data.stats || {}; | ||
| 163 | + const analysis = data.llm_analysis || {}; | ||
| 176 | 164 | ||
| 177 | - // Scale (Hero Main) | ||
| 178 | - if (insights.scale_evaluation) { | ||
| 179 | - heroHtml += ` | ||
| 180 | - <div class="assessment-item"> | ||
| 181 | - <div class="assessment-label">补货规模</div> | ||
| 182 | - <div class="assessment-main">${insights.scale_evaluation.current_vs_historical || '-'}</div> | ||
| 183 | - <div class="assessment-sub">${insights.scale_evaluation.possible_reasons || ''}</div> | ||
| 184 | - </div>`; | 165 | + // 兼容新旧数据结构 |
| 166 | + const conclusion = analysis.conclusion || analysis; | ||
| 167 | + const process = analysis.analysis_process || null; | ||
| 168 | + | ||
| 169 | + const ratio = stats.overall_ratio; | ||
| 170 | + const ratioDisplay = (ratio === 999 || ratio === null || ratio === undefined) ? '无销量' : Components.formatNumber(ratio); | ||
| 171 | + | ||
| 172 | + // LLM 分析文本渲染 | ||
| 173 | + let analysisHtml = ''; | ||
| 174 | + if (analysis.error) { | ||
| 175 | + analysisHtml = `<div class="report-analysis-text"><span style="color:var(--color-warning);">分析生成失败: ${analysis.error}</span></div>`; | ||
| 176 | + } else { | ||
| 177 | + const sections = []; | ||
| 178 | + | ||
| 179 | + // 季节信息(如果有) | ||
| 180 | + if (process && process.seasonal_analysis) { | ||
| 181 | + const sa = process.seasonal_analysis; | ||
| 182 | + sections.push(`<div class="analysis-block"> | ||
| 183 | + <div class="analysis-block-title"><i data-lucide="sun"></i> 季节性分析 <span class="season-tag"><i data-lucide="calendar"></i>${sa.current_season || ''}</span></div> | ||
| 184 | + <p>${sa.season_demand_feature || ''}</p> | ||
| 185 | + <p>${sa.inventory_fitness || ''}</p> | ||
| 186 | + ${sa.upcoming_season_preparation ? `<p>下季准备: ${sa.upcoming_season_preparation}</p>` : ''} | ||
| 187 | + </div>`); | ||
| 188 | + } | ||
| 189 | + | ||
| 190 | + if (conclusion.capital_assessment) { | ||
| 191 | + const ca = conclusion.capital_assessment; | ||
| 192 | + sections.push(`<div class="analysis-block"> | ||
| 193 | + <div class="analysis-block-title"><i data-lucide="wallet"></i> 资金占用评估 <span class="risk-tag risk-tag-${ca.risk_level || 'medium'}">${ca.risk_level === 'high' ? '高风险' : ca.risk_level === 'low' ? '低风险' : '中风险'}</span></div> | ||
| 194 | + <p>${ca.total_evaluation || ''}</p> | ||
| 195 | + <p>${ca.structure_ratio || ''}</p> | ||
| 196 | + </div>`); | ||
| 197 | + } | ||
| 198 | + if (conclusion.ratio_diagnosis) { | ||
| 199 | + const rd = conclusion.ratio_diagnosis; | ||
| 200 | + sections.push(`<div class="analysis-block"> | ||
| 201 | + <div class="analysis-block-title"><i data-lucide="gauge"></i> 库销比诊断 — ${rd.level || ''}</div> | ||
| 202 | + <p>${rd.analysis || ''}</p> | ||
| 203 | + <p class="analysis-benchmark">${rd.benchmark || ''}</p> | ||
| 204 | + </div>`); | ||
| 205 | + } | ||
| 206 | + if (conclusion.recommendations && conclusion.recommendations.length > 0) { | ||
| 207 | + const recHtml = conclusion.recommendations.map(r => { | ||
| 208 | + if (typeof r === 'object') { | ||
| 209 | + return `<li><strong>${r.action || ''}</strong>${r.reason ? ` - ${r.reason}` : ''}${r.expected_effect ? `<br><small>预期效果: ${r.expected_effect}</small>` : ''}</li>`; | ||
| 210 | + } | ||
| 211 | + return `<li>${r}</li>`; | ||
| 212 | + }).join(''); | ||
| 213 | + sections.push(`<div class="analysis-block"> | ||
| 214 | + <div class="analysis-block-title"><i data-lucide="lightbulb"></i> 库存结构建议</div> | ||
| 215 | + <ul class="analysis-rec-list">${recHtml}</ul> | ||
| 216 | + </div>`); | ||
| 217 | + } | ||
| 218 | + | ||
| 219 | + // 推理过程(可折叠) | ||
| 220 | + let processHtml = ''; | ||
| 221 | + if (process) { | ||
| 222 | + processHtml = this._renderAnalysisProcess(process, 'inventory-overview'); | ||
| 223 | + } | ||
| 224 | + | ||
| 225 | + analysisHtml = sections.length > 0 ? `<div class="report-analysis-text">${sections.join('')}${processHtml}</div>` : ''; | ||
| 185 | } | 226 | } |
| 227 | + | ||
| 228 | + container.innerHTML = ` | ||
| 229 | + <div class="report-section-title"> | ||
| 230 | + <i data-lucide="warehouse"></i> | ||
| 231 | + 库存总体概览 | ||
| 232 | + </div> | ||
| 233 | + <div class="report-stat-cards"> | ||
| 234 | + <div class="report-stat-card"> | ||
| 235 | + <div class="report-stat-label">有效库存总数量</div> | ||
| 236 | + <div class="report-stat-value">${Components.formatNumber(stats.total_valid_storage_cnt)}</div> | ||
| 237 | + </div> | ||
| 238 | + <div class="report-stat-card"> | ||
| 239 | + <div class="report-stat-label">资金占用(总金额)</div> | ||
| 240 | + <div class="report-stat-value">${Components.formatAmount(stats.total_valid_storage_amount)}</div> | ||
| 241 | + </div> | ||
| 242 | + <div class="report-stat-card"> | ||
| 243 | + <div class="report-stat-label">整体库销比</div> | ||
| 244 | + <div class="report-stat-value">${ratioDisplay}</div> | ||
| 245 | + </div> | ||
| 246 | + <div class="report-stat-card"> | ||
| 247 | + <div class="report-stat-label">配件种类数</div> | ||
| 248 | + <div class="report-stat-value">${stats.part_count || 0}</div> | ||
| 249 | + </div> | ||
| 250 | + </div> | ||
| 251 | + <div class="report-detail-table"> | ||
| 252 | + <table> | ||
| 253 | + <thead> | ||
| 254 | + <tr> | ||
| 255 | + <th>构成项</th> | ||
| 256 | + <th>数量</th> | ||
| 257 | + <th>金额</th> | ||
| 258 | + </tr> | ||
| 259 | + </thead> | ||
| 260 | + <tbody> | ||
| 261 | + <tr> | ||
| 262 | + <td>在库未锁</td> | ||
| 263 | + <td>${Components.formatNumber(stats.total_in_stock_unlocked_cnt)}</td> | ||
| 264 | + <td>${Components.formatAmount(stats.total_in_stock_unlocked_amount)}</td> | ||
| 265 | + </tr> | ||
| 266 | + <tr> | ||
| 267 | + <td>在途</td> | ||
| 268 | + <td>${Components.formatNumber(stats.total_on_the_way_cnt)}</td> | ||
| 269 | + <td>${Components.formatAmount(stats.total_on_the_way_amount)}</td> | ||
| 270 | + </tr> | ||
| 271 | + <tr> | ||
| 272 | + <td>计划数</td> | ||
| 273 | + <td>${Components.formatNumber(stats.total_has_plan_cnt)}</td> | ||
| 274 | + <td>${Components.formatAmount(stats.total_has_plan_amount)}</td> | ||
| 275 | + </tr> | ||
| 276 | + </tbody> | ||
| 277 | + </table> | ||
| 278 | + </div> | ||
| 279 | + ${analysisHtml} | ||
| 280 | + `; | ||
| 186 | 281 | ||
| 187 | - // Structure (Hero Middle) | ||
| 188 | - if (insights.structure_analysis) { | ||
| 189 | - const data = insights.structure_analysis; | ||
| 190 | - const details = [ | ||
| 191 | - data.category_distribution ? `• ${data.category_distribution}` : '', | ||
| 192 | - data.price_range_distribution ? `• ${data.price_range_distribution}` : '', | ||
| 193 | - data.turnover_distribution ? `• ${data.turnover_distribution}` : '' | ||
| 194 | - ].filter(Boolean).join('<br>'); | ||
| 195 | - | ||
| 196 | - heroHtml += ` | ||
| 197 | - <div class="assessment-item"> | ||
| 198 | - <div class="assessment-label">结构特征</div> | ||
| 199 | - <div class="assessment-main">${data.imbalance_warning || '结构均衡'}</div> | ||
| 200 | - <div class="assessment-sub">${details}</div> | ||
| 201 | - </div>`; | 282 | + // 绑定折叠事件 |
| 283 | + this._bindProcessToggle(container); | ||
| 284 | + }, | ||
| 285 | + | ||
| 286 | + /** | ||
| 287 | + * 渲染销量分析板块 | ||
| 288 | + */ | ||
| 289 | + renderSalesAnalysis(container, data) { | ||
| 290 | + if (!data) { | ||
| 291 | + container.innerHTML = ''; | ||
| 292 | + return; | ||
| 202 | } | 293 | } |
| 294 | + const stats = data.stats || {}; | ||
| 295 | + const analysis = data.llm_analysis || {}; | ||
| 203 | 296 | ||
| 204 | - // Timing (Hero End) | ||
| 205 | - if (insights.timing_judgment) { | ||
| 206 | - const data = insights.timing_judgment; | ||
| 207 | - const isPos = data.is_favorable; | ||
| 208 | - heroHtml += ` | ||
| 209 | - <div class="assessment-item"> | ||
| 210 | - <div class="assessment-label">时机判断</div> | ||
| 211 | - <div class="assessment-main" style="color:${isPos ? 'var(--color-success)' : 'var(--color-warning)'}"> | ||
| 212 | - ${isPos ? '有利时机' : '建议观望'} | 297 | + // 兼容新旧数据结构 |
| 298 | + const conclusion = analysis.conclusion || analysis; | ||
| 299 | + const process = analysis.analysis_process || null; | ||
| 300 | + | ||
| 301 | + // LLM 分析文本 | ||
| 302 | + let analysisHtml = ''; | ||
| 303 | + if (analysis.error) { | ||
| 304 | + analysisHtml = `<div class="report-analysis-text"><span style="color:var(--color-warning);">分析生成失败: ${analysis.error}</span></div>`; | ||
| 305 | + } else { | ||
| 306 | + const sections = []; | ||
| 307 | + | ||
| 308 | + // 季节信息(如果有) | ||
| 309 | + if (process && process.seasonal_analysis) { | ||
| 310 | + const sa = process.seasonal_analysis; | ||
| 311 | + sections.push(`<div class="analysis-block"> | ||
| 312 | + <div class="analysis-block-title"><i data-lucide="sun"></i> 季节性分析 <span class="season-tag"><i data-lucide="calendar"></i>${sa.current_season || ''}</span></div> | ||
| 313 | + <p>${sa.expected_performance || ''}</p> | ||
| 314 | + <p>${sa.actual_vs_expected || ''}</p> | ||
| 315 | + ${sa.seasonal_items_status ? `<p>${sa.seasonal_items_status}</p>` : ''} | ||
| 316 | + </div>`); | ||
| 317 | + } | ||
| 318 | + | ||
| 319 | + if (conclusion.composition_analysis) { | ||
| 320 | + const ca = conclusion.composition_analysis; | ||
| 321 | + sections.push(`<div class="analysis-block"> | ||
| 322 | + <div class="analysis-block-title"><i data-lucide="pie-chart"></i> 销量构成解读</div> | ||
| 323 | + <p>${ca.main_driver || ''}</p> | ||
| 324 | + <p>${ca.pending_orders_impact || ''}</p> | ||
| 325 | + <p>${ca.booking_trend || ''}</p> | ||
| 326 | + </div>`); | ||
| 327 | + } | ||
| 328 | + if (conclusion.activity_assessment) { | ||
| 329 | + const aa = conclusion.activity_assessment; | ||
| 330 | + sections.push(`<div class="analysis-block"> | ||
| 331 | + <div class="analysis-block-title"><i data-lucide="activity"></i> 销售活跃度</div> | ||
| 332 | + <p>${aa.active_ratio || ''}</p> | ||
| 333 | + <p>${aa.optimization_suggestion || ''}</p> | ||
| 334 | + </div>`); | ||
| 335 | + } | ||
| 336 | + if (conclusion.demand_trend) { | ||
| 337 | + const dt = conclusion.demand_trend; | ||
| 338 | + const dirIcon = dt.direction === '上升' ? 'trending-up' : dt.direction === '下降' ? 'trending-down' : 'minus'; | ||
| 339 | + sections.push(`<div class="analysis-block"> | ||
| 340 | + <div class="analysis-block-title"><i data-lucide="${dirIcon}"></i> 需求趋势 — ${dt.direction || ''}</div> | ||
| 341 | + <p>${dt.evidence || ''}</p> | ||
| 342 | + ${dt.seasonal_factor ? `<p>季节因素: ${dt.seasonal_factor}</p>` : ''} | ||
| 343 | + <p>${dt.forecast || ''}</p> | ||
| 344 | + </div>`); | ||
| 345 | + } | ||
| 346 | + | ||
| 347 | + // 推理过程(可折叠) | ||
| 348 | + let processHtml = ''; | ||
| 349 | + if (process) { | ||
| 350 | + processHtml = this._renderAnalysisProcess(process, 'sales-analysis'); | ||
| 351 | + } | ||
| 352 | + | ||
| 353 | + analysisHtml = sections.length > 0 ? `<div class="report-analysis-text">${sections.join('')}${processHtml}</div>` : ''; | ||
| 354 | + } | ||
| 355 | + | ||
| 356 | + const totalParts = (stats.has_sales_part_count || 0) + (stats.no_sales_part_count || 0); | ||
| 357 | + | ||
| 358 | + container.innerHTML = ` | ||
| 359 | + <div class="report-section-title"> | ||
| 360 | + <i data-lucide="bar-chart-3"></i> | ||
| 361 | + 销量分析 | ||
| 362 | + </div> | ||
| 363 | + <div class="report-stat-cards"> | ||
| 364 | + <div class="report-stat-card"> | ||
| 365 | + <div class="report-stat-label">月均销量总数量</div> | ||
| 366 | + <div class="report-stat-value">${Components.formatNumber(stats.total_avg_sales_cnt)}</div> | ||
| 213 | </div> | 367 | </div> |
| 214 | - <div class="assessment-sub"> | ||
| 215 | - ${data.recommendation}<br> | ||
| 216 | - <span style="opacity:0.7;font-size:0.85em;display:block;margin-top:4px;">${data.timing_factors || ''}</span> | 368 | + <div class="report-stat-card"> |
| 369 | + <div class="report-stat-label">月均销量总金额</div> | ||
| 370 | + <div class="report-stat-value">${Components.formatAmount(stats.total_avg_sales_amount)}</div> | ||
| 217 | </div> | 371 | </div> |
| 218 | - </div>`; | ||
| 219 | - } | 372 | + <div class="report-stat-card"> |
| 373 | + <div class="report-stat-label">有销量配件</div> | ||
| 374 | + <div class="report-stat-value">${stats.has_sales_part_count || 0} <span class="report-stat-sub">/ ${totalParts}</span></div> | ||
| 375 | + </div> | ||
| 376 | + <div class="report-stat-card"> | ||
| 377 | + <div class="report-stat-label">无销量配件</div> | ||
| 378 | + <div class="report-stat-value">${stats.no_sales_part_count || 0} <span class="report-stat-sub">/ ${totalParts}</span></div> | ||
| 379 | + </div> | ||
| 380 | + </div> | ||
| 381 | + <div class="report-detail-table"> | ||
| 382 | + <table> | ||
| 383 | + <thead> | ||
| 384 | + <tr> | ||
| 385 | + <th>构成项</th> | ||
| 386 | + <th>总量</th> | ||
| 387 | + </tr> | ||
| 388 | + </thead> | ||
| 389 | + <tbody> | ||
| 390 | + <tr> | ||
| 391 | + <td>90天出库数</td> | ||
| 392 | + <td>${Components.formatNumber(stats.total_out_stock_cnt)}</td> | ||
| 393 | + </tr> | ||
| 394 | + <tr> | ||
| 395 | + <td>未关单已锁</td> | ||
| 396 | + <td>${Components.formatNumber(stats.total_storage_locked_cnt)}</td> | ||
| 397 | + </tr> | ||
| 398 | + <tr> | ||
| 399 | + <td>未关单出库</td> | ||
| 400 | + <td>${Components.formatNumber(stats.total_out_stock_ongoing_cnt)}</td> | ||
| 401 | + </tr> | ||
| 402 | + <tr> | ||
| 403 | + <td>订件</td> | ||
| 404 | + <td>${Components.formatNumber(stats.total_buy_cnt)}</td> | ||
| 405 | + </tr> | ||
| 406 | + </tbody> | ||
| 407 | + </table> | ||
| 408 | + </div> | ||
| 409 | + ${analysisHtml} | ||
| 410 | + `; | ||
| 220 | 411 | ||
| 221 | - return `<div class="assessment-grid">${heroHtml}</div>`; | 412 | + // 绑定折叠事件 |
| 413 | + this._bindProcessToggle(container); | ||
| 222 | }, | 414 | }, |
| 223 | 415 | ||
| 224 | - renderRiskAlerts(risks) { | ||
| 225 | - if (!risks) return ''; | ||
| 226 | - | ||
| 227 | - let feedHtml = '<div class="risk-feed">'; | 416 | + /** |
| 417 | + * 渲染库存健康度板块(含 Chart.js 环形图) | ||
| 418 | + */ | ||
| 419 | + renderInventoryHealth(container, data) { | ||
| 420 | + if (!data) { | ||
| 421 | + container.innerHTML = ''; | ||
| 422 | + return; | ||
| 423 | + } | ||
| 424 | + const stats = data.stats || {}; | ||
| 425 | + const chartData = data.chart_data || {}; | ||
| 426 | + const analysis = data.llm_analysis || {}; | ||
| 228 | 427 | ||
| 229 | - const addRiskItem = (level, type, desc, action) => { | ||
| 230 | - let icon = 'alert-circle'; | ||
| 231 | - if (level === 'high') icon = 'alert-octagon'; | ||
| 232 | - if (level === 'low') icon = 'info'; | 428 | + // 兼容新旧数据结构 |
| 429 | + const conclusion = analysis.conclusion || analysis; | ||
| 430 | + const process = analysis.analysis_process || null; | ||
| 431 | + | ||
| 432 | + const shortage = stats.shortage || {}; | ||
| 433 | + const stagnant = stats.stagnant || {}; | ||
| 434 | + const low_freq = stats.low_freq || {}; | ||
| 435 | + const normal = stats.normal || {}; | ||
| 436 | + | ||
| 437 | + // LLM 分析文本 | ||
| 438 | + let analysisHtml = ''; | ||
| 439 | + if (analysis.error) { | ||
| 440 | + analysisHtml = `<div class="report-analysis-text"><span style="color:var(--color-warning);">分析生成失败: ${analysis.error}</span></div>`; | ||
| 441 | + } else { | ||
| 442 | + const sections = []; | ||
| 233 | 443 | ||
| 234 | - feedHtml += ` | ||
| 235 | - <div class="risk-item ${level}"> | ||
| 236 | - <div class="risk-icon"> | ||
| 237 | - <i data-lucide="${icon}"></i> | 444 | + // 季节信息(如果有) |
| 445 | + if (process && process.seasonal_analysis) { | ||
| 446 | + const sa = process.seasonal_analysis; | ||
| 447 | + sections.push(`<div class="analysis-block"> | ||
| 448 | + <div class="analysis-block-title"><i data-lucide="sun"></i> 季节性分析 <span class="season-tag"><i data-lucide="calendar"></i>${sa.current_season || ''}</span></div> | ||
| 449 | + ${sa.seasonal_stagnant_items ? `<p>${sa.seasonal_stagnant_items}</p>` : ''} | ||
| 450 | + ${sa.seasonal_shortage_risk ? `<p>${sa.seasonal_shortage_risk}</p>` : ''} | ||
| 451 | + ${sa.upcoming_season_alert ? `<p>下季关注: ${sa.upcoming_season_alert}</p>` : ''} | ||
| 452 | + </div>`); | ||
| 453 | + } | ||
| 454 | + | ||
| 455 | + if (conclusion.health_score) { | ||
| 456 | + const hs = conclusion.health_score; | ||
| 457 | + sections.push(`<div class="analysis-block"> | ||
| 458 | + <div class="analysis-block-title"><i data-lucide="heart-pulse"></i> 健康度评分 — ${hs.score || ''}</div> | ||
| 459 | + <p>${hs.normal_ratio_evaluation || ''}</p> | ||
| 460 | + </div>`); | ||
| 461 | + } | ||
| 462 | + if (conclusion.problem_diagnosis) { | ||
| 463 | + const pd = conclusion.problem_diagnosis; | ||
| 464 | + sections.push(`<div class="analysis-block"> | ||
| 465 | + <div class="analysis-block-title"><i data-lucide="stethoscope"></i> 问题诊断</div> | ||
| 466 | + ${pd.stagnant_analysis ? `<p>呆滞件: ${pd.stagnant_analysis}</p>` : ''} | ||
| 467 | + ${pd.shortage_analysis ? `<p>缺货件: ${pd.shortage_analysis}</p>` : ''} | ||
| 468 | + ${pd.low_freq_analysis ? `<p>低频件: ${pd.low_freq_analysis}</p>` : ''} | ||
| 469 | + </div>`); | ||
| 470 | + } | ||
| 471 | + if (conclusion.capital_release) { | ||
| 472 | + const cr = conclusion.capital_release; | ||
| 473 | + sections.push(`<div class="analysis-block"> | ||
| 474 | + <div class="analysis-block-title"><i data-lucide="banknote"></i> 资金释放机会</div> | ||
| 475 | + ${cr.stagnant_releasable ? `<p>呆滞件可释放: ${cr.stagnant_releasable}</p>` : ''} | ||
| 476 | + ${cr.low_freq_releasable ? `<p>低频件可释放: ${cr.low_freq_releasable}</p>` : ''} | ||
| 477 | + ${cr.action_plan ? `<p>${cr.action_plan}</p>` : ''} | ||
| 478 | + </div>`); | ||
| 479 | + } | ||
| 480 | + if (conclusion.priority_actions && conclusion.priority_actions.length > 0) { | ||
| 481 | + const actHtml = conclusion.priority_actions.map(a => { | ||
| 482 | + if (typeof a === 'object') { | ||
| 483 | + return `<li><strong>${a.action || ''}</strong>${a.reason ? ` - ${a.reason}` : ''}${a.expected_effect ? `<br><small>预期效果: ${a.expected_effect}</small>` : ''}</li>`; | ||
| 484 | + } | ||
| 485 | + return `<li>${a}</li>`; | ||
| 486 | + }).join(''); | ||
| 487 | + sections.push(`<div class="analysis-block"> | ||
| 488 | + <div class="analysis-block-title"><i data-lucide="list-ordered"></i> 改善优先级</div> | ||
| 489 | + <ol class="analysis-rec-list">${actHtml}</ol> | ||
| 490 | + </div>`); | ||
| 491 | + } | ||
| 492 | + | ||
| 493 | + // 推理过程(可折叠) | ||
| 494 | + let processHtml = ''; | ||
| 495 | + if (process) { | ||
| 496 | + processHtml = this._renderAnalysisProcess(process, 'inventory-health'); | ||
| 497 | + } | ||
| 498 | + | ||
| 499 | + analysisHtml = sections.length > 0 ? `<div class="report-analysis-text">${sections.join('')}${processHtml}</div>` : ''; | ||
| 500 | + } | ||
| 501 | + | ||
| 502 | + container.innerHTML = ` | ||
| 503 | + <div class="report-section-title"> | ||
| 504 | + <i data-lucide="heart-pulse"></i> | ||
| 505 | + 库存构成健康度 | ||
| 506 | + </div> | ||
| 507 | + <div class="report-stat-cards report-stat-cards-4"> | ||
| 508 | + <div class="report-stat-card report-stat-card-danger"> | ||
| 509 | + <div class="report-stat-label">缺货件</div> | ||
| 510 | + <div class="report-stat-value">${shortage.count || 0}</div> | ||
| 511 | + <div class="report-stat-pct">${Components.formatNumber(shortage.count_pct)}% · ${Components.formatAmount(shortage.amount)}</div> | ||
| 238 | </div> | 512 | </div> |
| 239 | - <div class="risk-content"> | ||
| 240 | - <div class="risk-title"> | ||
| 241 | - ${type} | ||
| 242 | - <span class="badgex">${level.toUpperCase()}</span> | ||
| 243 | - </div> | ||
| 244 | - <div class="risk-desc">${desc}</div> | ||
| 245 | - ${action ? `<div class="risk-action"><i data-lucide="arrow-right-circle" style="width:14px;"></i> ${action}</div>` : ''} | 513 | + <div class="report-stat-card report-stat-card-warning"> |
| 514 | + <div class="report-stat-label">呆滞件</div> | ||
| 515 | + <div class="report-stat-value">${stagnant.count || 0}</div> | ||
| 516 | + <div class="report-stat-pct">${Components.formatNumber(stagnant.count_pct)}% · ${Components.formatAmount(stagnant.amount)}</div> | ||
| 246 | </div> | 517 | </div> |
| 247 | - </div>`; | ||
| 248 | - }; | 518 | + <div class="report-stat-card report-stat-card-info"> |
| 519 | + <div class="report-stat-label">低频件</div> | ||
| 520 | + <div class="report-stat-value">${low_freq.count || 0}</div> | ||
| 521 | + <div class="report-stat-pct">${Components.formatNumber(low_freq.count_pct)}% · ${Components.formatAmount(low_freq.amount)}</div> | ||
| 522 | + </div> | ||
| 523 | + <div class="report-stat-card report-stat-card-success"> | ||
| 524 | + <div class="report-stat-label">正常件</div> | ||
| 525 | + <div class="report-stat-value">${normal.count || 0}</div> | ||
| 526 | + <div class="report-stat-pct">${Components.formatNumber(normal.count_pct)}% · ${Components.formatAmount(normal.amount)}</div> | ||
| 527 | + </div> | ||
| 528 | + </div> | ||
| 529 | + <div class="report-charts-row"> | ||
| 530 | + <div class="report-chart-container"> | ||
| 531 | + <div class="report-chart-title">数量占比</div> | ||
| 532 | + <canvas id="health-count-chart"></canvas> | ||
| 533 | + </div> | ||
| 534 | + <div class="report-chart-container"> | ||
| 535 | + <div class="report-chart-title">金额占比</div> | ||
| 536 | + <canvas id="health-amount-chart"></canvas> | ||
| 537 | + </div> | ||
| 538 | + </div> | ||
| 539 | + ${analysisHtml} | ||
| 540 | + `; | ||
| 541 | + | ||
| 542 | + // 渲染 Chart.js 环形图 | ||
| 543 | + this._renderHealthCharts(chartData); | ||
| 249 | 544 | ||
| 250 | - // Supply Risks | ||
| 251 | - if (risks.supply_risks && Array.isArray(risks.supply_risks)) { | ||
| 252 | - risks.supply_risks.forEach(r => addRiskItem( | ||
| 253 | - r.likelihood === '高' ? 'high' : 'medium', | ||
| 254 | - r.risk_type || '供应风险', | ||
| 255 | - r.affected_scope, | ||
| 256 | - r.mitigation | ||
| 257 | - )); | 545 | + // 绑定折叠事件 |
| 546 | + this._bindProcessToggle(container); | ||
| 547 | + }, | ||
| 548 | + | ||
| 549 | + /** | ||
| 550 | + * 渲染健康度环形图 | ||
| 551 | + */ | ||
| 552 | + _renderHealthCharts(chartData) { | ||
| 553 | + if (!chartData || !chartData.labels) return; | ||
| 554 | + if (typeof Chart === 'undefined') return; | ||
| 555 | + | ||
| 556 | + const colors = ['#ef4444', '#f59e0b', '#3b82f6', '#10b981']; | ||
| 557 | + const borderColors = ['#dc2626', '#d97706', '#2563eb', '#059669']; | ||
| 558 | + | ||
| 559 | + const chartOptions = { | ||
| 560 | + responsive: true, | ||
| 561 | + maintainAspectRatio: true, | ||
| 562 | + plugins: { | ||
| 563 | + legend: { | ||
| 564 | + position: 'bottom', | ||
| 565 | + labels: { | ||
| 566 | + color: '#94a3b8', | ||
| 567 | + padding: 16, | ||
| 568 | + usePointStyle: true, | ||
| 569 | + pointStyleWidth: 10, | ||
| 570 | + font: { size: 12 } | ||
| 571 | + } | ||
| 572 | + }, | ||
| 573 | + tooltip: { | ||
| 574 | + backgroundColor: '#1e293b', | ||
| 575 | + titleColor: '#f8fafc', | ||
| 576 | + bodyColor: '#94a3b8', | ||
| 577 | + borderColor: 'rgba(148,163,184,0.2)', | ||
| 578 | + borderWidth: 1 | ||
| 579 | + } | ||
| 580 | + }, | ||
| 581 | + cutout: '60%' | ||
| 582 | + }; | ||
| 583 | + | ||
| 584 | + // 数量占比图 | ||
| 585 | + const countCtx = document.getElementById('health-count-chart'); | ||
| 586 | + if (countCtx) { | ||
| 587 | + new Chart(countCtx, { | ||
| 588 | + type: 'doughnut', | ||
| 589 | + data: { | ||
| 590 | + labels: chartData.labels, | ||
| 591 | + datasets: [{ | ||
| 592 | + data: chartData.count_values, | ||
| 593 | + backgroundColor: colors, | ||
| 594 | + borderColor: borderColors, | ||
| 595 | + borderWidth: 2 | ||
| 596 | + }] | ||
| 597 | + }, | ||
| 598 | + options: chartOptions | ||
| 599 | + }); | ||
| 258 | } | 600 | } |
| 259 | - | ||
| 260 | - // Capital Risks | ||
| 261 | - if (risks.capital_risks) { | ||
| 262 | - const data = risks.capital_risks; | ||
| 263 | - addRiskItem('medium', '资金风险', data.cash_flow_pressure, data.recommendation); | 601 | + |
| 602 | + // 金额占比图 | ||
| 603 | + const amountCtx = document.getElementById('health-amount-chart'); | ||
| 604 | + if (amountCtx) { | ||
| 605 | + new Chart(amountCtx, { | ||
| 606 | + type: 'doughnut', | ||
| 607 | + data: { | ||
| 608 | + labels: chartData.labels, | ||
| 609 | + datasets: [{ | ||
| 610 | + data: chartData.amount_values, | ||
| 611 | + backgroundColor: colors, | ||
| 612 | + borderColor: borderColors, | ||
| 613 | + borderWidth: 2 | ||
| 614 | + }] | ||
| 615 | + }, | ||
| 616 | + options: { | ||
| 617 | + ...chartOptions, | ||
| 618 | + plugins: { | ||
| 619 | + ...chartOptions.plugins, | ||
| 620 | + tooltip: { | ||
| 621 | + ...chartOptions.plugins.tooltip, | ||
| 622 | + callbacks: { | ||
| 623 | + label: function(context) { | ||
| 624 | + const value = context.parsed; | ||
| 625 | + return ` ${context.label}: ¥${Number(value).toLocaleString('zh-CN', {minimumFractionDigits: 2})}`; | ||
| 626 | + } | ||
| 627 | + } | ||
| 628 | + } | ||
| 629 | + } | ||
| 630 | + } | ||
| 631 | + }); | ||
| 264 | } | 632 | } |
| 633 | + }, | ||
| 265 | 634 | ||
| 266 | - // Market Risks | ||
| 267 | - if (risks.market_risks && Array.isArray(risks.market_risks)) { | ||
| 268 | - risks.market_risks.forEach(r => addRiskItem('medium', '市场风险', r.risk_description, r.recommendation)); | 635 | + /** |
| 636 | + * 渲染补货建议板块 | ||
| 637 | + */ | ||
| 638 | + renderReplenishmentSummary(container, data) { | ||
| 639 | + if (!data) { | ||
| 640 | + container.innerHTML = ''; | ||
| 641 | + return; | ||
| 269 | } | 642 | } |
| 643 | + const stats = data.stats || {}; | ||
| 644 | + const analysis = data.llm_analysis || {}; | ||
| 270 | 645 | ||
| 271 | - // Execution | ||
| 272 | - if (risks.execution_anomalies && Array.isArray(risks.execution_anomalies)) { | ||
| 273 | - risks.execution_anomalies.forEach(a => addRiskItem('high', a.anomaly_type || '执行异常', a.description, a.review_suggestion)); | 646 | + // 兼容新旧数据结构 |
| 647 | + const conclusion = analysis.conclusion || analysis; | ||
| 648 | + const process = analysis.analysis_process || null; | ||
| 649 | + | ||
| 650 | + const urgent = stats.urgent || {}; | ||
| 651 | + const suggested = stats.suggested || {}; | ||
| 652 | + const optional = stats.optional || {}; | ||
| 653 | + | ||
| 654 | + // LLM 分析文本 | ||
| 655 | + let analysisHtml = ''; | ||
| 656 | + if (analysis.error) { | ||
| 657 | + analysisHtml = `<div class="report-analysis-text"><span style="color:var(--color-warning);">分析生成失败: ${analysis.error}</span></div>`; | ||
| 658 | + } else { | ||
| 659 | + const sections = []; | ||
| 660 | + | ||
| 661 | + // 季节信息(如果有) | ||
| 662 | + if (process && process.seasonal_analysis) { | ||
| 663 | + const sa = process.seasonal_analysis; | ||
| 664 | + sections.push(`<div class="analysis-block"> | ||
| 665 | + <div class="analysis-block-title"><i data-lucide="sun"></i> 季节性分析 <span class="season-tag"><i data-lucide="calendar"></i>${sa.current_season || ''}</span></div> | ||
| 666 | + ${sa.seasonal_priority_items ? `<p>${sa.seasonal_priority_items}</p>` : ''} | ||
| 667 | + ${sa.timeline_adjustment ? `<p>${sa.timeline_adjustment}</p>` : ''} | ||
| 668 | + ${sa.next_season_preparation ? `<p>下季准备: ${sa.next_season_preparation}</p>` : ''} | ||
| 669 | + </div>`); | ||
| 670 | + } | ||
| 671 | + | ||
| 672 | + if (conclusion.urgency_assessment) { | ||
| 673 | + const ua = conclusion.urgency_assessment; | ||
| 674 | + const riskTag = ua.risk_level === 'high' ? '高风险' : ua.risk_level === 'low' ? '低风险' : '中风险'; | ||
| 675 | + sections.push(`<div class="analysis-block"> | ||
| 676 | + <div class="analysis-block-title"><i data-lucide="alert-triangle"></i> 紧迫度评估 <span class="risk-tag risk-tag-${ua.risk_level || 'medium'}">${riskTag}</span></div> | ||
| 677 | + <p>${ua.urgent_ratio_evaluation || ''}</p> | ||
| 678 | + ${ua.immediate_action_needed ? '<p style="color:var(--color-danger);font-weight:600;">需要立即采取行动</p>' : ''} | ||
| 679 | + </div>`); | ||
| 680 | + } | ||
| 681 | + if (conclusion.budget_allocation) { | ||
| 682 | + const ba = conclusion.budget_allocation; | ||
| 683 | + sections.push(`<div class="analysis-block"> | ||
| 684 | + <div class="analysis-block-title"><i data-lucide="wallet"></i> 资金分配建议</div> | ||
| 685 | + <p>${ba.recommended_order || ''}</p> | ||
| 686 | + ${ba.urgent_budget ? `<p>急需补货预算: ${ba.urgent_budget}</p>` : ''} | ||
| 687 | + ${ba.suggested_budget ? `<p>建议补货预算: ${ba.suggested_budget}</p>` : ''} | ||
| 688 | + ${ba.optional_budget ? `<p>可选补货预算: ${ba.optional_budget}</p>` : ''} | ||
| 689 | + </div>`); | ||
| 690 | + } | ||
| 691 | + if (conclusion.execution_plan) { | ||
| 692 | + const ep = conclusion.execution_plan; | ||
| 693 | + sections.push(`<div class="analysis-block"> | ||
| 694 | + <div class="analysis-block-title"><i data-lucide="calendar-clock"></i> 执行节奏建议</div> | ||
| 695 | + ${ep.urgent_timeline ? `<p>急需: ${ep.urgent_timeline}</p>` : ''} | ||
| 696 | + ${ep.suggested_timeline ? `<p>建议: ${ep.suggested_timeline}</p>` : ''} | ||
| 697 | + ${ep.optional_timeline ? `<p>可选: ${ep.optional_timeline}</p>` : ''} | ||
| 698 | + </div>`); | ||
| 699 | + } | ||
| 700 | + if (conclusion.risk_warnings && conclusion.risk_warnings.length > 0) { | ||
| 701 | + const warnHtml = conclusion.risk_warnings.map(w => { | ||
| 702 | + if (typeof w === 'object') { | ||
| 703 | + return `<li><strong>${w.risk_type || ''}</strong>: ${w.description || ''}${w.mitigation ? `<br><small>应对: ${w.mitigation}</small>` : ''}</li>`; | ||
| 704 | + } | ||
| 705 | + return `<li>${w}</li>`; | ||
| 706 | + }).join(''); | ||
| 707 | + sections.push(`<div class="analysis-block"> | ||
| 708 | + <div class="analysis-block-title"><i data-lucide="shield-alert"></i> 风险提示</div> | ||
| 709 | + <ul class="analysis-rec-list">${warnHtml}</ul> | ||
| 710 | + </div>`); | ||
| 711 | + } | ||
| 712 | + | ||
| 713 | + // 推理过程(可折叠) | ||
| 714 | + let processHtml = ''; | ||
| 715 | + if (process) { | ||
| 716 | + processHtml = this._renderAnalysisProcess(process, 'replenishment-summary'); | ||
| 717 | + } | ||
| 718 | + | ||
| 719 | + analysisHtml = sections.length > 0 ? `<div class="report-analysis-text">${sections.join('')}${processHtml}</div>` : ''; | ||
| 274 | } | 720 | } |
| 721 | + | ||
| 722 | + container.innerHTML = ` | ||
| 723 | + <div class="report-section-title"> | ||
| 724 | + <i data-lucide="shopping-cart"></i> | ||
| 725 | + 补货建议生成情况 | ||
| 726 | + </div> | ||
| 727 | + <div class="report-detail-table"> | ||
| 728 | + <table> | ||
| 729 | + <thead> | ||
| 730 | + <tr> | ||
| 731 | + <th>优先级</th> | ||
| 732 | + <th>配件种类数</th> | ||
| 733 | + <th>建议补货金额</th> | ||
| 734 | + </tr> | ||
| 735 | + </thead> | ||
| 736 | + <tbody> | ||
| 737 | + <tr> | ||
| 738 | + <td><span class="priority-badge priority-high">急需补货</span></td> | ||
| 739 | + <td>${urgent.count || 0}</td> | ||
| 740 | + <td class="table-cell-amount">${Components.formatAmount(urgent.amount)}</td> | ||
| 741 | + </tr> | ||
| 742 | + <tr> | ||
| 743 | + <td><span class="priority-badge priority-medium">建议补货</span></td> | ||
| 744 | + <td>${suggested.count || 0}</td> | ||
| 745 | + <td class="table-cell-amount">${Components.formatAmount(suggested.amount)}</td> | ||
| 746 | + </tr> | ||
| 747 | + <tr> | ||
| 748 | + <td><span class="priority-badge priority-low">可选补货</span></td> | ||
| 749 | + <td>${optional.count || 0}</td> | ||
| 750 | + <td class="table-cell-amount">${Components.formatAmount(optional.amount)}</td> | ||
| 751 | + </tr> | ||
| 752 | + <tr style="font-weight:600; border-top: 2px solid var(--border-color-light);"> | ||
| 753 | + <td>合计</td> | ||
| 754 | + <td>${stats.total_count || 0}</td> | ||
| 755 | + <td class="table-cell-amount">${Components.formatAmount(stats.total_amount)}</td> | ||
| 756 | + </tr> | ||
| 757 | + </tbody> | ||
| 758 | + </table> | ||
| 759 | + </div> | ||
| 760 | + ${analysisHtml} | ||
| 761 | + `; | ||
| 275 | 762 | ||
| 276 | - feedHtml += '</div>'; | ||
| 277 | - return feedHtml; | 763 | + // 绑定折叠事件 |
| 764 | + this._bindProcessToggle(container); | ||
| 278 | }, | 765 | }, |
| 279 | 766 | ||
| 280 | - renderStrategy(strategy) { | ||
| 281 | - if (!strategy) return ''; | 767 | + /** |
| 768 | + * 渲染推理过程(可折叠) | ||
| 769 | + */ | ||
| 770 | + _renderAnalysisProcess(process, moduleId) { | ||
| 771 | + if (!process) return ''; | ||
| 282 | 772 | ||
| 283 | - let html = '<div class="strategy-steps">'; | 773 | + const sections = []; |
| 284 | 774 | ||
| 285 | - const addStep = (num, title, items) => { | ||
| 286 | - const listItems = Array.isArray(items) ? items : [items]; | ||
| 287 | - const listHtml = listItems.map(i => `<li>${i}</li>`).join(''); | ||
| 288 | - html += ` | ||
| 289 | - <div class="strategy-step"> | ||
| 290 | - <div class="strategy-number">0${num}</div> | ||
| 291 | - <div class="strategy-title">${title}</div> | ||
| 292 | - <ul class="strategy-list">${listHtml}</ul> | ||
| 293 | - </div>`; | ||
| 294 | - }; | 775 | + // 计算指标 |
| 776 | + if (process.calculated_metrics) { | ||
| 777 | + const items = Object.entries(process.calculated_metrics) | ||
| 778 | + .filter(([k, v]) => v && v !== '') | ||
| 779 | + .map(([k, v]) => `<div class="process-item"><span class="process-item-label">${this._formatProcessKey(k)}</span><span class="process-item-value">${v}</span></div>`) | ||
| 780 | + .join(''); | ||
| 781 | + if (items) { | ||
| 782 | + sections.push(`<div class="process-section"><div class="process-section-title">计算指标</div>${items}</div>`); | ||
| 783 | + } | ||
| 784 | + } | ||
| 295 | 785 | ||
| 296 | - // 1. Priority | ||
| 297 | - if (strategy.priority_principle) { | ||
| 298 | - const p = strategy.priority_principle; | ||
| 299 | - addStep(1, '优先级排序', [ | ||
| 300 | - `<strong style="color:var(--color-danger)">P1:</strong> ${p.tier1_criteria}`, | ||
| 301 | - `<strong style="color:var(--color-warning)">P2:</strong> ${p.tier2_criteria}`, | ||
| 302 | - `<span style="opacity:0.7">P3: ${p.tier3_criteria}</span>` | ||
| 303 | - ]); | 786 | + // 库销比诊断 |
| 787 | + if (process.ratio_diagnosis) { | ||
| 788 | + const rd = process.ratio_diagnosis; | ||
| 789 | + const items = []; | ||
| 790 | + if (rd.current_value) items.push(`<div class="process-item"><span class="process-item-label">当前值</span><span class="process-item-value highlight">${rd.current_value}</span></div>`); | ||
| 791 | + if (rd.level) items.push(`<div class="process-item"><span class="process-item-label">判断等级</span><span class="process-item-value highlight">${rd.level}</span></div>`); | ||
| 792 | + if (rd.reasoning) items.push(`<div class="process-item"><span class="process-item-label">判断依据</span><span class="process-item-value">${rd.reasoning}</span></div>`); | ||
| 793 | + if (rd.benchmark_comparison) items.push(`<div class="process-item"><span class="process-item-label">行业对比</span><span class="process-item-value">${rd.benchmark_comparison}</span></div>`); | ||
| 794 | + if (items.length > 0) { | ||
| 795 | + sections.push(`<div class="process-section"><div class="process-section-title">库销比诊断</div>${items.join('')}</div>`); | ||
| 796 | + } | ||
| 304 | } | 797 | } |
| 305 | 798 | ||
| 306 | - // 2. Phased | ||
| 307 | - if (strategy.phased_procurement) { | ||
| 308 | - addStep(2, '分批节奏', [ | ||
| 309 | - `节奏: ${strategy.phased_procurement.suggested_rhythm}`, | ||
| 310 | - `范围: ${strategy.phased_procurement.recommended_parts}` | ||
| 311 | - ]); | 799 | + // 结构分析 |
| 800 | + if (process.structure_analysis) { | ||
| 801 | + const sa = process.structure_analysis; | ||
| 802 | + const items = []; | ||
| 803 | + if (sa.in_stock_evaluation) items.push(`<div class="process-item"><span class="process-item-label">在库未锁</span><span class="process-item-value">${sa.in_stock_evaluation}</span></div>`); | ||
| 804 | + if (sa.on_way_evaluation) items.push(`<div class="process-item"><span class="process-item-label">在途</span><span class="process-item-value">${sa.on_way_evaluation}</span></div>`); | ||
| 805 | + if (sa.plan_evaluation) items.push(`<div class="process-item"><span class="process-item-label">计划数</span><span class="process-item-value">${sa.plan_evaluation}</span></div>`); | ||
| 806 | + if (sa.abnormal_items && sa.abnormal_items.length > 0) { | ||
| 807 | + items.push(`<div class="process-item"><span class="process-item-label">异常项</span><span class="process-item-value">${sa.abnormal_items.join('; ')}</span></div>`); | ||
| 808 | + } | ||
| 809 | + if (items.length > 0) { | ||
| 810 | + sections.push(`<div class="process-section"><div class="process-section-title">结构分析</div>${items.join('')}</div>`); | ||
| 811 | + } | ||
| 312 | } | 812 | } |
| 313 | - | ||
| 314 | - // 3. Coordination | ||
| 315 | - if (strategy.supplier_coordination) { | ||
| 316 | - addStep(3, '供应商协同', [ | ||
| 317 | - strategy.supplier_coordination.key_communications, | ||
| 318 | - `时机: ${strategy.supplier_coordination.timing_suggestions}` | ||
| 319 | - ]); | 813 | + |
| 814 | + // 构成诊断(销量分析) | ||
| 815 | + if (process.composition_diagnosis) { | ||
| 816 | + const cd = process.composition_diagnosis; | ||
| 817 | + const items = []; | ||
| 818 | + if (cd.out_stock_evaluation) items.push(`<div class="process-item"><span class="process-item-label">90天出库</span><span class="process-item-value">${cd.out_stock_evaluation}</span></div>`); | ||
| 819 | + if (cd.locked_evaluation) items.push(`<div class="process-item"><span class="process-item-label">未关单已锁</span><span class="process-item-value">${cd.locked_evaluation}</span></div>`); | ||
| 820 | + if (cd.ongoing_evaluation) items.push(`<div class="process-item"><span class="process-item-label">未关单出库</span><span class="process-item-value">${cd.ongoing_evaluation}</span></div>`); | ||
| 821 | + if (cd.buy_evaluation) items.push(`<div class="process-item"><span class="process-item-label">订件</span><span class="process-item-value">${cd.buy_evaluation}</span></div>`); | ||
| 822 | + if (items.length > 0) { | ||
| 823 | + sections.push(`<div class="process-section"><div class="process-section-title">构成诊断</div>${items.join('')}</div>`); | ||
| 824 | + } | ||
| 320 | } | 825 | } |
| 321 | 826 | ||
| 322 | - html += '</div>'; | ||
| 323 | - return html; | ||
| 324 | - }, | ||
| 325 | - | ||
| 326 | - renderExpectedImpact(impact) { | ||
| 327 | - if (!impact) return ''; | 827 | + // 活跃度诊断 |
| 828 | + if (process.activity_diagnosis) { | ||
| 829 | + const ad = process.activity_diagnosis; | ||
| 830 | + const items = []; | ||
| 831 | + if (ad.current_rate) items.push(`<div class="process-item"><span class="process-item-label">当前活跃率</span><span class="process-item-value highlight">${ad.current_rate}</span></div>`); | ||
| 832 | + if (ad.level) items.push(`<div class="process-item"><span class="process-item-label">判断等级</span><span class="process-item-value highlight">${ad.level}</span></div>`); | ||
| 833 | + if (ad.reasoning) items.push(`<div class="process-item"><span class="process-item-label">判断依据</span><span class="process-item-value">${ad.reasoning}</span></div>`); | ||
| 834 | + if (items.length > 0) { | ||
| 835 | + sections.push(`<div class="process-section"><div class="process-section-title">活跃度诊断</div>${items.join('')}</div>`); | ||
| 836 | + } | ||
| 837 | + } | ||
| 328 | 838 | ||
| 329 | - let html = '<div class="impact-panel">'; | 839 | + // 趋势诊断 |
| 840 | + if (process.trend_diagnosis) { | ||
| 841 | + const td = process.trend_diagnosis; | ||
| 842 | + const items = []; | ||
| 843 | + if (td.signals && td.signals.length > 0) items.push(`<div class="process-item"><span class="process-item-label">趋势信号</span><span class="process-item-value">${td.signals.join('; ')}</span></div>`); | ||
| 844 | + if (td.reasoning) items.push(`<div class="process-item"><span class="process-item-label">判断依据</span><span class="process-item-value">${td.reasoning}</span></div>`); | ||
| 845 | + if (items.length > 0) { | ||
| 846 | + sections.push(`<div class="process-section"><div class="process-section-title">趋势诊断</div>${items.join('')}</div>`); | ||
| 847 | + } | ||
| 848 | + } | ||
| 330 | 849 | ||
| 331 | - // Inventory | ||
| 332 | - if (impact.inventory_health) { | ||
| 333 | - html += ` | ||
| 334 | - <div class="kpi-item"> | ||
| 335 | - <div class="kpi-label">库存健康度</div> | ||
| 336 | - <div class="kpi-value">${Components.formatAmount(impact.inventory_health.shortage_reduction || 0)}</div> | ||
| 337 | - <div class="kpi-desc">${impact.inventory_health.structure_improvement}</div> | ||
| 338 | - </div>`; | 850 | + // 健康度诊断 |
| 851 | + if (process.health_score_diagnosis) { | ||
| 852 | + const hsd = process.health_score_diagnosis; | ||
| 853 | + const items = []; | ||
| 854 | + if (hsd.normal_ratio) items.push(`<div class="process-item"><span class="process-item-label">正常件占比</span><span class="process-item-value highlight">${hsd.normal_ratio}</span></div>`); | ||
| 855 | + if (hsd.score) items.push(`<div class="process-item"><span class="process-item-label">健康度评分</span><span class="process-item-value highlight">${hsd.score}</span></div>`); | ||
| 856 | + if (hsd.reasoning) items.push(`<div class="process-item"><span class="process-item-label">判断依据</span><span class="process-item-value">${hsd.reasoning}</span></div>`); | ||
| 857 | + if (items.length > 0) { | ||
| 858 | + sections.push(`<div class="process-section"><div class="process-section-title">健康度诊断</div>${items.join('')}</div>`); | ||
| 859 | + } | ||
| 339 | } | 860 | } |
| 340 | 861 | ||
| 341 | - // Efficiency | ||
| 342 | - if (impact.capital_efficiency) { | ||
| 343 | - html += ` | ||
| 344 | - <div class="kpi-item"> | ||
| 345 | - <div class="kpi-label">资金效率</div> | ||
| 346 | - <div class="kpi-value">${Components.formatAmount(impact.capital_efficiency.investment_amount)}</div> | ||
| 347 | - <div class="kpi-desc">${impact.capital_efficiency.expected_return}</div> | ||
| 348 | - </div>`; | 862 | + // 问题诊断(健康度) |
| 863 | + if (process.problem_diagnosis) { | ||
| 864 | + const pd = process.problem_diagnosis; | ||
| 865 | + const items = []; | ||
| 866 | + ['shortage', 'stagnant', 'low_freq'].forEach(key => { | ||
| 867 | + const item = pd[key]; | ||
| 868 | + if (item) { | ||
| 869 | + const label = key === 'shortage' ? '缺货件' : key === 'stagnant' ? '呆滞件' : '低频件'; | ||
| 870 | + if (item.threshold_comparison) items.push(`<div class="process-item"><span class="process-item-label">${label}</span><span class="process-item-value">${item.threshold_comparison}</span></div>`); | ||
| 871 | + } | ||
| 872 | + }); | ||
| 873 | + if (items.length > 0) { | ||
| 874 | + sections.push(`<div class="process-section"><div class="process-section-title">问题诊断</div>${items.join('')}</div>`); | ||
| 875 | + } | ||
| 349 | } | 876 | } |
| 350 | - | ||
| 351 | - // Next | ||
| 352 | - if (impact.follow_up_actions) { | ||
| 353 | - html += ` | ||
| 354 | - <div class="kpi-item"> | ||
| 355 | - <div class="kpi-label">下一步关注</div> | ||
| 356 | - <div class="kpi-value" style="font-size:1.5rem;background:none;-webkit-text-fill-color:var(--text-primary);">Key Actions</div> | ||
| 357 | - <div class="kpi-desc" style="text-align:left;display:inline-block;">${impact.follow_up_actions.next_steps}</div> | ||
| 358 | - </div>`; | 877 | + |
| 878 | + // 资金释放计算 | ||
| 879 | + if (process.capital_release_calculation) { | ||
| 880 | + const crc = process.capital_release_calculation; | ||
| 881 | + const items = []; | ||
| 882 | + if (crc.stagnant_calculation) items.push(`<div class="process-item"><span class="process-item-label">呆滞件</span><span class="process-item-value">${crc.stagnant_calculation}</span></div>`); | ||
| 883 | + if (crc.low_freq_calculation) items.push(`<div class="process-item"><span class="process-item-label">低频件</span><span class="process-item-value">${crc.low_freq_calculation}</span></div>`); | ||
| 884 | + if (crc.total_releasable) items.push(`<div class="process-item"><span class="process-item-label">总可释放</span><span class="process-item-value highlight">${crc.total_releasable}</span></div>`); | ||
| 885 | + if (items.length > 0) { | ||
| 886 | + sections.push(`<div class="process-section"><div class="process-section-title">资金释放计算</div>${items.join('')}</div>`); | ||
| 887 | + } | ||
| 359 | } | 888 | } |
| 360 | 889 | ||
| 361 | - html += '</div>'; | ||
| 362 | - return html; | 890 | + // 紧迫度诊断(补货建议) |
| 891 | + if (process.urgency_diagnosis) { | ||
| 892 | + const ud = process.urgency_diagnosis; | ||
| 893 | + const items = []; | ||
| 894 | + if (ud.urgent_ratio) items.push(`<div class="process-item"><span class="process-item-label">急需占比</span><span class="process-item-value highlight">${ud.urgent_ratio}</span></div>`); | ||
| 895 | + if (ud.level) items.push(`<div class="process-item"><span class="process-item-label">紧迫等级</span><span class="process-item-value highlight">${ud.level}</span></div>`); | ||
| 896 | + if (ud.reasoning) items.push(`<div class="process-item"><span class="process-item-label">判断依据</span><span class="process-item-value">${ud.reasoning}</span></div>`); | ||
| 897 | + if (items.length > 0) { | ||
| 898 | + sections.push(`<div class="process-section"><div class="process-section-title">紧迫度诊断</div>${items.join('')}</div>`); | ||
| 899 | + } | ||
| 900 | + } | ||
| 901 | + | ||
| 902 | + // 预算分析 | ||
| 903 | + if (process.budget_analysis) { | ||
| 904 | + const ba = process.budget_analysis; | ||
| 905 | + const items = []; | ||
| 906 | + if (ba.current_distribution) items.push(`<div class="process-item"><span class="process-item-label">当前分布</span><span class="process-item-value">${ba.current_distribution}</span></div>`); | ||
| 907 | + if (ba.comparison_with_standard) items.push(`<div class="process-item"><span class="process-item-label">标准对比</span><span class="process-item-value">${ba.comparison_with_standard}</span></div>`); | ||
| 908 | + if (ba.adjustment_needed) items.push(`<div class="process-item"><span class="process-item-label">调整建议</span><span class="process-item-value">${ba.adjustment_needed}</span></div>`); | ||
| 909 | + if (items.length > 0) { | ||
| 910 | + sections.push(`<div class="process-section"><div class="process-section-title">预算分析</div>${items.join('')}</div>`); | ||
| 911 | + } | ||
| 912 | + } | ||
| 913 | + | ||
| 914 | + // 风险识别 | ||
| 915 | + if (process.risk_identification) { | ||
| 916 | + const ri = process.risk_identification; | ||
| 917 | + const items = []; | ||
| 918 | + if (ri.capital_pressure_check) items.push(`<div class="process-item"><span class="process-item-label">资金压力</span><span class="process-item-value">${ri.capital_pressure_check}</span></div>`); | ||
| 919 | + if (ri.over_replenishment_check) items.push(`<div class="process-item"><span class="process-item-label">过度补货</span><span class="process-item-value">${ri.over_replenishment_check}</span></div>`); | ||
| 920 | + if (ri.identified_risks && ri.identified_risks.length > 0) { | ||
| 921 | + items.push(`<div class="process-item"><span class="process-item-label">识别风险</span><span class="process-item-value">${ri.identified_risks.join('; ')}</span></div>`); | ||
| 922 | + } | ||
| 923 | + if (items.length > 0) { | ||
| 924 | + sections.push(`<div class="process-section"><div class="process-section-title">风险识别</div>${items.join('')}</div>`); | ||
| 925 | + } | ||
| 926 | + } | ||
| 927 | + | ||
| 928 | + if (sections.length === 0) return ''; | ||
| 929 | + | ||
| 930 | + return ` | ||
| 931 | + <div class="analysis-process-toggle" data-target="${moduleId}-process"> | ||
| 932 | + <i data-lucide="chevron-down"></i> | ||
| 933 | + <span>查看分析推理过程</span> | ||
| 934 | + </div> | ||
| 935 | + <div class="analysis-process-content" id="${moduleId}-process"> | ||
| 936 | + ${sections.join('')} | ||
| 937 | + </div> | ||
| 938 | + `; | ||
| 939 | + }, | ||
| 940 | + | ||
| 941 | + /** | ||
| 942 | + * 格式化推理过程的key名称 | ||
| 943 | + */ | ||
| 944 | + _formatProcessKey(key) { | ||
| 945 | + const keyMap = { | ||
| 946 | + 'in_stock_ratio': '在库未锁占比', | ||
| 947 | + 'on_way_ratio': '在途占比', | ||
| 948 | + 'plan_ratio': '计划数占比', | ||
| 949 | + 'avg_cost': '平均成本', | ||
| 950 | + 'out_stock_ratio': '90天出库占比', | ||
| 951 | + 'locked_ratio': '未关单已锁占比', | ||
| 952 | + 'ongoing_ratio': '未关单出库占比', | ||
| 953 | + 'buy_ratio': '订件占比', | ||
| 954 | + 'sku_active_rate': 'SKU活跃率', | ||
| 955 | + 'avg_sales_price': '平均销售金额', | ||
| 956 | + 'urgent_count_ratio': '急需数量占比', | ||
| 957 | + 'urgent_amount_ratio': '急需金额占比', | ||
| 958 | + 'suggested_count_ratio': '建议数量占比', | ||
| 959 | + 'suggested_amount_ratio': '建议金额占比', | ||
| 960 | + 'optional_count_ratio': '可选数量占比', | ||
| 961 | + 'optional_amount_ratio': '可选金额占比', | ||
| 962 | + }; | ||
| 963 | + return keyMap[key] || key; | ||
| 964 | + }, | ||
| 965 | + | ||
| 966 | + /** | ||
| 967 | + * 绑定推理过程折叠事件 | ||
| 968 | + */ | ||
| 969 | + _bindProcessToggle(container) { | ||
| 970 | + const toggles = container.querySelectorAll('.analysis-process-toggle'); | ||
| 971 | + toggles.forEach(toggle => { | ||
| 972 | + toggle.addEventListener('click', () => { | ||
| 973 | + const targetId = toggle.dataset.target; | ||
| 974 | + const content = document.getElementById(targetId); | ||
| 975 | + if (content) { | ||
| 976 | + toggle.classList.toggle('expanded'); | ||
| 977 | + content.classList.toggle('expanded'); | ||
| 978 | + lucide.createIcons(); | ||
| 979 | + } | ||
| 980 | + }); | ||
| 981 | + }); | ||
| 363 | }, | 982 | }, |
| 364 | - | ||
| 365 | - // 辅助方法:renderReportCard, renderRiskCard, renderImpactCard 已被新的独立渲染逻辑取代,保留为空或删除 | ||
| 366 | - renderReportCard(title, data) { return ''; }, | ||
| 367 | - renderRiskCard(title, data, level) { return ''; }, | ||
| 368 | - renderImpactCard(title, data) { return ''; }, | ||
| 369 | 983 | ||
| 370 | /** | 984 | /** |
| 371 | * 初始化应用 | 985 | * 初始化应用 |