Commit cb771b7556f5be3aa336fc56366f746e36fb8ece

Authored by 朱焱飞
1 parent 490a3565

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

... ... @@ -14,10 +14,6 @@ MYSQL_USER=test
14 14 MYSQL_PASSWORD=mysql@pwd123
15 15 MYSQL_DATABASE=fw_pms
16 16  
17   -# 定时任务配置
18   -SCHEDULER_CRON_HOUR=2
19   -SCHEDULER_CRON_MINUTE=0
20   -
21 17 # 服务配置
22 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 21 # LLM 集成
22 22 "zhipuai>=2.0.0",
23 23  
24   - # 定时任务
25   - "apscheduler>=3.10.0",
26   -
27 24 # 数据库
28 25 "mysql-connector-python>=8.0.0",
29 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 19 from ..llm import get_llm_client
20 20 from ..services import DataService
21 21 from ..services.result_writer import ResultWriter
22   -from ..models import ReplenishmentDetail, TaskExecutionLog, LogStatus, ReplenishmentPartSummary
  22 +from ..models import ReplenishmentDetail, ReplenishmentPartSummary
23 23  
24 24 logger = logging.getLogger(__name__)
25 25  
  26 +# 执行状态常量
  27 +LOG_SUCCESS = 1
  28 +LOG_FAILED = 2
  29 +LOG_SKIPPED = 3
  30 +
