Commit cb771b7556f5be3aa336fc56366f746e36fb8ece
1 parent
490a3565
feat: 移除通用报告和调度模块,新增补货API及专用汇总存储。
Showing
29 changed files
with
159 additions
and
4508 deletions
.env
| @@ -14,10 +14,6 @@ MYSQL_USER=test | @@ -14,10 +14,6 @@ MYSQL_USER=test | ||
| 14 | MYSQL_PASSWORD=mysql@pwd123 | 14 | MYSQL_PASSWORD=mysql@pwd123 |
| 15 | MYSQL_DATABASE=fw_pms | 15 | MYSQL_DATABASE=fw_pms |
| 16 | 16 | ||
| 17 | -# 定时任务配置 | ||
| 18 | -SCHEDULER_CRON_HOUR=2 | ||
| 19 | -SCHEDULER_CRON_MINUTE=0 | ||
| 20 | - | ||
| 21 | # 服务配置 | 17 | # 服务配置 |
| 22 | SERVER_PORT=8009 | 18 | SERVER_PORT=8009 |
| 23 | 19 |
docs/商家组合维度分析需求设计.md deleted
| 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/report_inventory_health.md deleted
| 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 deleted
| 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 deleted
| 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 deleted
| 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. **所有百分比计算结果保留两位小数** |
pyproject.toml
| @@ -21,9 +21,6 @@ dependencies = [ | @@ -21,9 +21,6 @@ dependencies = [ | ||
| 21 | # LLM 集成 | 21 | # LLM 集成 |
| 22 | "zhipuai>=2.0.0", | 22 | "zhipuai>=2.0.0", |
| 23 | 23 | ||
| 24 | - # 定时任务 | ||
| 25 | - "apscheduler>=3.10.0", | ||
| 26 | - | ||
| 27 | # 数据库 | 24 | # 数据库 |
| 28 | "mysql-connector-python>=8.0.0", | 25 | "mysql-connector-python>=8.0.0", |
| 29 | "sqlalchemy>=2.0.0", | 26 | "sqlalchemy>=2.0.0", |
sql/migrate_analysis_report.sql deleted
| 1 | --- ============================================================================ | ||
| 2 | --- AI 补货建议分析报告表 | ||
| 3 | --- ============================================================================ | ||
| 4 | --- 版本: 3.0.0 | ||
| 5 | --- 更新日期: 2026-02-10 | ||
| 6 | --- 变更说明: 重构为四大数据驱动板块(库存概览/销量分析/健康度/补货建议) | ||
| 7 | --- ============================================================================ | ||
| 8 | - | ||
| 9 | -DROP TABLE IF EXISTS ai_analysis_report; | ||
| 10 | -CREATE TABLE ai_analysis_report ( | ||
| 11 | - id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', | ||
| 12 | - task_no VARCHAR(32) NOT NULL COMMENT '任务编号', | ||
| 13 | - group_id BIGINT NOT NULL COMMENT '集团ID', | ||
| 14 | - dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID', | ||
| 15 | - dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称', | ||
| 16 | - brand_grouping_id BIGINT COMMENT '品牌组合ID', | ||
| 17 | - report_type VARCHAR(32) DEFAULT 'replenishment' COMMENT '报告类型', | ||
| 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 | - | ||
| 25 | - -- LLM 元数据 | ||
| 26 | - llm_provider VARCHAR(32) COMMENT 'LLM提供商', | ||
| 27 | - llm_model VARCHAR(64) COMMENT 'LLM模型名称', | ||
| 28 | - llm_tokens INT DEFAULT 0 COMMENT 'LLM Token消耗', | ||
| 29 | - execution_time_ms INT DEFAULT 0 COMMENT '执行耗时(毫秒)', | ||
| 30 | - | ||
| 31 | - statistics_date VARCHAR(16) COMMENT '统计日期', | ||
| 32 | - create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', | ||
| 33 | - | ||
| 34 | - INDEX idx_task_no (task_no), | ||
| 35 | - INDEX idx_group_date (group_id, statistics_date), | ||
| 36 | - INDEX idx_dealer_grouping (dealer_grouping_id) | ||
| 37 | -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议分析报告表-重构版'; |
src/fw_pms_ai/__main__.py
0 → 100644
src/fw_pms_ai/agent/analysis_report_node.py deleted
| 1 | -""" | ||
| 2 | -分析报告生成节点 | ||
| 3 | - | ||
| 4 | -在补货建议工作流的最后一个节点执行,生成结构化分析报告。 | ||
| 5 | -包含四大板块的统计计算函数:库存概览、销量分析、库存健康度、补货建议。 | ||
| 6 | -""" | ||
| 7 | - | ||
| 8 | -import logging | ||
| 9 | -from decimal import Decimal, ROUND_HALF_UP | ||
| 10 | - | ||
| 11 | -logger = logging.getLogger(__name__) | ||
| 12 | - | ||
| 13 | - | ||
| 14 | -def _to_decimal(value) -> Decimal: | ||
| 15 | - """安全转换为 Decimal""" | ||
| 16 | - if value is None: | ||
| 17 | - return Decimal("0") | ||
| 18 | - return Decimal(str(value)) | ||
| 19 | - | ||
| 20 | - | ||
| 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 | ||
| 73 | - ) | ||
| 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") | ||
| 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 | - } | ||
| 100 | - | ||
| 101 | - | ||
| 102 | -def calculate_sales_analysis(part_ratios: list[dict]) -> dict: | ||
| 103 | - """ | ||
| 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 | - 销量分析统计字典 | ||
| 113 | - """ | ||
| 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, | ||
| 156 | - } | ||
| 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)) | ||
| 286 | - else: | ||
| 287 | - priority = getattr(item, "priority", 0) | ||
| 288 | - amount = _to_decimal(getattr(item, "total_suggest_amount", 0)) | ||
| 289 | - | ||
| 290 | - if priority == 1: | ||
| 291 | - urgent["count"] += 1 | ||
| 292 | - urgent["amount"] += amount | ||
| 293 | - elif priority == 2: | ||
| 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") | ||
| 356 | - else: | ||
| 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, | ||
| 459 | - } | ||
| 460 | - return analysis, usage | ||
| 461 | - | ||
| 462 | - | ||
| 463 | -def llm_analyze_sales(stats: dict, statistics_date: str = "", llm_client=None) -> tuple[dict, dict]: | ||
| 464 | - """ | ||
| 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) | ||
| 474 | - """ | ||
| 475 | - from ..llm import get_llm_client | ||
| 476 | - | ||
| 477 | - if llm_client is None: | ||
| 478 | - llm_client = get_llm_client() | ||
| 479 | - | ||
| 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]: | ||
| 515 | - """ | ||
| 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) | ||
| 525 | - """ | ||
| 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 | - | ||
| 560 | - try: | ||
| 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: | ||
| 590 | - llm_client = get_llm_client() | ||
| 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) | ||
| 688 | - return { | ||
| 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", ""), | ||
| 694 | - } | ||
| 695 | - except Exception as e: | ||
| 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) | ||
| 751 | - return { | ||
| 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", ""), | ||
| 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/nodes.py
| @@ -19,10 +19,15 @@ from ..models import ReplenishmentSuggestion, PartAnalysisResult | @@ -19,10 +19,15 @@ from ..models import ReplenishmentSuggestion, PartAnalysisResult | ||
| 19 | from ..llm import get_llm_client | 19 | from ..llm import get_llm_client |
| 20 | from ..services import DataService | 20 | from ..services import DataService |
| 21 | from ..services.result_writer import ResultWriter | 21 | from ..services.result_writer import ResultWriter |
| 22 | -from ..models import ReplenishmentDetail, TaskExecutionLog, LogStatus, ReplenishmentPartSummary | 22 | +from ..models import ReplenishmentDetail, ReplenishmentPartSummary |
| 23 | 23 | ||
| 24 | logger = logging.getLogger(__name__) | 24 | logger = logging.getLogger(__name__) |
| 25 | 25 | ||
| 26 | +# 执行状态常量 | ||
| 27 | +LOG_SUCCESS = 1 | ||
| 28 | +LOG_FAILED = 2 | ||
| 29 | +LOG_SKIPPED = 3 | ||
| 30 | + | ||
| 26 | 31 | ||
| 27 | def _load_prompt(filename: str) -> str: | 32 | def _load_prompt(filename: str) -> str: |
| 28 | """从prompts目录加载提示词文件""" | 33 | """从prompts目录加载提示词文件""" |
| @@ -71,7 +76,7 @@ def fetch_part_ratio_node(state: AgentState) -> AgentState: | @@ -71,7 +76,7 @@ def fetch_part_ratio_node(state: AgentState) -> AgentState: | ||
| 71 | log_entry = { | 76 | log_entry = { |
| 72 | "step_name": "fetch_part_ratio", | 77 | "step_name": "fetch_part_ratio", |
| 73 | "step_order": 1, | 78 | "step_order": 1, |
| 74 | - "status": LogStatus.SUCCESS if part_ratios else LogStatus.SKIPPED, | 79 | + "status": LOG_SUCCESS if part_ratios else LOG_SKIPPED, |
| 75 | "input_data": json.dumps({ | 80 | "input_data": json.dumps({ |
| 76 | "dealer_grouping_id": state["dealer_grouping_id"], | 81 | "dealer_grouping_id": state["dealer_grouping_id"], |
| 77 | "statistics_date": state["statistics_date"], | 82 | "statistics_date": state["statistics_date"], |
| @@ -118,7 +123,7 @@ def sql_agent_node(state: AgentState) -> AgentState: | @@ -118,7 +123,7 @@ def sql_agent_node(state: AgentState) -> AgentState: | ||
| 118 | log_entry = { | 123 | log_entry = { |
| 119 | "step_name": "sql_agent", | 124 | "step_name": "sql_agent", |
| 120 | "step_order": 2, | 125 | "step_order": 2, |
| 121 | - "status": LogStatus.SKIPPED, | 126 | + "status": LOG_SKIPPED, |
| 122 | "error_message": "无配件数据", | 127 | "error_message": "无配件数据", |
| 123 | "execution_time_ms": int((time.time() - start_time) * 1000), | 128 | "execution_time_ms": int((time.time() - start_time) * 1000), |
| 124 | } | 129 | } |
| @@ -156,8 +161,6 @@ def sql_agent_node(state: AgentState) -> AgentState: | @@ -156,8 +161,6 @@ def sql_agent_node(state: AgentState) -> AgentState: | ||
| 156 | ) | 161 | ) |
| 157 | 162 | ||
| 158 | # 定义批处理回调 | 163 | # 定义批处理回调 |
| 159 | - # 由于 models 中没有 ResultWriter 的引用,这里尝试直接从 services 导入或实例化 | ||
| 160 | - # 为避免循环导入,我们在函数内导入 | ||
| 161 | from ..services import ResultWriter as WriterService | 164 | from ..services import ResultWriter as WriterService |
| 162 | writer = WriterService() | 165 | writer = WriterService() |
| 163 | 166 | ||
| @@ -165,7 +168,7 @@ def sql_agent_node(state: AgentState) -> AgentState: | @@ -165,7 +168,7 @@ def sql_agent_node(state: AgentState) -> AgentState: | ||
| 165 | # logger.info(f"[SQLAgent] 清理旧建议数据: task_no={state['task_no']}") | 168 | # logger.info(f"[SQLAgent] 清理旧建议数据: task_no={state['task_no']}") |
| 166 | # writer.clear_llm_suggestions(state["task_no"]) | 169 | # writer.clear_llm_suggestions(state["task_no"]) |
| 167 | 170 | ||
| 168 | - # 2. 移除批处理回调(不再过程写入,改为最后统一写入) | 171 | + # 2. 移除批处理回调 |
| 169 | save_batch_callback = None | 172 | save_batch_callback = None |
| 170 | 173 | ||
| 171 | # 使用分组分析生成补货建议(按 part_code 分组,逐个配件分析各门店需求) | 174 | # 使用分组分析生成补货建议(按 part_code 分组,逐个配件分析各门店需求) |
| @@ -175,7 +178,7 @@ def sql_agent_node(state: AgentState) -> AgentState: | @@ -175,7 +178,7 @@ def sql_agent_node(state: AgentState) -> AgentState: | ||
| 175 | dealer_grouping_name=state["dealer_grouping_name"], | 178 | dealer_grouping_name=state["dealer_grouping_name"], |
| 176 | statistics_date=state["statistics_date"], | 179 | statistics_date=state["statistics_date"], |
| 177 | target_ratio=base_ratio if base_ratio > 0 else Decimal("1.3"), | 180 | target_ratio=base_ratio if base_ratio > 0 else Decimal("1.3"), |
| 178 | - limit=1000, | 181 | + limit=10, |
| 179 | callback=save_batch_callback, | 182 | callback=save_batch_callback, |
| 180 | ) | 183 | ) |
| 181 | 184 | ||
| @@ -185,7 +188,7 @@ def sql_agent_node(state: AgentState) -> AgentState: | @@ -185,7 +188,7 @@ def sql_agent_node(state: AgentState) -> AgentState: | ||
| 185 | log_entry = { | 188 | log_entry = { |
| 186 | "step_name": "sql_agent", | 189 | "step_name": "sql_agent", |
| 187 | "step_order": 2, | 190 | "step_order": 2, |
| 188 | - "status": LogStatus.SUCCESS, | 191 | + "status": LOG_SUCCESS, |
| 189 | "input_data": json.dumps({ | 192 | "input_data": json.dumps({ |
| 190 | "part_ratios_count": len(part_ratios), | 193 | "part_ratios_count": len(part_ratios), |
| 191 | }), | 194 | }), |
| @@ -222,7 +225,7 @@ def sql_agent_node(state: AgentState) -> AgentState: | @@ -222,7 +225,7 @@ def sql_agent_node(state: AgentState) -> AgentState: | ||
| 222 | log_entry = { | 225 | log_entry = { |
| 223 | "step_name": "sql_agent", | 226 | "step_name": "sql_agent", |
| 224 | "step_order": 2, | 227 | "step_order": 2, |
| 225 | - "status": LogStatus.FAILED, | 228 | + "status": LOG_FAILED, |
| 226 | "error_message": str(e), | 229 | "error_message": str(e), |
| 227 | "retry_count": retry_count, | 230 | "retry_count": retry_count, |
| 228 | "execution_time_ms": int((time.time() - start_time) * 1000), | 231 | "execution_time_ms": int((time.time() - start_time) * 1000), |
| @@ -255,7 +258,6 @@ def sql_agent_node(state: AgentState) -> AgentState: | @@ -255,7 +258,6 @@ def sql_agent_node(state: AgentState) -> AgentState: | ||
| 255 | def allocate_budget_node(state: AgentState) -> AgentState: | 258 | def allocate_budget_node(state: AgentState) -> AgentState: |
| 256 | """ | 259 | """ |
| 257 | 节点3: 转换LLM建议为补货明细 | 260 | 节点3: 转换LLM建议为补货明细 |
| 258 | - 注意:不做预算截断,所有建议直接输出 | ||
| 259 | """ | 261 | """ |
| 260 | logger.info(f"[AllocateBudget] 开始处理LLM建议") | 262 | logger.info(f"[AllocateBudget] 开始处理LLM建议") |
| 261 | 263 | ||
| @@ -269,7 +271,7 @@ def allocate_budget_node(state: AgentState) -> AgentState: | @@ -269,7 +271,7 @@ def allocate_budget_node(state: AgentState) -> AgentState: | ||
| 269 | log_entry = { | 271 | log_entry = { |
| 270 | "step_name": "allocate_budget", | 272 | "step_name": "allocate_budget", |
| 271 | "step_order": 3, | 273 | "step_order": 3, |
| 272 | - "status": LogStatus.SKIPPED, | 274 | + "status": LOG_SKIPPED, |
| 273 | "error_message": "无LLM建议", | 275 | "error_message": "无LLM建议", |
| 274 | "execution_time_ms": int((time.time() - start_time) * 1000), | 276 | "execution_time_ms": int((time.time() - start_time) * 1000), |
| 275 | } | 277 | } |
| @@ -295,7 +297,7 @@ def allocate_budget_node(state: AgentState) -> AgentState: | @@ -295,7 +297,7 @@ def allocate_budget_node(state: AgentState) -> AgentState: | ||
| 295 | allocated_details = [] | 297 | allocated_details = [] |
| 296 | total_amount = Decimal("0") | 298 | total_amount = Decimal("0") |
| 297 | 299 | ||
| 298 | - # 转换所有建议为明细(包括不需要补货的配件,以便记录完整分析结果) | 300 | + # 转换所有建议为明细(包括不需要补货的配件) |
| 299 | for suggestion in sorted_suggestions: | 301 | for suggestion in sorted_suggestions: |
| 300 | # 获取该配件对应的 brand_grouping_id | 302 | # 获取该配件对应的 brand_grouping_id |
| 301 | bg_id = part_brand_map.get(suggestion.part_code) | 303 | bg_id = part_brand_map.get(suggestion.part_code) |
| @@ -341,7 +343,7 @@ def allocate_budget_node(state: AgentState) -> AgentState: | @@ -341,7 +343,7 @@ def allocate_budget_node(state: AgentState) -> AgentState: | ||
| 341 | log_entry = { | 343 | log_entry = { |
| 342 | "step_name": "allocate_budget", | 344 | "step_name": "allocate_budget", |
| 343 | "step_order": 3, | 345 | "step_order": 3, |
| 344 | - "status": LogStatus.SUCCESS, | 346 | + "status": LOG_SUCCESS, |
| 345 | "input_data": json.dumps({ | 347 | "input_data": json.dumps({ |
| 346 | "suggestions_count": len(llm_suggestions), | 348 | "suggestions_count": len(llm_suggestions), |
| 347 | }), | 349 | }), |
| @@ -408,7 +410,7 @@ def allocate_budget_node(state: AgentState) -> AgentState: | @@ -408,7 +410,7 @@ def allocate_budget_node(state: AgentState) -> AgentState: | ||
| 408 | error_log = { | 410 | error_log = { |
| 409 | "step_name": "allocate_budget", | 411 | "step_name": "allocate_budget", |
| 410 | "step_order": 3, | 412 | "step_order": 3, |
| 411 | - "status": LogStatus.FAILED, | 413 | + "status": LOG_FAILED, |
| 412 | "error_message": f"保存结果失败: {str(e)}", | 414 | "error_message": f"保存结果失败: {str(e)}", |
| 413 | "execution_time_ms": 0, | 415 | "execution_time_ms": 0, |
| 414 | } | 416 | } |
src/fw_pms_ai/agent/replenishment.py
| 1 | """ | 1 | """ |
| 2 | 补货建议 Agent | 2 | 补货建议 Agent |
| 3 | 3 | ||
| 4 | -重构版本:使用 part_ratio + SQL Agent + LangGraph | 4 | +使用 part_ratio + SQL Agent + LangGraph |
| 5 | """ | 5 | """ |
| 6 | 6 | ||
| 7 | import logging | 7 | import logging |
| @@ -20,8 +20,7 @@ from .nodes import ( | @@ -20,8 +20,7 @@ from .nodes import ( | ||
| 20 | allocate_budget_node, | 20 | allocate_budget_node, |
| 21 | should_retry_sql, | 21 | should_retry_sql, |
| 22 | ) | 22 | ) |
| 23 | -from .analysis_report_node import generate_analysis_report_node | ||
| 24 | -from ..models import ReplenishmentTask, TaskStatus, TaskExecutionLog, LogStatus, ReplenishmentPartSummary | 23 | +from ..models import ReplenishmentTask, TaskStatus, ReplenishmentPartSummary |
| 25 | from ..services import ResultWriter | 24 | from ..services import ResultWriter |
| 26 | 25 | ||
| 27 | logger = logging.getLogger(__name__) | 26 | logger = logging.getLogger(__name__) |
| @@ -44,25 +43,19 @@ class ReplenishmentAgent: | @@ -44,25 +43,19 @@ class ReplenishmentAgent: | ||
| 44 | def _build_graph(self) -> StateGraph: | 43 | def _build_graph(self) -> StateGraph: |
| 45 | """ | 44 | """ |
| 46 | 构建 LangGraph 工作流 | 45 | 构建 LangGraph 工作流 |
| 47 | - | 46 | + |
| 48 | 工作流结构: | 47 | 工作流结构: |
| 49 | - fetch_part_ratio → sql_agent → allocate_budget → generate_analysis_report → END | 48 | + fetch_part_ratio → sql_agent → allocate_budget → END |
| 50 | """ | 49 | """ |
| 51 | workflow = StateGraph(AgentState) | 50 | workflow = StateGraph(AgentState) |
| 52 | 51 | ||
| 53 | - # 添加核心节点 | ||
| 54 | workflow.add_node("fetch_part_ratio", fetch_part_ratio_node) | 52 | workflow.add_node("fetch_part_ratio", fetch_part_ratio_node) |
| 55 | workflow.add_node("sql_agent", sql_agent_node) | 53 | workflow.add_node("sql_agent", sql_agent_node) |
| 56 | workflow.add_node("allocate_budget", allocate_budget_node) | 54 | workflow.add_node("allocate_budget", allocate_budget_node) |
| 57 | - workflow.add_node("generate_analysis_report", generate_analysis_report_node) | ||
| 58 | 55 | ||
| 59 | - # 设置入口 | ||
| 60 | workflow.set_entry_point("fetch_part_ratio") | 56 | workflow.set_entry_point("fetch_part_ratio") |
| 61 | - | ||
| 62 | - # 添加边 | ||
| 63 | workflow.add_edge("fetch_part_ratio", "sql_agent") | 57 | workflow.add_edge("fetch_part_ratio", "sql_agent") |
| 64 | - | ||
| 65 | - # SQL Agent 条件边(支持重试) | 58 | + |
| 66 | workflow.add_conditional_edges( | 59 | workflow.add_conditional_edges( |
| 67 | "sql_agent", | 60 | "sql_agent", |
| 68 | should_retry_sql, | 61 | should_retry_sql, |
| @@ -71,10 +64,8 @@ class ReplenishmentAgent: | @@ -71,10 +64,8 @@ class ReplenishmentAgent: | ||
| 71 | "continue": "allocate_budget", | 64 | "continue": "allocate_budget", |
| 72 | } | 65 | } |
| 73 | ) | 66 | ) |
| 74 | - | ||
| 75 | - # allocate_budget → generate_analysis_report → END | ||
| 76 | - workflow.add_edge("allocate_budget", "generate_analysis_report") | ||
| 77 | - workflow.add_edge("generate_analysis_report", END) | 67 | + |
| 68 | + workflow.add_edge("allocate_budget", END) | ||
| 78 | 69 | ||
| 79 | return workflow.compile() | 70 | return workflow.compile() |
| 80 | 71 | ||
| @@ -126,7 +117,6 @@ class ReplenishmentAgent: | @@ -126,7 +117,6 @@ class ReplenishmentAgent: | ||
| 126 | "details": [], | 117 | "details": [], |
| 127 | "llm_suggestions": [], | 118 | "llm_suggestions": [], |
| 128 | "part_results": [], | 119 | "part_results": [], |
| 129 | - "report": None, | ||
| 130 | "llm_provider": "", | 120 | "llm_provider": "", |
| 131 | "llm_model": "", | 121 | "llm_model": "", |
| 132 | "llm_prompt_tokens": 0, | 122 | "llm_prompt_tokens": 0, |
| @@ -175,30 +165,6 @@ class ReplenishmentAgent: | @@ -175,30 +165,6 @@ class ReplenishmentAgent: | ||
| 175 | 165 | ||
| 176 | self._result_writer.update_task(task) | 166 | self._result_writer.update_task(task) |
| 177 | 167 | ||
| 178 | - | ||
| 179 | - | ||
| 180 | - # 保存执行日志 | ||
| 181 | - if final_state.get("sql_execution_logs"): | ||
| 182 | - self._save_execution_logs( | ||
| 183 | - task_no=task_no, | ||
| 184 | - group_id=group_id, | ||
| 185 | - brand_grouping_id=brand_grouping_id, | ||
| 186 | - brand_grouping_name=brand_grouping_name, | ||
| 187 | - dealer_grouping_id=dealer_grouping_id, | ||
| 188 | - dealer_grouping_name=dealer_grouping_name, | ||
| 189 | - logs=final_state["sql_execution_logs"], | ||
| 190 | - ) | ||
| 191 | - | ||
| 192 | - # 配件汇总已在 allocate_budget_node 中保存,此处跳过避免重复 | ||
| 193 | - # if final_state.get("part_results"): | ||
| 194 | - # self._save_part_summaries( | ||
| 195 | - # task_no=task_no, | ||
| 196 | - # group_id=group_id, | ||
| 197 | - # dealer_grouping_id=dealer_grouping_id, | ||
| 198 | - # statistics_date=statistics_date, | ||
| 199 | - # part_results=final_state["part_results"], | ||
| 200 | - # ) | ||
| 201 | - | ||
| 202 | logger.info( | 168 | logger.info( |
| 203 | f"补货建议执行完成: task_no={task_no}, " | 169 | f"补货建议执行完成: task_no={task_no}, " |
| 204 | f"parts={task.part_count}, amount={actual_amount}, " | 170 | f"parts={task.part_count}, amount={actual_amount}, " |
| @@ -220,39 +186,6 @@ class ReplenishmentAgent: | @@ -220,39 +186,6 @@ class ReplenishmentAgent: | ||
| 220 | finally: | 186 | finally: |
| 221 | self._result_writer.close() | 187 | self._result_writer.close() |
| 222 | 188 | ||
| 223 | - def _save_execution_logs( | ||
| 224 | - self, | ||
| 225 | - task_no: str, | ||
| 226 | - group_id: int, | ||
| 227 | - brand_grouping_id: Optional[int], | ||
| 228 | - brand_grouping_name: str, | ||
| 229 | - dealer_grouping_id: int, | ||
| 230 | - dealer_grouping_name: str, | ||
| 231 | - logs: List[dict], | ||
| 232 | - ): | ||
| 233 | - """保存执行日志""" | ||
| 234 | - for log_data in logs: | ||
| 235 | - log = TaskExecutionLog( | ||
| 236 | - task_no=task_no, | ||
| 237 | - group_id=group_id, | ||
| 238 | - brand_grouping_id=brand_grouping_id, | ||
| 239 | - brand_grouping_name=brand_grouping_name, | ||
| 240 | - dealer_grouping_id=dealer_grouping_id, | ||
| 241 | - dealer_grouping_name=dealer_grouping_name, | ||
| 242 | - step_name=log_data.get("step_name", ""), | ||
| 243 | - step_order=log_data.get("step_order", 0), | ||
| 244 | - status=log_data.get("status", LogStatus.SUCCESS), | ||
| 245 | - input_data=log_data.get("input_data", ""), | ||
| 246 | - output_data=log_data.get("output_data", ""), | ||
| 247 | - error_message=log_data.get("error_message", ""), | ||
| 248 | - retry_count=log_data.get("retry_count", 0), | ||
| 249 | - sql_query=log_data.get("sql_query", ""), | ||
| 250 | - llm_prompt=log_data.get("llm_prompt", ""), | ||
| 251 | - llm_response=log_data.get("llm_response", ""), | ||
| 252 | - llm_tokens=log_data.get("llm_tokens", 0), | ||
| 253 | - execution_time_ms=log_data.get("execution_time_ms", 0), | ||
| 254 | - ) | ||
| 255 | - self._result_writer.save_execution_log(log) | ||
| 256 | 189 | ||
| 257 | def _save_part_summaries( | 190 | def _save_part_summaries( |
| 258 | self, | 191 | self, |
src/fw_pms_ai/agent/state.py
| @@ -72,10 +72,7 @@ class AgentState(TypedDict, total=False): | @@ -72,10 +72,7 @@ class AgentState(TypedDict, total=False): | ||
| 72 | 72 | ||
| 73 | # 配件汇总结果 | 73 | # 配件汇总结果 |
| 74 | part_results: Annotated[List[Any], merge_lists] | 74 | part_results: Annotated[List[Any], merge_lists] |
| 75 | - | ||
| 76 | - # 分析报告 | ||
| 77 | - analysis_report: Annotated[Optional[dict], keep_last] | ||
| 78 | - | 75 | + |
| 79 | # LLM 统计(使用累加,合并多个并行节点的 token 使用量) | 76 | # LLM 统计(使用累加,合并多个并行节点的 token 使用量) |
| 80 | llm_provider: Annotated[str, keep_last] | 77 | llm_provider: Annotated[str, keep_last] |
| 81 | llm_model: Annotated[str, keep_last] | 78 | llm_model: Annotated[str, keep_last] |
src/fw_pms_ai/api/app.py
| @@ -12,7 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware | @@ -12,7 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware | ||
| 12 | from fastapi.staticfiles import StaticFiles | 12 | from fastapi.staticfiles import StaticFiles |
| 13 | from fastapi.responses import FileResponse | 13 | from fastapi.responses import FileResponse |
| 14 | 14 | ||
| 15 | -from .routes import tasks | 15 | +from .routes import tasks, replenishment |
| 16 | from ..config import register_service, deregister_service | 16 | from ..config import register_service, deregister_service |
| 17 | 17 | ||
| 18 | logger = logging.getLogger(__name__) | 18 | logger = logging.getLogger(__name__) |
| @@ -46,6 +46,7 @@ app.add_middleware( | @@ -46,6 +46,7 @@ app.add_middleware( | ||
| 46 | 46 | ||
| 47 | # 挂载路由 | 47 | # 挂载路由 |
| 48 | app.include_router(tasks.router, prefix="/api", tags=["Tasks"]) | 48 | app.include_router(tasks.router, prefix="/api", tags=["Tasks"]) |
| 49 | +app.include_router(replenishment.router, prefix="/api", tags=["Replenishment"]) | ||
| 49 | 50 | ||
| 50 | # 静态文件服务 | 51 | # 静态文件服务 |
| 51 | ui_path = Path(__file__).parent.parent.parent.parent / "ui" | 52 | ui_path = Path(__file__).parent.parent.parent.parent / "ui" |
src/fw_pms_ai/api/routes/replenishment.py
0 → 100644
| 1 | +""" | ||
| 2 | +补货建议触发接口 | ||
| 3 | +替代原定时任务,提供接口触发补货建议生成 | ||
| 4 | +""" | ||
| 5 | + | ||
| 6 | +import logging | ||
| 7 | +from typing import Optional | ||
| 8 | + | ||
| 9 | +from fastapi import APIRouter, HTTPException | ||
| 10 | +from pydantic import BaseModel | ||
| 11 | + | ||
| 12 | +from ...agent import ReplenishmentAgent | ||
| 13 | +from ...services import DataService | ||
| 14 | + | ||
| 15 | +logger = logging.getLogger(__name__) | ||
| 16 | +router = APIRouter() | ||
| 17 | + | ||
| 18 | + | ||
| 19 | +class InitRequest(BaseModel): | ||
| 20 | + """初始化请求""" | ||
| 21 | + group_id: int | ||
| 22 | + dealer_grouping_id: Optional[int] = None | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +class InitResponse(BaseModel): | ||
| 26 | + """初始化响应""" | ||
| 27 | + success: bool | ||
| 28 | + message: str | ||
| 29 | + task_count: int = 0 | ||
| 30 | + | ||
| 31 | + | ||
| 32 | +@router.post("/replenishment/init", response_model=InitResponse) | ||
| 33 | +async def init_replenishment(req: InitRequest): | ||
| 34 | + """ | ||
| 35 | + 初始化补货建议 | ||
| 36 | + | ||
| 37 | + 触发全量补货建议生成。 | ||
| 38 | + - 若指定 dealer_grouping_id,仅处理该商家组合 | ||
| 39 | + - 若未指定,处理 group_id 下所有商家组合 | ||
| 40 | + """ | ||
| 41 | + try: | ||
| 42 | + agent = ReplenishmentAgent() | ||
| 43 | + | ||
| 44 | + if req.dealer_grouping_id: | ||
| 45 | + data_service = DataService() | ||
| 46 | + try: | ||
| 47 | + groupings = data_service.get_dealer_groupings(req.group_id) | ||
| 48 | + grouping = next( | ||
| 49 | + (g for g in groupings if g["id"] == req.dealer_grouping_id), | ||
| 50 | + None, | ||
| 51 | + ) | ||
| 52 | + if not grouping: | ||
| 53 | + raise HTTPException( | ||
| 54 | + status_code=404, | ||
| 55 | + detail=f"未找到商家组合: {req.dealer_grouping_id}", | ||
| 56 | + ) | ||
| 57 | + agent.run( | ||
| 58 | + group_id=req.group_id, | ||
| 59 | + dealer_grouping_id=grouping["id"], | ||
| 60 | + dealer_grouping_name=grouping["name"], | ||
| 61 | + ) | ||
| 62 | + return InitResponse( | ||
| 63 | + success=True, | ||
| 64 | + message=f"商家组合 [{grouping['name']}] 补货建议生成完成", | ||
| 65 | + task_count=1, | ||
| 66 | + ) | ||
| 67 | + finally: | ||
| 68 | + data_service.close() | ||
| 69 | + else: | ||
| 70 | + data_service = DataService() | ||
| 71 | + try: | ||
| 72 | + groupings = data_service.get_dealer_groupings(req.group_id) | ||
| 73 | + finally: | ||
| 74 | + data_service.close() | ||
| 75 | + | ||
| 76 | + task_count = 0 | ||
| 77 | + for grouping in groupings: | ||
| 78 | + try: | ||
| 79 | + agent.run( | ||
| 80 | + group_id=req.group_id, | ||
| 81 | + dealer_grouping_id=grouping["id"], | ||
| 82 | + dealer_grouping_name=grouping["name"], | ||
| 83 | + ) | ||
| 84 | + task_count += 1 | ||
| 85 | + except Exception as e: | ||
| 86 | + logger.error( | ||
| 87 | + f"商家组合执行失败: {grouping['name']}, error={e}", | ||
| 88 | + exc_info=True, | ||
| 89 | + ) | ||
| 90 | + continue | ||
| 91 | + | ||
| 92 | + return InitResponse( | ||
| 93 | + success=True, | ||
| 94 | + message=f"补货建议生成完成,共处理 {task_count}/{len(groupings)} 个商家组合", | ||
| 95 | + task_count=task_count, | ||
| 96 | + ) | ||
| 97 | + | ||
| 98 | + except HTTPException: | ||
| 99 | + raise | ||
| 100 | + except Exception as e: | ||
| 101 | + logger.error(f"补货建议初始化失败: {e}", exc_info=True) | ||
| 102 | + raise HTTPException(status_code=500, detail=str(e)) |
src/fw_pms_ai/api/routes/tasks.py
| @@ -3,10 +3,8 @@ | @@ -3,10 +3,8 @@ | ||
| 3 | """ | 3 | """ |
| 4 | 4 | ||
| 5 | import logging | 5 | import logging |
| 6 | -from typing import Optional, List, Dict, Any | ||
| 7 | -import json | 6 | +from typing import Optional, List |
| 8 | from datetime import datetime | 7 | from datetime import datetime |
| 9 | -from decimal import Decimal | ||
| 10 | 8 | ||
| 11 | from fastapi import APIRouter, Query, HTTPException | 9 | from fastapi import APIRouter, Query, HTTPException |
| 12 | from pydantic import BaseModel | 10 | from pydantic import BaseModel |
| @@ -135,14 +133,10 @@ async def list_tasks( | @@ -135,14 +133,10 @@ async def list_tasks( | ||
| 135 | # 查询分页数据 | 133 | # 查询分页数据 |
| 136 | offset = (page - 1) * page_size | 134 | offset = (page - 1) * page_size |
| 137 | data_sql = f""" | 135 | data_sql = f""" |
| 138 | - SELECT t.*, | ||
| 139 | - COALESCE( | ||
| 140 | - NULLIF(t.llm_total_tokens, 0), | ||
| 141 | - (SELECT SUM(l.llm_tokens) FROM ai_task_execution_log l WHERE l.task_no = t.task_no) | ||
| 142 | - ) as calculated_tokens | ||
| 143 | - FROM ai_replenishment_task t | 136 | + SELECT * |
| 137 | + FROM ai_replenishment_task | ||
| 144 | WHERE {where_sql} | 138 | WHERE {where_sql} |
| 145 | - ORDER BY t.create_time DESC | 139 | + ORDER BY create_time DESC |
| 146 | LIMIT %s OFFSET %s | 140 | LIMIT %s OFFSET %s |
| 147 | """ | 141 | """ |
| 148 | cursor.execute(data_sql, params + [page_size, offset]) | 142 | cursor.execute(data_sql, params + [page_size, offset]) |
| @@ -171,7 +165,7 @@ async def list_tasks( | @@ -171,7 +165,7 @@ async def list_tasks( | ||
| 171 | error_message=row.get("error_message"), | 165 | error_message=row.get("error_message"), |
| 172 | llm_provider=row.get("llm_provider"), | 166 | llm_provider=row.get("llm_provider"), |
| 173 | llm_model=row.get("llm_model"), | 167 | llm_model=row.get("llm_model"), |
| 174 | - llm_total_tokens=int(row.get("calculated_tokens") or 0), | 168 | + llm_total_tokens=int(row.get("llm_total_tokens") or 0), |
| 175 | statistics_date=row.get("statistics_date"), | 169 | statistics_date=row.get("statistics_date"), |
| 176 | start_time=format_datetime(row.get("start_time")), | 170 | start_time=format_datetime(row.get("start_time")), |
| 177 | end_time=format_datetime(row.get("end_time")), | 171 | end_time=format_datetime(row.get("end_time")), |
| @@ -200,13 +194,9 @@ async def get_task(task_no: str): | @@ -200,13 +194,9 @@ async def get_task(task_no: str): | ||
| 200 | try: | 194 | try: |
| 201 | cursor.execute( | 195 | cursor.execute( |
| 202 | """ | 196 | """ |
| 203 | - SELECT t.*, | ||
| 204 | - COALESCE( | ||
| 205 | - NULLIF(t.llm_total_tokens, 0), | ||
| 206 | - (SELECT SUM(l.llm_tokens) FROM ai_task_execution_log l WHERE l.task_no = t.task_no) | ||
| 207 | - ) as calculated_tokens | ||
| 208 | - FROM ai_replenishment_task t | ||
| 209 | - WHERE t.task_no = %s | 197 | + SELECT * |
| 198 | + FROM ai_replenishment_task | ||
| 199 | + WHERE task_no = %s | ||
| 210 | """, | 200 | """, |
| 211 | (task_no,) | 201 | (task_no,) |
| 212 | ) | 202 | ) |
| @@ -235,7 +225,7 @@ async def get_task(task_no: str): | @@ -235,7 +225,7 @@ async def get_task(task_no: str): | ||
| 235 | error_message=row.get("error_message"), | 225 | error_message=row.get("error_message"), |
| 236 | llm_provider=row.get("llm_provider"), | 226 | llm_provider=row.get("llm_provider"), |
| 237 | llm_model=row.get("llm_model"), | 227 | llm_model=row.get("llm_model"), |
| 238 | - llm_total_tokens=int(row.get("calculated_tokens") or 0), | 228 | + llm_total_tokens=int(row.get("llm_total_tokens") or 0), |
| 239 | statistics_date=row.get("statistics_date"), | 229 | statistics_date=row.get("statistics_date"), |
| 240 | start_time=format_datetime(row.get("start_time")), | 230 | start_time=format_datetime(row.get("start_time")), |
| 241 | end_time=format_datetime(row.get("end_time")), | 231 | end_time=format_datetime(row.get("end_time")), |
| @@ -336,94 +326,6 @@ async def get_task_details( | @@ -336,94 +326,6 @@ async def get_task_details( | ||
| 336 | conn.close() | 326 | conn.close() |
| 337 | 327 | ||
| 338 | 328 | ||
| 339 | -class ExecutionLogResponse(BaseModel): | ||
| 340 | - """执行日志响应""" | ||
| 341 | - id: int | ||
| 342 | - task_no: str | ||
| 343 | - step_name: str | ||
| 344 | - step_order: int | ||
| 345 | - status: int | ||
| 346 | - status_text: str = "" | ||
| 347 | - input_data: Optional[str] = None | ||
| 348 | - output_data: Optional[str] = None | ||
| 349 | - error_message: Optional[str] = None | ||
| 350 | - retry_count: int = 0 | ||
| 351 | - llm_tokens: int = 0 | ||
| 352 | - execution_time_ms: int = 0 | ||
| 353 | - start_time: Optional[str] = None | ||
| 354 | - end_time: Optional[str] = None | ||
| 355 | - create_time: Optional[str] = None | ||
| 356 | - | ||
| 357 | - | ||
| 358 | -class ExecutionLogListResponse(BaseModel): | ||
| 359 | - """执行日志列表响应""" | ||
| 360 | - total: int | ||
| 361 | - items: List[ExecutionLogResponse] | ||
| 362 | - | ||
| 363 | - | ||
| 364 | -def get_log_status_text(status: int) -> str: | ||
| 365 | - """获取日志状态文本""" | ||
| 366 | - status_map = {0: "运行中", 1: "成功", 2: "失败", 3: "跳过"} | ||
| 367 | - return status_map.get(status, "未知") | ||
| 368 | - | ||
| 369 | - | ||
| 370 | -def get_step_name_display(step_name: str) -> str: | ||
| 371 | - """获取步骤名称显示""" | ||
| 372 | - step_map = { | ||
| 373 | - "fetch_part_ratio": "获取配件数据", | ||
| 374 | - "sql_agent": "AI分析建议", | ||
| 375 | - "allocate_budget": "分配预算", | ||
| 376 | - "generate_report": "生成报告", | ||
| 377 | - } | ||
| 378 | - return step_map.get(step_name, step_name) | ||
| 379 | - | ||
| 380 | - | ||
| 381 | -@router.get("/tasks/{task_no}/logs", response_model=ExecutionLogListResponse) | ||
| 382 | -async def get_task_logs(task_no: str): | ||
| 383 | - """获取任务执行日志""" | ||
| 384 | - conn = get_connection() | ||
| 385 | - cursor = conn.cursor(dictionary=True) | ||
| 386 | - | ||
| 387 | - try: | ||
| 388 | - cursor.execute( | ||
| 389 | - """ | ||
| 390 | - SELECT * FROM ai_task_execution_log | ||
| 391 | - WHERE task_no = %s | ||
| 392 | - ORDER BY step_order ASC | ||
| 393 | - """, | ||
| 394 | - (task_no,) | ||
| 395 | - ) | ||
| 396 | - rows = cursor.fetchall() | ||
| 397 | - | ||
| 398 | - items = [] | ||
| 399 | - for row in rows: | ||
| 400 | - items.append(ExecutionLogResponse( | ||
| 401 | - id=row["id"], | ||
| 402 | - task_no=row["task_no"], | ||
| 403 | - step_name=row["step_name"], | ||
| 404 | - step_order=row.get("step_order") or 0, | ||
| 405 | - status=row.get("status") or 0, | ||
| 406 | - status_text=get_log_status_text(row.get("status") or 0), | ||
| 407 | - input_data=row.get("input_data"), | ||
| 408 | - output_data=row.get("output_data"), | ||
| 409 | - error_message=row.get("error_message"), | ||
| 410 | - retry_count=row.get("retry_count") or 0, | ||
| 411 | - llm_tokens=row.get("llm_tokens") or 0, | ||
| 412 | - execution_time_ms=row.get("execution_time_ms") or 0, | ||
| 413 | - start_time=format_datetime(row.get("start_time")), | ||
| 414 | - end_time=format_datetime(row.get("end_time")), | ||
| 415 | - create_time=format_datetime(row.get("create_time")), | ||
| 416 | - )) | ||
| 417 | - | ||
| 418 | - return ExecutionLogListResponse( | ||
| 419 | - total=len(items), | ||
| 420 | - items=items, | ||
| 421 | - ) | ||
| 422 | - | ||
| 423 | - finally: | ||
| 424 | - cursor.close() | ||
| 425 | - conn.close() | ||
| 426 | - | ||
| 427 | 329 | ||
| 428 | class PartSummaryResponse(BaseModel): | 330 | class PartSummaryResponse(BaseModel): |
| 429 | """配件汇总响应""" | 331 | """配件汇总响应""" |
| @@ -507,7 +409,6 @@ async def get_task_part_summaries( | @@ -507,7 +409,6 @@ async def get_task_part_summaries( | ||
| 507 | offset = (page - 1) * page_size | 409 | offset = (page - 1) * page_size |
| 508 | 410 | ||
| 509 | # 动态计算计划后库销比: (库存 + 建议) / 月均销 | 411 | # 动态计算计划后库销比: (库存 + 建议) / 月均销 |
| 510 | - # 注意: total_avg_sales_cnt 可能为 0, 需要处理除以零的情况 | ||
| 511 | query_sql = f""" | 412 | query_sql = f""" |
| 512 | SELECT *, | 413 | SELECT *, |
| 513 | ( | 414 | ( |
| @@ -611,83 +512,3 @@ async def get_part_shop_details( | @@ -611,83 +512,3 @@ async def get_part_shop_details( | ||
| 611 | cursor.close() | 512 | cursor.close() |
| 612 | conn.close() | 513 | conn.close() |
| 613 | 514 | ||
| 614 | - | ||
| 615 | -class AnalysisReportResponse(BaseModel): | ||
| 616 | - """分析报告响应""" | ||
| 617 | - id: int | ||
| 618 | - task_no: str | ||
| 619 | - group_id: int | ||
| 620 | - dealer_grouping_id: int | ||
| 621 | - dealer_grouping_name: Optional[str] = None | ||
| 622 | - report_type: str | ||
| 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 | - | ||
| 630 | - llm_provider: Optional[str] = None | ||
| 631 | - llm_model: Optional[str] = None | ||
| 632 | - llm_tokens: int = 0 | ||
| 633 | - execution_time_ms: int = 0 | ||
| 634 | - statistics_date: Optional[str] = None | ||
| 635 | - create_time: Optional[str] = None | ||
| 636 | - | ||
| 637 | - | ||
| 638 | -@router.get("/tasks/{task_no}/analysis-report", response_model=Optional[AnalysisReportResponse]) | ||
| 639 | -async def get_analysis_report(task_no: str): | ||
| 640 | - """获取任务的分析报告""" | ||
| 641 | - conn = get_connection() | ||
| 642 | - cursor = conn.cursor(dictionary=True) | ||
| 643 | - | ||
| 644 | - try: | ||
| 645 | - cursor.execute( | ||
| 646 | - """ | ||
| 647 | - SELECT * FROM ai_analysis_report | ||
| 648 | - WHERE task_no = %s | ||
| 649 | - ORDER BY create_time DESC | ||
| 650 | - LIMIT 1 | ||
| 651 | - """, | ||
| 652 | - (task_no,) | ||
| 653 | - ) | ||
| 654 | - row = cursor.fetchone() | ||
| 655 | - | ||
| 656 | - if not row: | ||
| 657 | - return None | ||
| 658 | - | ||
| 659 | - # 解析 JSON 字段 | ||
| 660 | - def parse_json(value): | ||
| 661 | - if value is None: | ||
| 662 | - return None | ||
| 663 | - if isinstance(value, dict): | ||
| 664 | - return value | ||
| 665 | - if isinstance(value, str): | ||
| 666 | - try: | ||
| 667 | - return json.loads(value) | ||
| 668 | - except (json.JSONDecodeError, TypeError): | ||
| 669 | - return None | ||
| 670 | - return None | ||
| 671 | - | ||
| 672 | - return AnalysisReportResponse( | ||
| 673 | - id=row["id"], | ||
| 674 | - task_no=row["task_no"], | ||
| 675 | - group_id=row["group_id"], | ||
| 676 | - dealer_grouping_id=row["dealer_grouping_id"], | ||
| 677 | - dealer_grouping_name=row.get("dealer_grouping_name"), | ||
| 678 | - report_type=row.get("report_type", "replenishment"), | ||
| 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")), | ||
| 683 | - llm_provider=row.get("llm_provider"), | ||
| 684 | - llm_model=row.get("llm_model"), | ||
| 685 | - llm_tokens=row.get("llm_tokens") or 0, | ||
| 686 | - execution_time_ms=row.get("execution_time_ms") or 0, | ||
| 687 | - statistics_date=row.get("statistics_date"), | ||
| 688 | - create_time=format_datetime(row.get("create_time")), | ||
| 689 | - ) | ||
| 690 | - | ||
| 691 | - finally: | ||
| 692 | - cursor.close() | ||
| 693 | - conn.close() |
src/fw_pms_ai/config/settings.py
| @@ -41,10 +41,6 @@ class Settings(BaseSettings): | @@ -41,10 +41,6 @@ class Settings(BaseSettings): | ||
| 41 | mysql_password: str = "" | 41 | mysql_password: str = "" |
| 42 | mysql_database: str = "fw_pms" | 42 | mysql_database: str = "fw_pms" |
| 43 | 43 | ||
| 44 | - # 定时任务配置 | ||
| 45 | - scheduler_cron_hour: int = 2 | ||
| 46 | - scheduler_cron_minute: int = 0 | ||
| 47 | - | ||
| 48 | # 服务配置 | 44 | # 服务配置 |
| 49 | server_port: int = 8009 | 45 | server_port: int = 8009 |
| 50 | 46 |
src/fw_pms_ai/models/__init__.py
| @@ -2,25 +2,17 @@ | @@ -2,25 +2,17 @@ | ||
| 2 | 2 | ||
| 3 | from .part_ratio import PartRatio | 3 | from .part_ratio import PartRatio |
| 4 | from .task import ReplenishmentTask, ReplenishmentDetail, TaskStatus | 4 | from .task import ReplenishmentTask, ReplenishmentDetail, TaskStatus |
| 5 | -from .execution_log import TaskExecutionLog, LogStatus | ||
| 6 | from .part_summary import ReplenishmentPartSummary | 5 | from .part_summary import ReplenishmentPartSummary |
| 7 | from .sql_result import SQLExecutionResult | 6 | from .sql_result import SQLExecutionResult |
| 8 | from .suggestion import ReplenishmentSuggestion, PartAnalysisResult | 7 | from .suggestion import ReplenishmentSuggestion, PartAnalysisResult |
| 9 | -from .analysis_report import AnalysisReport | ||
| 10 | 8 | ||
| 11 | __all__ = [ | 9 | __all__ = [ |
| 12 | "PartRatio", | 10 | "PartRatio", |
| 13 | "ReplenishmentTask", | 11 | "ReplenishmentTask", |
| 14 | "ReplenishmentDetail", | 12 | "ReplenishmentDetail", |
| 15 | "TaskStatus", | 13 | "TaskStatus", |
| 16 | - "TaskExecutionLog", | ||
| 17 | - "LogStatus", | ||
| 18 | "ReplenishmentPartSummary", | 14 | "ReplenishmentPartSummary", |
| 19 | "SQLExecutionResult", | 15 | "SQLExecutionResult", |
| 20 | "ReplenishmentSuggestion", | 16 | "ReplenishmentSuggestion", |
| 21 | "PartAnalysisResult", | 17 | "PartAnalysisResult", |
| 22 | - "AnalysisReport", | ||
| 23 | ] | 18 | ] |
| 24 | - | ||
| 25 | - | ||
| 26 | - |
src/fw_pms_ai/models/analysis_report.py deleted
| 1 | -""" | ||
| 2 | -数据模型 - 分析报告 | ||
| 3 | -四大板块:库存概览、销量分析、库存健康度、补货建议 | ||
| 4 | -""" | ||
| 5 | - | ||
| 6 | -from dataclasses import dataclass, field | ||
| 7 | -from datetime import datetime | ||
| 8 | -from typing import Any, Dict, Optional | ||
| 9 | - | ||
| 10 | - | ||
| 11 | -@dataclass | ||
| 12 | -class AnalysisReport: | ||
| 13 | - """分析报告数据模型""" | ||
| 14 | - | ||
| 15 | - task_no: str | ||
| 16 | - group_id: int | ||
| 17 | - dealer_grouping_id: int | ||
| 18 | - | ||
| 19 | - id: Optional[int] = None | ||
| 20 | - dealer_grouping_name: Optional[str] = None | ||
| 21 | - brand_grouping_id: Optional[int] = None | ||
| 22 | - report_type: str = "replenishment" | ||
| 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 | - | ||
| 30 | - # LLM 元数据 | ||
| 31 | - llm_provider: str = "" | ||
| 32 | - llm_model: str = "" | ||
| 33 | - llm_tokens: int = 0 | ||
| 34 | - execution_time_ms: int = 0 | ||
| 35 | - | ||
| 36 | - statistics_date: str = "" | ||
| 37 | - create_time: Optional[datetime] = None | ||
| 38 | - | ||
| 39 | - def to_dict(self) -> Dict[str, Any]: | ||
| 40 | - """将报告转换为可序列化的字典""" | ||
| 41 | - return { | ||
| 42 | - "id": self.id, | ||
| 43 | - "task_no": self.task_no, | ||
| 44 | - "group_id": self.group_id, | ||
| 45 | - "dealer_grouping_id": self.dealer_grouping_id, | ||
| 46 | - "dealer_grouping_name": self.dealer_grouping_name, | ||
| 47 | - "brand_grouping_id": self.brand_grouping_id, | ||
| 48 | - "report_type": self.report_type, | ||
| 49 | - "inventory_overview": self.inventory_overview, | ||
| 50 | - "sales_analysis": self.sales_analysis, | ||
| 51 | - "inventory_health": self.inventory_health, | ||
| 52 | - "replenishment_summary": self.replenishment_summary, | ||
| 53 | - "llm_provider": self.llm_provider, | ||
| 54 | - "llm_model": self.llm_model, | ||
| 55 | - "llm_tokens": self.llm_tokens, | ||
| 56 | - "execution_time_ms": self.execution_time_ms, | ||
| 57 | - "statistics_date": self.statistics_date, | ||
| 58 | - "create_time": self.create_time.isoformat() if self.create_time else None, | ||
| 59 | - } |
src/fw_pms_ai/models/execution_log.py deleted
| 1 | -""" | ||
| 2 | -任务执行日志模型和LLM建议明细模型 | ||
| 3 | -""" | ||
| 4 | - | ||
| 5 | -from dataclasses import dataclass, field | ||
| 6 | -from datetime import datetime | ||
| 7 | -from decimal import Decimal | ||
| 8 | -from typing import Optional | ||
| 9 | -from enum import IntEnum | ||
| 10 | - | ||
| 11 | - | ||
| 12 | -class LogStatus(IntEnum): | ||
| 13 | - """日志状态""" | ||
| 14 | - RUNNING = 0 | ||
| 15 | - SUCCESS = 1 | ||
| 16 | - FAILED = 2 | ||
| 17 | - SKIPPED = 3 | ||
| 18 | - | ||
| 19 | - | ||
| 20 | -@dataclass | ||
| 21 | -class TaskExecutionLog: | ||
| 22 | - """任务执行日志""" | ||
| 23 | - task_no: str | ||
| 24 | - group_id: int | ||
| 25 | - dealer_grouping_id: int | ||
| 26 | - step_name: str | ||
| 27 | - | ||
| 28 | - id: Optional[int] = None | ||
| 29 | - brand_grouping_id: Optional[int] = None | ||
| 30 | - brand_grouping_name: str = "" | ||
| 31 | - dealer_grouping_name: str = "" | ||
| 32 | - step_order: int = 0 | ||
| 33 | - status: LogStatus = LogStatus.RUNNING | ||
| 34 | - input_data: str = "" | ||
| 35 | - output_data: str = "" | ||
| 36 | - error_message: str = "" | ||
| 37 | - retry_count: int = 0 | ||
| 38 | - sql_query: str = "" | ||
| 39 | - llm_prompt: str = "" | ||
| 40 | - llm_response: str = "" | ||
| 41 | - llm_tokens: int = 0 | ||
| 42 | - execution_time_ms: int = 0 | ||
| 43 | - start_time: Optional[datetime] = None | ||
| 44 | - end_time: Optional[datetime] = None | ||
| 45 | - create_time: Optional[datetime] = None | ||
| 46 | - | ||
| 47 | - | ||
| 48 | - |
src/fw_pms_ai/scheduler/__init__.py deleted
src/fw_pms_ai/scheduler/tasks.py deleted
| 1 | -""" | ||
| 2 | -定时任务 | ||
| 3 | -使用 APScheduler 实现每日凌晨执行 | ||
| 4 | -""" | ||
| 5 | - | ||
| 6 | -import logging | ||
| 7 | -import argparse | ||
| 8 | -from datetime import date | ||
| 9 | - | ||
| 10 | -from apscheduler.schedulers.blocking import BlockingScheduler | ||
| 11 | -from apscheduler.triggers.cron import CronTrigger | ||
| 12 | - | ||
| 13 | -from ..config import get_settings | ||
| 14 | -from ..agent import ReplenishmentAgent | ||
| 15 | - | ||
| 16 | -logger = logging.getLogger(__name__) | ||
| 17 | - | ||
| 18 | - | ||
| 19 | -def run_replenishment_task(): | ||
| 20 | - """执行补货建议任务""" | ||
| 21 | - logger.info("="*50) | ||
| 22 | - logger.info("开始执行 AI 补货建议定时任务") | ||
| 23 | - logger.info("="*50) | ||
| 24 | - | ||
| 25 | - try: | ||
| 26 | - agent = ReplenishmentAgent() | ||
| 27 | - | ||
| 28 | - # 默认配置 - 可从数据库读取 | ||
| 29 | - group_id = 2 | ||
| 30 | - | ||
| 31 | - agent.run_for_all_groupings( | ||
| 32 | - group_id=group_id, | ||
| 33 | - ) | ||
| 34 | - | ||
| 35 | - logger.info("="*50) | ||
| 36 | - logger.info("AI 补货建议定时任务执行完成") | ||
| 37 | - logger.info("="*50) | ||
| 38 | - | ||
| 39 | - except Exception as e: | ||
| 40 | - logger.error(f"定时任务执行失败: {e}", exc_info=True) | ||
| 41 | - raise | ||
| 42 | - | ||
| 43 | - | ||
| 44 | -def start_scheduler(): | ||
| 45 | - """启动定时调度""" | ||
| 46 | - settings = get_settings() | ||
| 47 | - | ||
| 48 | - scheduler = BlockingScheduler() | ||
| 49 | - | ||
| 50 | - # 添加定时任务 | ||
| 51 | - trigger = CronTrigger( | ||
| 52 | - hour=settings.scheduler_cron_hour, | ||
| 53 | - minute=settings.scheduler_cron_minute, | ||
| 54 | - ) | ||
| 55 | - | ||
| 56 | - scheduler.add_job( | ||
| 57 | - run_replenishment_task, | ||
| 58 | - trigger=trigger, | ||
| 59 | - id="replenishment_task", | ||
| 60 | - name="AI 补货建议任务", | ||
| 61 | - replace_existing=True, | ||
| 62 | - ) | ||
| 63 | - | ||
| 64 | - logger.info( | ||
| 65 | - f"定时任务已配置: 每日 {settings.scheduler_cron_hour:02d}:{settings.scheduler_cron_minute:02d} 执行" | ||
| 66 | - ) | ||
| 67 | - | ||
| 68 | - try: | ||
| 69 | - logger.info("调度器启动...") | ||
| 70 | - scheduler.start() | ||
| 71 | - except (KeyboardInterrupt, SystemExit): | ||
| 72 | - logger.info("调度器停止") | ||
| 73 | - scheduler.shutdown() | ||
| 74 | - | ||
| 75 | - | ||
| 76 | -def main(): | ||
| 77 | - """CLI 入口""" | ||
| 78 | - parser = argparse.ArgumentParser(description="AI 补货建议定时任务") | ||
| 79 | - parser.add_argument( | ||
| 80 | - "--run-once", | ||
| 81 | - action="store_true", | ||
| 82 | - help="立即执行一次(不启动调度器)", | ||
| 83 | - ) | ||
| 84 | - parser.add_argument( | ||
| 85 | - "--group-id", | ||
| 86 | - type=int, | ||
| 87 | - default=2, | ||
| 88 | - help="集团ID (默认: 2)", | ||
| 89 | - ) | ||
| 90 | - parser.add_argument( | ||
| 91 | - "--dealer-grouping-id", | ||
| 92 | - type=int, | ||
| 93 | - help="指定商家组合ID (可选)", | ||
| 94 | - ) | ||
| 95 | - | ||
| 96 | - args = parser.parse_args() | ||
| 97 | - | ||
| 98 | - # 配置日志 | ||
| 99 | - logging.basicConfig( | ||
| 100 | - level=logging.INFO, | ||
| 101 | - format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", | ||
| 102 | - ) | ||
| 103 | - | ||
| 104 | - if args.run_once: | ||
| 105 | - logger.info("单次执行模式") | ||
| 106 | - | ||
| 107 | - if args.dealer_grouping_id: | ||
| 108 | - # 指定商家组合 | ||
| 109 | - from ..services import DataService | ||
| 110 | - | ||
| 111 | - agent = ReplenishmentAgent() | ||
| 112 | - data_service = DataService() | ||
| 113 | - | ||
| 114 | - try: | ||
| 115 | - groupings = data_service.get_dealer_groupings(args.group_id) | ||
| 116 | - grouping = next((g for g in groupings if g["id"] == args.dealer_grouping_id), None) | ||
| 117 | - | ||
| 118 | - if not grouping: | ||
| 119 | - logger.error(f"未找到商家组合: {args.dealer_grouping_id}") | ||
| 120 | - return | ||
| 121 | - | ||
| 122 | - # 直接使用预计划数据,不需要 shop_ids | ||
| 123 | - agent.run( | ||
| 124 | - group_id=args.group_id, | ||
| 125 | - dealer_grouping_id=grouping["id"], | ||
| 126 | - dealer_grouping_name=grouping["name"], | ||
| 127 | - ) | ||
| 128 | - | ||
| 129 | - finally: | ||
| 130 | - data_service.close() | ||
| 131 | - else: | ||
| 132 | - # 所有商家组合 | ||
| 133 | - run_replenishment_task() | ||
| 134 | - else: | ||
| 135 | - start_scheduler() | ||
| 136 | - | ||
| 137 | - | ||
| 138 | -if __name__ == "__main__": | ||
| 139 | - main() |
src/fw_pms_ai/services/__init__.py
| @@ -2,15 +2,12 @@ | @@ -2,15 +2,12 @@ | ||
| 2 | 2 | ||
| 3 | from .data_service import DataService | 3 | from .data_service import DataService |
| 4 | from .result_writer import ResultWriter | 4 | from .result_writer import ResultWriter |
| 5 | -from .repository import TaskRepository, DetailRepository, LogRepository, SummaryRepository | 5 | +from .repository import TaskRepository, DetailRepository, SummaryRepository |
| 6 | 6 | ||
| 7 | __all__ = [ | 7 | __all__ = [ |
| 8 | "DataService", | 8 | "DataService", |
| 9 | "ResultWriter", | 9 | "ResultWriter", |
| 10 | "TaskRepository", | 10 | "TaskRepository", |
| 11 | "DetailRepository", | 11 | "DetailRepository", |
| 12 | - "LogRepository", | ||
| 13 | "SummaryRepository", | 12 | "SummaryRepository", |
| 14 | ] | 13 | ] |
| 15 | - | ||
| 16 | - |
src/fw_pms_ai/services/repository/__init__.py
| @@ -6,11 +6,10 @@ Repository 数据访问层子包 | @@ -6,11 +6,10 @@ Repository 数据访问层子包 | ||
| 6 | 6 | ||
| 7 | from .task_repo import TaskRepository | 7 | from .task_repo import TaskRepository |
| 8 | from .detail_repo import DetailRepository | 8 | from .detail_repo import DetailRepository |
| 9 | -from .log_repo import LogRepository, SummaryRepository | 9 | +from .summary_repo import SummaryRepository |
| 10 | 10 | ||
| 11 | __all__ = [ | 11 | __all__ = [ |
| 12 | "TaskRepository", | 12 | "TaskRepository", |
| 13 | "DetailRepository", | 13 | "DetailRepository", |
| 14 | - "LogRepository", | ||
| 15 | "SummaryRepository", | 14 | "SummaryRepository", |
| 16 | ] | 15 | ] |
src/fw_pms_ai/services/repository/log_repo.py renamed to src/fw_pms_ai/services/repository/summary_repo.py
| 1 | """ | 1 | """ |
| 2 | -日志和汇总数据访问层 | 2 | +配件汇总数据访问层 |
| 3 | 3 | ||
| 4 | -提供 ai_task_execution_log 和 ai_replenishment_part_summary 表的 CRUD 操作 | 4 | +提供 ai_replenishment_part_summary 表的 CRUD 操作 |
| 5 | """ | 5 | """ |
| 6 | 6 | ||
| 7 | import logging | 7 | import logging |
| 8 | from typing import List | 8 | from typing import List |
| 9 | 9 | ||
| 10 | from ..db import get_connection | 10 | from ..db import get_connection |
| 11 | -from ...models import TaskExecutionLog, ReplenishmentPartSummary | 11 | +from ...models import ReplenishmentPartSummary |
| 12 | 12 | ||
| 13 | logger = logging.getLogger(__name__) | 13 | logger = logging.getLogger(__name__) |
| 14 | 14 | ||
| 15 | 15 | ||
| 16 | -class LogRepository: | ||
| 17 | - """执行日志数据访问""" | ||
| 18 | - | ||
| 19 | - def __init__(self, connection=None): | ||
| 20 | - self._conn = connection | ||
| 21 | - | ||
| 22 | - def _get_connection(self): | ||
| 23 | - """获取数据库连接""" | ||
| 24 | - if self._conn is None or not self._conn.is_connected(): | ||
| 25 | - self._conn = get_connection() | ||
| 26 | - return self._conn | ||
| 27 | - | ||
| 28 | - def close(self): | ||
| 29 | - """关闭连接""" | ||
| 30 | - if self._conn and self._conn.is_connected(): | ||
| 31 | - self._conn.close() | ||
| 32 | - self._conn = None | ||
| 33 | - | ||
| 34 | - def create(self, log: TaskExecutionLog) -> int: | ||
| 35 | - """ | ||
| 36 | - 保存执行日志 | ||
| 37 | - | ||
| 38 | - Returns: | ||
| 39 | - 插入的日志ID | ||
| 40 | - """ | ||
| 41 | - conn = self._get_connection() | ||
| 42 | - cursor = conn.cursor() | ||
| 43 | - | ||
| 44 | - try: | ||
| 45 | - sql = """ | ||
| 46 | - INSERT INTO ai_task_execution_log ( | ||
| 47 | - task_no, group_id, dealer_grouping_id, brand_grouping_id, | ||
| 48 | - brand_grouping_name, dealer_grouping_name, | ||
| 49 | - step_name, step_order, status, input_data, output_data, | ||
| 50 | - error_message, retry_count, sql_query, llm_prompt, llm_response, | ||
| 51 | - llm_tokens, execution_time_ms, start_time, end_time, create_time | ||
| 52 | - ) VALUES ( | ||
| 53 | - %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, | ||
| 54 | - %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW() | ||
| 55 | - ) | ||
| 56 | - """ | ||
| 57 | - | ||
| 58 | - values = ( | ||
| 59 | - log.task_no, log.group_id, log.dealer_grouping_id, | ||
| 60 | - log.brand_grouping_id, log.brand_grouping_name, | ||
| 61 | - log.dealer_grouping_name, | ||
| 62 | - log.step_name, log.step_order, int(log.status), | ||
| 63 | - log.input_data, log.output_data, log.error_message, | ||
| 64 | - log.retry_count, log.sql_query, log.llm_prompt, log.llm_response, | ||
| 65 | - log.llm_tokens, log.execution_time_ms, | ||
| 66 | - log.start_time, log.end_time, | ||
| 67 | - ) | ||
| 68 | - | ||
| 69 | - cursor.execute(sql, values) | ||
| 70 | - conn.commit() | ||
| 71 | - | ||
| 72 | - return cursor.lastrowid | ||
| 73 | - | ||
| 74 | - finally: | ||
| 75 | - cursor.close() | ||
| 76 | - | ||
| 77 | - | ||
| 78 | class SummaryRepository: | 16 | class SummaryRepository: |
| 79 | """配件汇总数据访问""" | 17 | """配件汇总数据访问""" |
| 80 | 18 |
src/fw_pms_ai/services/result_writer.py
| @@ -12,9 +12,7 @@ from .db import get_connection | @@ -12,9 +12,7 @@ from .db import get_connection | ||
| 12 | from ..models import ( | 12 | from ..models import ( |
| 13 | ReplenishmentTask, | 13 | ReplenishmentTask, |
| 14 | ReplenishmentDetail, | 14 | ReplenishmentDetail, |
| 15 | - TaskExecutionLog, | ||
| 16 | ReplenishmentPartSummary, | 15 | ReplenishmentPartSummary, |
| 17 | - AnalysisReport, | ||
| 18 | ) | 16 | ) |
| 19 | 17 | ||
| 20 | logger = logging.getLogger(__name__) | 18 | logger = logging.getLogger(__name__) |
| @@ -188,54 +186,10 @@ class ResultWriter: | @@ -188,54 +186,10 @@ class ResultWriter: | ||
| 188 | finally: | 186 | finally: |
| 189 | cursor.close() | 187 | cursor.close() |
| 190 | 188 | ||
| 191 | - def save_execution_log(self, log: TaskExecutionLog) -> int: | ||
| 192 | - """ | ||
| 193 | - 保存执行日志 | ||
| 194 | - | ||
| 195 | - Returns: | ||
| 196 | - 插入的日志ID | ||
| 197 | - """ | ||
| 198 | - conn = self._get_connection() | ||
| 199 | - cursor = conn.cursor() | ||
| 200 | - | ||
| 201 | - try: | ||
| 202 | - sql = """ | ||
| 203 | - INSERT INTO ai_task_execution_log ( | ||
| 204 | - task_no, group_id, dealer_grouping_id, brand_grouping_id, | ||
| 205 | - brand_grouping_name, dealer_grouping_name, | ||
| 206 | - step_name, step_order, status, input_data, output_data, | ||
| 207 | - error_message, retry_count, sql_query, llm_prompt, llm_response, | ||
| 208 | - llm_tokens, execution_time_ms, start_time, end_time, create_time | ||
| 209 | - ) VALUES ( | ||
| 210 | - %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, | ||
| 211 | - %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW() | ||
| 212 | - ) | ||
| 213 | - """ | ||
| 214 | - | ||
| 215 | - values = ( | ||
| 216 | - log.task_no, log.group_id, log.dealer_grouping_id, | ||
| 217 | - log.brand_grouping_id, log.brand_grouping_name, | ||
| 218 | - log.dealer_grouping_name, | ||
| 219 | - log.step_name, log.step_order, int(log.status), | ||
| 220 | - log.input_data, log.output_data, log.error_message, | ||
| 221 | - log.retry_count, log.sql_query, log.llm_prompt, log.llm_response, | ||
| 222 | - log.llm_tokens, log.execution_time_ms, | ||
| 223 | - log.start_time, log.end_time, | ||
| 224 | - ) | ||
| 225 | - | ||
| 226 | - cursor.execute(sql, values) | ||
| 227 | - conn.commit() | ||
| 228 | - | ||
| 229 | - log_id = cursor.lastrowid | ||
| 230 | - return log_id | ||
| 231 | - | ||
| 232 | - finally: | ||
| 233 | - cursor.close() | ||
| 234 | - | ||
| 235 | def save_part_summaries(self, summaries: List[ReplenishmentPartSummary]) -> int: | 189 | def save_part_summaries(self, summaries: List[ReplenishmentPartSummary]) -> int: |
| 236 | """ | 190 | """ |
| 237 | 保存配件汇总信息 | 191 | 保存配件汇总信息 |
| 238 | - | 192 | + |
| 239 | Returns: | 193 | Returns: |
| 240 | 插入的行数 | 194 | 插入的行数 |
| 241 | """ | 195 | """ |
| @@ -258,7 +212,7 @@ class ResultWriter: | @@ -258,7 +212,7 @@ class ResultWriter: | ||
| 258 | %s, %s, %s, %s, %s, %s, %s, %s, NOW() | 212 | %s, %s, %s, %s, %s, %s, %s, %s, NOW() |
| 259 | ) | 213 | ) |
| 260 | """ | 214 | """ |
| 261 | - | 215 | + |
| 262 | values = [ | 216 | values = [ |
| 263 | ( | 217 | ( |
| 264 | s.task_no, s.group_id, s.dealer_grouping_id, s.part_code, s.part_name, | 218 | s.task_no, s.group_id, s.dealer_grouping_id, s.part_code, s.part_name, |
| @@ -271,10 +225,10 @@ class ResultWriter: | @@ -271,10 +225,10 @@ class ResultWriter: | ||
| 271 | ) | 225 | ) |
| 272 | for s in summaries | 226 | for s in summaries |
| 273 | ] | 227 | ] |
| 274 | - | 228 | + |
| 275 | cursor.executemany(sql, values) | 229 | cursor.executemany(sql, values) |
| 276 | conn.commit() | 230 | conn.commit() |
| 277 | - | 231 | + |
| 278 | logger.info(f"保存配件汇总: {cursor.rowcount}条") | 232 | logger.info(f"保存配件汇总: {cursor.rowcount}条") |
| 279 | return cursor.rowcount | 233 | return cursor.rowcount |
| 280 | 234 | ||
| @@ -284,7 +238,7 @@ class ResultWriter: | @@ -284,7 +238,7 @@ class ResultWriter: | ||
| 284 | def delete_part_summaries_by_task(self, task_no: str) -> int: | 238 | def delete_part_summaries_by_task(self, task_no: str) -> int: |
| 285 | """ | 239 | """ |
| 286 | 删除指定任务的配件汇总 | 240 | 删除指定任务的配件汇总 |
| 287 | - | 241 | + |
| 288 | Returns: | 242 | Returns: |
| 289 | 删除的行数 | 243 | 删除的行数 |
| 290 | """ | 244 | """ |
| @@ -295,7 +249,7 @@ class ResultWriter: | @@ -295,7 +249,7 @@ class ResultWriter: | ||
| 295 | sql = "DELETE FROM ai_replenishment_part_summary WHERE task_no = %s" | 249 | sql = "DELETE FROM ai_replenishment_part_summary WHERE task_no = %s" |
| 296 | cursor.execute(sql, (task_no,)) | 250 | cursor.execute(sql, (task_no,)) |
| 297 | conn.commit() | 251 | conn.commit() |
| 298 | - | 252 | + |
| 299 | logger.info(f"删除配件汇总: task_no={task_no}, rows={cursor.rowcount}") | 253 | logger.info(f"删除配件汇总: task_no={task_no}, rows={cursor.rowcount}") |
| 300 | return cursor.rowcount | 254 | return cursor.rowcount |
| 301 | 255 | ||
| @@ -305,7 +259,7 @@ class ResultWriter: | @@ -305,7 +259,7 @@ class ResultWriter: | ||
| 305 | def delete_details_by_task(self, task_no: str) -> int: | 259 | def delete_details_by_task(self, task_no: str) -> int: |
| 306 | """ | 260 | """ |
| 307 | 删除指定任务的补货明细 | 261 | 删除指定任务的补货明细 |
| 308 | - | 262 | + |
| 309 | Returns: | 263 | Returns: |
| 310 | 删除的行数 | 264 | 删除的行数 |
| 311 | """ | 265 | """ |
| @@ -316,65 +270,9 @@ class ResultWriter: | @@ -316,65 +270,9 @@ class ResultWriter: | ||
| 316 | sql = "DELETE FROM ai_replenishment_detail WHERE task_no = %s" | 270 | sql = "DELETE FROM ai_replenishment_detail WHERE task_no = %s" |
| 317 | cursor.execute(sql, (task_no,)) | 271 | cursor.execute(sql, (task_no,)) |
| 318 | conn.commit() | 272 | conn.commit() |
| 319 | - | 273 | + |
| 320 | logger.info(f"删除补货明细: task_no={task_no}, rows={cursor.rowcount}") | 274 | logger.info(f"删除补货明细: task_no={task_no}, rows={cursor.rowcount}") |
| 321 | return cursor.rowcount | 275 | return cursor.rowcount |
| 322 | 276 | ||
| 323 | finally: | 277 | finally: |
| 324 | cursor.close() | 278 | cursor.close() |
| 325 | - | ||
| 326 | - def save_analysis_report(self, report: AnalysisReport) -> int: | ||
| 327 | - """ | ||
| 328 | - 保存分析报告(四大板块 JSON 结构) | ||
| 329 | - | ||
| 330 | - Returns: | ||
| 331 | - 插入的报告ID | ||
| 332 | - """ | ||
| 333 | - conn = self._get_connection() | ||
| 334 | - cursor = conn.cursor() | ||
| 335 | - | ||
| 336 | - try: | ||
| 337 | - sql = """ | ||
| 338 | - INSERT INTO ai_analysis_report ( | ||
| 339 | - task_no, group_id, dealer_grouping_id, dealer_grouping_name, | ||
| 340 | - brand_grouping_id, report_type, | ||
| 341 | - inventory_overview, sales_analysis, | ||
| 342 | - inventory_health, replenishment_summary, | ||
| 343 | - llm_provider, llm_model, llm_tokens, execution_time_ms, | ||
| 344 | - statistics_date, create_time | ||
| 345 | - ) VALUES ( | ||
| 346 | - %s, %s, %s, %s, %s, %s, | ||
| 347 | - %s, %s, %s, %s, | ||
| 348 | - %s, %s, %s, %s, | ||
| 349 | - %s, NOW() | ||
| 350 | - ) | ||
| 351 | - """ | ||
| 352 | - | ||
| 353 | - values = ( | ||
| 354 | - report.task_no, | ||
| 355 | - report.group_id, | ||
| 356 | - report.dealer_grouping_id, | ||
| 357 | - report.dealer_grouping_name, | ||
| 358 | - report.brand_grouping_id, | ||
| 359 | - report.report_type, | ||
| 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, | ||
| 364 | - report.llm_provider, | ||
| 365 | - report.llm_model, | ||
| 366 | - report.llm_tokens, | ||
| 367 | - report.execution_time_ms, | ||
| 368 | - report.statistics_date, | ||
| 369 | - ) | ||
| 370 | - | ||
| 371 | - cursor.execute(sql, values) | ||
| 372 | - conn.commit() | ||
| 373 | - | ||
| 374 | - report_id = cursor.lastrowid | ||
| 375 | - logger.info(f"保存分析报告: task_no={report.task_no}, id={report_id}") | ||
| 376 | - return report_id | ||
| 377 | - | ||
| 378 | - finally: | ||
| 379 | - cursor.close() | ||
| 380 | - |
ui/js/api.js
| @@ -70,12 +70,7 @@ const API = { | @@ -70,12 +70,7 @@ const API = { | ||
| 70 | return this.get('/stats/summary'); | 70 | return this.get('/stats/summary'); |
| 71 | }, | 71 | }, |
| 72 | 72 | ||
| 73 | - /** | ||
| 74 | - * 获取任务执行日志 | ||
| 75 | - */ | ||
| 76 | - async getTaskLogs(taskNo) { | ||
| 77 | - return this.get(`/tasks/${taskNo}/logs`); | ||
| 78 | - }, | 73 | + |
| 79 | 74 | ||
| 80 | /** | 75 | /** |
| 81 | * 获取任务配件汇总列表 | 76 | * 获取任务配件汇总列表 |
| @@ -91,12 +86,7 @@ const API = { | @@ -91,12 +86,7 @@ const API = { | ||
| 91 | return this.get(`/tasks/${taskNo}/parts/${encodeURIComponent(partCode)}/shops`); | 86 | return this.get(`/tasks/${taskNo}/parts/${encodeURIComponent(partCode)}/shops`); |
| 92 | }, | 87 | }, |
| 93 | 88 | ||
| 94 | - /** | ||
| 95 | - * 获取任务分析报告 | ||
| 96 | - */ | ||
| 97 | - async getAnalysisReport(taskNo) { | ||
| 98 | - return this.get(`/tasks/${taskNo}/analysis-report`); | ||
| 99 | - }, | 89 | + |
| 100 | }; | 90 | }; |
| 101 | 91 | ||
| 102 | // 导出到全局 | 92 | // 导出到全局 |
ui/js/app.js
| @@ -97,889 +97,6 @@ const App = { | @@ -97,889 +97,6 @@ const App = { | ||
| 97 | }, | 97 | }, |
| 98 | 98 | ||
| 99 | 99 | ||
| 100 | - /** | ||
| 101 | - * 渲染分析报告标签页(四大板块:库存概览/销量分析/健康度/补货建议) | ||
| 102 | - */ | ||
| 103 | - async renderReportTab(container, taskNo) { | ||
| 104 | - container.innerHTML = '<div class="loading-shops">加载分析报告...</div>'; | ||
| 105 | - | ||
| 106 | - try { | ||
| 107 | - const report = await API.getAnalysisReport(taskNo); | ||
| 108 | - | ||
| 109 | - if (!report) { | ||
| 110 | - container.innerHTML = ` | ||
| 111 | - <div class="card"> | ||
| 112 | - ${Components.renderEmptyState('file-x', '暂无分析报告', '该任务尚未生成分析报告')} | ||
| 113 | - </div> | ||
| 114 | - `; | ||
| 115 | - return; | ||
| 116 | - } | ||
| 117 | - | ||
| 118 | - container.innerHTML = ` | ||
| 119 | - <div 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> | ||
| 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 | - ); | ||
| 141 | - | ||
| 142 | - lucide.createIcons(); | ||
| 143 | - } catch (error) { | ||
| 144 | - container.innerHTML = ` | ||
| 145 | - <div class="card" style="text-align: center; color: var(--color-danger);"> | ||
| 146 | - <i data-lucide="alert-circle" style="width: 48px; height: 48px; margin-bottom: 1rem;"></i> | ||
| 147 | - <p>加载报告失败: ${error.message}</p> | ||
| 148 | - </div> | ||
| 149 | - `; | ||
| 150 | - lucide.createIcons(); | ||
| 151 | - } | ||
| 152 | - }, | ||
| 153 | - | ||
| 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 || {}; | ||
| 164 | - | ||
| 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>` : ''; | ||
| 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 | - `; | ||
| 281 | - | ||
| 282 | - // 绑定折叠事件 | ||
| 283 | - this._bindProcessToggle(container); | ||
| 284 | - }, | ||
| 285 | - | ||
| 286 | - /** | ||
| 287 | - * 渲染销量分析板块 | ||
| 288 | - */ | ||
| 289 | - renderSalesAnalysis(container, data) { | ||
| 290 | - if (!data) { | ||
| 291 | - container.innerHTML = ''; | ||
| 292 | - return; | ||
| 293 | - } | ||
| 294 | - const stats = data.stats || {}; | ||
| 295 | - const analysis = data.llm_analysis || {}; | ||
| 296 | - | ||
| 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> | ||
| 367 | - </div> | ||
| 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> | ||
| 371 | - </div> | ||
| 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 | - `; | ||
| 411 | - | ||
| 412 | - // 绑定折叠事件 | ||
| 413 | - this._bindProcessToggle(container); | ||
| 414 | - }, | ||
| 415 | - | ||
| 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 || {}; | ||
| 427 | - | ||
| 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 = []; | ||
| 443 | - | ||
| 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> | ||
| 512 | - </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> | ||
| 517 | - </div> | ||
| 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); | ||
| 544 | - | ||
| 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 | - }); | ||
| 600 | - } | ||
| 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 | - }); | ||
| 632 | - } | ||
| 633 | - }, | ||
| 634 | - | ||
| 635 | - /** | ||
| 636 | - * 渲染补货建议板块 | ||
| 637 | - */ | ||
| 638 | - renderReplenishmentSummary(container, data) { | ||
| 639 | - if (!data) { | ||
| 640 | - container.innerHTML = ''; | ||
| 641 | - return; | ||
| 642 | - } | ||
| 643 | - const stats = data.stats || {}; | ||
| 644 | - const analysis = data.llm_analysis || {}; | ||
| 645 | - | ||
| 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>` : ''; | ||
| 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 | - `; | ||
| 762 | - | ||
| 763 | - // 绑定折叠事件 | ||
| 764 | - this._bindProcessToggle(container); | ||
| 765 | - }, | ||
| 766 | - | ||
| 767 | - /** | ||
| 768 | - * 渲染推理过程(可折叠) | ||
| 769 | - */ | ||
| 770 | - _renderAnalysisProcess(process, moduleId) { | ||
| 771 | - if (!process) return ''; | ||
| 772 | - | ||
| 773 | - const sections = []; | ||
| 774 | - | ||
| 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 | - } | ||
| 785 | - | ||
| 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 | - } | ||
| 797 | - } | ||
| 798 | - | ||
| 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 | - } | ||
| 812 | - } | ||
| 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 | - } | ||
| 825 | - } | ||
| 826 | - | ||
| 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 | - } | ||
| 838 | - | ||
| 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 | - } | ||
| 849 | - | ||
| 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 | - } | ||
| 860 | - } | ||
| 861 | - | ||
| 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 | - } | ||
| 876 | - } | ||
| 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 | - } | ||
| 888 | - } | ||
| 889 | - | ||
| 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 | - }); | ||
| 982 | - }, | ||
| 983 | 100 | ||
| 984 | /** | 101 | /** |
| 985 | * 初始化应用 | 102 | * 初始化应用 |
| @@ -1310,14 +427,13 @@ const App = { | @@ -1310,14 +427,13 @@ const App = { | ||
| 1310 | try { | 427 | try { |
| 1311 | Components.showLoading(); | 428 | Components.showLoading(); |
| 1312 | 429 | ||
| 1313 | - const [task, partSummaries, logs] = await Promise.all([ | 430 | + const [task, partSummaries] = await Promise.all([ |
| 1314 | API.getTask(taskNo), | 431 | API.getTask(taskNo), |
| 1315 | API.getPartSummaries(taskNo, { page: 1, page_size: 100 }).catch(() => ({ items: [], total: 0 })), | 432 | API.getPartSummaries(taskNo, { page: 1, page_size: 100 }).catch(() => ({ items: [], total: 0 })), |
| 1316 | - API.getTaskLogs(taskNo).catch(() => ({ items: [] })), | ||
| 1317 | ]); | 433 | ]); |
| 1318 | 434 | ||
| 1319 | Components.hideLoading(); | 435 | Components.hideLoading(); |
| 1320 | - this.renderTaskDetail(task, partSummaries, logs); | 436 | + this.renderTaskDetail(task, partSummaries); |
| 1321 | lucide.createIcons(); | 437 | lucide.createIcons(); |
| 1322 | } catch (error) { | 438 | } catch (error) { |
| 1323 | Components.hideLoading(); | 439 | Components.hideLoading(); |
| @@ -1328,8 +444,7 @@ const App = { | @@ -1328,8 +444,7 @@ const App = { | ||
| 1328 | /** | 444 | /** |
| 1329 | * 渲染任务详情 | 445 | * 渲染任务详情 |
| 1330 | */ | 446 | */ |
| 1331 | - renderTaskDetail(task, partSummaries, logs) { | ||
| 1332 | - this._currentLogs = logs; | 447 | + renderTaskDetail(task, partSummaries) { |
| 1333 | this._partSummaries = partSummaries; | 448 | this._partSummaries = partSummaries; |
| 1334 | const container = document.getElementById('task-detail-container'); | 449 | const container = document.getElementById('task-detail-container'); |
| 1335 | 450 | ||
| @@ -1378,14 +493,6 @@ const App = { | @@ -1378,14 +493,6 @@ const App = { | ||
| 1378 | <i data-lucide="list"></i> | 493 | <i data-lucide="list"></i> |
| 1379 | 配件明细 | 494 | 配件明细 |
| 1380 | </button> | 495 | </button> |
| 1381 | - <button class="tab" data-tab="report"> | ||
| 1382 | - <i data-lucide="file-text"></i> | ||
| 1383 | - 分析报告 | ||
| 1384 | - </button> | ||
| 1385 | - <button class="tab" data-tab="logs"> | ||
| 1386 | - <i data-lucide="activity"></i> | ||
| 1387 | - 执行日志 | ||
| 1388 | - </button> | ||
| 1389 | <button class="tab" data-tab="info"> | 496 | <button class="tab" data-tab="info"> |
| 1390 | <i data-lucide="info"></i> | 497 | <i data-lucide="info"></i> |
| 1391 | 任务信息 | 498 | 任务信息 |
| @@ -1421,12 +528,6 @@ const App = { | @@ -1421,12 +528,6 @@ const App = { | ||
| 1421 | this.renderDetailsTab(container, details); | 528 | this.renderDetailsTab(container, details); |
| 1422 | break; | 529 | break; |
| 1423 | 530 | ||
| 1424 | - case 'logs': | ||
| 1425 | - this.renderLogsTab(container, this._currentLogs); | ||
| 1426 | - break; | ||
| 1427 | - case 'report': | ||
| 1428 | - this.renderReportTab(container, task.task_no); | ||
| 1429 | - break; | ||
| 1430 | case 'info': | 531 | case 'info': |
| 1431 | this.renderInfoTab(container, task); | 532 | this.renderInfoTab(container, task); |
| 1432 | break; | 533 | break; |
| @@ -1761,78 +862,6 @@ const App = { | @@ -1761,78 +862,6 @@ const App = { | ||
| 1761 | /** | 862 | /** |
| 1762 | * 渲染执行日志标签页 | 863 | * 渲染执行日志标签页 |
| 1763 | */ | 864 | */ |
| 1764 | - renderLogsTab(container, logs) { | ||
| 1765 | - if (!logs || !logs.items || logs.items.length === 0) { | ||
| 1766 | - container.innerHTML = ` | ||
| 1767 | - <div class="card"> | ||
| 1768 | - ${Components.renderEmptyState('activity', '暂无执行日志', '该任务没有执行日志记录')} | ||
| 1769 | - </div> | ||
| 1770 | - `; | ||
| 1771 | - return; | ||
| 1772 | - } | ||
| 1773 | - | ||
| 1774 | - const items = logs.items; | ||
| 1775 | - const totalTokens = items.reduce((sum, item) => sum + (item.llm_tokens || 0), 0); | ||
| 1776 | - const totalTime = items.reduce((sum, item) => sum + (item.execution_time_ms || 0), 0); | ||
| 1777 | - | ||
| 1778 | - container.innerHTML = ` | ||
| 1779 | - <div class="card"> | ||
| 1780 | - <div class="card-header"> | ||
| 1781 | - <h3 class="card-title"> | ||
| 1782 | - <i data-lucide="activity"></i> | ||
| 1783 | - 执行日志时间线 | ||
| 1784 | - </h3> | ||
| 1785 | - <div class="card-actions"> | ||
| 1786 | - <span class="text-muted">总耗时: ${Components.formatDuration(totalTime / 1000)} | 总Tokens: ${totalTokens}</span> | ||
| 1787 | - </div> | ||
| 1788 | - </div> | ||
| 1789 | - <div class="timeline"> | ||
| 1790 | - ${items.map((log, index) => ` | ||
| 1791 | - <div class="timeline-item ${log.status === 2 ? 'timeline-item-error' : 'timeline-item-success'}"> | ||
| 1792 | - <div class="timeline-marker"> | ||
| 1793 | - <div class="timeline-icon"> | ||
| 1794 | - ${log.status === 1 ? '<i data-lucide="check-circle"></i>' : | ||
| 1795 | - log.status === 2 ? '<i data-lucide="x-circle"></i>' : | ||
| 1796 | - '<i data-lucide="loader"></i>'} | ||
| 1797 | - </div> | ||
| 1798 | - ${index < items.length - 1 ? '<div class="timeline-line"></div>' : ''} | ||
| 1799 | - </div> | ||
| 1800 | - <div class="timeline-content"> | ||
| 1801 | - <div class="timeline-header"> | ||
| 1802 | - <span class="timeline-title">${Components.getStepNameDisplay(log.step_name)}</span> | ||
| 1803 | - ${Components.getLogStatusBadge(log.status)} | ||
| 1804 | - </div> | ||
| 1805 | - <div class="timeline-meta"> | ||
| 1806 | - <span class="meta-item"> | ||
| 1807 | - <i data-lucide="clock"></i> | ||
| 1808 | - ${log.execution_time_ms ? Components.formatDuration(log.execution_time_ms / 1000) : '-'} | ||
| 1809 | - </span> | ||
| 1810 | - ${log.llm_tokens > 0 ? ` | ||
| 1811 | - <span class="meta-item"> | ||
| 1812 | - <i data-lucide="cpu"></i> | ||
| 1813 | - ${log.llm_tokens} tokens | ||
| 1814 | - </span> | ||
| 1815 | - ` : ''} | ||
| 1816 | - ${log.retry_count > 0 ? ` | ||
| 1817 | - <span class="meta-item meta-warning"> | ||
| 1818 | - <i data-lucide="refresh-cw"></i> | ||
| 1819 | - 重试 ${log.retry_count} 次 | ||
| 1820 | - </span> | ||
| 1821 | - ` : ''} | ||
| 1822 | - </div> | ||
| 1823 | - ${log.error_message ? ` | ||
| 1824 | - <div class="timeline-error"> | ||
| 1825 | - <i data-lucide="alert-triangle"></i> | ||
| 1826 | - ${log.error_message} | ||
| 1827 | - </div> | ||
| 1828 | - ` : ''} | ||
| 1829 | - </div> | ||
| 1830 | - </div> | ||
| 1831 | - `).join('')} | ||
| 1832 | - </div> | ||
| 1833 | - </div> | ||
| 1834 | - `; | ||
| 1835 | - }, | ||
| 1836 | 865 | ||
| 1837 | /** | 866 | /** |
| 1838 | * 渲染任务信息标签页 | 867 | * 渲染任务信息标签页 |
ui/js/components.js
| @@ -62,32 +62,7 @@ const Components = { | @@ -62,32 +62,7 @@ const Components = { | ||
| 62 | return `<span class="part-tag ${tag.class}">${tag.text}</span>`; | 62 | return `<span class="part-tag ${tag.class}">${tag.text}</span>`; |
| 63 | }, | 63 | }, |
| 64 | 64 | ||
| 65 | - /** | ||
| 66 | - * 获取步骤名称显示 | ||
| 67 | - */ | ||
| 68 | - getStepNameDisplay(stepName) { | ||
| 69 | - const stepMap = { | ||
| 70 | - 'fetch_part_ratio': '获取配件数据', | ||
| 71 | - 'sql_agent': 'AI分析建议', | ||
| 72 | - 'allocate_budget': '分配预算', | ||
| 73 | - 'generate_report': '生成报告', | ||
| 74 | - }; | ||
| 75 | - return stepMap[stepName] || stepName; | ||
| 76 | - }, | ||
| 77 | 65 | ||
| 78 | - /** | ||
| 79 | - * 获取日志状态徽章 | ||
| 80 | - */ | ||
| 81 | - getLogStatusBadge(status) { | ||
| 82 | - const statusMap = { | ||
| 83 | - 0: { class: 'badge-info', text: '运行中' }, | ||
| 84 | - 1: { class: 'badge-success', text: '成功' }, | ||
| 85 | - 2: { class: 'badge-danger', text: '失败' }, | ||
| 86 | - 3: { class: 'badge-warning', text: '跳过' }, | ||
| 87 | - }; | ||
| 88 | - const config = statusMap[status] || { class: 'badge-neutral', text: '未知' }; | ||
| 89 | - return `<span class="badge ${config.class}"><span class="badge-dot"></span>${config.text}</span>`; | ||
| 90 | - }, | ||
| 91 | 66 | ||
| 92 | /** | 67 | /** |
| 93 | * 格式化时长 | 68 | * 格式化时长 |
| @@ -273,197 +248,6 @@ const Components = { | @@ -273,197 +248,6 @@ const Components = { | ||
| 273 | return `<div class="markdown-content"><pre>${content}</pre></div>`; | 248 | return `<div class="markdown-content"><pre>${content}</pre></div>`; |
| 274 | }, | 249 | }, |
| 275 | 250 | ||
| 276 | - /** | ||
| 277 | - * 渲染结构化报告 Section | ||
| 278 | - */ | ||
| 279 | - renderReportSection(section) { | ||
| 280 | - if (!section) return ''; | ||
| 281 | - | ||
| 282 | - const iconMap = { | ||
| 283 | - 'executive_summary': { icon: 'file-text', class: 'summary' }, | ||
| 284 | - 'inventory_analysis': { icon: 'bar-chart-2', class: 'analysis' }, | ||
| 285 | - 'risk_assessment': { icon: 'alert-triangle', class: 'risk' }, | ||
| 286 | - 'purchase_recommendations': { icon: 'shopping-cart', class: 'recommendation' }, | ||
| 287 | - 'optimization_suggestions': { icon: 'lightbulb', class: 'optimization' }, | ||
| 288 | - }; | ||
| 289 | - | ||
| 290 | - const config = iconMap[section.type] || { icon: 'file', class: 'summary' }; | ||
| 291 | - | ||
| 292 | - let contentHtml = ''; | ||
| 293 | - | ||
| 294 | - switch (section.type) { | ||
| 295 | - case 'executive_summary': | ||
| 296 | - contentHtml = this.renderSummarySection(section); | ||
| 297 | - break; | ||
| 298 | - case 'inventory_analysis': | ||
| 299 | - contentHtml = this.renderAnalysisSection(section); | ||
| 300 | - break; | ||
| 301 | - case 'risk_assessment': | ||
| 302 | - contentHtml = this.renderRiskSection(section); | ||
| 303 | - break; | ||
| 304 | - case 'purchase_recommendations': | ||
| 305 | - contentHtml = this.renderRecommendationSection(section); | ||
| 306 | - break; | ||
| 307 | - case 'optimization_suggestions': | ||
| 308 | - contentHtml = this.renderSuggestionSection(section); | ||
| 309 | - break; | ||
| 310 | - default: | ||
| 311 | - contentHtml = `<div class="summary-text">${JSON.stringify(section)}</div>`; | ||
| 312 | - } | ||
| 313 | - | ||
| 314 | - return ` | ||
| 315 | - <div class="card"> | ||
| 316 | - <div class="report-section-header"> | ||
| 317 | - <div class="report-section-icon ${config.class}"> | ||
| 318 | - <i data-lucide="${config.icon}"></i> | ||
| 319 | - </div> | ||
| 320 | - <div class="report-section-title">${section.title || ''}</div> | ||
| 321 | - </div> | ||
| 322 | - <div class="report-section-content"> | ||
| 323 | - ${contentHtml} | ||
| 324 | - </div> | ||
| 325 | - </div> | ||
| 326 | - `; | ||
| 327 | - }, | ||
| 328 | - | ||
| 329 | - /** | ||
| 330 | - * 渲染执行摘要 | ||
| 331 | - */ | ||
| 332 | - renderSummarySection(section) { | ||
| 333 | - const items = section.items || []; | ||
| 334 | - const text = section.text || ''; | ||
| 335 | - | ||
| 336 | - const itemsHtml = items.length > 0 ? ` | ||
| 337 | - <div class="summary-items"> | ||
| 338 | - ${items.map(item => ` | ||
| 339 | - <div class="summary-item status-${item.status || 'normal'}"> | ||
| 340 | - <div class="summary-item-label">${item.label || ''}</div> | ||
| 341 | - <div class="summary-item-value">${item.value || ''}</div> | ||
| 342 | - </div> | ||
| 343 | - `).join('')} | ||
| 344 | - </div> | ||
| 345 | - ` : ''; | ||
| 346 | - | ||
| 347 | - const textHtml = text ? `<div class="summary-text">${text}</div>` : ''; | ||
| 348 | - | ||
| 349 | - return itemsHtml + textHtml; | ||
| 350 | - }, | ||
| 351 | - | ||
| 352 | - /** | ||
| 353 | - * 渲染库存分析 | ||
| 354 | - */ | ||
| 355 | - renderAnalysisSection(section) { | ||
| 356 | - const paragraphs = section.paragraphs || []; | ||
| 357 | - const highlights = section.highlights || []; | ||
| 358 | - | ||
| 359 | - const highlightsHtml = highlights.length > 0 ? ` | ||
| 360 | - <div class="analysis-highlights"> | ||
| 361 | - ${highlights.map(h => ` | ||
| 362 | - <div class="analysis-highlight-card"> | ||
| 363 | - <span class="highlight-label">${h.label || ''}</span> | ||
| 364 | - <span class="highlight-value">${h.value || ''}</span> | ||
| 365 | - </div> | ||
| 366 | - `).join('')} | ||
| 367 | - </div> | ||
| 368 | - ` : ''; | ||
| 369 | - | ||
| 370 | - const paragraphsHtml = paragraphs.length > 0 ? ` | ||
| 371 | - <div class="analysis-paragraphs"> | ||
| 372 | - ${paragraphs.map(p => ` | ||
| 373 | - <div class="analysis-paragraph"> | ||
| 374 | - ${p} | ||
| 375 | - </div> | ||
| 376 | - `).join('')} | ||
| 377 | - </div> | ||
| 378 | - ` : ''; | ||
| 379 | - | ||
| 380 | - // Put highlights first for better visibility | ||
| 381 | - return highlightsHtml + paragraphsHtml; | ||
| 382 | - }, | ||
| 383 | - | ||
| 384 | - /** | ||
| 385 | - * 渲染风险评估 | ||
| 386 | - */ | ||
| 387 | - renderRiskSection(section) { | ||
| 388 | - const risks = section.risks || []; | ||
| 389 | - | ||
| 390 | - if (risks.length === 0) { | ||
| 391 | - return '<div class="summary-text">暂无风险评估</div>'; | ||
| 392 | - } | ||
| 393 | - | ||
| 394 | - const levelText = { high: '高', medium: '中', low: '低' }; | ||
| 395 | - | ||
| 396 | - // 按风险等级排序:high > medium > low | ||
| 397 | - const sortOrder = { high: 0, medium: 1, low: 2 }; | ||
| 398 | - const sortedRisks = [...risks].sort((a, b) => { | ||
| 399 | - const orderA = sortOrder[a.level] || 99; | ||
| 400 | - const orderB = sortOrder[b.level] || 99; | ||
| 401 | - return orderA - orderB; | ||
| 402 | - }); | ||
| 403 | - | ||
| 404 | - return ` | ||
| 405 | - <div class="risk-grid"> | ||
| 406 | - ${sortedRisks.map(risk => ` | ||
| 407 | - <div class="risk-card level-${risk.level || 'medium'}"> | ||
| 408 | - <div class="risk-card-header"> | ||
| 409 | - <span class="risk-badge ${risk.level || 'medium'}">${levelText[risk.level] || '中'}风险</span> | ||
| 410 | - <span class="risk-category-tag">${risk.category || '通用'}</span> | ||
| 411 | - </div> | ||
| 412 | - <div class="risk-card-content"> | ||
| 413 | - <div class="risk-description">${risk.description || ''}</div> | ||
| 414 | - </div> | ||
| 415 | - </div> | ||
| 416 | - `).join('')} | ||
| 417 | - </div> | ||
| 418 | - `; | ||
| 419 | - }, | ||
| 420 | - | ||
| 421 | - /** | ||
| 422 | - * 渲染采购建议 | ||
| 423 | - */ | ||
| 424 | - renderRecommendationSection(section) { | ||
| 425 | - const recommendations = section.recommendations || []; | ||
| 426 | - | ||
| 427 | - if (recommendations.length === 0) { | ||
| 428 | - return '<div class="summary-text">暂无采购建议</div>'; | ||
| 429 | - } | ||
| 430 | - | ||
| 431 | - return ` | ||
| 432 | - <div class="recommendation-list"> | ||
| 433 | - ${recommendations.map(rec => ` | ||
| 434 | - <div class="recommendation-item"> | ||
| 435 | - <div class="recommendation-priority priority-${rec.priority || 3}">${rec.priority || 3}</div> | ||
| 436 | - <div class="recommendation-content"> | ||
| 437 | - <div class="recommendation-action">${rec.action || ''}</div> | ||
| 438 | - <div class="recommendation-reason">${rec.reason || ''}</div> | ||
| 439 | - </div> | ||
| 440 | - </div> | ||
| 441 | - `).join('')} | ||
| 442 | - </div> | ||
| 443 | - `; | ||
| 444 | - }, | ||
| 445 | - | ||
| 446 | - /** | ||
| 447 | - * 渲染优化建议 | ||
| 448 | - */ | ||
| 449 | - renderSuggestionSection(section) { | ||
| 450 | - const suggestions = section.suggestions || []; | ||
| 451 | - | ||
| 452 | - if (suggestions.length === 0) { | ||
| 453 | - return '<div class="summary-text">暂无优化建议</div>'; | ||
| 454 | - } | ||
| 455 | - | ||
| 456 | - return ` | ||
| 457 | - <div class="suggestion-list"> | ||
| 458 | - ${suggestions.map(s => ` | ||
| 459 | - <div class="suggestion-item"> | ||
| 460 | - <div class="suggestion-icon"><i data-lucide="check-circle"></i></div> | ||
| 461 | - <div class="suggestion-text">${s}</div> | ||
| 462 | - </div> | ||
| 463 | - `).join('')} | ||
| 464 | - </div> | ||
| 465 | - `; | ||
| 466 | - }, | ||
| 467 | 251 | ||
| 468 | /** | 252 | /** |
| 469 | * 显示 Toast 通知 | 253 | * 显示 Toast 通知 |