Commit cb771b7556f5be3aa336fc56366f746e36fb8ece

Authored by 朱焱飞
1 parent 490a3565

feat: 移除通用报告和调度模块,新增补货API及专用汇总存储。

@@ -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
  1 +
  2 +from .main import main
  3 +
  4 +main()
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) -&gt; AgentState: @@ -71,7 +76,7 @@ def fetch_part_ratio_node(state: AgentState) -&gt; 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) -&gt; AgentState: @@ -118,7 +123,7 @@ def sql_agent_node(state: AgentState) -&gt; 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) -&gt; AgentState: @@ -156,8 +161,6 @@ def sql_agent_node(state: AgentState) -&gt; 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) -&gt; AgentState: @@ -165,7 +168,7 @@ def sql_agent_node(state: AgentState) -&gt; 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) -&gt; AgentState: @@ -175,7 +178,7 @@ def sql_agent_node(state: AgentState) -&gt; 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) -&gt; AgentState: @@ -185,7 +188,7 @@ def sql_agent_node(state: AgentState) -&gt; 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) -&gt; AgentState: @@ -222,7 +225,7 @@ def sql_agent_node(state: AgentState) -&gt; 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) -&gt; AgentState: @@ -255,7 +258,6 @@ def sql_agent_node(state: AgentState) -&gt; 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) -&gt; AgentState: @@ -269,7 +271,7 @@ def allocate_budget_node(state: AgentState) -&gt; 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) -&gt; AgentState: @@ -295,7 +297,7 @@ def allocate_budget_node(state: AgentState) -&gt; 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) -&gt; AgentState: @@ -341,7 +343,7 @@ def allocate_budget_node(state: AgentState) -&gt; 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) -&gt; AgentState: @@ -408,7 +410,7 @@ def allocate_budget_node(state: AgentState) -&gt; 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
1 -"""定时任务模块"""  
2 -  
3 -from .tasks import run_replenishment_task, start_scheduler  
4 -  
5 -__all__ = [  
6 - "run_replenishment_task",  
7 - "start_scheduler",  
8 -]  
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 通知