26 31  
27 32 def _load_prompt(filename: str) -> str:
28 33 """从prompts目录加载提示词文件"""
... ... @@ -71,7 +76,7 @@ def fetch_part_ratio_node(state: AgentState) -&gt; AgentState:
71 76 log_entry = {
72 77 "step_name": "fetch_part_ratio",
73 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 80 "input_data": json.dumps({
76 81 "dealer_grouping_id": state["dealer_grouping_id"],
77 82 "statistics_date": state["statistics_date"],
... ... @@ -118,7 +123,7 @@ def sql_agent_node(state: AgentState) -&gt; AgentState:
118 123 log_entry = {
119 124 "step_name": "sql_agent",
120 125 "step_order": 2,
121   - "status": LogStatus.SKIPPED,
  126 + "status": LOG_SKIPPED,
122 127 "error_message": "无配件数据",
123 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 161 )
157 162  
158 163 # 定义批处理回调
159   - # 由于 models 中没有 ResultWriter 的引用,这里尝试直接从 services 导入或实例化
160   - # 为避免循环导入,我们在函数内导入
161 164 from ..services import ResultWriter as WriterService
162 165 writer = WriterService()
163 166  
... ... @@ -165,7 +168,7 @@ def sql_agent_node(state: AgentState) -&gt; AgentState:
165 168 # logger.info(f"[SQLAgent] 清理旧建议数据: task_no={state['task_no']}")
166 169 # writer.clear_llm_suggestions(state["task_no"])
167 170  
168   - # 2. 移除批处理回调(不再过程写入,改为最后统一写入)
  171 + # 2. 移除批处理回调
169 172 save_batch_callback = None
170 173  
171 174 # 使用分组分析生成补货建议(按 part_code 分组,逐个配件分析各门店需求)
... ... @@ -175,7 +178,7 @@ def sql_agent_node(state: AgentState) -&gt; AgentState:
175 178 dealer_grouping_name=state["dealer_grouping_name"],
176 179 statistics_date=state["statistics_date"],
177 180 target_ratio=base_ratio if base_ratio > 0 else Decimal("1.3"),
178   - limit=1000,
  181 + limit=10,
179 182 callback=save_batch_callback,
180 183 )
181 184  
... ... @@ -185,7 +188,7 @@ def sql_agent_node(state: AgentState) -&gt; AgentState:
185 188 log_entry = {
186 189 "step_name": "sql_agent",
187 190 "step_order": 2,
188   - "status": LogStatus.SUCCESS,
  191 + "status": LOG_SUCCESS,
189 192 "input_data": json.dumps({
190 193 "part_ratios_count": len(part_ratios),
191 194 }),
... ... @@ -222,7 +225,7 @@ def sql_agent_node(state: AgentState) -&gt; AgentState:
222 225 log_entry = {
223 226 "step_name": "sql_agent",
224 227 "step_order": 2,
225   - "status": LogStatus.FAILED,
  228 + "status": LOG_FAILED,
226 229 "error_message": str(e),
227 230 "retry_count": retry_count,
228 231 "execution_time_ms": int((time.time() - start_time) * 1000),
... ... @@ -255,7 +258,6 @@ def sql_agent_node(state: AgentState) -&gt; AgentState:
255 258 def allocate_budget_node(state: AgentState) -> AgentState:
256 259 """
257 260 节点3: 转换LLM建议为补货明细
258   - 注意:不做预算截断,所有建议直接输出
259 261 """
260 262 logger.info(f"[AllocateBudget] 开始处理LLM建议")
261 263  
... ... @@ -269,7 +271,7 @@ def allocate_budget_node(state: AgentState) -&gt; AgentState:
269 271 log_entry = {
270 272 "step_name": "allocate_budget",
271 273 "step_order": 3,
272   - "status": LogStatus.SKIPPED,
  274 + "status": LOG_SKIPPED,
273 275 "error_message": "无LLM建议",
274 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 297 allocated_details = []
296 298 total_amount = Decimal("0")
297 299  
298   - # 转换所有建议为明细(包括不需要补货的配件,以便记录完整分析结果
  300 + # 转换所有建议为明细(包括不需要补货的配件
299 301 for suggestion in sorted_suggestions:
300 302 # 获取该配件对应的 brand_grouping_id
301 303 bg_id = part_brand_map.get(suggestion.part_code)
... ... @@ -341,7 +343,7 @@ def allocate_budget_node(state: AgentState) -&gt; AgentState:
341 343 log_entry = {
342 344 "step_name": "allocate_budget",
343 345 "step_order": 3,
344   - "status": LogStatus.SUCCESS,
  346 + "status": LOG_SUCCESS,
345 347 "input_data": json.dumps({
346 348 "suggestions_count": len(llm_suggestions),
347 349 }),
... ... @@ -408,7 +410,7 @@ def allocate_budget_node(state: AgentState) -&gt; AgentState:
408 410 error_log = {
409 411 "step_name": "allocate_budget",
410 412 "step_order": 3,
411   - "status": LogStatus.FAILED,
  413 + "status": LOG_FAILED,
412 414 "error_message": f"保存结果失败: {str(e)}",
413 415 "execution_time_ms": 0,
414 416 }
... ...
src/fw_pms_ai/agent/replenishment.py
1 1 """
2 2 补货建议 Agent
3 3  
4   -重构版本:使用 part_ratio + SQL Agent + LangGraph
  4 +使用 part_ratio + SQL Agent + LangGraph
5 5 """
6 6  
7 7 import logging
... ... @@ -20,8 +20,7 @@ from .nodes import (
20 20 allocate_budget_node,
21 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 24 from ..services import ResultWriter
26 25  
27 26 logger = logging.getLogger(__name__)
... ... @@ -44,25 +43,19 @@ class ReplenishmentAgent:
44 43 def _build_graph(self) -> StateGraph:
45 44 """
46 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 50 workflow = StateGraph(AgentState)
52 51  
53   - # 添加核心节点
54 52 workflow.add_node("fetch_part_ratio", fetch_part_ratio_node)
55 53 workflow.add_node("sql_agent", sql_agent_node)
56 54 workflow.add_node("allocate_budget", allocate_budget_node)
57   - workflow.add_node("generate_analysis_report", generate_analysis_report_node)
58 55  
59   - # 设置入口
60 56 workflow.set_entry_point("fetch_part_ratio")
61   -
62   - # 添加边
63 57 workflow.add_edge("fetch_part_ratio", "sql_agent")
64   -
65   - # SQL Agent 条件边(支持重试)
  58 +
66 59 workflow.add_conditional_edges(
67 60 "sql_agent",
68 61 should_retry_sql,
... ... @@ -71,10 +64,8 @@ class ReplenishmentAgent:
71 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 70 return workflow.compile()
80 71  
... ... @@ -126,7 +117,6 @@ class ReplenishmentAgent:
126 117 "details": [],
127 118 "llm_suggestions": [],
128 119 "part_results": [],
129   - "report": None,
130 120 "llm_provider": "",
131 121 "llm_model": "",
132 122 "llm_prompt_tokens": 0,
... ... @@ -175,30 +165,6 @@ class ReplenishmentAgent:
175 165  
176 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 168 logger.info(
203 169 f"补货建议执行完成: task_no={task_no}, "
204 170 f"parts={task.part_count}, amount={actual_amount}, "
... ... @@ -220,39 +186,6 @@ class ReplenishmentAgent:
220 186 finally:
221 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 190 def _save_part_summaries(
258 191 self,
... ...
src/fw_pms_ai/agent/state.py
... ... @@ -72,10 +72,7 @@ class AgentState(TypedDict, total=False):
72 72  
73 73 # 配件汇总结果
74 74 part_results: Annotated[List[Any], merge_lists]
75   -
76   - # 分析报告
77   - analysis_report: Annotated[Optional[dict], keep_last]
78   -
  75 +
79 76 # LLM 统计(使用累加,合并多个并行节点的 token 使用量)
80 77 llm_provider: Annotated[str, keep_last]
81 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 12 from fastapi.staticfiles import StaticFiles
13 13 from fastapi.responses import FileResponse
14 14  
15   -from .routes import tasks
  15 +from .routes import tasks, replenishment
16 16 from ..config import register_service, deregister_service
17 17  
18 18 logger = logging.getLogger(__name__)
... ... @@ -46,6 +46,7 @@ app.add_middleware(
46 46  
47 47 # 挂载路由
48 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 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 3 """
4 4  
5 5 import logging
6   -from typing import Optional, List, Dict, Any
7   -import json
  6 +from typing import Optional, List
8 7 from datetime import datetime
9   -from decimal import Decimal
10 8  
11 9 from fastapi import APIRouter, Query, HTTPException
12 10 from pydantic import BaseModel
... ... @@ -135,14 +133,10 @@ async def list_tasks(
135 133 # 查询分页数据
136 134 offset = (page - 1) * page_size
137 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 138 WHERE {where_sql}
145   - ORDER BY t.create_time DESC
  139 + ORDER BY create_time DESC
146 140 LIMIT %s OFFSET %s
147 141 """
148 142 cursor.execute(data_sql, params + [page_size, offset])
... ... @@ -171,7 +165,7 @@ async def list_tasks(
171 165 error_message=row.get("error_message"),
172 166 llm_provider=row.get("llm_provider"),
173 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 169 statistics_date=row.get("statistics_date"),
176 170 start_time=format_datetime(row.get("start_time")),
177 171 end_time=format_datetime(row.get("end_time")),
... ... @@ -200,13 +194,9 @@ async def get_task(task_no: str):
200 194 try:
201 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 201 (task_no,)
212 202 )
... ... @@ -235,7 +225,7 @@ async def get_task(task_no: str):
235 225 error_message=row.get("error_message"),
236 226 llm_provider=row.get("llm_provider"),
237 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 229 statistics_date=row.get("statistics_date"),
240 230 start_time=format_datetime(row.get("start_time")),
241 231 end_time=format_datetime(row.get("end_time")),
... ... @@ -336,94 +326,6 @@ async def get_task_details(
336 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 330 class PartSummaryResponse(BaseModel):
429 331 """配件汇总响应"""
... ... @@ -507,7 +409,6 @@ async def get_task_part_summaries(
507 409 offset = (page - 1) * page_size
508 410  
509 411 # 动态计算计划后库销比: (库存 + 建议) / 月均销
510   - # 注意: total_avg_sales_cnt 可能为 0, 需要处理除以零的情况
511 412 query_sql = f"""
512 413 SELECT *,
513 414 (
... ... @@ -611,83 +512,3 @@ async def get_part_shop_details(
611 512 cursor.close()
612 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 41 mysql_password: str = ""
42 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 45 server_port: int = 8009
50 46  
... ...
src/fw_pms_ai/models/__init__.py
... ... @@ -2,25 +2,17 @@
2 2  
3 3 from .part_ratio import PartRatio
4 4 from .task import ReplenishmentTask, ReplenishmentDetail, TaskStatus
5   -from .execution_log import TaskExecutionLog, LogStatus
6 5 from .part_summary import ReplenishmentPartSummary
7 6 from .sql_result import SQLExecutionResult
8 7 from .suggestion import ReplenishmentSuggestion, PartAnalysisResult
9   -from .analysis_report import AnalysisReport
10 8  
11 9 __all__ = [
12 10 "PartRatio",
13 11 "ReplenishmentTask",
14 12 "ReplenishmentDetail",
15 13 "TaskStatus",
16   - "TaskExecutionLog",
17   - "LogStatus",
18 14 "ReplenishmentPartSummary",
19 15 "SQLExecutionResult",
20 16 "ReplenishmentSuggestion",
21 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 2  
3 3 from .data_service import DataService
4 4 from .result_writer import ResultWriter
5   -from .repository import TaskRepository, DetailRepository, LogRepository, SummaryRepository
  5 +from .repository import TaskRepository, DetailRepository, SummaryRepository
6 6  
7 7 __all__ = [
8 8 "DataService",
9 9 "ResultWriter",
10 10 "TaskRepository",
11 11 "DetailRepository",
12   - "LogRepository",
13 12 "SummaryRepository",
14 13 ]
15   -
16   -
... ...
src/fw_pms_ai/services/repository/__init__.py
... ... @@ -6,11 +6,10 @@ Repository 数据访问层子包
6 6  
7 7 from .task_repo import TaskRepository
8 8 from .detail_repo import DetailRepository
9   -from .log_repo import LogRepository, SummaryRepository
  9 +from .summary_repo import SummaryRepository
10 10  
11 11 __all__ = [
12 12 "TaskRepository",
13 13 "DetailRepository",
14   - "LogRepository",
15 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 7 import logging
8 8 from typing import List
9 9  
10 10 from ..db import get_connection
11   -from ...models import TaskExecutionLog, ReplenishmentPartSummary
  11 +from ...models import ReplenishmentPartSummary
12 12  
13 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 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 12 from ..models import (
13 13 ReplenishmentTask,
14 14 ReplenishmentDetail,
15   - TaskExecutionLog,
16 15 ReplenishmentPartSummary,
17   - AnalysisReport,
18 16 )
19 17  
20 18 logger = logging.getLogger(__name__)
... ... @@ -188,54 +186,10 @@ class ResultWriter:
188 186 finally:
189 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 189 def save_part_summaries(self, summaries: List[ReplenishmentPartSummary]) -> int:
236 190 """
237 191 保存配件汇总信息
238   -
  192 +
239 193 Returns:
240 194 插入的行数
241 195 """
... ... @@ -258,7 +212,7 @@ class ResultWriter:
258 212 %s, %s, %s, %s, %s, %s, %s, %s, NOW()
259 213 )
260 214 """
261   -
  215 +
262 216 values = [
263 217 (
264 218 s.task_no, s.group_id, s.dealer_grouping_id, s.part_code, s.part_name,
... ... @@ -271,10 +225,10 @@ class ResultWriter:
271 225 )
272 226 for s in summaries
273 227 ]
274   -
  228 +
275 229 cursor.executemany(sql, values)
276 230 conn.commit()
277   -
  231 +
278 232 logger.info(f"保存配件汇总: {cursor.rowcount}条")
279 233 return cursor.rowcount
280 234  
... ... @@ -284,7 +238,7 @@ class ResultWriter:
284 238 def delete_part_summaries_by_task(self, task_no: str) -> int:
285 239 """
286 240 删除指定任务的配件汇总
287   -
  241 +
288 242 Returns:
289 243 删除的行数
290 244 """
... ... @@ -295,7 +249,7 @@ class ResultWriter:
295 249 sql = "DELETE FROM ai_replenishment_part_summary WHERE task_no = %s"
296 250 cursor.execute(sql, (task_no,))
297 251 conn.commit()
298   -
  252 +
299 253 logger.info(f"删除配件汇总: task_no={task_no}, rows={cursor.rowcount}")
300 254 return cursor.rowcount
301 255  
... ... @@ -305,7 +259,7 @@ class ResultWriter:
305 259 def delete_details_by_task(self, task_no: str) -> int:
306 260 """
307 261 删除指定任务的补货明细
308   -
  262 +
309 263 Returns:
310 264 删除的行数
311 265 """
... ... @@ -316,65 +270,9 @@ class ResultWriter:
316 270 sql = "DELETE FROM ai_replenishment_detail WHERE task_no = %s"
317 271 cursor.execute(sql, (task_no,))
318 272 conn.commit()
319   -
  273 +
320 274 logger.info(f"删除补货明细: task_no={task_no}, rows={cursor.rowcount}")
321 275 return cursor.rowcount
322 276  
323 277 finally:
324 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 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 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 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 427 try {
1311 428 Components.showLoading();
1312 429  
1313   - const [task, partSummaries, logs] = await Promise.all([
  430 + const [task, partSummaries] = await Promise.all([
1314 431 API.getTask(taskNo),
1315 432 API.getPartSummaries(taskNo, { page: 1, page_size: 100 }).catch(() => ({ items: [], total: 0 })),
1316   - API.getTaskLogs(taskNo).catch(() => ({ items: [] })),
1317 433 ]);
1318 434  
1319 435 Components.hideLoading();
1320   - this.renderTaskDetail(task, partSummaries, logs);
  436 + this.renderTaskDetail(task, partSummaries);
1321 437 lucide.createIcons();
1322 438 } catch (error) {
1323 439 Components.hideLoading();
... ... @@ -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 448 this._partSummaries = partSummaries;
1334 449 const container = document.getElementById('task-detail-container');
1335 450  
... ... @@ -1378,14 +493,6 @@ const App = {
1378 493 <i data-lucide="list"></i>
1379 494 配件明细
1380 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 496 <button class="tab" data-tab="info">
1390 497 <i data-lucide="info"></i>
1391 498 任务信息
... ... @@ -1421,12 +528,6 @@ const App = {
1421 528 this.renderDetailsTab(container, details);
1422 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 531 case 'info':
1431 532 this.renderInfoTab(container, task);
1432 533 break;
... ... @@ -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 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 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 253 * 显示 Toast 通知
... ...