diff --git a/.env b/.env
index 2c70d39..bea0f85 100644
--- a/.env
+++ b/.env
@@ -14,10 +14,6 @@ MYSQL_USER=test
MYSQL_PASSWORD=mysql@pwd123
MYSQL_DATABASE=fw_pms
-# 定时任务配置
-SCHEDULER_CRON_HOUR=2
-SCHEDULER_CRON_MINUTE=0
-
# 服务配置
SERVER_PORT=8009
diff --git a/docs/商家组合维度分析需求设计.md b/docs/商家组合维度分析需求设计.md
deleted file mode 100644
index 6f75584..0000000
--- a/docs/商家组合维度分析需求设计.md
+++ /dev/null
@@ -1,856 +0,0 @@
-# 商家组合维度分析报告 - 需求分析与设计文档
-
-> **版本**: 2.0.0
-> **日期**: 2026-02-12
-> **项目**: fw-pms-ai(AI配件补货建议系统)
-
----
-
-## 1. 业务背景与目标
-
-### 1.1 业务痛点
-
-汽车配件管理面临以下核心挑战:
-
-| 痛点 | 描述 | 影响 |
-|------|------|------|
-| **库存失衡** | 部分配件长期缺货,部分配件严重呆滞 | 缺货导致客户流失,呆滞占用资金 |
-| **人工决策低效** | 传统补货依赖采购员经验判断 | 效率低、易出错、难以规模化 |
-| **多门店协调困难** | 同一商家组合下的多门店库存无法统一调配 | 资源利用率低,部分门店过剩而另一部分缺货 |
-| **数据利用不足** | 丰富的销售数据未能有效转化为决策依据 | 补货缺乏数据支撑,决策质量参差不齐 |
-
-### 1.2 项目目标
-
-```mermaid
-mindmap
- root((商家组合维度分析))
- 智能补货建议
- LLM驱动分析
- 配件级决策
- 门店级分配
- 风险识别与预警
- 呆滞件识别
- 低频件过滤
- 缺货预警
- 数据驱动分析报告
- 库存概览
- 销量分析
- 库存健康度
- 补货建议汇总
- 可视化展示
- 分析报告
- 配件明细
- 执行日志
-```
-
-**核心价值主张**:
-1. **智能化**:通过 LLM 自动分析库销比数据,生成专业补货建议
-2. **精细化**:从商家组合维度统一分析,再下钻到配件级、门店级
-3. **专业化**:输出的分析理由贴合采购人员专业度,包含具体数据指标
-
----
-
-## 2. 功能模块设计
-
-### 2.1 功能架构
-
-```mermaid
-flowchart TB
- subgraph 用户层["🖥️ 用户层"]
- UI[Web管理界面]
- end
-
- subgraph 应用层["⚙️ 应用层"]
- API[FastAPI 接口层]
-
- subgraph Agent["🤖 LangGraph Agent"]
- N1[获取配件库销比
fetch_part_ratio]
- N2[LLM分析生成建议
sql_agent]
- N3[转换补货明细
allocate_budget]
- N4[生成分析报告
generate_analysis_report]
- end
-
- subgraph ReportSubgraph["📊 分析报告并发子图"]
- R1[库存概览 LLM]
- R2[销量分析 LLM]
- R3[健康度 LLM]
- R4[补货建议 LLM]
- end
- end
-
- subgraph 基础设施["🔧 基础设施"]
- LLM[LLM服务
智谱GLM/豆包/OpenAI/Anthropic]
- DB[(MySQL数据库)]
- end
-
- UI --> API
- API --> Agent
- N1 --> N2 --> N3 --> N4
- N4 --> ReportSubgraph
- N1 -.-> DB
- N2 -.-> LLM
- N3 -.-> DB
- R1 & R2 & R3 & R4 -.-> LLM
-```
-
-### 2.2 功能模块清单
-
-| 模块 | 功能 | 输入 | 输出 |
-|------|------|------|------|
-| **数据获取** | 获取商家组合内所有配件的库销比数据 | dealer_grouping_id | part_ratios[] |
-| **LLM分析** | 按配件分组分析,生成补货建议和决策理由 | part_ratios, base_ratio | llm_suggestions[] |
-| **建议转换** | 将LLM建议转换为结构化的补货明细 | llm_suggestions[] | details[], part_summaries[] |
-| **分析报告** | 四大板块统计计算 + 并发LLM分析 | part_ratios, part_results | analysis_report |
-
----
-
-## 3. 系统架构设计
-
-### 3.1 整体架构
-
-```mermaid
-C4Component
- title 商家组合维度分析系统 - 组件架构
-
- Container_Boundary(web, "Web层") {
- Component(ui, "前端UI", "HTML/CSS/JS", "任务管理、结果展示")
- }
-
- Container_Boundary(api, "API层") {
- Component(routes, "路由模块", "FastAPI", "REST API接口")
- Component(scheduler, "定时调度", "APScheduler", "任务调度")
- }
-
- Container_Boundary(agent, "Agent层") {
- Component(workflow, "工作流引擎", "LangGraph", "状态机编排")
- Component(nodes, "节点实现", "Python", "业务逻辑")
- Component(report_subgraph, "报告子图", "LangGraph 并发子图", "4路并发LLM分析")
- Component(prompts, "提示词", "Markdown", "LLM指令")
- }
-
- Container_Boundary(service, "服务层") {
- Component(data, "数据服务", "Python", "数据查询")
- Component(writer, "写入服务", "Python", "结果持久化")
- }
-
- Container_Boundary(infra, "基础设施") {
- ComponentDb(mysql, "MySQL", "数据库", "业务数据存储")
- Component(llm, "LLM", "GLM/Doubao/OpenAI/Anthropic", "大语言模型")
- }
-
- Rel(ui, routes, "HTTP请求")
- Rel(routes, workflow, "触发任务")
- Rel(workflow, nodes, "执行")
- Rel(nodes, report_subgraph, "fan-out/fan-in")
- Rel(nodes, prompts, "加载")
- Rel(nodes, llm, "调用")
- Rel(nodes, data, "查询")
- Rel(nodes, writer, "写入")
- Rel(data, mysql, "SQL")
- Rel(writer, mysql, "SQL")
-```
-
-### 3.2 工作流状态机
-
-```mermaid
-stateDiagram-v2
- [*] --> FetchPartRatio: 启动任务
-
- FetchPartRatio --> SQLAgent: 获取库销比数据
- FetchPartRatio --> [*]: 无数据
-
- SQLAgent --> SQLAgent: 重试(错误 & 次数<3)
- SQLAgent --> AllocateBudget: 分析完成
- SQLAgent --> [*]: 重试失败
-
- AllocateBudget --> GenerateReport: 转换完成
-
- GenerateReport --> [*]: 生成报告
-
- state GenerateReport {
- [*] --> 统计计算
- 统计计算 --> 并发LLM子图
-
- state 并发LLM子图 {
- [*] --> 库存概览LLM
- [*] --> 销量分析LLM
- [*] --> 健康度LLM
- [*] --> 补货建议LLM
- 库存概览LLM --> [*]
- 销量分析LLM --> [*]
- 健康度LLM --> [*]
- 补货建议LLM --> [*]
- }
-
- 并发LLM子图 --> 汇总写入
- 汇总写入 --> [*]
- }
-```
-
----
-
-## 4. 核心算法说明
-
-### 4.1 三层决策逻辑
-
-```mermaid
-flowchart LR
- subgraph L1["第一层: 配件级判断"]
- A1[汇总商家组合内
所有门店数据]
- A2[计算整体库销比]
- A3{是否需要补货?}
- A4[生成配件级理由]
- end
-
- subgraph L2["第二层: 门店级分配"]
- B1[按库销比从低到高排序]
- B2[计算各门店缺口]
- B3[分配补货数量]
- end
-
- subgraph L3["第三层: 决策理由生成"]
- C1[状态判定标签]
- C2[关键指标数据]
- C3[缺口分析]
- C4[天数说明]
- end
-
- A1 --> A2 --> A3
- A3 -->|是| L2
- A3 -->|否| A4
- B1 --> B2 --> B3
- B3 --> L3
- C1 --> C2 --> C3 --> C4
-```
-
-### 4.2 补货数量计算公式
-
-```
-suggest_cnt = ceil(目标库销比 × 月均销量 - 当前库存)
-```
-
-其中:
-- **有效库存** = `in_stock_unlocked_cnt` + `on_the_way_cnt` + `has_plan_cnt`
-- **月均销量** = (`out_stock_cnt` + `storage_locked_cnt` + `out_stock_ongoing_cnt` + `buy_cnt`) / 3
-- **资金占用** = (`in_stock_unlocked_cnt` + `on_the_way_cnt`) × `cost_price`
-
-### 4.3 配件分类与处理规则
-
-| 分类 | 判定条件 | 处理策略 |
-|------|----------|----------|
-| **缺货件** | 有效库存 = 0 且 月均销量 ≥ 1 | 优先补货 |
-| **呆滞件** | 有效库存 > 0 且 90天出库数 = 0 | 不补货,建议清理 |
-| **低频件** | 月均销量 < 1 或 出库次数 < 3 或 出库间隔 ≥ 30天 | 不补货 |
-| **正常件** | 不属于以上三类 | 按缺口补货 |
-
-> 分类优先级:缺货件 > 呆滞件 > 低频件 > 正常件(按顺序判断,命中即止)
-
-### 4.4 优先级判定标准
-
-```mermaid
-flowchart TD
- A{库存状态} -->|库存=0 且 销量活跃| H[高优先级
急需补货]
- A -->|库销比<0.5| M[中优先级
建议补货]
- A -->|0.5≤库销比<目标值| L[低优先级
可选补货]
- A -->|库销比≥目标值| N[无需补货
库存充足]
-
- style H fill:#ff6b6b
- style M fill:#feca57
- style L fill:#48dbfb
- style N fill:#2ecc71
-```
-
----
-
-## 5. 数据模型设计
-
-### 5.1 ER图
-
-```mermaid
-erDiagram
- AI_REPLENISHMENT_TASK ||--o{ AI_REPLENISHMENT_DETAIL : contains
- AI_REPLENISHMENT_TASK ||--o{ AI_REPLENISHMENT_PART_SUMMARY : contains
- AI_REPLENISHMENT_TASK ||--o{ AI_TASK_EXECUTION_LOG : logs
- AI_REPLENISHMENT_TASK ||--o| AI_ANALYSIS_REPORT : generates
- PART_RATIO }o--|| AI_REPLENISHMENT_DETAIL : references
-
- AI_REPLENISHMENT_TASK {
- bigint id PK
- varchar task_no UK "AI-开头"
- bigint group_id "集团ID"
- bigint dealer_grouping_id
- varchar dealer_grouping_name
- bigint brand_grouping_id "品牌组合ID"
- decimal plan_amount "计划采购金额"
- decimal actual_amount "实际分配金额"
- int part_count "配件数量"
- decimal base_ratio "基准库销比"
- tinyint status "0运行/1成功/2失败"
- varchar llm_provider
- varchar llm_model
- int llm_total_tokens
- varchar statistics_date
- datetime start_time
- datetime end_time
- }
-
- AI_REPLENISHMENT_DETAIL {
- bigint id PK
- varchar task_no FK
- bigint group_id
- bigint dealer_grouping_id
- bigint brand_grouping_id
- bigint shop_id "库房ID"
- varchar shop_name
- varchar part_code "配件编码"
- varchar part_name
- varchar unit
- decimal cost_price
- decimal current_ratio "当前库销比"
- decimal base_ratio "基准库销比"
- decimal post_plan_ratio "计划后库销比"
- decimal valid_storage_cnt "有效库存"
- decimal avg_sales_cnt "月均销量"
- int suggest_cnt "建议数量"
- decimal suggest_amount "建议金额"
- text suggestion_reason "决策理由"
- int priority "1高/2中/3低"
- float llm_confidence "LLM置信度"
- }
-
- AI_REPLENISHMENT_PART_SUMMARY {
- bigint id PK
- varchar task_no FK
- bigint group_id
- bigint dealer_grouping_id
- varchar part_code "配件编码"
- varchar part_name
- varchar unit
- decimal cost_price
- decimal total_storage_cnt "总库存"
- decimal total_avg_sales_cnt "总月均销量"
- decimal group_current_ratio "商家组合库销比"
- int total_suggest_cnt "总建议数量"
- decimal total_suggest_amount "总建议金额"
- int shop_count "涉及门店数"
- int need_replenishment_shop_count "需补货门店数"
- text part_decision_reason "配件级理由"
- int priority "1高/2中/3低"
- float llm_confidence
- }
-
- AI_TASK_EXECUTION_LOG {
- bigint id PK
- varchar task_no FK
- bigint group_id
- bigint brand_grouping_id
- varchar brand_grouping_name
- bigint dealer_grouping_id
- varchar dealer_grouping_name
- varchar step_name "步骤名称"
- int step_order "步骤顺序"
- tinyint status "0进行/1成功/2失败/3跳过"
- text input_data "输入JSON"
- text output_data "输出JSON"
- text error_message
- int retry_count
- text sql_query
- text llm_prompt
- text llm_response
- int llm_tokens "Token消耗"
- int execution_time_ms "耗时"
- }
-
- AI_ANALYSIS_REPORT {
- bigint id PK
- varchar task_no FK
- bigint group_id
- bigint dealer_grouping_id
- varchar dealer_grouping_name
- bigint brand_grouping_id
- varchar report_type "默认replenishment"
- json inventory_overview "库存概览"
- json sales_analysis "销量分析"
- json inventory_health "健康度"
- json replenishment_summary "补货建议"
- varchar llm_provider
- varchar llm_model
- int llm_tokens
- int execution_time_ms
- }
-
- PART_RATIO {
- bigint id PK
- bigint shop_id
- varchar part_code
- decimal in_stock_unlocked_cnt "在库未锁定"
- decimal on_the_way_cnt "在途"
- decimal has_plan_cnt "已有计划"
- decimal out_stock_cnt "出库数"
- decimal storage_locked_cnt "库存锁定"
- decimal out_stock_ongoing_cnt "出库在途"
- decimal buy_cnt "采购数"
- decimal cost_price "成本价"
- int out_times "出库次数"
- int out_duration "平均出库间隔"
- }
-```
-
-### 5.2 核心表结构
-
-#### ai_replenishment_task(任务主表)
-| 字段 | 类型 | 说明 |
-|------|------|------|
-| task_no | VARCHAR(32) | 任务编号,AI-开头,唯一 |
-| group_id | BIGINT | 集团ID |
-| dealer_grouping_id | BIGINT | 商家组合ID |
-| dealer_grouping_name | VARCHAR(128) | 商家组合名称 |
-| brand_grouping_id | BIGINT | 品牌组合ID |
-| plan_amount | DECIMAL(14,2) | 计划采购金额(预算) |
-| actual_amount | DECIMAL(14,2) | 实际分配金额 |
-| part_count | INT | 配件数量 |
-| base_ratio | DECIMAL(10,4) | 基准库销比 |
-| status | TINYINT | 状态: 0运行中/1成功/2失败 |
-| llm_provider | VARCHAR(32) | LLM提供商 |
-| llm_model | VARCHAR(64) | LLM模型名称 |
-| statistics_date | VARCHAR(16) | 统计日期 |
-| start_time / end_time | DATETIME | 任务执行起止时间 |
-
-#### ai_replenishment_part_summary(配件汇总表)
-| 字段 | 类型 | 说明 |
-|------|------|------|
-| task_no | VARCHAR(32) | 任务编号 |
-| group_id | BIGINT | 集团ID |
-| dealer_grouping_id | BIGINT | 商家组合ID |
-| part_code | VARCHAR(64) | 配件编码 |
-| part_name | VARCHAR(256) | 配件名称 |
-| cost_price | DECIMAL(14,2) | 成本价 |
-| total_storage_cnt | DECIMAL(14,2) | 商家组合内总库存 |
-| total_avg_sales_cnt | DECIMAL(14,2) | 总月均销量 |
-| group_current_ratio | DECIMAL(10,4) | 商家组合级库销比 |
-| total_suggest_cnt | INT | 总建议数量 |
-| total_suggest_amount | DECIMAL(14,2) | 总建议金额 |
-| shop_count | INT | 涉及门店数 |
-| need_replenishment_shop_count | INT | 需补货门店数 |
-| part_decision_reason | TEXT | 配件级补货理由 |
-| priority | INT | 优先级: 1高/2中/3低 |
-
-#### ai_analysis_report(分析报告表)
-| 字段 | 类型 | 说明 |
-|------|------|------|
-| task_no | VARCHAR(32) | 任务编号 |
-| group_id | BIGINT | 集团ID |
-| dealer_grouping_id | BIGINT | 商家组合ID |
-| report_type | VARCHAR(32) | 报告类型(默认 replenishment) |
-| inventory_overview | JSON | 库存总体概览(stats + llm_analysis) |
-| sales_analysis | JSON | 销量分析(stats + llm_analysis) |
-| inventory_health | JSON | 库存健康度(stats + chart_data + llm_analysis) |
-| replenishment_summary | JSON | 补货建议(stats + llm_analysis) |
-| llm_provider | VARCHAR(32) | LLM提供商 |
-| llm_model | VARCHAR(64) | LLM模型名称 |
-| llm_tokens | INT | LLM Token总消耗 |
-| execution_time_ms | INT | 执行耗时(毫秒) |
-
----
-
-## 6. API 接口设计
-
-### 6.1 接口总览
-
-```mermaid
-flowchart LR
- subgraph Tasks["任务管理"]
- T1["GET /api/tasks"]
- T2["GET /api/tasks/:task_no"]
- end
-
- subgraph Details["明细查询"]
- D1["GET /api/tasks/:task_no/details"]
- D2["GET /api/tasks/:task_no/part-summaries"]
- D3["GET /api/tasks/:task_no/parts/:part_code/shops"]
- end
-
- subgraph Reports["报告模块"]
- R1["GET /api/tasks/:task_no/analysis-report"]
- R2["GET /api/tasks/:task_no/logs"]
- end
-
- subgraph Health["健康检查"]
- H1["GET /health"]
- end
-```
-
-### 6.2 核心接口定义
-
-#### 1. 获取任务列表
-```
-GET /api/tasks?page=1&page_size=20&status=1&dealer_grouping_id=100&statistics_date=20260212
-```
-
-**响应示例**:
-```json
-{
- "items": [
- {
- "id": 1,
- "task_no": "AI-ABC12345",
- "group_id": 2,
- "dealer_grouping_id": 100,
- "dealer_grouping_name": "华东区商家组合",
- "brand_grouping_id": 50,
- "plan_amount": 100000.00,
- "actual_amount": 89520.50,
- "part_count": 156,
- "base_ratio": 1.5000,
- "status": 1,
- "status_text": "成功",
- "llm_provider": "openai_compat",
- "llm_model": "glm-4-7-251222",
- "llm_total_tokens": 8500,
- "statistics_date": "20260212",
- "start_time": "2026-02-12 02:00:00",
- "end_time": "2026-02-12 02:05:30",
- "duration_seconds": 330,
- "create_time": "2026-02-12 02:00:00"
- }
- ],
- "total": 100,
- "page": 1,
- "page_size": 20
-}
-```
-
-#### 2. 获取配件汇总(商家组合维度)
-```
-GET /api/tasks/{task_no}/part-summaries?sort_by=total_suggest_amount&sort_order=desc&priority=1
-```
-
-**响应示例**:
-```json
-{
- "items": [
- {
- "id": 1,
- "task_no": "AI-ABC12345",
- "part_code": "C211F280503",
- "part_name": "机油滤芯",
- "unit": "个",
- "cost_price": 140.00,
- "total_storage_cnt": 25,
- "total_avg_sales_cnt": 18.5,
- "group_current_ratio": 1.35,
- "group_post_plan_ratio": 2.0,
- "total_suggest_cnt": 12,
- "total_suggest_amount": 1680.00,
- "shop_count": 5,
- "need_replenishment_shop_count": 3,
- "part_decision_reason": "【配件决策】该配件在商家组合内总库存25件...",
- "priority": 1,
- "llm_confidence": 0.85
- }
- ],
- "total": 50,
- "page": 1,
- "page_size": 50
-}
-```
-
-#### 3. 获取门店级明细
-```
-GET /api/tasks/{task_no}/parts/{part_code}/shops
-```
-
-**响应示例**:
-```json
-{
- "total": 3,
- "items": [
- {
- "id": 101,
- "task_no": "AI-ABC12345",
- "shop_id": 1001,
- "shop_name": "杭州西湖店",
- "part_code": "C211F280503",
- "part_name": "机油滤芯",
- "cost_price": 140.00,
- "valid_storage_cnt": 5,
- "avg_sales_cnt": 6.2,
- "current_ratio": 0.81,
- "post_plan_ratio": 1.61,
- "suggest_cnt": 5,
- "suggest_amount": 700.00,
- "suggestion_reason": "「建议补货」当前库存5件,月均销量6.2件...",
- "priority": 1
- }
- ]
-}
-```
-
-#### 4. 获取配件建议明细
-```
-GET /api/tasks/{task_no}/details?page=1&page_size=50&sort_by=suggest_amount&sort_order=desc&part_code=C211
-```
-
-#### 5. 获取分析报告
-```
-GET /api/tasks/{task_no}/analysis-report
-```
-
-**响应示例**:
-```json
-{
- "id": 1,
- "task_no": "AI-ABC12345",
- "group_id": 2,
- "dealer_grouping_id": 100,
- "report_type": "replenishment",
- "inventory_overview": {
- "stats": {
- "total_valid_storage_cnt": 2500,
- "total_valid_storage_amount": 350000.0,
- "total_capital_occupation": 280000.0,
- "overall_ratio": 1.35,
- "part_count": 156
- },
- "llm_analysis": { "..." : "LLM生成的分析结论" }
- },
- "sales_analysis": {
- "stats": { "total_avg_sales_cnt": 1850, "..." : "..." },
- "llm_analysis": { "..." : "..." }
- },
- "inventory_health": {
- "stats": { "shortage": { "count": 12, "amount": 5000 }, "..." : "..." },
- "chart_data": { "labels": ["缺货件","呆滞件","低频件","正常件"], "..." : "..." },
- "llm_analysis": { "..." : "..." }
- },
- "replenishment_summary": {
- "stats": { "urgent": { "count": 15, "amount": 25000 }, "..." : "..." },
- "llm_analysis": { "..." : "..." }
- },
- "llm_tokens": 3200,
- "execution_time_ms": 12000
-}
-```
-
-#### 6. 获取执行日志
-```
-GET /api/tasks/{task_no}/logs
-```
-
----
-
-## 7. 前端交互设计
-
-### 7.1 页面结构
-
-```mermaid
-flowchart TB
- subgraph Dashboard["仪表盘"]
- S1[统计卡片]
- S2[最近任务列表]
- end
-
- subgraph TaskList["任务列表页"]
- L1[筛选条件]
- L2[任务表格]
- L3[分页控件]
- end
-
- subgraph TaskDetail["任务详情页"]
- D1[任务头部信息]
- D2[统计卡片]
-
- subgraph Tabs["标签页"]
- T1[配件明细]
- T2[分析报告]
- T3[执行日志]
- T4[任务信息]
- end
- end
-
- Dashboard --> TaskList --> TaskDetail
-```
-
-### 7.2 配件明细交互
-
-```mermaid
-sequenceDiagram
- participant U as 用户
- participant UI as 前端UI
- participant API as 后端API
-
- U->>UI: 点击任务详情
- UI->>API: GET /api/tasks/{task_no}/part-summaries
- API-->>UI: 返回配件汇总列表
- UI->>UI: 渲染配件表格(可排序/筛选/优先级)
-
- U->>UI: 点击展开某配件
- UI->>API: GET /api/tasks/{task_no}/parts/{part_code}/shops
- API-->>UI: 返回门店级明细
- UI->>UI: 展开子表格显示门店数据
-
- Note over UI: 门店数据包含:
库存、销量、库销比
建议数量、建议理由
计划后库销比
-```
-
-### 7.3 关键UI组件
-
-| 组件 | 功能 | 交互方式 |
-|------|------|----------|
-| **配件汇总表格** | 展示商家组合维度的配件建议 | 支持排序、筛选、分页、优先级筛选 |
-| **可展开行** | 展示配件下的门店明细 | 点击行展开/收起 |
-| **配件决策卡片** | 显示LLM生成的配件级理由 | 展开配件时显示 |
-| **库销比指示器** | 直观显示库销比健康度 | 颜色渐变(红/黄/绿) |
-| **分析报告面板** | 四大板块数据驱动展示 | 统计数据 + LLM 分析 + 图表 |
-
----
-
-## 8. 分析报告设计
-
-### 8.1 报告模块结构
-
-分析报告由 **统计计算** + **4路并发 LLM 分析** 的 LangGraph 子图生成。每个板块包含 `stats`(统计数据)和 `llm_analysis`(LLM 分析结论)。
-
-```mermaid
-flowchart TB
- subgraph Report["分析报告四大板块"]
- M1["板块1: 库存总体概览
inventory_overview"]
- M2["板块2: 销量分析
sales_analysis"]
- M3["板块3: 库存构成健康度
inventory_health"]
- M4["板块4: 补货建议生成情况
replenishment_summary"]
- end
-
- M1 --> S1[有效库存/资金占用]
- M1 --> S2[在库/在途/已有计划]
- M1 --> S3[整体库销比]
-
- M2 --> R1[月均销量/销售金额]
- M2 --> R2[有销量/无销量配件数]
- M2 --> R3[出库/锁定/采购统计]
-
- M3 --> P1[缺货件统计]
- M3 --> P2[呆滞件统计]
- M3 --> P3[低频件统计]
- M3 --> P4[正常件统计]
- M3 --> P5[chart_data图表数据]
-
- M4 --> E1[急需补货统计]
- M4 --> E2[建议补货统计]
- M4 --> E3[可选补货统计]
-```
-
-### 8.2 各板块统计计算与LLM分析
-
-| 板块 | 统计计算 | LLM 分析 | 提示词文件 |
-|------|---------|---------|-----------|
-| **库存概览** | 有效库存、资金占用、配件总数、整体库销比 | 库存状况综合评价 | `report_inventory_overview.md` |
-| **销量分析** | 月均销量、出库频次、有/无销量配件数 | 销售趋势洞察 | `report_sales_analysis.md` |
-| **库存健康度** | 缺货/呆滞/低频/正常分类统计(数量/金额/占比) | 健康度风险提示 | `report_inventory_health.md` |
-| **补货建议汇总** | 按优先级(急需/建议/可选)分类统计 | 补货策略建议 | `report_replenishment_summary.md` |
-
-> 四个 LLM 分析节点使用 LangGraph 子图 **并发执行**(fan-out / fan-in),单板块失败不影响其他板块。
-
-### 8.3 并发子图实现
-
-```mermaid
-flowchart LR
- START --> A[库存概览LLM] --> END2[END]
- START --> B[销量分析LLM] --> END2
- START --> C[健康度LLM] --> END2
- START --> D[补货建议LLM] --> END2
-```
-
-子图采用 `ReportLLMState` TypedDict 定义状态,使用 `Annotated` reducer 合并并发结果:
-- 分析结果:`_merge_dict`(保留非 None)
-- Token 用量:`_sum_int`(累加)
-
----
-
-## 9. 技术选型
-
-| 组件 | 技术 | 选型理由 |
-|------|------|----------|
-| **编程语言** | Python 3.11+ | 丰富的AI/ML生态 |
-| **Agent框架** | LangChain + LangGraph | 成熟的LLM编排框架,支持并发子图 |
-| **API框架** | FastAPI | 高性能、自动文档 |
-| **数据库** | MySQL | 与主系统保持一致 |
-| **LLM** | 智谱GLM / 豆包 / OpenAI兼容 / Anthropic兼容 | 多模型支持,优先级自动选择 |
-| **前端** | 原生HTML+CSS+JS | 轻量级,无构建依赖 |
-
-### LLM 客户端优先级
-
-| 优先级 | 客户端 | 触发条件 |
-|--------|--------|----------|
-| 1 | `OpenAICompatClient` | `OPENAI_COMPAT_API_KEY` 已配置 |
-| 2 | `AnthropicCompatClient` | `ANTHROPIC_API_KEY` 已配置 |
-| 3 | `GLMClient` | `GLM_API_KEY` 已配置 |
-| 4 | `DoubaoClient` | `DOUBAO_API_KEY` 已配置 |
-
----
-
-## 10. 部署与运维
-
-### 10.1 部署架构
-
-```mermaid
-flowchart LR
- subgraph Client["客户端"]
- Browser[浏览器]
- end
-
- subgraph Server["服务器"]
- Nginx[Nginx
静态资源/反向代理]
- API[FastAPI
API服务]
- Scheduler[APScheduler
定时任务]
- end
-
- subgraph External["外部服务"]
- LLM[LLM API]
- DB[(MySQL)]
- end
-
- Browser --> Nginx
- Nginx --> API
- API --> LLM
- API --> DB
- Scheduler --> API
-```
-
-### 10.2 关键监控指标
-
-| 指标 | 阈值 | 告警方式 |
-|------|------|----------|
-| 任务成功率 | < 95% | 邮件 |
-| LLM响应时间 | > 30s | 日志 |
-| Token消耗 | > 10000/任务 | 日志 |
-| API响应时间 | > 2s | 监控 |
-
----
-
-## 附录
-
-### A. 术语表
-
-| 术语 | 定义 |
-|------|------|
-| 商家组合 | 多个经销商/门店的逻辑分组 |
-| 库销比 | 库存数量 / 月均销量,衡量库存健康度 |
-| 呆滞件 | 有库存但90天无出库数的配件 |
-| 低频件 | 月均销量<1 或 出库次数<3 或 出库间隔≥30天的配件 |
-| 有效库存 | 在库未锁定 + 在途 + 已有计划 |
-| 资金占用 | (在库未锁定 + 在途) × 成本价 |
-
-### B. 参考文档
-
-- [docs/architecture.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/docs/architecture.md) - 系统架构文档
-- [prompts/part_shop_analysis.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/part_shop_analysis.md) - 配件分析提示词
-- [prompts/report_inventory_overview.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/report_inventory_overview.md) - 库存概览提示词
-- [prompts/report_sales_analysis.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/report_sales_analysis.md) - 销量分析提示词
-- [prompts/report_inventory_health.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/report_inventory_health.md) - 库存健康度提示词
-- [prompts/report_replenishment_summary.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/report_replenishment_summary.md) - 补货建议提示词
-
-### C. 版本变更记录
-
-| 版本 | 日期 | 变更说明 |
-|------|------|----------|
-| 1.0.0 | 2026-02-09 | 初始版本 |
-| 2.0.0 | 2026-02-12 | 根据实际实现更新:分析报告重构为四大数据驱动板块、ER图更新、API路径和字段对齐、新增LLM客户端等 |
diff --git a/prompts/report_inventory_health.md b/prompts/report_inventory_health.md
deleted file mode 100644
index 09a0f0b..0000000
--- a/prompts/report_inventory_health.md
+++ /dev/null
@@ -1,200 +0,0 @@
-# 库存健康度分析提示词
-
-你是一位汽车配件库存健康度诊断专家,擅长从库存结构数据中识别问题并提出改善方案。请基于以下健康度统计数据,进行专业的库存健康度诊断。
-
----
-
-## 统计数据
-
-| 指标 | 数值 |
-|------|------|
-| 配件总种类数 | {total_count} |
-| 库存总金额 | {total_amount} 元 |
-
-### 各类型配件统计
-
-| 类型 | 数量 | 数量占比 | 金额(元) | 金额占比 |
-|------|------|----------|------------|----------|
-| 缺货件 | {shortage_count} | {shortage_count_pct}% | {shortage_amount} | {shortage_amount_pct}% |
-| 呆滞件 | {stagnant_count} | {stagnant_count_pct}% | {stagnant_amount} | {stagnant_amount_pct}% |
-| 低频件 | {low_freq_count} | {low_freq_count_pct}% | {low_freq_amount} | {low_freq_amount_pct}% |
-| 正常件 | {normal_count} | {normal_count_pct}% | {normal_amount} | {normal_amount_pct}% |
-
----
-
-## 术语说明
-
-- **缺货件**: 有效库存 = 0 且月均销量 >= 1,有需求但无库存
-- **呆滞件**: 有效库存 > 0 且90天出库数 = 0,有库存但无销售
-- **低频件**: 月均销量 < 1 或出库次数 < 3 或出库间隔 >= 30天
-- **正常件**: 不属于以上三类的配件
-
----
-
-## 当前季节信息
-
-- **当前季节**: {current_season}
-- **统计日期**: {statistics_date}
-
----
-
-## 季节性因素参考
-
-| 季节 | 健康度评估调整 | 特别关注 |
-|------|--------------|---------|
-| 春季(3-5月) | 呆滞件中可能包含冬季配件,属正常现象 | 关注冬季配件是否及时清理 |
-| 夏季(6-8月) | 制冷配件缺货风险高,需重点关注 | 空调、冷却系统配件缺货影响大 |
-| 秋季(9-11月) | 夏季配件可能转为低频,需提前处理 | 关注夏季配件库存消化 |
-| 冬季(12-2月) | 电瓶、暖风配件缺货影响大 | 春节前缺货损失更大,需提前备货 |
-
----
-
-## 分析框架与判断标准
-
-### 健康度评分标准
-| 正常件数量占比 | 健康度等级 | 说明 |
-|---------------|-----------|------|
-| > 70% | 健康 | 库存结构良好,继续保持 |
-| 50% - 70% | 亚健康 | 存在优化空间,需关注问题件 |
-| < 50% | 不健康 | 库存结构严重失衡,需立即改善 |
-
-### 各类型问题件风险评估标准
-| 类型 | 数量占比阈值 | 金额占比阈值 | 风险等级 |
-|------|-------------|-------------|---------|
-| 缺货件 | > 10% | - | 高风险(影响销售) |
-| 呆滞件 | > 15% | > 20% | 高风险(资金占用) |
-| 低频件 | > 25% | > 30% | 中风险(周转效率) |
-
-### 资金释放潜力评估
-| 类型 | 可释放比例 | 释放方式 |
-|------|-----------|---------|
-| 呆滞件 | 60%-80% | 促销清仓、退货供应商、调拨其他门店 |
-| 低频件 | 30%-50% | 降价促销、减少补货、逐步淘汰 |
-
----
-
-## 分析任务
-
-请严格按照以下步骤进行分析,每一步都要展示推理过程:
-
-### 步骤1:健康度评分
-- 读取正常件数量占比
-- 对照健康度评分标准,确定健康度等级
-- 说明判断依据
-
-### 步骤2:问题件诊断
-对每类问题件进行分析:
-
-**缺货件分析:**
-- 对照风险阈值(数量占比>10%),判断风险等级
-- 分析缺货对业务的影响(销售损失、客户流失)
-- 推断可能原因(补货不及时、需求预测不准、供应链问题)
-
-**呆滞件分析:**
-- 对照风险阈值(数量占比>15%或金额占比>20%),判断风险等级
-- 分析呆滞对资金的影响
-- 推断可能原因(采购决策失误、市场变化、产品更新换代)
-
-**低频件分析:**
-- 对照风险阈值(数量占比>25%或金额占比>30%),判断风险等级
-- 分析低频件对SKU效率的影响
-- 推断可能原因(长尾需求、季节性产品、新品导入)
-
-### 步骤3:资金释放机会评估
-- 计算呆滞件可释放资金 = 呆滞件金额 × 可释放比例(60%-80%)
-- 计算低频件可释放资金 = 低频件金额 × 可释放比例(30%-50%)
-- 给出具体的资金释放行动方案
-
-### 步骤4:改善优先级排序
-- 根据风险等级和影响程度,排序问题类型
-- 给出2-3条优先级最高的改善行动
-
----
-
-## 输出格式
-
-直接输出JSON对象,**不要**包含 ```json 标记:
-
-{{
- "analysis_process": {{
- "health_score_diagnosis": {{
- "normal_ratio": "正常件数量占比(直接读取:{normal_count_pct}%)",
- "score": "健康/亚健康/不健康",
- "reasoning": "判断依据:对照标准xxx,当前正常件占比为xxx%,因此判断为xxx"
- }},
- "problem_diagnosis": {{
- "shortage": {{
- "risk_level": "高/中/低",
- "threshold_comparison": "对照阈值>10%,当前{shortage_count_pct}%,结论",
- "business_impact": "对业务的具体影响分析",
- "possible_causes": ["可能原因1", "可能原因2"]
- }},
- "stagnant": {{
- "risk_level": "高/中/低",
- "threshold_comparison": "对照阈值(数量>15%或金额>20%),当前数量{stagnant_count_pct}%/金额{stagnant_amount_pct}%,结论",
- "capital_impact": "对资金的具体影响分析",
- "possible_causes": ["可能原因1", "可能原因2"]
- }},
- "low_freq": {{
- "risk_level": "高/中/低",
- "threshold_comparison": "对照阈值(数量>25%或金额>30%),当前数量{low_freq_count_pct}%/金额{low_freq_amount_pct}%,结论",
- "efficiency_impact": "对SKU效率的具体影响分析",
- "possible_causes": ["可能原因1", "可能原因2"]
- }}
- }},
- "capital_release_calculation": {{
- "stagnant_calculation": "呆滞件可释放资金 = {stagnant_amount} × 70% = xxx元(取中间值70%)",
- "low_freq_calculation": "低频件可释放资金 = {low_freq_amount} × 40% = xxx元(取中间值40%)",
- "total_releasable": "总可释放资金 = xxx元"
- }},
- "seasonal_analysis": {{
- "current_season": "当前季节",
- "seasonal_stagnant_items": "呆滞件中是否包含季节性配件(如冬季的空调配件)",
- "seasonal_shortage_risk": "当季高需求配件的缺货风险评估",
- "upcoming_season_alert": "下一季节需要关注的配件类型"
- }}
- }},
- "conclusion": {{
- "health_score": {{
- "score": "健康/亚健康/不健康",
- "normal_ratio_evaluation": "正常件占比评估结论(基于分析得出)"
- }},
- "problem_diagnosis": {{
- "stagnant_analysis": "呆滞件问题分析及原因(基于分析得出)",
- "shortage_analysis": "缺货件问题分析及影响(基于分析得出)",
- "low_freq_analysis": "低频件问题分析及建议(基于分析得出)"
- }},
- "capital_release": {{
- "stagnant_releasable": "呆滞件可释放资金估算(基于计算得出)",
- "low_freq_releasable": "低频件可释放资金估算(基于计算得出)",
- "action_plan": "资金释放行动方案(具体步骤)"
- }},
- "priority_actions": [
- {{
- "priority": 1,
- "action": "最优先处理事项",
- "reason": "优先原因",
- "expected_effect": "预期效果"
- }},
- {{
- "priority": 2,
- "action": "次优先处理事项",
- "reason": "优先原因",
- "expected_effect": "预期效果"
- }}
- ]
- }}
-}}
-
----
-
-## 重要约束
-
-1. **输出必须是合法的JSON对象**
-2. **分析必须基于提供的数据,不要编造数据**
-3. **每个结论都必须有明确的推理依据和数据支撑**
-4. **资金释放估算应基于实际数据和给定的释放比例范围**
-5. **score 只能是"健康"、"亚健康"、"不健康"三个值之一**
-6. **priority_actions 数组至少包含2条,最多3条**
-7. **如果数据全为零,请在分析中说明无有效数据,并给出相应建议**
-8. **所有金额计算结果保留两位小数**
diff --git a/prompts/report_inventory_overview.md b/prompts/report_inventory_overview.md
deleted file mode 100644
index 211b000..0000000
--- a/prompts/report_inventory_overview.md
+++ /dev/null
@@ -1,182 +0,0 @@
-# 库存概览分析提示词
-
-你是一位资深汽车配件库存管理专家,拥有20年以上的汽车后市场库存管理经验。请基于以下库存概览统计数据,进行专业的库存分析。
-
----
-
-## 统计数据
-
-| 指标 | 数值 |
-|------|------|
-| 配件总种类数 | {part_count} |
-| 有效库存总数量 | {total_valid_storage_cnt} |
-| 有效库存总金额(资金占用) | {total_valid_storage_amount} 元 |
-| 月均销量总数量 | {total_avg_sales_cnt} |
-| 整体库销比 | {overall_ratio} |
-
-### 库存三项构成明细
-
-| 构成项 | 数量 | 金额(元) | 数量占比 | 金额占比 |
-|--------|------|------------|----------|----------|
-| 在库未锁 | {total_in_stock_unlocked_cnt} | {total_in_stock_unlocked_amount} | - | - |
-| 在途 | {total_on_the_way_cnt} | {total_on_the_way_amount} | - | - |
-| 计划数 | {total_has_plan_cnt} | {total_has_plan_amount} | - | - |
-
----
-
-## 术语说明
-
-- **有效库存**: 在库未锁 + 在途 + 计划数
-- **月均销量**: (90天出库数 + 未关单已锁 + 未关单出库 + 订件) / 3
-- **库销比**: 有效库存总数量 / 月均销量总数量,反映库存周转效率
-
----
-
-## 当前季节信息
-
-- **当前季节**: {current_season}
-- **统计日期**: {statistics_date}
-
----
-
-## 季节性因素参考
-
-| 季节 | 需求特征 | 库存策略建议 |
-|------|---------|-------------|
-| 春季(3-5月) | 需求回暖,维修保养高峰前期 | 适当增加库存,为旺季做准备 |
-| 夏季(6-8月) | 空调、冷却系统配件需求旺盛 | 重点备货制冷相关配件,库销比可适当放宽至2.5 |
-| 秋季(9-11月) | 需求平稳,换季保养需求 | 保持正常库存水平,关注轮胎、刹车片等 |
-| 冬季(12-2月) | 电瓶、暖风系统需求增加,春节前备货期 | 提前备货,库销比可适当放宽至2.5-3.0 |
-
----
-
-## 分析框架与判断标准
-
-### 库销比判断标准
-| 库销比范围 | 判断等级 | 含义 |
-|-----------|---------|------|
-| < 1.0 | 库存不足 | 可能面临缺货风险,需要加快补货 |
-| 1.0 - 2.0 | 合理 | 库存水平健康,周转效率良好 |
-| 2.0 - 3.0 | 偏高 | 库存积压风险,需关注周转 |
-| > 3.0 | 严重积压 | 资金占用过高,需立即优化 |
-| = 999 | 无销量 | 月均销量为零,需特别关注 |
-
-### 库存结构健康标准
-| 构成项 | 健康占比范围 | 风险提示 |
-|--------|-------------|---------|
-| 在库未锁 | 60%-80% | 过高说明周转慢,过低说明库存不足 |
-| 在途 | 10%-25% | 过高说明到货延迟风险,过低说明补货不及时 |
-| 计划数 | 5%-15% | 过高说明计划执行滞后 |
-
-### 资金占用风险等级
-| 条件 | 风险等级 |
-|------|---------|
-| 库销比 > 3.0 或 在库未锁占比 > 85% | high |
-| 库销比 2.0-3.0 或 在库未锁占比 80%-85% | medium |
-| 库销比 < 2.0 且 结构合理 | low |
-
----
-
-## 分析任务
-
-请严格按照以下步骤进行分析,每一步都要展示推理过程:
-
-### 步骤1:计算关键指标
-首先计算以下指标(请在分析中展示计算过程):
-- 各构成项的数量占比 = 构成项数量 / 有效库存总数量 × 100%
-- 各构成项的金额占比 = 构成项金额 / 有效库存总金额 × 100%
-- 单件平均成本 = 有效库存总金额 / 有效库存总数量
-
-### 步骤2:库销比诊断
-- 对照判断标准,确定当前库销比所处等级
-- 说明该等级的业务含义
-- 与行业经验值(1.5-2.5)进行对比
-
-### 步骤3:库存结构分析
-- 对照健康标准,评估各构成项占比是否合理
-- 识别偏离健康范围的构成项
-- 分析偏离的可能原因
-
-### 步骤4:风险评估
-- 根据风险等级判断条件,确定当前风险等级
-- 列出具体的风险点
-
-### 步骤5:季节性考量
-- 结合当前季节特征,评估库存水平是否适合当前季节
-- 考虑即将到来的季节变化,是否需要提前调整
-
-### 步骤6:形成建议
-- 基于以上分析,提出2-3条具体可操作的改善建议
-- 每条建议需说明预期效果
-- 建议需考虑季节性因素
-
----
-
-## 输出格式
-
-直接输出JSON对象,**不要**包含 ```json 标记:
-
-{{
- "analysis_process": {{
- "calculated_metrics": {{
- "in_stock_ratio": "在库未锁数量占比(计算过程:xxx / xxx = xx%)",
- "on_way_ratio": "在途数量占比(计算过程)",
- "plan_ratio": "计划数占比(计算过程)",
- "avg_cost": "单件平均成本(计算过程)"
- }},
- "ratio_diagnosis": {{
- "current_value": "当前库销比数值",
- "level": "不足/合理/偏高/严重积压/无销量",
- "reasoning": "判断依据:对照标准xxx,当前值xxx,因此判断为xxx",
- "benchmark_comparison": "与行业经验值1.5-2.5对比的结论"
- }},
- "structure_analysis": {{
- "in_stock_evaluation": "在库未锁占比评估(对照标准60%-80%,当前xx%,结论)",
- "on_way_evaluation": "在途占比评估(对照标准10%-25%,当前xx%,结论)",
- "plan_evaluation": "计划数占比评估(对照标准5%-15%,当前xx%,结论)",
- "abnormal_items": ["偏离健康范围的项目及原因分析"]
- }},
- "seasonal_analysis": {{
- "current_season": "当前季节",
- "season_demand_feature": "当前季节需求特征",
- "inventory_fitness": "当前库存水平是否适合本季节(结合季节性因素评估)",
- "upcoming_season_preparation": "对即将到来季节的准备建议"
- }}
- }},
- "conclusion": {{
- "capital_assessment": {{
- "total_evaluation": "总资金占用评估(基于以上分析得出的一句话结论)",
- "structure_ratio": "各构成部分的资金比例分析结论",
- "risk_level": "high/medium/low(基于风险等级判断条件得出)"
- }},
- "ratio_diagnosis": {{
- "level": "不足/合理/偏高/严重积压",
- "analysis": "库销比分析结论",
- "benchmark": "行业参考值对比结论"
- }},
- "recommendations": [
- {{
- "action": "具体建议1",
- "reason": "建议依据",
- "expected_effect": "预期效果"
- }},
- {{
- "action": "具体建议2",
- "reason": "建议依据",
- "expected_effect": "预期效果"
- }}
- ]
- }}
-}}
-
----
-
-## 重要约束
-
-1. **输出必须是合法的JSON对象**
-2. **分析必须基于提供的数据,不要编造数据**
-3. **每个结论都必须有明确的推理依据**
-4. **建议必须具体可操作,避免空泛的表述**
-5. **risk_level 只能是 high、medium、low 三个值之一**
-6. **如果数据全为零,请在分析中说明无有效数据,并给出相应建议**
-7. **所有百分比计算结果保留两位小数**
diff --git a/prompts/report_replenishment_summary.md b/prompts/report_replenishment_summary.md
deleted file mode 100644
index aeb5a45..0000000
--- a/prompts/report_replenishment_summary.md
+++ /dev/null
@@ -1,173 +0,0 @@
-# 补货建议分析提示词
-
-你是一位汽车配件采购策略顾问,擅长制定科学的补货计划和资金分配方案。请基于以下补货建议统计数据,进行专业的补货策略分析。
-
----
-
-## 统计数据
-
-| 指标 | 数值 |
-|------|------|
-| 补货配件总种类数 | {total_count} |
-| 补货总金额 | {total_amount} 元 |
-
-### 各优先级统计
-
-| 优先级 | 配件种类数 | 金额(元) |
-|--------|-----------|------------|
-| 急需补货(优先级1) | {urgent_count} | {urgent_amount} |
-| 建议补货(优先级2) | {suggested_count} | {suggested_amount} |
-| 可选补货(优先级3) | {optional_count} | {optional_amount} |
-
----
-
-## 术语说明
-
-- **急需补货(优先级1)**: 库销比 < 0.5 且月均销量 >= 1,库存严重不足,面临断货风险
-- **建议补货(优先级2)**: 库销比 0.5-1.0 且月均销量 >= 1,库存偏低,建议及时补充
-- **可选补货(优先级3)**: 库销比 1.0-目标值 且月均销量 >= 1,库存尚可,可灵活安排
-
----
-
-## 当前季节信息
-
-- **当前季节**: {current_season}
-- **统计日期**: {statistics_date}
-
----
-
-## 季节性因素参考
-
-| 季节 | 补货策略调整 | 重点补货品类 |
-|------|-------------|-------------|
-| 春季(3-5月) | 为夏季旺季提前备货 | 空调、冷却系统配件 |
-| 夏季(6-8月) | 制冷配件紧急补货优先级更高 | 空调压缩机、冷凝器、制冷剂 |
-| 秋季(9-11月) | 为冬季备货,减少夏季配件补货 | 电瓶、暖风系统、防冻液 |
-| 冬季(12-2月) | 春节前加快补货节奏 | 电瓶、启动机、暖风配件 |
-
----
-
-## 分析框架与判断标准
-
-### 紧迫度评估标准
-| 急需补货占比 | 紧迫度等级 | 风险等级 | 建议 |
-|-------------|-----------|---------|------|
-| > 30% | 非常紧迫 | high | 立即启动紧急补货流程 |
-| 15% - 30% | 较紧迫 | medium | 优先处理急需补货 |
-| < 15% | 一般 | low | 按正常流程处理 |
-
-### 资金分配优先级原则
-| 优先级 | 建议预算占比 | 执行时间 |
-|--------|-------------|---------|
-| 急需补货 | 50%-70% | 1-3天内 |
-| 建议补货 | 20%-35% | 1-2周内 |
-| 可选补货 | 10%-15% | 2-4周内 |
-
-### 风险预警阈值
-| 风险类型 | 触发条件 | 预警等级 |
-|---------|---------|---------|
-| 资金压力 | 急需补货金额占比 > 60% | 高 |
-| 过度补货 | 可选补货金额占比 > 40% | 中 |
-
----
-
-## 分析任务
-
-请严格按照以下步骤进行分析,展示推理过程:
-
-### 步骤1:计算关键指标
-- 各优先级数量占比 = 数量 / 总数量 × 100%
-- 各优先级金额占比 = 金额 / 总金额 × 100%
-
-### 步骤2:紧迫度评估
-- 对照标准确定紧迫度等级和风险等级
-- 判断是否需要立即行动
-
-### 步骤3:资金分配建议
-- 对照建议预算占比,判断当前分布是否合理
-- 给出具体资金分配建议
-
-### 步骤4:执行节奏规划
-- 规划各类补货的执行时间
-
-### 步骤5:风险识别
-- 对照风险预警阈值,识别潜在风险
-
----
-
-## 输出格式
-
-直接输出JSON对象,**不要**包含 ```json 标记:
-
-{{
- "analysis_process": {{
- "calculated_metrics": {{
- "urgent_count_ratio": "急需补货数量占比(计算:xxx / xxx = xx%)",
- "urgent_amount_ratio": "急需补货金额占比(计算)",
- "suggested_count_ratio": "建议补货数量占比(计算)",
- "suggested_amount_ratio": "建议补货金额占比(计算)",
- "optional_count_ratio": "可选补货数量占比(计算)",
- "optional_amount_ratio": "可选补货金额占比(计算)"
- }},
- "urgency_diagnosis": {{
- "urgent_ratio": "急需补货数量占比",
- "level": "非常紧迫/较紧迫/一般",
- "reasoning": "判断依据:对照标准xxx,当前占比xxx%,因此判断为xxx"
- }},
- "budget_analysis": {{
- "current_distribution": "当前各优先级金额分布情况",
- "comparison_with_standard": "与建议预算占比对比分析",
- "adjustment_needed": "是否需要调整及原因"
- }},
- "risk_identification": {{
- "capital_pressure_check": "资金压力检查(急需占比是否>60%)",
- "over_replenishment_check": "过度补货检查(可选占比是否>40%)",
- "identified_risks": ["识别到的风险1", "识别到的风险2"]
- }},
- "seasonal_analysis": {{
- "current_season": "当前季节",
- "seasonal_priority_items": "当季重点补货品类是否在急需列表中",
- "timeline_adjustment": "是否需要根据季节调整补货时间(如春节前加快)",
- "next_season_preparation": "为下一季节需要提前准备的配件"
- }}
- }},
- "conclusion": {{
- "urgency_assessment": {{
- "urgent_ratio_evaluation": "急需补货占比评估结论",
- "risk_level": "high/medium/low",
- "immediate_action_needed": true或false
- }},
- "budget_allocation": {{
- "recommended_order": "建议资金分配顺序(基于分析得出)",
- "urgent_budget": "急需补货建议预算(具体金额或比例)",
- "suggested_budget": "建议补货建议预算",
- "optional_budget": "可选补货建议预算"
- }},
- "execution_plan": {{
- "urgent_timeline": "急需补货执行时间(1-3天内)",
- "suggested_timeline": "建议补货执行时间(1-2周内)",
- "optional_timeline": "可选补货执行时间(2-4周内)"
- }},
- "risk_warnings": [
- {{
- "risk_type": "风险类型",
- "description": "风险描述",
- "mitigation": "应对建议"
- }}
- ]
- }}
-}}
-
----
-
-## 重要约束
-
-1. **输出必须是合法的JSON对象**
-2. **分析必须基于提供的数据,不要编造数据**
-3. **每个结论都必须有明确的推理依据和数据支撑**
-4. **建议必须具体可操作,包含时间和金额参考**
-5. **risk_level 只能是 high、medium、low 三个值之一**
-6. **immediate_action_needed 必须是布尔值 true 或 false**
-7. **risk_warnings 数组至少包含1条,最多3条**
-8. **如果数据全为零,请在分析中说明无补货建议数据**
-9. **所有百分比计算结果保留两位小数**
diff --git a/prompts/report_sales_analysis.md b/prompts/report_sales_analysis.md
deleted file mode 100644
index 0718b84..0000000
--- a/prompts/report_sales_analysis.md
+++ /dev/null
@@ -1,178 +0,0 @@
-# 销量分析提示词
-
-你是一位汽车配件销售数据分析师,擅长从销量数据中洞察需求趋势和业务机会。请基于以下销量统计数据,进行专业的销量分析。
-
----
-
-## 统计数据
-
-| 指标 | 数值 |
-|------|------|
-| 月均销量总数量 | {total_avg_sales_cnt} |
-| 月均销量总金额 | {total_avg_sales_amount} 元 |
-| 有销量配件数 | {has_sales_part_count} |
-| 无销量配件数 | {no_sales_part_count} |
-
-### 销量构成明细
-
-| 构成项 | 数量 | 说明 |
-|--------|------|------|
-| 90天出库数 | {total_out_stock_cnt} | 近90天实际出库,反映正常销售 |
-| 未关单已锁 | {total_storage_locked_cnt} | 已锁定库存但订单未关闭,反映待处理订单 |
-| 未关单出库 | {total_out_stock_ongoing_cnt} | 已出库但订单未关闭,反映在途交付 |
-| 订件 | {total_buy_cnt} | 客户预订的配件数量,反映预订需求 |
-
----
-
-## 术语说明
-
-- **月均销量**: (90天出库数 + 未关单已锁 + 未关单出库 + 订件) / 3
-- **有销量配件**: 月均销量 > 0 的配件
-- **无销量配件**: 月均销量 = 0 的配件
-- **SKU活跃率**: 有销量配件数 / 总配件数 × 100%
-
----
-
-## 当前季节信息
-
-- **当前季节**: {current_season}
-- **统计日期**: {statistics_date}
-
----
-
-## 季节性因素参考
-
-| 季节 | 销量特征 | 关注重点 |
-|------|---------|---------|
-| 春季(3-5月) | 销量逐步回升,保养类配件需求增加 | 关注机油、滤芯等保养件销量变化 |
-| 夏季(6-8月) | 空调、冷却系统配件销量高峰 | 制冷配件销量应明显上升,否则需关注 |
-| 秋季(9-11月) | 销量平稳,换季保养需求 | 轮胎、刹车片等安全件需求增加 |
-| 冬季(12-2月) | 电瓶、暖风配件需求增加,春节前订单高峰 | 订件占比可能上升,属正常现象 |
-
----
-
-## 分析框架与判断标准
-
-### 销量构成健康标准
-| 构成项 | 健康占比范围 | 异常信号 |
-|--------|-------------|---------|
-| 90天出库数 | > 70% | 占比过低说明正常销售不足,可能存在订单积压 |
-| 未关单已锁 | < 15% | 占比过高说明订单处理效率低,需关注 |
-| 未关单出库 | < 10% | 占比过高说明交付周期长,客户体验受影响 |
-| 订件 | 5%-15% | 过高说明预订需求旺盛但库存不足,过低说明预订渠道不畅 |
-
-### SKU活跃度判断标准
-| 活跃率范围 | 判断等级 | 建议 |
-|-----------|---------|------|
-| > 80% | 优秀 | SKU管理良好,保持现状 |
-| 70%-80% | 良好 | 可适当优化无销量SKU |
-| 50%-70% | 一般 | 需要重点关注SKU精简 |
-| < 50% | 较差 | SKU管理存在严重问题,需立即优化 |
-
-### 需求趋势判断依据
-| 信号 | 趋势判断 |
-|------|---------|
-| 订件占比上升 + 未关单占比上升 | 上升(需求增长但供应跟不上) |
-| 90天出库占比稳定 + 各项占比均衡 | 稳定(供需平衡) |
-| 90天出库占比下降 + 订件占比下降 | 下降(需求萎缩) |
-
----
-
-## 分析任务
-
-请严格按照以下步骤进行分析,每一步都要展示推理过程:
-
-### 步骤1:计算关键指标
-首先计算以下指标(请在分析中展示计算过程):
-- 各构成项占比 = 构成项数量 / (90天出库数 + 未关单已锁 + 未关单出库 + 订件) × 100%
-- SKU活跃率 = 有销量配件数 / (有销量配件数 + 无销量配件数) × 100%
-- 单件平均销售金额 = 月均销量总金额 / 月均销量总数量
-
-### 步骤2:销量构成分析
-- 对照健康标准,评估各构成项占比是否合理
-- 识别主要销量来源
-- 分析未关单(已锁+出库)对整体销量的影响
-
-### 步骤3:SKU活跃度评估
-- 对照活跃度标准,确定当前活跃率等级
-- 分析无销量配件占比的业务影响
-- 提出SKU优化方向
-
-### 步骤4:季节性分析
-- 结合当前季节特征,评估销量表现是否符合季节预期
-- 分析季节性配件的销量是否正常
-
-### 步骤5:需求趋势判断
-- 根据各构成项的占比关系,判断需求趋势
-- 结合季节因素,说明判断依据
-- 给出短期需求预测(考虑季节变化)
-
----
-
-## 输出格式
-
-直接输出JSON对象,**不要**包含 ```json 标记:
-
-{{
- "analysis_process": {{
- "calculated_metrics": {{
- "out_stock_ratio": "90天出库占比(计算过程:xxx / xxx = xx%)",
- "locked_ratio": "未关单已锁占比(计算过程)",
- "ongoing_ratio": "未关单出库占比(计算过程)",
- "buy_ratio": "订件占比(计算过程)",
- "sku_active_rate": "SKU活跃率(计算过程:xxx / xxx = xx%)",
- "avg_sales_price": "单件平均销售金额(计算过程)"
- }},
- "composition_diagnosis": {{
- "out_stock_evaluation": "90天出库占比评估(对照标准>70%,当前xx%,结论)",
- "locked_evaluation": "未关单已锁占比评估(对照标准<15%,当前xx%,结论)",
- "ongoing_evaluation": "未关单出库占比评估(对照标准<10%,当前xx%,结论)",
- "buy_evaluation": "订件占比评估(对照标准5%-15%,当前xx%,结论)",
- "abnormal_items": ["偏离健康范围的项目及原因分析"]
- }},
- "activity_diagnosis": {{
- "current_rate": "当前SKU活跃率",
- "level": "优秀/良好/一般/较差",
- "reasoning": "判断依据:对照标准xxx,当前值xxx,因此判断为xxx"
- }},
- "trend_diagnosis": {{
- "signals": ["观察到的趋势信号1", "观察到的趋势信号2"],
- "reasoning": "基于以上信号,判断需求趋势为xxx,因为xxx"
- }},
- "seasonal_analysis": {{
- "current_season": "当前季节",
- "expected_performance": "本季节预期销量特征",
- "actual_vs_expected": "实际表现与季节预期对比",
- "seasonal_items_status": "季节性配件销量状态评估"
- }}
- }},
- "conclusion": {{
- "composition_analysis": {{
- "main_driver": "主要销量来源分析(基于占比计算得出)",
- "pending_orders_impact": "未关单对销量的影响(基于占比计算得出)",
- "booking_trend": "订件趋势分析(基于占比计算得出)"
- }},
- "activity_assessment": {{
- "active_ratio": "活跃SKU占比评估结论",
- "optimization_suggestion": "SKU优化建议(基于活跃度等级给出)"
- }},
- "demand_trend": {{
- "direction": "上升/稳定/下降",
- "evidence": "判断依据(列出具体数据支撑)",
- "seasonal_factor": "季节因素对趋势的影响",
- "forecast": "短期需求预测(考虑季节变化)"
- }}
- }}
-}}
-
----
-
-## 重要约束
-
-1. **输出必须是合法的JSON对象**
-2. **分析必须基于提供的数据,不要编造数据**
-3. **每个结论都必须有明确的推理依据和数据支撑**
-4. **建议必须具体可操作,避免空泛的表述**
-5. **direction 只能是"上升"、"稳定"、"下降"三个值之一**
-6. **如果数据全为零,请在分析中说明无有效数据,并给出相应建议**
-7. **所有百分比计算结果保留两位小数**
diff --git a/pyproject.toml b/pyproject.toml
index 777a80f..1a349b6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -21,9 +21,6 @@ dependencies = [
# LLM 集成
"zhipuai>=2.0.0",
- # 定时任务
- "apscheduler>=3.10.0",
-
# 数据库
"mysql-connector-python>=8.0.0",
"sqlalchemy>=2.0.0",
diff --git a/sql/migrate_analysis_report.sql b/sql/migrate_analysis_report.sql
deleted file mode 100644
index acd232d..0000000
--- a/sql/migrate_analysis_report.sql
+++ /dev/null
@@ -1,37 +0,0 @@
--- ============================================================================
--- AI 补货建议分析报告表
--- ============================================================================
--- 版本: 3.0.0
--- 更新日期: 2026-02-10
--- 变更说明: 重构为四大数据驱动板块(库存概览/销量分析/健康度/补货建议)
--- ============================================================================
-
-DROP TABLE IF EXISTS ai_analysis_report;
-CREATE TABLE ai_analysis_report (
- id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
- task_no VARCHAR(32) NOT NULL COMMENT '任务编号',
- group_id BIGINT NOT NULL COMMENT '集团ID',
- dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID',
- dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称',
- brand_grouping_id BIGINT COMMENT '品牌组合ID',
- report_type VARCHAR(32) DEFAULT 'replenishment' COMMENT '报告类型',
-
- -- 四大板块 (JSON 结构化存储,每个字段包含 stats + llm_analysis)
- inventory_overview JSON COMMENT '库存总体概览(统计数据+LLM分析)',
- sales_analysis JSON COMMENT '销量分析(统计数据+LLM分析)',
- inventory_health JSON COMMENT '库存构成健康度(统计数据+图表数据+LLM分析)',
- replenishment_summary JSON COMMENT '补货建议生成情况(统计数据+LLM分析)',
-
- -- LLM 元数据
- llm_provider VARCHAR(32) COMMENT 'LLM提供商',
- llm_model VARCHAR(64) COMMENT 'LLM模型名称',
- llm_tokens INT DEFAULT 0 COMMENT 'LLM Token消耗',
- execution_time_ms INT DEFAULT 0 COMMENT '执行耗时(毫秒)',
-
- statistics_date VARCHAR(16) COMMENT '统计日期',
- create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
-
- INDEX idx_task_no (task_no),
- INDEX idx_group_date (group_id, statistics_date),
- INDEX idx_dealer_grouping (dealer_grouping_id)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议分析报告表-重构版';
diff --git a/src/fw_pms_ai/__main__.py b/src/fw_pms_ai/__main__.py
new file mode 100644
index 0000000..ac23724
--- /dev/null
+++ b/src/fw_pms_ai/__main__.py
@@ -0,0 +1,4 @@
+
+from .main import main
+
+main()
diff --git a/src/fw_pms_ai/agent/analysis_report_node.py b/src/fw_pms_ai/agent/analysis_report_node.py
deleted file mode 100644
index f45176c..0000000
--- a/src/fw_pms_ai/agent/analysis_report_node.py
+++ /dev/null
@@ -1,945 +0,0 @@
-"""
-分析报告生成节点
-
-在补货建议工作流的最后一个节点执行,生成结构化分析报告。
-包含四大板块的统计计算函数:库存概览、销量分析、库存健康度、补货建议。
-"""
-
-import logging
-from decimal import Decimal, ROUND_HALF_UP
-
-logger = logging.getLogger(__name__)
-
-
-def _to_decimal(value) -> Decimal:
- """安全转换为 Decimal"""
- if value is None:
- return Decimal("0")
- return Decimal(str(value))
-
-
-def calculate_inventory_overview(part_ratios: list[dict]) -> dict:
- """
- 计算库存总体概览统计数据
-
- 有效库存 = in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt
- 资金占用 = in_stock_unlocked_cnt + on_the_way_cnt(仅计算实际占用资金的库存)
-
- Args:
- part_ratios: PartRatio 字典列表
-
- Returns:
- 库存概览统计字典
- """
- total_in_stock_unlocked_cnt = Decimal("0")
- total_in_stock_unlocked_amount = Decimal("0")
- total_on_the_way_cnt = Decimal("0")
- total_on_the_way_amount = Decimal("0")
- total_has_plan_cnt = Decimal("0")
- total_has_plan_amount = Decimal("0")
- total_avg_sales_cnt = Decimal("0")
- # 资金占用合计 = (在库未锁 + 在途) * 成本价
- total_capital_occupation = Decimal("0")
-
- for p in part_ratios:
- cost_price = _to_decimal(p.get("cost_price", 0))
-
- in_stock = _to_decimal(p.get("in_stock_unlocked_cnt", 0))
- on_way = _to_decimal(p.get("on_the_way_cnt", 0))
- has_plan = _to_decimal(p.get("has_plan_cnt", 0))
-
- total_in_stock_unlocked_cnt += in_stock
- total_in_stock_unlocked_amount += in_stock * cost_price
- total_on_the_way_cnt += on_way
- total_on_the_way_amount += on_way * cost_price
- total_has_plan_cnt += has_plan
- total_has_plan_amount += has_plan * cost_price
-
- # 资金占用 = 在库未锁 + 在途
- total_capital_occupation += (in_stock + on_way) * cost_price
-
- # 月均销量
- out_stock = _to_decimal(p.get("out_stock_cnt", 0))
- locked = _to_decimal(p.get("storage_locked_cnt", 0))
- ongoing = _to_decimal(p.get("out_stock_ongoing_cnt", 0))
- buy = _to_decimal(p.get("buy_cnt", 0))
- avg_sales = (out_stock + locked + ongoing + buy) / Decimal("3")
- total_avg_sales_cnt += avg_sales
-
- total_valid_storage_cnt = (
- total_in_stock_unlocked_cnt
- + total_on_the_way_cnt
- + total_has_plan_cnt
- )
- total_valid_storage_amount = (
- total_in_stock_unlocked_amount
- + total_on_the_way_amount
- + total_has_plan_amount
- )
-
- # 库销比:月均销量为零时标记为特殊值
- if total_avg_sales_cnt > 0:
- overall_ratio = total_valid_storage_cnt / total_avg_sales_cnt
- else:
- overall_ratio = Decimal("999")
-
- return {
- "total_valid_storage_cnt": total_valid_storage_cnt,
- "total_valid_storage_amount": total_valid_storage_amount,
- "total_capital_occupation": total_capital_occupation,
- "total_in_stock_unlocked_cnt": total_in_stock_unlocked_cnt,
- "total_in_stock_unlocked_amount": total_in_stock_unlocked_amount,
- "total_on_the_way_cnt": total_on_the_way_cnt,
- "total_on_the_way_amount": total_on_the_way_amount,
- "total_has_plan_cnt": total_has_plan_cnt,
- "total_has_plan_amount": total_has_plan_amount,
- "total_avg_sales_cnt": total_avg_sales_cnt,
- "overall_ratio": overall_ratio,
- "part_count": len(part_ratios),
- }
-
-
-def calculate_sales_analysis(part_ratios: list[dict]) -> dict:
- """
- 计算销量分析统计数据
-
- 月均销量 = (out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3
-
- Args:
- part_ratios: PartRatio 字典列表
-
- Returns:
- 销量分析统计字典
- """
- total_out_stock_cnt = Decimal("0")
- total_storage_locked_cnt = Decimal("0")
- total_out_stock_ongoing_cnt = Decimal("0")
- total_buy_cnt = Decimal("0")
- total_avg_sales_amount = Decimal("0")
- has_sales_part_count = 0
- no_sales_part_count = 0
-
- for p in part_ratios:
- cost_price = _to_decimal(p.get("cost_price", 0))
-
- out_stock = _to_decimal(p.get("out_stock_cnt", 0))
- locked = _to_decimal(p.get("storage_locked_cnt", 0))
- ongoing = _to_decimal(p.get("out_stock_ongoing_cnt", 0))
- buy = _to_decimal(p.get("buy_cnt", 0))
-
- total_out_stock_cnt += out_stock
- total_storage_locked_cnt += locked
- total_out_stock_ongoing_cnt += ongoing
- total_buy_cnt += buy
-
- avg_sales = (out_stock + locked + ongoing + buy) / Decimal("3")
- total_avg_sales_amount += avg_sales * cost_price
-
- if avg_sales > 0:
- has_sales_part_count += 1
- else:
- no_sales_part_count += 1
-
- total_avg_sales_cnt = (
- total_out_stock_cnt + total_storage_locked_cnt + total_out_stock_ongoing_cnt + total_buy_cnt
- ) / Decimal("3")
-
- return {
- "total_avg_sales_cnt": total_avg_sales_cnt,
- "total_avg_sales_amount": total_avg_sales_amount,
- "total_out_stock_cnt": total_out_stock_cnt,
- "total_storage_locked_cnt": total_storage_locked_cnt,
- "total_out_stock_ongoing_cnt": total_out_stock_ongoing_cnt,
- "total_buy_cnt": total_buy_cnt,
- "has_sales_part_count": has_sales_part_count,
- "no_sales_part_count": no_sales_part_count,
- }
-
-
-def _classify_part(p: dict) -> str:
- """
- 将配件分类为缺货/呆滞/低频/正常
-
- 分类规则(按优先级顺序判断):
- - 缺货件: 有效库存 = 0 且 月均销量 >= 1
- - 呆滞件: 有效库存 > 0 且 90天出库数 = 0
- - 低频件: 月均销量 < 1 或 出库次数 < 3 或 出库间隔 >= 30天
- - 正常件: 不属于以上三类
- """
- in_stock = _to_decimal(p.get("in_stock_unlocked_cnt", 0))
- on_way = _to_decimal(p.get("on_the_way_cnt", 0))
- has_plan = _to_decimal(p.get("has_plan_cnt", 0))
- valid_storage = in_stock + on_way + has_plan
-
- out_stock = _to_decimal(p.get("out_stock_cnt", 0))
- locked = _to_decimal(p.get("storage_locked_cnt", 0))
- ongoing = _to_decimal(p.get("out_stock_ongoing_cnt", 0))
- buy = _to_decimal(p.get("buy_cnt", 0))
- avg_sales = (out_stock + locked + ongoing + buy) / Decimal("3")
-
- out_times = int(p.get("out_times", 0) or 0)
- out_duration = int(p.get("out_duration", 0) or 0)
-
- # 缺货件
- if valid_storage == 0 and avg_sales >= 1:
- return "shortage"
-
- # 呆滞件
- if valid_storage > 0 and out_stock == 0:
- return "stagnant"
-
- # 低频件
- if avg_sales < 1 or out_times < 3 or out_duration >= 30:
- return "low_freq"
-
- return "normal"
-
-
-def calculate_inventory_health(part_ratios: list[dict]) -> dict:
- """
- 计算库存构成健康度统计数据
-
- 将每个配件归类为缺货件/呆滞件/低频件/正常件,统计各类型数量/金额/百分比,
- 并生成 chart_data 供前端图表使用。
-
- Args:
- part_ratios: PartRatio 字典列表
-
- Returns:
- 健康度统计字典(含 chart_data)
- """
- categories = {
- "shortage": {"count": 0, "amount": Decimal("0")},
- "stagnant": {"count": 0, "amount": Decimal("0")},
- "low_freq": {"count": 0, "amount": Decimal("0")},
- "normal": {"count": 0, "amount": Decimal("0")},
- }
-
- for p in part_ratios:
- cat = _classify_part(p)
- cost_price = _to_decimal(p.get("cost_price", 0))
-
- # 有效库存金额
- in_stock = _to_decimal(p.get("in_stock_unlocked_cnt", 0))
- on_way = _to_decimal(p.get("on_the_way_cnt", 0))
- has_plan = _to_decimal(p.get("has_plan_cnt", 0))
- valid_storage = in_stock + on_way + has_plan
- amount = valid_storage * cost_price
-
- categories[cat]["count"] += 1
- categories[cat]["amount"] += amount
-
- total_count = len(part_ratios)
- total_amount = sum(c["amount"] for c in categories.values())
-
- # 计算百分比
- result = {}
- for cat_name, data in categories.items():
- count_pct = (data["count"] / total_count * 100) if total_count > 0 else 0.0
- amount_pct = (float(data["amount"]) / float(total_amount) * 100) if total_amount > 0 else 0.0
- result[cat_name] = {
- "count": data["count"],
- "amount": data["amount"],
- "count_pct": round(count_pct, 2),
- "amount_pct": round(amount_pct, 2),
- }
-
- result["total_count"] = total_count
- result["total_amount"] = total_amount
-
- # chart_data 供前端 Chart.js 使用
- labels = ["缺货件", "呆滞件", "低频件", "正常件"]
- cat_keys = ["shortage", "stagnant", "low_freq", "normal"]
- result["chart_data"] = {
- "labels": labels,
- "count_values": [categories[k]["count"] for k in cat_keys],
- "amount_values": [float(categories[k]["amount"]) for k in cat_keys],
- }
-
- return result
-
-
-def calculate_replenishment_summary(part_results: list) -> dict:
- """
- 计算补货建议生成情况统计数据
-
- 按优先级分类统计:
- - priority=1: 急需补货
- - priority=2: 建议补货
- - priority=3: 可选补货
-
- Args:
- part_results: 配件汇总结果列表(字典或 ReplenishmentPartSummary 对象)
-
- Returns:
- 补货建议统计字典
- """
- urgent = {"count": 0, "amount": Decimal("0")}
- suggested = {"count": 0, "amount": Decimal("0")}
- optional = {"count": 0, "amount": Decimal("0")}
-
- for item in part_results:
- # 兼容字典和对象两种形式
- if isinstance(item, dict):
- priority = int(item.get("priority", 0))
- amount = _to_decimal(item.get("total_suggest_amount", 0))
- else:
- priority = getattr(item, "priority", 0)
- amount = _to_decimal(getattr(item, "total_suggest_amount", 0))
-
- if priority == 1:
- urgent["count"] += 1
- urgent["amount"] += amount
- elif priority == 2:
- suggested["count"] += 1
- suggested["amount"] += amount
- elif priority == 3:
- optional["count"] += 1
- optional["amount"] += amount
-
- total_count = urgent["count"] + suggested["count"] + optional["count"]
- total_amount = urgent["amount"] + suggested["amount"] + optional["amount"]
-
- return {
- "urgent": urgent,
- "suggested": suggested,
- "optional": optional,
- "total_count": total_count,
- "total_amount": total_amount,
- }
-
-
-# ============================================================
-# LLM 分析函数
-# ============================================================
-
-import os
-import json
-import time
-from langchain_core.messages import SystemMessage, HumanMessage
-
-
-def _load_prompt(filename: str) -> str:
- """从 prompts 目录加载提示词文件"""
- prompt_path = os.path.join(
- os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))),
- "prompts",
- filename,
- )
- with open(prompt_path, "r", encoding="utf-8") as f:
- return f.read()
-
-
-def _format_decimal(value) -> str:
- """将 Decimal 格式化为字符串,用于填充提示词"""
- if value is None:
- return "0"
- return str(round(float(value), 2))
-
-
-def _get_season_from_date(date_str: str) -> str:
- """
- 根据日期字符串获取季节
-
- Args:
- date_str: 日期字符串,格式如 "2024-01-15" 或 "20240115"
-
- Returns:
- 季节名称:春季/夏季/秋季/冬季
- """
- from datetime import datetime
-
- try:
- # 尝试解析不同格式的日期
- if "-" in date_str:
- dt = datetime.strptime(date_str[:10], "%Y-%m-%d")
- else:
- dt = datetime.strptime(date_str[:8], "%Y%m%d")
- month = dt.month
- except (ValueError, TypeError):
- # 解析失败时使用当前月份
- month = datetime.now().month
-
- if month in (3, 4, 5):
- return "春季(3-5月)"
- elif month in (6, 7, 8):
- return "夏季(6-8月)"
- elif month in (9, 10, 11):
- return "秋季(9-11月)"
- else:
- return "冬季(12-2月)"
-
-
-def _parse_llm_json(content: str) -> dict:
- """
- 解析 LLM 返回的 JSON 内容
-
- 尝试直接解析,如果失败则尝试提取 ```json 代码块中的内容。
- """
- text = content.strip()
-
- # 尝试直接解析
- try:
- return json.loads(text)
- except json.JSONDecodeError:
- pass
-
- # 尝试提取 ```json ... ``` 代码块
- import re
- match = re.search(r"```json\s*(.*?)\s*```", text, re.DOTALL)
- if match:
- try:
- return json.loads(match.group(1))
- except json.JSONDecodeError:
- pass
-
- # 尝试提取 { ... } 块
- start = text.find("{")
- end = text.rfind("}")
- if start != -1 and end != -1 and end > start:
- try:
- return json.loads(text[start : end + 1])
- except json.JSONDecodeError:
- pass
-
- # 解析失败
- raise json.JSONDecodeError("无法从 LLM 响应中解析 JSON", text, 0)
-
-
-def llm_analyze_inventory_overview(stats: dict, statistics_date: str = "", llm_client=None) -> tuple[dict, dict]:
- """
- LLM 分析库存概览
-
- Args:
- stats: calculate_inventory_overview 的输出
- statistics_date: 统计日期
- llm_client: LLM 客户端实例,为 None 时自动获取
-
- Returns:
- (llm_analysis_dict, usage_dict)
- """
- from ..llm import get_llm_client
-
- if llm_client is None:
- llm_client = get_llm_client()
-
- current_season = _get_season_from_date(statistics_date)
-
- prompt_template = _load_prompt("report_inventory_overview.md")
- prompt = prompt_template.format(
- part_count=stats.get("part_count", 0),
- total_valid_storage_cnt=_format_decimal(stats.get("total_valid_storage_cnt")),
- total_valid_storage_amount=_format_decimal(stats.get("total_valid_storage_amount")),
- total_avg_sales_cnt=_format_decimal(stats.get("total_avg_sales_cnt")),
- overall_ratio=_format_decimal(stats.get("overall_ratio")),
- total_in_stock_unlocked_cnt=_format_decimal(stats.get("total_in_stock_unlocked_cnt")),
- total_in_stock_unlocked_amount=_format_decimal(stats.get("total_in_stock_unlocked_amount")),
- total_on_the_way_cnt=_format_decimal(stats.get("total_on_the_way_cnt")),
- total_on_the_way_amount=_format_decimal(stats.get("total_on_the_way_amount")),
- total_has_plan_cnt=_format_decimal(stats.get("total_has_plan_cnt")),
- total_has_plan_amount=_format_decimal(stats.get("total_has_plan_amount")),
- current_season=current_season,
- statistics_date=statistics_date or "未知",
- )
-
- messages = [HumanMessage(content=prompt)]
- response = llm_client.invoke(messages)
-
- try:
- analysis = _parse_llm_json(response.content)
- except json.JSONDecodeError:
- logger.warning(f"库存概览 LLM JSON 解析失败,原始响应: {response.content[:200]}")
- analysis = {"error": "JSON解析失败", "raw": response.content[:200]}
-
- usage = {
- "provider": response.usage.provider,
- "model": response.usage.model,
- "prompt_tokens": response.usage.prompt_tokens,
- "completion_tokens": response.usage.completion_tokens,
- }
- return analysis, usage
-
-
-def llm_analyze_sales(stats: dict, statistics_date: str = "", llm_client=None) -> tuple[dict, dict]:
- """
- LLM 分析销量
-
- Args:
- stats: calculate_sales_analysis 的输出
- statistics_date: 统计日期
- llm_client: LLM 客户端实例
-
- Returns:
- (llm_analysis_dict, usage_dict)
- """
- from ..llm import get_llm_client
-
- if llm_client is None:
- llm_client = get_llm_client()
-
- current_season = _get_season_from_date(statistics_date)
-
- prompt_template = _load_prompt("report_sales_analysis.md")
- prompt = prompt_template.format(
- total_avg_sales_cnt=_format_decimal(stats.get("total_avg_sales_cnt")),
- total_avg_sales_amount=_format_decimal(stats.get("total_avg_sales_amount")),
- has_sales_part_count=stats.get("has_sales_part_count", 0),
- no_sales_part_count=stats.get("no_sales_part_count", 0),
- total_out_stock_cnt=_format_decimal(stats.get("total_out_stock_cnt")),
- total_storage_locked_cnt=_format_decimal(stats.get("total_storage_locked_cnt")),
- total_out_stock_ongoing_cnt=_format_decimal(stats.get("total_out_stock_ongoing_cnt")),
- total_buy_cnt=_format_decimal(stats.get("total_buy_cnt")),
- current_season=current_season,
- statistics_date=statistics_date or "未知",
- )
-
- messages = [HumanMessage(content=prompt)]
- response = llm_client.invoke(messages)
-
- try:
- analysis = _parse_llm_json(response.content)
- except json.JSONDecodeError:
- logger.warning(f"销量分析 LLM JSON 解析失败,原始响应: {response.content[:200]}")
- analysis = {"error": "JSON解析失败", "raw": response.content[:200]}
-
- usage = {
- "provider": response.usage.provider,
- "model": response.usage.model,
- "prompt_tokens": response.usage.prompt_tokens,
- "completion_tokens": response.usage.completion_tokens,
- }
- return analysis, usage
-
-
-def llm_analyze_inventory_health(stats: dict, statistics_date: str = "", llm_client=None) -> tuple[dict, dict]:
- """
- LLM 分析库存健康度
-
- Args:
- stats: calculate_inventory_health 的输出
- statistics_date: 统计日期
- llm_client: LLM 客户端实例
-
- Returns:
- (llm_analysis_dict, usage_dict)
- """
- from ..llm import get_llm_client
-
- if llm_client is None:
- llm_client = get_llm_client()
-
- current_season = _get_season_from_date(statistics_date)
-
- prompt_template = _load_prompt("report_inventory_health.md")
- prompt = prompt_template.format(
- total_count=stats.get("total_count", 0),
- total_amount=_format_decimal(stats.get("total_amount")),
- shortage_count=stats.get("shortage", {}).get("count", 0),
- shortage_count_pct=stats.get("shortage", {}).get("count_pct", 0),
- shortage_amount=_format_decimal(stats.get("shortage", {}).get("amount")),
- shortage_amount_pct=stats.get("shortage", {}).get("amount_pct", 0),
- stagnant_count=stats.get("stagnant", {}).get("count", 0),
- stagnant_count_pct=stats.get("stagnant", {}).get("count_pct", 0),
- stagnant_amount=_format_decimal(stats.get("stagnant", {}).get("amount")),
- stagnant_amount_pct=stats.get("stagnant", {}).get("amount_pct", 0),
- low_freq_count=stats.get("low_freq", {}).get("count", 0),
- low_freq_count_pct=stats.get("low_freq", {}).get("count_pct", 0),
- low_freq_amount=_format_decimal(stats.get("low_freq", {}).get("amount")),
- low_freq_amount_pct=stats.get("low_freq", {}).get("amount_pct", 0),
- normal_count=stats.get("normal", {}).get("count", 0),
- normal_count_pct=stats.get("normal", {}).get("count_pct", 0),
- normal_amount=_format_decimal(stats.get("normal", {}).get("amount")),
- normal_amount_pct=stats.get("normal", {}).get("amount_pct", 0),
- current_season=current_season,
- statistics_date=statistics_date or "未知",
- )
-
- messages = [HumanMessage(content=prompt)]
- response = llm_client.invoke(messages)
-
- try:
- analysis = _parse_llm_json(response.content)
- except json.JSONDecodeError:
- logger.warning(f"健康度 LLM JSON 解析失败,原始响应: {response.content[:200]}")
- analysis = {"error": "JSON解析失败", "raw": response.content[:200]}
-
- usage = {
- "provider": response.usage.provider,
- "model": response.usage.model,
- "prompt_tokens": response.usage.prompt_tokens,
- "completion_tokens": response.usage.completion_tokens,
- }
- return analysis, usage
-
-
-def llm_analyze_replenishment_summary(stats: dict, statistics_date: str = "", llm_client=None) -> tuple[dict, dict]:
- """
- LLM 分析补货建议
-
- Args:
- stats: calculate_replenishment_summary 的输出
- statistics_date: 统计日期
- llm_client: LLM 客户端实例
-
- Returns:
- (llm_analysis_dict, usage_dict)
- """
- from ..llm import get_llm_client
-
- if llm_client is None:
- llm_client = get_llm_client()
-
- current_season = _get_season_from_date(statistics_date)
-
- prompt_template = _load_prompt("report_replenishment_summary.md")
- prompt = prompt_template.format(
- total_count=stats.get("total_count", 0),
- total_amount=_format_decimal(stats.get("total_amount")),
- urgent_count=stats.get("urgent", {}).get("count", 0),
- urgent_amount=_format_decimal(stats.get("urgent", {}).get("amount")),
- suggested_count=stats.get("suggested", {}).get("count", 0),
- suggested_amount=_format_decimal(stats.get("suggested", {}).get("amount")),
- optional_count=stats.get("optional", {}).get("count", 0),
- optional_amount=_format_decimal(stats.get("optional", {}).get("amount")),
- current_season=current_season,
- statistics_date=statistics_date or "未知",
- )
-
- messages = [HumanMessage(content=prompt)]
- response = llm_client.invoke(messages)
-
- try:
- analysis = _parse_llm_json(response.content)
- except json.JSONDecodeError:
- logger.warning(f"补货建议 LLM JSON 解析失败,原始响应: {response.content[:200]}")
- analysis = {"error": "JSON解析失败", "raw": response.content[:200]}
-
- usage = {
- "provider": response.usage.provider,
- "model": response.usage.model,
- "prompt_tokens": response.usage.prompt_tokens,
- "completion_tokens": response.usage.completion_tokens,
- }
- return analysis, usage
-
-
-# ============================================================
-# LangGraph 并发子图
-# ============================================================
-
-from typing import TypedDict, Optional, Any, Annotated, Dict
-
-from langgraph.graph import StateGraph, START, END
-
-
-def _merge_dict(left: Optional[dict], right: Optional[dict]) -> Optional[dict]:
- """合并字典,保留非 None 的值"""
- if right is not None:
- return right
- return left
-
-
-def _sum_int(left: int, right: int) -> int:
- """累加整数"""
- return (left or 0) + (right or 0)
-
-
-def _merge_str(left: Optional[str], right: Optional[str]) -> Optional[str]:
- """合并字符串,保留非 None 的值"""
- if right is not None:
- return right
- return left
-
-
-class ReportLLMState(TypedDict, total=False):
- """并发 LLM 分析子图的状态"""
-
- # 输入:四大板块的统计数据(只读,由主函数写入)
- inventory_overview_stats: Annotated[Optional[dict], _merge_dict]
- sales_analysis_stats: Annotated[Optional[dict], _merge_dict]
- inventory_health_stats: Annotated[Optional[dict], _merge_dict]
- replenishment_summary_stats: Annotated[Optional[dict], _merge_dict]
-
- # 输入:统计日期(用于季节判断)
- statistics_date: Annotated[Optional[str], _merge_str]
-
- # 输出:四大板块的 LLM 分析结果(各节点独立写入)
- inventory_overview_analysis: Annotated[Optional[dict], _merge_dict]
- sales_analysis_analysis: Annotated[Optional[dict], _merge_dict]
- inventory_health_analysis: Annotated[Optional[dict], _merge_dict]
- replenishment_summary_analysis: Annotated[Optional[dict], _merge_dict]
-
- # LLM 使用量(累加)
- total_prompt_tokens: Annotated[int, _sum_int]
- total_completion_tokens: Annotated[int, _sum_int]
- llm_provider: Annotated[Optional[str], _merge_dict]
- llm_model: Annotated[Optional[str], _merge_dict]
-
-
-def _node_inventory_overview(state: ReportLLMState) -> ReportLLMState:
- """并发节点:库存概览 LLM 分析"""
- stats = state.get("inventory_overview_stats")
- statistics_date = state.get("statistics_date", "")
- if not stats:
- return {"inventory_overview_analysis": {"error": "无统计数据"}}
-
- try:
- analysis, usage = llm_analyze_inventory_overview(stats, statistics_date)
- return {
- "inventory_overview_analysis": analysis,
- "total_prompt_tokens": usage.get("prompt_tokens", 0),
- "total_completion_tokens": usage.get("completion_tokens", 0),
- "llm_provider": usage.get("provider", ""),
- "llm_model": usage.get("model", ""),
- }
- except Exception as e:
- logger.error(f"库存概览 LLM 分析失败: {e}")
- return {"inventory_overview_analysis": {"error": str(e)}}
-
-
-def _node_sales_analysis(state: ReportLLMState) -> ReportLLMState:
- """并发节点:销量分析 LLM 分析"""
- stats = state.get("sales_analysis_stats")
- statistics_date = state.get("statistics_date", "")
- if not stats:
- return {"sales_analysis_analysis": {"error": "无统计数据"}}
-
- try:
- analysis, usage = llm_analyze_sales(stats, statistics_date)
- return {
- "sales_analysis_analysis": analysis,
- "total_prompt_tokens": usage.get("prompt_tokens", 0),
- "total_completion_tokens": usage.get("completion_tokens", 0),
- "llm_provider": usage.get("provider", ""),
- "llm_model": usage.get("model", ""),
- }
- except Exception as e:
- logger.error(f"销量分析 LLM 分析失败: {e}")
- return {"sales_analysis_analysis": {"error": str(e)}}
-
-
-def _node_inventory_health(state: ReportLLMState) -> ReportLLMState:
- """并发节点:健康度 LLM 分析"""
- stats = state.get("inventory_health_stats")
- statistics_date = state.get("statistics_date", "")
- if not stats:
- return {"inventory_health_analysis": {"error": "无统计数据"}}
-
- try:
- analysis, usage = llm_analyze_inventory_health(stats, statistics_date)
- return {
- "inventory_health_analysis": analysis,
- "total_prompt_tokens": usage.get("prompt_tokens", 0),
- "total_completion_tokens": usage.get("completion_tokens", 0),
- "llm_provider": usage.get("provider", ""),
- "llm_model": usage.get("model", ""),
- }
- except Exception as e:
- logger.error(f"健康度 LLM 分析失败: {e}")
- return {"inventory_health_analysis": {"error": str(e)}}
-
-
-def _node_replenishment_summary(state: ReportLLMState) -> ReportLLMState:
- """并发节点:补货建议 LLM 分析"""
- stats = state.get("replenishment_summary_stats")
- statistics_date = state.get("statistics_date", "")
- if not stats:
- return {"replenishment_summary_analysis": {"error": "无统计数据"}}
-
- try:
- analysis, usage = llm_analyze_replenishment_summary(stats, statistics_date)
- return {
- "replenishment_summary_analysis": analysis,
- "total_prompt_tokens": usage.get("prompt_tokens", 0),
- "total_completion_tokens": usage.get("completion_tokens", 0),
- "llm_provider": usage.get("provider", ""),
- "llm_model": usage.get("model", ""),
- }
- except Exception as e:
- logger.error(f"补货建议 LLM 分析失败: {e}")
- return {"replenishment_summary_analysis": {"error": str(e)}}
-
-
-def build_report_llm_subgraph() -> StateGraph:
- """
- 构建并发 LLM 分析子图
-
- 四个 LLM 节点从 START fan-out 并发执行,结果 fan-in 汇总到 END。
- """
- graph = StateGraph(ReportLLMState)
-
- # 添加四个并发节点
- graph.add_node("inventory_overview_llm", _node_inventory_overview)
- graph.add_node("sales_analysis_llm", _node_sales_analysis)
- graph.add_node("inventory_health_llm", _node_inventory_health)
- graph.add_node("replenishment_summary_llm", _node_replenishment_summary)
-
- # fan-out: START → 四个节点
- graph.add_edge(START, "inventory_overview_llm")
- graph.add_edge(START, "sales_analysis_llm")
- graph.add_edge(START, "inventory_health_llm")
- graph.add_edge(START, "replenishment_summary_llm")
-
- # fan-in: 四个节点 → END
- graph.add_edge("inventory_overview_llm", END)
- graph.add_edge("sales_analysis_llm", END)
- graph.add_edge("inventory_health_llm", END)
- graph.add_edge("replenishment_summary_llm", END)
-
- return graph.compile()
-
-
-# ============================================================
-# 主节点函数
-# ============================================================
-
-
-def _serialize_stats(stats: dict) -> dict:
- """将统计数据中的 Decimal 转换为 float,以便 JSON 序列化"""
- result = {}
- for k, v in stats.items():
- if isinstance(v, Decimal):
- result[k] = float(v)
- elif isinstance(v, dict):
- result[k] = _serialize_stats(v)
- elif isinstance(v, list):
- result[k] = [
- _serialize_stats(item) if isinstance(item, dict) else (float(item) if isinstance(item, Decimal) else item)
- for item in v
- ]
- else:
- result[k] = v
- return result
-
-
-def generate_analysis_report_node(state: dict) -> dict:
- """
- 分析报告生成主节点
-
- 串联流程:
- 1. 统计计算(四大板块)
- 2. 并发 LLM 分析(LangGraph 子图)
- 3. 汇总报告
- 4. 写入数据库
-
- 单板块 LLM 失败不影响其他板块。
-
- Args:
- state: AgentState 字典
-
- Returns:
- 更新后的 state 字典
- """
- from .state import AgentState
- from ..models import AnalysisReport
- from ..services.result_writer import ResultWriter
-
- logger.info("[AnalysisReport] ========== 开始生成分析报告 ==========")
- start_time = time.time()
-
- part_ratios = state.get("part_ratios", [])
- part_results = state.get("part_results", [])
-
- # ---- 1. 统计计算 ----
- logger.info(f"[AnalysisReport] 统计计算: part_ratios={len(part_ratios)}, part_results={len(part_results)}")
-
- inventory_overview_stats = calculate_inventory_overview(part_ratios)
- sales_analysis_stats = calculate_sales_analysis(part_ratios)
- inventory_health_stats = calculate_inventory_health(part_ratios)
- replenishment_summary_stats = calculate_replenishment_summary(part_results)
-
- # 序列化统计数据(Decimal → float)
- io_stats_serialized = _serialize_stats(inventory_overview_stats)
- sa_stats_serialized = _serialize_stats(sales_analysis_stats)
- ih_stats_serialized = _serialize_stats(inventory_health_stats)
- rs_stats_serialized = _serialize_stats(replenishment_summary_stats)
-
- # ---- 2. 并发 LLM 分析 ----
- logger.info("[AnalysisReport] 启动并发 LLM 分析子图")
-
- statistics_date = state.get("statistics_date", "")
-
- subgraph = build_report_llm_subgraph()
- llm_state: ReportLLMState = {
- "inventory_overview_stats": io_stats_serialized,
- "sales_analysis_stats": sa_stats_serialized,
- "inventory_health_stats": ih_stats_serialized,
- "replenishment_summary_stats": rs_stats_serialized,
- "statistics_date": statistics_date,
- "inventory_overview_analysis": None,
- "sales_analysis_analysis": None,
- "inventory_health_analysis": None,
- "replenishment_summary_analysis": None,
- "total_prompt_tokens": 0,
- "total_completion_tokens": 0,
- "llm_provider": None,
- "llm_model": None,
- }
-
- try:
- llm_result = subgraph.invoke(llm_state)
- except Exception as e:
- logger.error(f"[AnalysisReport] LLM 子图执行异常: {e}")
- llm_result = llm_state # 使用初始状态(所有分析为 None)
-
- # ---- 3. 汇总报告 ----
- inventory_overview_data = {
- "stats": io_stats_serialized,
- "llm_analysis": llm_result.get("inventory_overview_analysis") or {"error": "未生成"},
- }
- sales_analysis_data = {
- "stats": sa_stats_serialized,
- "llm_analysis": llm_result.get("sales_analysis_analysis") or {"error": "未生成"},
- }
- inventory_health_data = {
- "stats": ih_stats_serialized,
- "chart_data": ih_stats_serialized.get("chart_data"),
- "llm_analysis": llm_result.get("inventory_health_analysis") or {"error": "未生成"},
- }
- replenishment_summary_data = {
- "stats": rs_stats_serialized,
- "llm_analysis": llm_result.get("replenishment_summary_analysis") or {"error": "未生成"},
- }
-
- total_tokens = (
- (llm_result.get("total_prompt_tokens") or 0)
- + (llm_result.get("total_completion_tokens") or 0)
- )
- execution_time_ms = int((time.time() - start_time) * 1000)
-
- # ---- 4. 写入数据库 ----
- report = AnalysisReport(
- task_no=state.get("task_no", ""),
- group_id=state.get("group_id", 0),
- dealer_grouping_id=state.get("dealer_grouping_id", 0),
- dealer_grouping_name=state.get("dealer_grouping_name"),
- brand_grouping_id=state.get("brand_grouping_id"),
- inventory_overview=inventory_overview_data,
- sales_analysis=sales_analysis_data,
- inventory_health=inventory_health_data,
- replenishment_summary=replenishment_summary_data,
- llm_provider=llm_result.get("llm_provider") or "",
- llm_model=llm_result.get("llm_model") or "",
- llm_tokens=total_tokens,
- execution_time_ms=execution_time_ms,
- statistics_date=state.get("statistics_date", ""),
- )
-
- try:
- writer = ResultWriter()
- report_id = writer.save_analysis_report(report)
- writer.close()
- logger.info(f"[AnalysisReport] 报告已保存: id={report_id}, tokens={total_tokens}, 耗时={execution_time_ms}ms")
- except Exception as e:
- logger.error(f"[AnalysisReport] 报告写入数据库失败: {e}")
-
- # 返回更新后的状态
- return {
- "analysis_report": report.to_dict(),
- "llm_provider": llm_result.get("llm_provider") or state.get("llm_provider", ""),
- "llm_model": llm_result.get("llm_model") or state.get("llm_model", ""),
- "llm_prompt_tokens": llm_result.get("total_prompt_tokens") or 0,
- "llm_completion_tokens": llm_result.get("total_completion_tokens") or 0,
- "current_node": "generate_analysis_report",
- "next_node": "end",
- }
diff --git a/src/fw_pms_ai/agent/nodes.py b/src/fw_pms_ai/agent/nodes.py
index 8a420e4..f4afbee 100644
--- a/src/fw_pms_ai/agent/nodes.py
+++ b/src/fw_pms_ai/agent/nodes.py
@@ -19,10 +19,15 @@ from ..models import ReplenishmentSuggestion, PartAnalysisResult
from ..llm import get_llm_client
from ..services import DataService
from ..services.result_writer import ResultWriter
-from ..models import ReplenishmentDetail, TaskExecutionLog, LogStatus, ReplenishmentPartSummary
+from ..models import ReplenishmentDetail, ReplenishmentPartSummary
logger = logging.getLogger(__name__)
+# 执行状态常量
+LOG_SUCCESS = 1
+LOG_FAILED = 2
+LOG_SKIPPED = 3
+
def _load_prompt(filename: str) -> str:
"""从prompts目录加载提示词文件"""
@@ -71,7 +76,7 @@ def fetch_part_ratio_node(state: AgentState) -> AgentState:
log_entry = {
"step_name": "fetch_part_ratio",
"step_order": 1,
- "status": LogStatus.SUCCESS if part_ratios else LogStatus.SKIPPED,
+ "status": LOG_SUCCESS if part_ratios else LOG_SKIPPED,
"input_data": json.dumps({
"dealer_grouping_id": state["dealer_grouping_id"],
"statistics_date": state["statistics_date"],
@@ -118,7 +123,7 @@ def sql_agent_node(state: AgentState) -> AgentState:
log_entry = {
"step_name": "sql_agent",
"step_order": 2,
- "status": LogStatus.SKIPPED,
+ "status": LOG_SKIPPED,
"error_message": "无配件数据",
"execution_time_ms": int((time.time() - start_time) * 1000),
}
@@ -156,8 +161,6 @@ def sql_agent_node(state: AgentState) -> AgentState:
)
# 定义批处理回调
- # 由于 models 中没有 ResultWriter 的引用,这里尝试直接从 services 导入或实例化
- # 为避免循环导入,我们在函数内导入
from ..services import ResultWriter as WriterService
writer = WriterService()
@@ -165,7 +168,7 @@ def sql_agent_node(state: AgentState) -> AgentState:
# logger.info(f"[SQLAgent] 清理旧建议数据: task_no={state['task_no']}")
# writer.clear_llm_suggestions(state["task_no"])
- # 2. 移除批处理回调(不再过程写入,改为最后统一写入)
+ # 2. 移除批处理回调
save_batch_callback = None
# 使用分组分析生成补货建议(按 part_code 分组,逐个配件分析各门店需求)
@@ -175,7 +178,7 @@ def sql_agent_node(state: AgentState) -> AgentState:
dealer_grouping_name=state["dealer_grouping_name"],
statistics_date=state["statistics_date"],
target_ratio=base_ratio if base_ratio > 0 else Decimal("1.3"),
- limit=1000,
+ limit=10,
callback=save_batch_callback,
)
@@ -185,7 +188,7 @@ def sql_agent_node(state: AgentState) -> AgentState:
log_entry = {
"step_name": "sql_agent",
"step_order": 2,
- "status": LogStatus.SUCCESS,
+ "status": LOG_SUCCESS,
"input_data": json.dumps({
"part_ratios_count": len(part_ratios),
}),
@@ -222,7 +225,7 @@ def sql_agent_node(state: AgentState) -> AgentState:
log_entry = {
"step_name": "sql_agent",
"step_order": 2,
- "status": LogStatus.FAILED,
+ "status": LOG_FAILED,
"error_message": str(e),
"retry_count": retry_count,
"execution_time_ms": int((time.time() - start_time) * 1000),
@@ -255,7 +258,6 @@ def sql_agent_node(state: AgentState) -> AgentState:
def allocate_budget_node(state: AgentState) -> AgentState:
"""
节点3: 转换LLM建议为补货明细
- 注意:不做预算截断,所有建议直接输出
"""
logger.info(f"[AllocateBudget] 开始处理LLM建议")
@@ -269,7 +271,7 @@ def allocate_budget_node(state: AgentState) -> AgentState:
log_entry = {
"step_name": "allocate_budget",
"step_order": 3,
- "status": LogStatus.SKIPPED,
+ "status": LOG_SKIPPED,
"error_message": "无LLM建议",
"execution_time_ms": int((time.time() - start_time) * 1000),
}
@@ -295,7 +297,7 @@ def allocate_budget_node(state: AgentState) -> AgentState:
allocated_details = []
total_amount = Decimal("0")
- # 转换所有建议为明细(包括不需要补货的配件,以便记录完整分析结果)
+ # 转换所有建议为明细(包括不需要补货的配件)
for suggestion in sorted_suggestions:
# 获取该配件对应的 brand_grouping_id
bg_id = part_brand_map.get(suggestion.part_code)
@@ -341,7 +343,7 @@ def allocate_budget_node(state: AgentState) -> AgentState:
log_entry = {
"step_name": "allocate_budget",
"step_order": 3,
- "status": LogStatus.SUCCESS,
+ "status": LOG_SUCCESS,
"input_data": json.dumps({
"suggestions_count": len(llm_suggestions),
}),
@@ -408,7 +410,7 @@ def allocate_budget_node(state: AgentState) -> AgentState:
error_log = {
"step_name": "allocate_budget",
"step_order": 3,
- "status": LogStatus.FAILED,
+ "status": LOG_FAILED,
"error_message": f"保存结果失败: {str(e)}",
"execution_time_ms": 0,
}
diff --git a/src/fw_pms_ai/agent/replenishment.py b/src/fw_pms_ai/agent/replenishment.py
index 8121bfc..8ab58f0 100644
--- a/src/fw_pms_ai/agent/replenishment.py
+++ b/src/fw_pms_ai/agent/replenishment.py
@@ -1,7 +1,7 @@
"""
补货建议 Agent
-重构版本:使用 part_ratio + SQL Agent + LangGraph
+使用 part_ratio + SQL Agent + LangGraph
"""
import logging
@@ -20,8 +20,7 @@ from .nodes import (
allocate_budget_node,
should_retry_sql,
)
-from .analysis_report_node import generate_analysis_report_node
-from ..models import ReplenishmentTask, TaskStatus, TaskExecutionLog, LogStatus, ReplenishmentPartSummary
+from ..models import ReplenishmentTask, TaskStatus, ReplenishmentPartSummary
from ..services import ResultWriter
logger = logging.getLogger(__name__)
@@ -44,25 +43,19 @@ class ReplenishmentAgent:
def _build_graph(self) -> StateGraph:
"""
构建 LangGraph 工作流
-
+
工作流结构:
- fetch_part_ratio → sql_agent → allocate_budget → generate_analysis_report → END
+ fetch_part_ratio → sql_agent → allocate_budget → END
"""
workflow = StateGraph(AgentState)
- # 添加核心节点
workflow.add_node("fetch_part_ratio", fetch_part_ratio_node)
workflow.add_node("sql_agent", sql_agent_node)
workflow.add_node("allocate_budget", allocate_budget_node)
- workflow.add_node("generate_analysis_report", generate_analysis_report_node)
- # 设置入口
workflow.set_entry_point("fetch_part_ratio")
-
- # 添加边
workflow.add_edge("fetch_part_ratio", "sql_agent")
-
- # SQL Agent 条件边(支持重试)
+
workflow.add_conditional_edges(
"sql_agent",
should_retry_sql,
@@ -71,10 +64,8 @@ class ReplenishmentAgent:
"continue": "allocate_budget",
}
)
-
- # allocate_budget → generate_analysis_report → END
- workflow.add_edge("allocate_budget", "generate_analysis_report")
- workflow.add_edge("generate_analysis_report", END)
+
+ workflow.add_edge("allocate_budget", END)
return workflow.compile()
@@ -126,7 +117,6 @@ class ReplenishmentAgent:
"details": [],
"llm_suggestions": [],
"part_results": [],
- "report": None,
"llm_provider": "",
"llm_model": "",
"llm_prompt_tokens": 0,
@@ -175,30 +165,6 @@ class ReplenishmentAgent:
self._result_writer.update_task(task)
-
-
- # 保存执行日志
- if final_state.get("sql_execution_logs"):
- self._save_execution_logs(
- task_no=task_no,
- group_id=group_id,
- brand_grouping_id=brand_grouping_id,
- brand_grouping_name=brand_grouping_name,
- dealer_grouping_id=dealer_grouping_id,
- dealer_grouping_name=dealer_grouping_name,
- logs=final_state["sql_execution_logs"],
- )
-
- # 配件汇总已在 allocate_budget_node 中保存,此处跳过避免重复
- # if final_state.get("part_results"):
- # self._save_part_summaries(
- # task_no=task_no,
- # group_id=group_id,
- # dealer_grouping_id=dealer_grouping_id,
- # statistics_date=statistics_date,
- # part_results=final_state["part_results"],
- # )
-
logger.info(
f"补货建议执行完成: task_no={task_no}, "
f"parts={task.part_count}, amount={actual_amount}, "
@@ -220,39 +186,6 @@ class ReplenishmentAgent:
finally:
self._result_writer.close()
- def _save_execution_logs(
- self,
- task_no: str,
- group_id: int,
- brand_grouping_id: Optional[int],
- brand_grouping_name: str,
- dealer_grouping_id: int,
- dealer_grouping_name: str,
- logs: List[dict],
- ):
- """保存执行日志"""
- for log_data in logs:
- log = TaskExecutionLog(
- task_no=task_no,
- group_id=group_id,
- brand_grouping_id=brand_grouping_id,
- brand_grouping_name=brand_grouping_name,
- dealer_grouping_id=dealer_grouping_id,
- dealer_grouping_name=dealer_grouping_name,
- step_name=log_data.get("step_name", ""),
- step_order=log_data.get("step_order", 0),
- status=log_data.get("status", LogStatus.SUCCESS),
- input_data=log_data.get("input_data", ""),
- output_data=log_data.get("output_data", ""),
- error_message=log_data.get("error_message", ""),
- retry_count=log_data.get("retry_count", 0),
- sql_query=log_data.get("sql_query", ""),
- llm_prompt=log_data.get("llm_prompt", ""),
- llm_response=log_data.get("llm_response", ""),
- llm_tokens=log_data.get("llm_tokens", 0),
- execution_time_ms=log_data.get("execution_time_ms", 0),
- )
- self._result_writer.save_execution_log(log)
def _save_part_summaries(
self,
diff --git a/src/fw_pms_ai/agent/state.py b/src/fw_pms_ai/agent/state.py
index 42a9633..3d6b7b7 100644
--- a/src/fw_pms_ai/agent/state.py
+++ b/src/fw_pms_ai/agent/state.py
@@ -72,10 +72,7 @@ class AgentState(TypedDict, total=False):
# 配件汇总结果
part_results: Annotated[List[Any], merge_lists]
-
- # 分析报告
- analysis_report: Annotated[Optional[dict], keep_last]
-
+
# LLM 统计(使用累加,合并多个并行节点的 token 使用量)
llm_provider: Annotated[str, keep_last]
llm_model: Annotated[str, keep_last]
diff --git a/src/fw_pms_ai/api/app.py b/src/fw_pms_ai/api/app.py
index bc7677c..4ff0f36 100644
--- a/src/fw_pms_ai/api/app.py
+++ b/src/fw_pms_ai/api/app.py
@@ -12,7 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
-from .routes import tasks
+from .routes import tasks, replenishment
from ..config import register_service, deregister_service
logger = logging.getLogger(__name__)
@@ -46,6 +46,7 @@ app.add_middleware(
# 挂载路由
app.include_router(tasks.router, prefix="/api", tags=["Tasks"])
+app.include_router(replenishment.router, prefix="/api", tags=["Replenishment"])
# 静态文件服务
ui_path = Path(__file__).parent.parent.parent.parent / "ui"
diff --git a/src/fw_pms_ai/api/routes/replenishment.py b/src/fw_pms_ai/api/routes/replenishment.py
new file mode 100644
index 0000000..3fab371
--- /dev/null
+++ b/src/fw_pms_ai/api/routes/replenishment.py
@@ -0,0 +1,102 @@
+"""
+补货建议触发接口
+替代原定时任务,提供接口触发补货建议生成
+"""
+
+import logging
+from typing import Optional
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+
+from ...agent import ReplenishmentAgent
+from ...services import DataService
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+
+
+class InitRequest(BaseModel):
+ """初始化请求"""
+ group_id: int
+ dealer_grouping_id: Optional[int] = None
+
+
+class InitResponse(BaseModel):
+ """初始化响应"""
+ success: bool
+ message: str
+ task_count: int = 0
+
+
+@router.post("/replenishment/init", response_model=InitResponse)
+async def init_replenishment(req: InitRequest):
+ """
+ 初始化补货建议
+
+ 触发全量补货建议生成。
+ - 若指定 dealer_grouping_id,仅处理该商家组合
+ - 若未指定,处理 group_id 下所有商家组合
+ """
+ try:
+ agent = ReplenishmentAgent()
+
+ if req.dealer_grouping_id:
+ data_service = DataService()
+ try:
+ groupings = data_service.get_dealer_groupings(req.group_id)
+ grouping = next(
+ (g for g in groupings if g["id"] == req.dealer_grouping_id),
+ None,
+ )
+ if not grouping:
+ raise HTTPException(
+ status_code=404,
+ detail=f"未找到商家组合: {req.dealer_grouping_id}",
+ )
+ agent.run(
+ group_id=req.group_id,
+ dealer_grouping_id=grouping["id"],
+ dealer_grouping_name=grouping["name"],
+ )
+ return InitResponse(
+ success=True,
+ message=f"商家组合 [{grouping['name']}] 补货建议生成完成",
+ task_count=1,
+ )
+ finally:
+ data_service.close()
+ else:
+ data_service = DataService()
+ try:
+ groupings = data_service.get_dealer_groupings(req.group_id)
+ finally:
+ data_service.close()
+
+ task_count = 0
+ for grouping in groupings:
+ try:
+ agent.run(
+ group_id=req.group_id,
+ dealer_grouping_id=grouping["id"],
+ dealer_grouping_name=grouping["name"],
+ )
+ task_count += 1
+ except Exception as e:
+ logger.error(
+ f"商家组合执行失败: {grouping['name']}, error={e}",
+ exc_info=True,
+ )
+ continue
+
+ return InitResponse(
+ success=True,
+ message=f"补货建议生成完成,共处理 {task_count}/{len(groupings)} 个商家组合",
+ task_count=task_count,
+ )
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"补货建议初始化失败: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
diff --git a/src/fw_pms_ai/api/routes/tasks.py b/src/fw_pms_ai/api/routes/tasks.py
index 49604eb..3fef160 100644
--- a/src/fw_pms_ai/api/routes/tasks.py
+++ b/src/fw_pms_ai/api/routes/tasks.py
@@ -3,10 +3,8 @@
"""
import logging
-from typing import Optional, List, Dict, Any
-import json
+from typing import Optional, List
from datetime import datetime
-from decimal import Decimal
from fastapi import APIRouter, Query, HTTPException
from pydantic import BaseModel
@@ -135,14 +133,10 @@ async def list_tasks(
# 查询分页数据
offset = (page - 1) * page_size
data_sql = f"""
- SELECT t.*,
- COALESCE(
- NULLIF(t.llm_total_tokens, 0),
- (SELECT SUM(l.llm_tokens) FROM ai_task_execution_log l WHERE l.task_no = t.task_no)
- ) as calculated_tokens
- FROM ai_replenishment_task t
+ SELECT *
+ FROM ai_replenishment_task
WHERE {where_sql}
- ORDER BY t.create_time DESC
+ ORDER BY create_time DESC
LIMIT %s OFFSET %s
"""
cursor.execute(data_sql, params + [page_size, offset])
@@ -171,7 +165,7 @@ async def list_tasks(
error_message=row.get("error_message"),
llm_provider=row.get("llm_provider"),
llm_model=row.get("llm_model"),
- llm_total_tokens=int(row.get("calculated_tokens") or 0),
+ llm_total_tokens=int(row.get("llm_total_tokens") or 0),
statistics_date=row.get("statistics_date"),
start_time=format_datetime(row.get("start_time")),
end_time=format_datetime(row.get("end_time")),
@@ -200,13 +194,9 @@ async def get_task(task_no: str):
try:
cursor.execute(
"""
- SELECT t.*,
- COALESCE(
- NULLIF(t.llm_total_tokens, 0),
- (SELECT SUM(l.llm_tokens) FROM ai_task_execution_log l WHERE l.task_no = t.task_no)
- ) as calculated_tokens
- FROM ai_replenishment_task t
- WHERE t.task_no = %s
+ SELECT *
+ FROM ai_replenishment_task
+ WHERE task_no = %s
""",
(task_no,)
)
@@ -235,7 +225,7 @@ async def get_task(task_no: str):
error_message=row.get("error_message"),
llm_provider=row.get("llm_provider"),
llm_model=row.get("llm_model"),
- llm_total_tokens=int(row.get("calculated_tokens") or 0),
+ llm_total_tokens=int(row.get("llm_total_tokens") or 0),
statistics_date=row.get("statistics_date"),
start_time=format_datetime(row.get("start_time")),
end_time=format_datetime(row.get("end_time")),
@@ -336,94 +326,6 @@ async def get_task_details(
conn.close()
-class ExecutionLogResponse(BaseModel):
- """执行日志响应"""
- id: int
- task_no: str
- step_name: str
- step_order: int
- status: int
- status_text: str = ""
- input_data: Optional[str] = None
- output_data: Optional[str] = None
- error_message: Optional[str] = None
- retry_count: int = 0
- llm_tokens: int = 0
- execution_time_ms: int = 0
- start_time: Optional[str] = None
- end_time: Optional[str] = None
- create_time: Optional[str] = None
-
-
-class ExecutionLogListResponse(BaseModel):
- """执行日志列表响应"""
- total: int
- items: List[ExecutionLogResponse]
-
-
-def get_log_status_text(status: int) -> str:
- """获取日志状态文本"""
- status_map = {0: "运行中", 1: "成功", 2: "失败", 3: "跳过"}
- return status_map.get(status, "未知")
-
-
-def get_step_name_display(step_name: str) -> str:
- """获取步骤名称显示"""
- step_map = {
- "fetch_part_ratio": "获取配件数据",
- "sql_agent": "AI分析建议",
- "allocate_budget": "分配预算",
- "generate_report": "生成报告",
- }
- return step_map.get(step_name, step_name)
-
-
-@router.get("/tasks/{task_no}/logs", response_model=ExecutionLogListResponse)
-async def get_task_logs(task_no: str):
- """获取任务执行日志"""
- conn = get_connection()
- cursor = conn.cursor(dictionary=True)
-
- try:
- cursor.execute(
- """
- SELECT * FROM ai_task_execution_log
- WHERE task_no = %s
- ORDER BY step_order ASC
- """,
- (task_no,)
- )
- rows = cursor.fetchall()
-
- items = []
- for row in rows:
- items.append(ExecutionLogResponse(
- id=row["id"],
- task_no=row["task_no"],
- step_name=row["step_name"],
- step_order=row.get("step_order") or 0,
- status=row.get("status") or 0,
- status_text=get_log_status_text(row.get("status") or 0),
- input_data=row.get("input_data"),
- output_data=row.get("output_data"),
- error_message=row.get("error_message"),
- retry_count=row.get("retry_count") or 0,
- llm_tokens=row.get("llm_tokens") or 0,
- execution_time_ms=row.get("execution_time_ms") or 0,
- start_time=format_datetime(row.get("start_time")),
- end_time=format_datetime(row.get("end_time")),
- create_time=format_datetime(row.get("create_time")),
- ))
-
- return ExecutionLogListResponse(
- total=len(items),
- items=items,
- )
-
- finally:
- cursor.close()
- conn.close()
-
class PartSummaryResponse(BaseModel):
"""配件汇总响应"""
@@ -507,7 +409,6 @@ async def get_task_part_summaries(
offset = (page - 1) * page_size
# 动态计算计划后库销比: (库存 + 建议) / 月均销
- # 注意: total_avg_sales_cnt 可能为 0, 需要处理除以零的情况
query_sql = f"""
SELECT *,
(
@@ -611,83 +512,3 @@ async def get_part_shop_details(
cursor.close()
conn.close()
-
-class AnalysisReportResponse(BaseModel):
- """分析报告响应"""
- id: int
- task_no: str
- group_id: int
- dealer_grouping_id: int
- dealer_grouping_name: Optional[str] = None
- report_type: str
-
- # 四大板块(统计数据 + LLM 分析)
- inventory_overview: Optional[Dict[str, Any]] = None
- sales_analysis: Optional[Dict[str, Any]] = None
- inventory_health: Optional[Dict[str, Any]] = None
- replenishment_summary: Optional[Dict[str, Any]] = None
-
- llm_provider: Optional[str] = None
- llm_model: Optional[str] = None
- llm_tokens: int = 0
- execution_time_ms: int = 0
- statistics_date: Optional[str] = None
- create_time: Optional[str] = None
-
-
-@router.get("/tasks/{task_no}/analysis-report", response_model=Optional[AnalysisReportResponse])
-async def get_analysis_report(task_no: str):
- """获取任务的分析报告"""
- conn = get_connection()
- cursor = conn.cursor(dictionary=True)
-
- try:
- cursor.execute(
- """
- SELECT * FROM ai_analysis_report
- WHERE task_no = %s
- ORDER BY create_time DESC
- LIMIT 1
- """,
- (task_no,)
- )
- row = cursor.fetchone()
-
- if not row:
- return None
-
- # 解析 JSON 字段
- def parse_json(value):
- if value is None:
- return None
- if isinstance(value, dict):
- return value
- if isinstance(value, str):
- try:
- return json.loads(value)
- except (json.JSONDecodeError, TypeError):
- return None
- return None
-
- return AnalysisReportResponse(
- id=row["id"],
- task_no=row["task_no"],
- group_id=row["group_id"],
- dealer_grouping_id=row["dealer_grouping_id"],
- dealer_grouping_name=row.get("dealer_grouping_name"),
- report_type=row.get("report_type", "replenishment"),
- inventory_overview=parse_json(row.get("inventory_overview")),
- sales_analysis=parse_json(row.get("sales_analysis")),
- inventory_health=parse_json(row.get("inventory_health")),
- replenishment_summary=parse_json(row.get("replenishment_summary")),
- llm_provider=row.get("llm_provider"),
- llm_model=row.get("llm_model"),
- llm_tokens=row.get("llm_tokens") or 0,
- execution_time_ms=row.get("execution_time_ms") or 0,
- statistics_date=row.get("statistics_date"),
- create_time=format_datetime(row.get("create_time")),
- )
-
- finally:
- cursor.close()
- conn.close()
diff --git a/src/fw_pms_ai/config/settings.py b/src/fw_pms_ai/config/settings.py
index dd407d6..07a8fd5 100644
--- a/src/fw_pms_ai/config/settings.py
+++ b/src/fw_pms_ai/config/settings.py
@@ -41,10 +41,6 @@ class Settings(BaseSettings):
mysql_password: str = ""
mysql_database: str = "fw_pms"
- # 定时任务配置
- scheduler_cron_hour: int = 2
- scheduler_cron_minute: int = 0
-
# 服务配置
server_port: int = 8009
diff --git a/src/fw_pms_ai/models/__init__.py b/src/fw_pms_ai/models/__init__.py
index d06a8c2..9a5463c 100644
--- a/src/fw_pms_ai/models/__init__.py
+++ b/src/fw_pms_ai/models/__init__.py
@@ -2,25 +2,17 @@
from .part_ratio import PartRatio
from .task import ReplenishmentTask, ReplenishmentDetail, TaskStatus
-from .execution_log import TaskExecutionLog, LogStatus
from .part_summary import ReplenishmentPartSummary
from .sql_result import SQLExecutionResult
from .suggestion import ReplenishmentSuggestion, PartAnalysisResult
-from .analysis_report import AnalysisReport
__all__ = [
"PartRatio",
"ReplenishmentTask",
"ReplenishmentDetail",
"TaskStatus",
- "TaskExecutionLog",
- "LogStatus",
"ReplenishmentPartSummary",
"SQLExecutionResult",
"ReplenishmentSuggestion",
"PartAnalysisResult",
- "AnalysisReport",
]
-
-
-
diff --git a/src/fw_pms_ai/models/analysis_report.py b/src/fw_pms_ai/models/analysis_report.py
deleted file mode 100644
index 907e1e8..0000000
--- a/src/fw_pms_ai/models/analysis_report.py
+++ /dev/null
@@ -1,59 +0,0 @@
-"""
-数据模型 - 分析报告
-四大板块:库存概览、销量分析、库存健康度、补货建议
-"""
-
-from dataclasses import dataclass, field
-from datetime import datetime
-from typing import Any, Dict, Optional
-
-
-@dataclass
-class AnalysisReport:
- """分析报告数据模型"""
-
- task_no: str
- group_id: int
- dealer_grouping_id: int
-
- id: Optional[int] = None
- dealer_grouping_name: Optional[str] = None
- brand_grouping_id: Optional[int] = None
- report_type: str = "replenishment"
-
- # 四大板块
- inventory_overview: Optional[Dict[str, Any]] = field(default=None)
- sales_analysis: Optional[Dict[str, Any]] = field(default=None)
- inventory_health: Optional[Dict[str, Any]] = field(default=None)
- replenishment_summary: Optional[Dict[str, Any]] = field(default=None)
-
- # LLM 元数据
- llm_provider: str = ""
- llm_model: str = ""
- llm_tokens: int = 0
- execution_time_ms: int = 0
-
- statistics_date: str = ""
- create_time: Optional[datetime] = None
-
- def to_dict(self) -> Dict[str, Any]:
- """将报告转换为可序列化的字典"""
- return {
- "id": self.id,
- "task_no": self.task_no,
- "group_id": self.group_id,
- "dealer_grouping_id": self.dealer_grouping_id,
- "dealer_grouping_name": self.dealer_grouping_name,
- "brand_grouping_id": self.brand_grouping_id,
- "report_type": self.report_type,
- "inventory_overview": self.inventory_overview,
- "sales_analysis": self.sales_analysis,
- "inventory_health": self.inventory_health,
- "replenishment_summary": self.replenishment_summary,
- "llm_provider": self.llm_provider,
- "llm_model": self.llm_model,
- "llm_tokens": self.llm_tokens,
- "execution_time_ms": self.execution_time_ms,
- "statistics_date": self.statistics_date,
- "create_time": self.create_time.isoformat() if self.create_time else None,
- }
diff --git a/src/fw_pms_ai/models/execution_log.py b/src/fw_pms_ai/models/execution_log.py
deleted file mode 100644
index 8023d1d..0000000
--- a/src/fw_pms_ai/models/execution_log.py
+++ /dev/null
@@ -1,48 +0,0 @@
-"""
-任务执行日志模型和LLM建议明细模型
-"""
-
-from dataclasses import dataclass, field
-from datetime import datetime
-from decimal import Decimal
-from typing import Optional
-from enum import IntEnum
-
-
-class LogStatus(IntEnum):
- """日志状态"""
- RUNNING = 0
- SUCCESS = 1
- FAILED = 2
- SKIPPED = 3
-
-
-@dataclass
-class TaskExecutionLog:
- """任务执行日志"""
- task_no: str
- group_id: int
- dealer_grouping_id: int
- step_name: str
-
- id: Optional[int] = None
- brand_grouping_id: Optional[int] = None
- brand_grouping_name: str = ""
- dealer_grouping_name: str = ""
- step_order: int = 0
- status: LogStatus = LogStatus.RUNNING
- input_data: str = ""
- output_data: str = ""
- error_message: str = ""
- retry_count: int = 0
- sql_query: str = ""
- llm_prompt: str = ""
- llm_response: str = ""
- llm_tokens: int = 0
- execution_time_ms: int = 0
- start_time: Optional[datetime] = None
- end_time: Optional[datetime] = None
- create_time: Optional[datetime] = None
-
-
-
diff --git a/src/fw_pms_ai/scheduler/__init__.py b/src/fw_pms_ai/scheduler/__init__.py
deleted file mode 100644
index a5dcde1..0000000
--- a/src/fw_pms_ai/scheduler/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-"""定时任务模块"""
-
-from .tasks import run_replenishment_task, start_scheduler
-
-__all__ = [
- "run_replenishment_task",
- "start_scheduler",
-]
diff --git a/src/fw_pms_ai/scheduler/tasks.py b/src/fw_pms_ai/scheduler/tasks.py
deleted file mode 100644
index f90b332..0000000
--- a/src/fw_pms_ai/scheduler/tasks.py
+++ /dev/null
@@ -1,139 +0,0 @@
-"""
-定时任务
-使用 APScheduler 实现每日凌晨执行
-"""
-
-import logging
-import argparse
-from datetime import date
-
-from apscheduler.schedulers.blocking import BlockingScheduler
-from apscheduler.triggers.cron import CronTrigger
-
-from ..config import get_settings
-from ..agent import ReplenishmentAgent
-
-logger = logging.getLogger(__name__)
-
-
-def run_replenishment_task():
- """执行补货建议任务"""
- logger.info("="*50)
- logger.info("开始执行 AI 补货建议定时任务")
- logger.info("="*50)
-
- try:
- agent = ReplenishmentAgent()
-
- # 默认配置 - 可从数据库读取
- group_id = 2
-
- agent.run_for_all_groupings(
- group_id=group_id,
- )
-
- logger.info("="*50)
- logger.info("AI 补货建议定时任务执行完成")
- logger.info("="*50)
-
- except Exception as e:
- logger.error(f"定时任务执行失败: {e}", exc_info=True)
- raise
-
-
-def start_scheduler():
- """启动定时调度"""
- settings = get_settings()
-
- scheduler = BlockingScheduler()
-
- # 添加定时任务
- trigger = CronTrigger(
- hour=settings.scheduler_cron_hour,
- minute=settings.scheduler_cron_minute,
- )
-
- scheduler.add_job(
- run_replenishment_task,
- trigger=trigger,
- id="replenishment_task",
- name="AI 补货建议任务",
- replace_existing=True,
- )
-
- logger.info(
- f"定时任务已配置: 每日 {settings.scheduler_cron_hour:02d}:{settings.scheduler_cron_minute:02d} 执行"
- )
-
- try:
- logger.info("调度器启动...")
- scheduler.start()
- except (KeyboardInterrupt, SystemExit):
- logger.info("调度器停止")
- scheduler.shutdown()
-
-
-def main():
- """CLI 入口"""
- parser = argparse.ArgumentParser(description="AI 补货建议定时任务")
- parser.add_argument(
- "--run-once",
- action="store_true",
- help="立即执行一次(不启动调度器)",
- )
- parser.add_argument(
- "--group-id",
- type=int,
- default=2,
- help="集团ID (默认: 2)",
- )
- parser.add_argument(
- "--dealer-grouping-id",
- type=int,
- help="指定商家组合ID (可选)",
- )
-
- args = parser.parse_args()
-
- # 配置日志
- logging.basicConfig(
- level=logging.INFO,
- format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
- )
-
- if args.run_once:
- logger.info("单次执行模式")
-
- if args.dealer_grouping_id:
- # 指定商家组合
- from ..services import DataService
-
- agent = ReplenishmentAgent()
- data_service = DataService()
-
- try:
- groupings = data_service.get_dealer_groupings(args.group_id)
- grouping = next((g for g in groupings if g["id"] == args.dealer_grouping_id), None)
-
- if not grouping:
- logger.error(f"未找到商家组合: {args.dealer_grouping_id}")
- return
-
- # 直接使用预计划数据,不需要 shop_ids
- agent.run(
- group_id=args.group_id,
- dealer_grouping_id=grouping["id"],
- dealer_grouping_name=grouping["name"],
- )
-
- finally:
- data_service.close()
- else:
- # 所有商家组合
- run_replenishment_task()
- else:
- start_scheduler()
-
-
-if __name__ == "__main__":
- main()
diff --git a/src/fw_pms_ai/services/__init__.py b/src/fw_pms_ai/services/__init__.py
index 2146ca3..edeeef9 100644
--- a/src/fw_pms_ai/services/__init__.py
+++ b/src/fw_pms_ai/services/__init__.py
@@ -2,15 +2,12 @@
from .data_service import DataService
from .result_writer import ResultWriter
-from .repository import TaskRepository, DetailRepository, LogRepository, SummaryRepository
+from .repository import TaskRepository, DetailRepository, SummaryRepository
__all__ = [
"DataService",
"ResultWriter",
"TaskRepository",
"DetailRepository",
- "LogRepository",
"SummaryRepository",
]
-
-
diff --git a/src/fw_pms_ai/services/repository/__init__.py b/src/fw_pms_ai/services/repository/__init__.py
index f5457e1..d7644e0 100644
--- a/src/fw_pms_ai/services/repository/__init__.py
+++ b/src/fw_pms_ai/services/repository/__init__.py
@@ -6,11 +6,10 @@ Repository 数据访问层子包
from .task_repo import TaskRepository
from .detail_repo import DetailRepository
-from .log_repo import LogRepository, SummaryRepository
+from .summary_repo import SummaryRepository
__all__ = [
"TaskRepository",
"DetailRepository",
- "LogRepository",
"SummaryRepository",
]
diff --git a/src/fw_pms_ai/services/repository/log_repo.py b/src/fw_pms_ai/services/repository/summary_repo.py
index f0eefbb..ee6438b 100644
--- a/src/fw_pms_ai/services/repository/log_repo.py
+++ b/src/fw_pms_ai/services/repository/summary_repo.py
@@ -1,80 +1,18 @@
"""
-日志和汇总数据访问层
+配件汇总数据访问层
-提供 ai_task_execution_log 和 ai_replenishment_part_summary 表的 CRUD 操作
+提供 ai_replenishment_part_summary 表的 CRUD 操作
"""
import logging
from typing import List
from ..db import get_connection
-from ...models import TaskExecutionLog, ReplenishmentPartSummary
+from ...models import ReplenishmentPartSummary
logger = logging.getLogger(__name__)
-class LogRepository:
- """执行日志数据访问"""
-
- def __init__(self, connection=None):
- self._conn = connection
-
- def _get_connection(self):
- """获取数据库连接"""
- if self._conn is None or not self._conn.is_connected():
- self._conn = get_connection()
- return self._conn
-
- def close(self):
- """关闭连接"""
- if self._conn and self._conn.is_connected():
- self._conn.close()
- self._conn = None
-
- def create(self, log: TaskExecutionLog) -> int:
- """
- 保存执行日志
-
- Returns:
- 插入的日志ID
- """
- conn = self._get_connection()
- cursor = conn.cursor()
-
- try:
- sql = """
- INSERT INTO ai_task_execution_log (
- task_no, group_id, dealer_grouping_id, brand_grouping_id,
- brand_grouping_name, dealer_grouping_name,
- step_name, step_order, status, input_data, output_data,
- error_message, retry_count, sql_query, llm_prompt, llm_response,
- llm_tokens, execution_time_ms, start_time, end_time, create_time
- ) VALUES (
- %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
- %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()
- )
- """
-
- values = (
- log.task_no, log.group_id, log.dealer_grouping_id,
- log.brand_grouping_id, log.brand_grouping_name,
- log.dealer_grouping_name,
- log.step_name, log.step_order, int(log.status),
- log.input_data, log.output_data, log.error_message,
- log.retry_count, log.sql_query, log.llm_prompt, log.llm_response,
- log.llm_tokens, log.execution_time_ms,
- log.start_time, log.end_time,
- )
-
- cursor.execute(sql, values)
- conn.commit()
-
- return cursor.lastrowid
-
- finally:
- cursor.close()
-
-
class SummaryRepository:
"""配件汇总数据访问"""
diff --git a/src/fw_pms_ai/services/result_writer.py b/src/fw_pms_ai/services/result_writer.py
index 149000c..25ee872 100644
--- a/src/fw_pms_ai/services/result_writer.py
+++ b/src/fw_pms_ai/services/result_writer.py
@@ -12,9 +12,7 @@ from .db import get_connection
from ..models import (
ReplenishmentTask,
ReplenishmentDetail,
- TaskExecutionLog,
ReplenishmentPartSummary,
- AnalysisReport,
)
logger = logging.getLogger(__name__)
@@ -188,54 +186,10 @@ class ResultWriter:
finally:
cursor.close()
- def save_execution_log(self, log: TaskExecutionLog) -> int:
- """
- 保存执行日志
-
- Returns:
- 插入的日志ID
- """
- conn = self._get_connection()
- cursor = conn.cursor()
-
- try:
- sql = """
- INSERT INTO ai_task_execution_log (
- task_no, group_id, dealer_grouping_id, brand_grouping_id,
- brand_grouping_name, dealer_grouping_name,
- step_name, step_order, status, input_data, output_data,
- error_message, retry_count, sql_query, llm_prompt, llm_response,
- llm_tokens, execution_time_ms, start_time, end_time, create_time
- ) VALUES (
- %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
- %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()
- )
- """
-
- values = (
- log.task_no, log.group_id, log.dealer_grouping_id,
- log.brand_grouping_id, log.brand_grouping_name,
- log.dealer_grouping_name,
- log.step_name, log.step_order, int(log.status),
- log.input_data, log.output_data, log.error_message,
- log.retry_count, log.sql_query, log.llm_prompt, log.llm_response,
- log.llm_tokens, log.execution_time_ms,
- log.start_time, log.end_time,
- )
-
- cursor.execute(sql, values)
- conn.commit()
-
- log_id = cursor.lastrowid
- return log_id
-
- finally:
- cursor.close()
-
def save_part_summaries(self, summaries: List[ReplenishmentPartSummary]) -> int:
"""
保存配件汇总信息
-
+
Returns:
插入的行数
"""
@@ -258,7 +212,7 @@ class ResultWriter:
%s, %s, %s, %s, %s, %s, %s, %s, NOW()
)
"""
-
+
values = [
(
s.task_no, s.group_id, s.dealer_grouping_id, s.part_code, s.part_name,
@@ -271,10 +225,10 @@ class ResultWriter:
)
for s in summaries
]
-
+
cursor.executemany(sql, values)
conn.commit()
-
+
logger.info(f"保存配件汇总: {cursor.rowcount}条")
return cursor.rowcount
@@ -284,7 +238,7 @@ class ResultWriter:
def delete_part_summaries_by_task(self, task_no: str) -> int:
"""
删除指定任务的配件汇总
-
+
Returns:
删除的行数
"""
@@ -295,7 +249,7 @@ class ResultWriter:
sql = "DELETE FROM ai_replenishment_part_summary WHERE task_no = %s"
cursor.execute(sql, (task_no,))
conn.commit()
-
+
logger.info(f"删除配件汇总: task_no={task_no}, rows={cursor.rowcount}")
return cursor.rowcount
@@ -305,7 +259,7 @@ class ResultWriter:
def delete_details_by_task(self, task_no: str) -> int:
"""
删除指定任务的补货明细
-
+
Returns:
删除的行数
"""
@@ -316,65 +270,9 @@ class ResultWriter:
sql = "DELETE FROM ai_replenishment_detail WHERE task_no = %s"
cursor.execute(sql, (task_no,))
conn.commit()
-
+
logger.info(f"删除补货明细: task_no={task_no}, rows={cursor.rowcount}")
return cursor.rowcount
finally:
cursor.close()
-
- def save_analysis_report(self, report: AnalysisReport) -> int:
- """
- 保存分析报告(四大板块 JSON 结构)
-
- Returns:
- 插入的报告ID
- """
- conn = self._get_connection()
- cursor = conn.cursor()
-
- try:
- sql = """
- INSERT INTO ai_analysis_report (
- task_no, group_id, dealer_grouping_id, dealer_grouping_name,
- brand_grouping_id, report_type,
- inventory_overview, sales_analysis,
- inventory_health, replenishment_summary,
- llm_provider, llm_model, llm_tokens, execution_time_ms,
- statistics_date, create_time
- ) VALUES (
- %s, %s, %s, %s, %s, %s,
- %s, %s, %s, %s,
- %s, %s, %s, %s,
- %s, NOW()
- )
- """
-
- values = (
- report.task_no,
- report.group_id,
- report.dealer_grouping_id,
- report.dealer_grouping_name,
- report.brand_grouping_id,
- report.report_type,
- json.dumps(report.inventory_overview, ensure_ascii=False) if report.inventory_overview else None,
- json.dumps(report.sales_analysis, ensure_ascii=False) if report.sales_analysis else None,
- json.dumps(report.inventory_health, ensure_ascii=False) if report.inventory_health else None,
- json.dumps(report.replenishment_summary, ensure_ascii=False) if report.replenishment_summary else None,
- report.llm_provider,
- report.llm_model,
- report.llm_tokens,
- report.execution_time_ms,
- report.statistics_date,
- )
-
- cursor.execute(sql, values)
- conn.commit()
-
- report_id = cursor.lastrowid
- logger.info(f"保存分析报告: task_no={report.task_no}, id={report_id}")
- return report_id
-
- finally:
- cursor.close()
-
diff --git a/ui/js/api.js b/ui/js/api.js
index fd89c7c..c73fd8f 100644
--- a/ui/js/api.js
+++ b/ui/js/api.js
@@ -70,12 +70,7 @@ const API = {
return this.get('/stats/summary');
},
- /**
- * 获取任务执行日志
- */
- async getTaskLogs(taskNo) {
- return this.get(`/tasks/${taskNo}/logs`);
- },
+
/**
* 获取任务配件汇总列表
@@ -91,12 +86,7 @@ const API = {
return this.get(`/tasks/${taskNo}/parts/${encodeURIComponent(partCode)}/shops`);
},
- /**
- * 获取任务分析报告
- */
- async getAnalysisReport(taskNo) {
- return this.get(`/tasks/${taskNo}/analysis-report`);
- },
+
};
// 导出到全局
diff --git a/ui/js/app.js b/ui/js/app.js
index 10fba26..66cff41 100644
--- a/ui/js/app.js
+++ b/ui/js/app.js
@@ -97,889 +97,6 @@ const App = {
},
- /**
- * 渲染分析报告标签页(四大板块:库存概览/销量分析/健康度/补货建议)
- */
- async renderReportTab(container, taskNo) {
- container.innerHTML = '
加载报告失败: ${error.message}
-${sa.season_demand_feature || ''}
-${sa.inventory_fitness || ''}
- ${sa.upcoming_season_preparation ? `下季准备: ${sa.upcoming_season_preparation}
` : ''} -${ca.total_evaluation || ''}
-${ca.structure_ratio || ''}
-${rd.analysis || ''}
-${rd.benchmark || ''}
-| 构成项 | -数量 | -金额 | -
|---|---|---|
| 在库未锁 | -${Components.formatNumber(stats.total_in_stock_unlocked_cnt)} | -${Components.formatAmount(stats.total_in_stock_unlocked_amount)} | -
| 在途 | -${Components.formatNumber(stats.total_on_the_way_cnt)} | -${Components.formatAmount(stats.total_on_the_way_amount)} | -
| 计划数 | -${Components.formatNumber(stats.total_has_plan_cnt)} | -${Components.formatAmount(stats.total_has_plan_amount)} | -
${sa.expected_performance || ''}
-${sa.actual_vs_expected || ''}
- ${sa.seasonal_items_status ? `${sa.seasonal_items_status}
` : ''} -${ca.main_driver || ''}
-${ca.pending_orders_impact || ''}
-${ca.booking_trend || ''}
-${aa.active_ratio || ''}
-${aa.optimization_suggestion || ''}
-${dt.evidence || ''}
- ${dt.seasonal_factor ? `季节因素: ${dt.seasonal_factor}
` : ''} -${dt.forecast || ''}
-| 构成项 | -总量 | -
|---|---|
| 90天出库数 | -${Components.formatNumber(stats.total_out_stock_cnt)} | -
| 未关单已锁 | -${Components.formatNumber(stats.total_storage_locked_cnt)} | -
| 未关单出库 | -${Components.formatNumber(stats.total_out_stock_ongoing_cnt)} | -
| 订件 | -${Components.formatNumber(stats.total_buy_cnt)} | -
${sa.seasonal_stagnant_items}
` : ''} - ${sa.seasonal_shortage_risk ? `${sa.seasonal_shortage_risk}
` : ''} - ${sa.upcoming_season_alert ? `下季关注: ${sa.upcoming_season_alert}
` : ''} -${hs.normal_ratio_evaluation || ''}
-呆滞件: ${pd.stagnant_analysis}
` : ''} - ${pd.shortage_analysis ? `缺货件: ${pd.shortage_analysis}
` : ''} - ${pd.low_freq_analysis ? `低频件: ${pd.low_freq_analysis}
` : ''} -呆滞件可释放: ${cr.stagnant_releasable}
` : ''} - ${cr.low_freq_releasable ? `低频件可释放: ${cr.low_freq_releasable}
` : ''} - ${cr.action_plan ? `${cr.action_plan}
` : ''} -${sa.seasonal_priority_items}
` : ''} - ${sa.timeline_adjustment ? `${sa.timeline_adjustment}
` : ''} - ${sa.next_season_preparation ? `下季准备: ${sa.next_season_preparation}
` : ''} -${ua.urgent_ratio_evaluation || ''}
- ${ua.immediate_action_needed ? '需要立即采取行动
' : ''} -${ba.recommended_order || ''}
- ${ba.urgent_budget ? `急需补货预算: ${ba.urgent_budget}
` : ''} - ${ba.suggested_budget ? `建议补货预算: ${ba.suggested_budget}
` : ''} - ${ba.optional_budget ? `可选补货预算: ${ba.optional_budget}
` : ''} -急需: ${ep.urgent_timeline}
` : ''} - ${ep.suggested_timeline ? `建议: ${ep.suggested_timeline}
` : ''} - ${ep.optional_timeline ? `可选: ${ep.optional_timeline}
` : ''} -| 优先级 | -配件种类数 | -建议补货金额 | -
|---|---|---|
| 急需补货 | -${urgent.count || 0} | -${Components.formatAmount(urgent.amount)} | -
| 建议补货 | -${suggested.count || 0} | -${Components.formatAmount(suggested.amount)} | -
| 可选补货 | -${optional.count || 0} | -${Components.formatAmount(optional.amount)} | -
| 合计 | -${stats.total_count || 0} | -${Components.formatAmount(stats.total_amount)} | -