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 = '
加载分析报告...
'; - - try { - const report = await API.getAnalysisReport(taskNo); - - if (!report) { - container.innerHTML = ` -
- ${Components.renderEmptyState('file-x', '暂无分析报告', '该任务尚未生成分析报告')} -
- `; - return; - } - - container.innerHTML = ` -
-
-
-
- `; - - this.renderInventoryOverview( - document.getElementById('report-inventory-overview'), - report.inventory_overview - ); - this.renderSalesAnalysis( - document.getElementById('report-sales-analysis'), - report.sales_analysis - ); - this.renderInventoryHealth( - document.getElementById('report-inventory-health'), - report.inventory_health - ); - this.renderReplenishmentSummary( - document.getElementById('report-replenishment-summary'), - report.replenishment_summary - ); - - lucide.createIcons(); - } catch (error) { - container.innerHTML = ` -
- -

加载报告失败: ${error.message}

-
- `; - lucide.createIcons(); - } - }, - - /** - * 渲染库存概览板块 - */ - renderInventoryOverview(container, data) { - if (!data) { - container.innerHTML = ''; - return; - } - const stats = data.stats || {}; - const analysis = data.llm_analysis || {}; - - // 兼容新旧数据结构 - const conclusion = analysis.conclusion || analysis; - const process = analysis.analysis_process || null; - - const ratio = stats.overall_ratio; - const ratioDisplay = (ratio === 999 || ratio === null || ratio === undefined) ? '无销量' : Components.formatNumber(ratio); - - // LLM 分析文本渲染 - let analysisHtml = ''; - if (analysis.error) { - analysisHtml = `
分析生成失败: ${analysis.error}
`; - } else { - const sections = []; - - // 季节信息(如果有) - if (process && process.seasonal_analysis) { - const sa = process.seasonal_analysis; - sections.push(`
-
季节性分析 ${sa.current_season || ''}
-

${sa.season_demand_feature || ''}

-

${sa.inventory_fitness || ''}

- ${sa.upcoming_season_preparation ? `

下季准备: ${sa.upcoming_season_preparation}

` : ''} -
`); - } - - if (conclusion.capital_assessment) { - const ca = conclusion.capital_assessment; - sections.push(`
-
资金占用评估 ${ca.risk_level === 'high' ? '高风险' : ca.risk_level === 'low' ? '低风险' : '中风险'}
-

${ca.total_evaluation || ''}

-

${ca.structure_ratio || ''}

-
`); - } - if (conclusion.ratio_diagnosis) { - const rd = conclusion.ratio_diagnosis; - sections.push(`
-
库销比诊断 — ${rd.level || ''}
-

${rd.analysis || ''}

-

${rd.benchmark || ''}

-
`); - } - if (conclusion.recommendations && conclusion.recommendations.length > 0) { - const recHtml = conclusion.recommendations.map(r => { - if (typeof r === 'object') { - return `
  • ${r.action || ''}${r.reason ? ` - ${r.reason}` : ''}${r.expected_effect ? `
    预期效果: ${r.expected_effect}` : ''}
  • `; - } - return `
  • ${r}
  • `; - }).join(''); - sections.push(`
    -
    库存结构建议
    - -
    `); - } - - // 推理过程(可折叠) - let processHtml = ''; - if (process) { - processHtml = this._renderAnalysisProcess(process, 'inventory-overview'); - } - - analysisHtml = sections.length > 0 ? `
    ${sections.join('')}${processHtml}
    ` : ''; - } - - container.innerHTML = ` -
    - - 库存总体概览 -
    -
    -
    -
    有效库存总数量
    -
    ${Components.formatNumber(stats.total_valid_storage_cnt)}
    -
    -
    -
    资金占用(总金额)
    -
    ${Components.formatAmount(stats.total_valid_storage_amount)}
    -
    -
    -
    整体库销比
    -
    ${ratioDisplay}
    -
    -
    -
    配件种类数
    -
    ${stats.part_count || 0}
    -
    -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - -
    构成项数量金额
    在库未锁${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)}
    -
    - ${analysisHtml} - `; - - // 绑定折叠事件 - this._bindProcessToggle(container); - }, - - /** - * 渲染销量分析板块 - */ - renderSalesAnalysis(container, data) { - if (!data) { - container.innerHTML = ''; - return; - } - const stats = data.stats || {}; - const analysis = data.llm_analysis || {}; - - // 兼容新旧数据结构 - const conclusion = analysis.conclusion || analysis; - const process = analysis.analysis_process || null; - - // LLM 分析文本 - let analysisHtml = ''; - if (analysis.error) { - analysisHtml = `
    分析生成失败: ${analysis.error}
    `; - } else { - const sections = []; - - // 季节信息(如果有) - if (process && process.seasonal_analysis) { - const sa = process.seasonal_analysis; - sections.push(`
    -
    季节性分析 ${sa.current_season || ''}
    -

    ${sa.expected_performance || ''}

    -

    ${sa.actual_vs_expected || ''}

    - ${sa.seasonal_items_status ? `

    ${sa.seasonal_items_status}

    ` : ''} -
    `); - } - - if (conclusion.composition_analysis) { - const ca = conclusion.composition_analysis; - sections.push(`
    -
    销量构成解读
    -

    ${ca.main_driver || ''}

    -

    ${ca.pending_orders_impact || ''}

    -

    ${ca.booking_trend || ''}

    -
    `); - } - if (conclusion.activity_assessment) { - const aa = conclusion.activity_assessment; - sections.push(`
    -
    销售活跃度
    -

    ${aa.active_ratio || ''}

    -

    ${aa.optimization_suggestion || ''}

    -
    `); - } - if (conclusion.demand_trend) { - const dt = conclusion.demand_trend; - const dirIcon = dt.direction === '上升' ? 'trending-up' : dt.direction === '下降' ? 'trending-down' : 'minus'; - sections.push(`
    -
    需求趋势 — ${dt.direction || ''}
    -

    ${dt.evidence || ''}

    - ${dt.seasonal_factor ? `

    季节因素: ${dt.seasonal_factor}

    ` : ''} -

    ${dt.forecast || ''}

    -
    `); - } - - // 推理过程(可折叠) - let processHtml = ''; - if (process) { - processHtml = this._renderAnalysisProcess(process, 'sales-analysis'); - } - - analysisHtml = sections.length > 0 ? `
    ${sections.join('')}${processHtml}
    ` : ''; - } - - const totalParts = (stats.has_sales_part_count || 0) + (stats.no_sales_part_count || 0); - - container.innerHTML = ` -
    - - 销量分析 -
    -
    -
    -
    月均销量总数量
    -
    ${Components.formatNumber(stats.total_avg_sales_cnt)}
    -
    -
    -
    月均销量总金额
    -
    ${Components.formatAmount(stats.total_avg_sales_amount)}
    -
    -
    -
    有销量配件
    -
    ${stats.has_sales_part_count || 0} / ${totalParts}
    -
    -
    -
    无销量配件
    -
    ${stats.no_sales_part_count || 0} / ${totalParts}
    -
    -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - -
    构成项总量
    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)}
    -
    - ${analysisHtml} - `; - - // 绑定折叠事件 - this._bindProcessToggle(container); - }, - - /** - * 渲染库存健康度板块(含 Chart.js 环形图) - */ - renderInventoryHealth(container, data) { - if (!data) { - container.innerHTML = ''; - return; - } - const stats = data.stats || {}; - const chartData = data.chart_data || {}; - const analysis = data.llm_analysis || {}; - - // 兼容新旧数据结构 - const conclusion = analysis.conclusion || analysis; - const process = analysis.analysis_process || null; - - const shortage = stats.shortage || {}; - const stagnant = stats.stagnant || {}; - const low_freq = stats.low_freq || {}; - const normal = stats.normal || {}; - - // LLM 分析文本 - let analysisHtml = ''; - if (analysis.error) { - analysisHtml = `
    分析生成失败: ${analysis.error}
    `; - } else { - const sections = []; - - // 季节信息(如果有) - if (process && process.seasonal_analysis) { - const sa = process.seasonal_analysis; - sections.push(`
    -
    季节性分析 ${sa.current_season || ''}
    - ${sa.seasonal_stagnant_items ? `

    ${sa.seasonal_stagnant_items}

    ` : ''} - ${sa.seasonal_shortage_risk ? `

    ${sa.seasonal_shortage_risk}

    ` : ''} - ${sa.upcoming_season_alert ? `

    下季关注: ${sa.upcoming_season_alert}

    ` : ''} -
    `); - } - - if (conclusion.health_score) { - const hs = conclusion.health_score; - sections.push(`
    -
    健康度评分 — ${hs.score || ''}
    -

    ${hs.normal_ratio_evaluation || ''}

    -
    `); - } - if (conclusion.problem_diagnosis) { - const pd = conclusion.problem_diagnosis; - sections.push(`
    -
    问题诊断
    - ${pd.stagnant_analysis ? `

    呆滞件: ${pd.stagnant_analysis}

    ` : ''} - ${pd.shortage_analysis ? `

    缺货件: ${pd.shortage_analysis}

    ` : ''} - ${pd.low_freq_analysis ? `

    低频件: ${pd.low_freq_analysis}

    ` : ''} -
    `); - } - if (conclusion.capital_release) { - const cr = conclusion.capital_release; - sections.push(`
    -
    资金释放机会
    - ${cr.stagnant_releasable ? `

    呆滞件可释放: ${cr.stagnant_releasable}

    ` : ''} - ${cr.low_freq_releasable ? `

    低频件可释放: ${cr.low_freq_releasable}

    ` : ''} - ${cr.action_plan ? `

    ${cr.action_plan}

    ` : ''} -
    `); - } - if (conclusion.priority_actions && conclusion.priority_actions.length > 0) { - const actHtml = conclusion.priority_actions.map(a => { - if (typeof a === 'object') { - return `
  • ${a.action || ''}${a.reason ? ` - ${a.reason}` : ''}${a.expected_effect ? `
    预期效果: ${a.expected_effect}` : ''}
  • `; - } - return `
  • ${a}
  • `; - }).join(''); - sections.push(`
    -
    改善优先级
    -
      ${actHtml}
    -
    `); - } - - // 推理过程(可折叠) - let processHtml = ''; - if (process) { - processHtml = this._renderAnalysisProcess(process, 'inventory-health'); - } - - analysisHtml = sections.length > 0 ? `
    ${sections.join('')}${processHtml}
    ` : ''; - } - - container.innerHTML = ` -
    - - 库存构成健康度 -
    -
    -
    -
    缺货件
    -
    ${shortage.count || 0}
    -
    ${Components.formatNumber(shortage.count_pct)}% · ${Components.formatAmount(shortage.amount)}
    -
    -
    -
    呆滞件
    -
    ${stagnant.count || 0}
    -
    ${Components.formatNumber(stagnant.count_pct)}% · ${Components.formatAmount(stagnant.amount)}
    -
    -
    -
    低频件
    -
    ${low_freq.count || 0}
    -
    ${Components.formatNumber(low_freq.count_pct)}% · ${Components.formatAmount(low_freq.amount)}
    -
    -
    -
    正常件
    -
    ${normal.count || 0}
    -
    ${Components.formatNumber(normal.count_pct)}% · ${Components.formatAmount(normal.amount)}
    -
    -
    -
    -
    -
    数量占比
    - -
    -
    -
    金额占比
    - -
    -
    - ${analysisHtml} - `; - - // 渲染 Chart.js 环形图 - this._renderHealthCharts(chartData); - - // 绑定折叠事件 - this._bindProcessToggle(container); - }, - - /** - * 渲染健康度环形图 - */ - _renderHealthCharts(chartData) { - if (!chartData || !chartData.labels) return; - if (typeof Chart === 'undefined') return; - - const colors = ['#ef4444', '#f59e0b', '#3b82f6', '#10b981']; - const borderColors = ['#dc2626', '#d97706', '#2563eb', '#059669']; - - const chartOptions = { - responsive: true, - maintainAspectRatio: true, - plugins: { - legend: { - position: 'bottom', - labels: { - color: '#94a3b8', - padding: 16, - usePointStyle: true, - pointStyleWidth: 10, - font: { size: 12 } - } - }, - tooltip: { - backgroundColor: '#1e293b', - titleColor: '#f8fafc', - bodyColor: '#94a3b8', - borderColor: 'rgba(148,163,184,0.2)', - borderWidth: 1 - } - }, - cutout: '60%' - }; - - // 数量占比图 - const countCtx = document.getElementById('health-count-chart'); - if (countCtx) { - new Chart(countCtx, { - type: 'doughnut', - data: { - labels: chartData.labels, - datasets: [{ - data: chartData.count_values, - backgroundColor: colors, - borderColor: borderColors, - borderWidth: 2 - }] - }, - options: chartOptions - }); - } - - // 金额占比图 - const amountCtx = document.getElementById('health-amount-chart'); - if (amountCtx) { - new Chart(amountCtx, { - type: 'doughnut', - data: { - labels: chartData.labels, - datasets: [{ - data: chartData.amount_values, - backgroundColor: colors, - borderColor: borderColors, - borderWidth: 2 - }] - }, - options: { - ...chartOptions, - plugins: { - ...chartOptions.plugins, - tooltip: { - ...chartOptions.plugins.tooltip, - callbacks: { - label: function(context) { - const value = context.parsed; - return ` ${context.label}: ¥${Number(value).toLocaleString('zh-CN', {minimumFractionDigits: 2})}`; - } - } - } - } - } - }); - } - }, - - /** - * 渲染补货建议板块 - */ - renderReplenishmentSummary(container, data) { - if (!data) { - container.innerHTML = ''; - return; - } - const stats = data.stats || {}; - const analysis = data.llm_analysis || {}; - - // 兼容新旧数据结构 - const conclusion = analysis.conclusion || analysis; - const process = analysis.analysis_process || null; - - const urgent = stats.urgent || {}; - const suggested = stats.suggested || {}; - const optional = stats.optional || {}; - - // LLM 分析文本 - let analysisHtml = ''; - if (analysis.error) { - analysisHtml = `
    分析生成失败: ${analysis.error}
    `; - } else { - const sections = []; - - // 季节信息(如果有) - if (process && process.seasonal_analysis) { - const sa = process.seasonal_analysis; - sections.push(`
    -
    季节性分析 ${sa.current_season || ''}
    - ${sa.seasonal_priority_items ? `

    ${sa.seasonal_priority_items}

    ` : ''} - ${sa.timeline_adjustment ? `

    ${sa.timeline_adjustment}

    ` : ''} - ${sa.next_season_preparation ? `

    下季准备: ${sa.next_season_preparation}

    ` : ''} -
    `); - } - - if (conclusion.urgency_assessment) { - const ua = conclusion.urgency_assessment; - const riskTag = ua.risk_level === 'high' ? '高风险' : ua.risk_level === 'low' ? '低风险' : '中风险'; - sections.push(`
    -
    紧迫度评估 ${riskTag}
    -

    ${ua.urgent_ratio_evaluation || ''}

    - ${ua.immediate_action_needed ? '

    需要立即采取行动

    ' : ''} -
    `); - } - if (conclusion.budget_allocation) { - const ba = conclusion.budget_allocation; - sections.push(`
    -
    资金分配建议
    -

    ${ba.recommended_order || ''}

    - ${ba.urgent_budget ? `

    急需补货预算: ${ba.urgent_budget}

    ` : ''} - ${ba.suggested_budget ? `

    建议补货预算: ${ba.suggested_budget}

    ` : ''} - ${ba.optional_budget ? `

    可选补货预算: ${ba.optional_budget}

    ` : ''} -
    `); - } - if (conclusion.execution_plan) { - const ep = conclusion.execution_plan; - sections.push(`
    -
    执行节奏建议
    - ${ep.urgent_timeline ? `

    急需: ${ep.urgent_timeline}

    ` : ''} - ${ep.suggested_timeline ? `

    建议: ${ep.suggested_timeline}

    ` : ''} - ${ep.optional_timeline ? `

    可选: ${ep.optional_timeline}

    ` : ''} -
    `); - } - if (conclusion.risk_warnings && conclusion.risk_warnings.length > 0) { - const warnHtml = conclusion.risk_warnings.map(w => { - if (typeof w === 'object') { - return `
  • ${w.risk_type || ''}: ${w.description || ''}${w.mitigation ? `
    应对: ${w.mitigation}` : ''}
  • `; - } - return `
  • ${w}
  • `; - }).join(''); - sections.push(`
    -
    风险提示
    - -
    `); - } - - // 推理过程(可折叠) - let processHtml = ''; - if (process) { - processHtml = this._renderAnalysisProcess(process, 'replenishment-summary'); - } - - analysisHtml = sections.length > 0 ? `
    ${sections.join('')}${processHtml}
    ` : ''; - } - - container.innerHTML = ` -
    - - 补货建议生成情况 -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    优先级配件种类数建议补货金额
    急需补货${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)}
    -
    - ${analysisHtml} - `; - - // 绑定折叠事件 - this._bindProcessToggle(container); - }, - - /** - * 渲染推理过程(可折叠) - */ - _renderAnalysisProcess(process, moduleId) { - if (!process) return ''; - - const sections = []; - - // 计算指标 - if (process.calculated_metrics) { - const items = Object.entries(process.calculated_metrics) - .filter(([k, v]) => v && v !== '') - .map(([k, v]) => `
    ${this._formatProcessKey(k)}${v}
    `) - .join(''); - if (items) { - sections.push(`
    计算指标
    ${items}
    `); - } - } - - // 库销比诊断 - if (process.ratio_diagnosis) { - const rd = process.ratio_diagnosis; - const items = []; - if (rd.current_value) items.push(`
    当前值${rd.current_value}
    `); - if (rd.level) items.push(`
    判断等级${rd.level}
    `); - if (rd.reasoning) items.push(`
    判断依据${rd.reasoning}
    `); - if (rd.benchmark_comparison) items.push(`
    行业对比${rd.benchmark_comparison}
    `); - if (items.length > 0) { - sections.push(`
    库销比诊断
    ${items.join('')}
    `); - } - } - - // 结构分析 - if (process.structure_analysis) { - const sa = process.structure_analysis; - const items = []; - if (sa.in_stock_evaluation) items.push(`
    在库未锁${sa.in_stock_evaluation}
    `); - if (sa.on_way_evaluation) items.push(`
    在途${sa.on_way_evaluation}
    `); - if (sa.plan_evaluation) items.push(`
    计划数${sa.plan_evaluation}
    `); - if (sa.abnormal_items && sa.abnormal_items.length > 0) { - items.push(`
    异常项${sa.abnormal_items.join('; ')}
    `); - } - if (items.length > 0) { - sections.push(`
    结构分析
    ${items.join('')}
    `); - } - } - - // 构成诊断(销量分析) - if (process.composition_diagnosis) { - const cd = process.composition_diagnosis; - const items = []; - if (cd.out_stock_evaluation) items.push(`
    90天出库${cd.out_stock_evaluation}
    `); - if (cd.locked_evaluation) items.push(`
    未关单已锁${cd.locked_evaluation}
    `); - if (cd.ongoing_evaluation) items.push(`
    未关单出库${cd.ongoing_evaluation}
    `); - if (cd.buy_evaluation) items.push(`
    订件${cd.buy_evaluation}
    `); - if (items.length > 0) { - sections.push(`
    构成诊断
    ${items.join('')}
    `); - } - } - - // 活跃度诊断 - if (process.activity_diagnosis) { - const ad = process.activity_diagnosis; - const items = []; - if (ad.current_rate) items.push(`
    当前活跃率${ad.current_rate}
    `); - if (ad.level) items.push(`
    判断等级${ad.level}
    `); - if (ad.reasoning) items.push(`
    判断依据${ad.reasoning}
    `); - if (items.length > 0) { - sections.push(`
    活跃度诊断
    ${items.join('')}
    `); - } - } - - // 趋势诊断 - if (process.trend_diagnosis) { - const td = process.trend_diagnosis; - const items = []; - if (td.signals && td.signals.length > 0) items.push(`
    趋势信号${td.signals.join('; ')}
    `); - if (td.reasoning) items.push(`
    判断依据${td.reasoning}
    `); - if (items.length > 0) { - sections.push(`
    趋势诊断
    ${items.join('')}
    `); - } - } - - // 健康度诊断 - if (process.health_score_diagnosis) { - const hsd = process.health_score_diagnosis; - const items = []; - if (hsd.normal_ratio) items.push(`
    正常件占比${hsd.normal_ratio}
    `); - if (hsd.score) items.push(`
    健康度评分${hsd.score}
    `); - if (hsd.reasoning) items.push(`
    判断依据${hsd.reasoning}
    `); - if (items.length > 0) { - sections.push(`
    健康度诊断
    ${items.join('')}
    `); - } - } - - // 问题诊断(健康度) - if (process.problem_diagnosis) { - const pd = process.problem_diagnosis; - const items = []; - ['shortage', 'stagnant', 'low_freq'].forEach(key => { - const item = pd[key]; - if (item) { - const label = key === 'shortage' ? '缺货件' : key === 'stagnant' ? '呆滞件' : '低频件'; - if (item.threshold_comparison) items.push(`
    ${label}${item.threshold_comparison}
    `); - } - }); - if (items.length > 0) { - sections.push(`
    问题诊断
    ${items.join('')}
    `); - } - } - - // 资金释放计算 - if (process.capital_release_calculation) { - const crc = process.capital_release_calculation; - const items = []; - if (crc.stagnant_calculation) items.push(`
    呆滞件${crc.stagnant_calculation}
    `); - if (crc.low_freq_calculation) items.push(`
    低频件${crc.low_freq_calculation}
    `); - if (crc.total_releasable) items.push(`
    总可释放${crc.total_releasable}
    `); - if (items.length > 0) { - sections.push(`
    资金释放计算
    ${items.join('')}
    `); - } - } - - // 紧迫度诊断(补货建议) - if (process.urgency_diagnosis) { - const ud = process.urgency_diagnosis; - const items = []; - if (ud.urgent_ratio) items.push(`
    急需占比${ud.urgent_ratio}
    `); - if (ud.level) items.push(`
    紧迫等级${ud.level}
    `); - if (ud.reasoning) items.push(`
    判断依据${ud.reasoning}
    `); - if (items.length > 0) { - sections.push(`
    紧迫度诊断
    ${items.join('')}
    `); - } - } - - // 预算分析 - if (process.budget_analysis) { - const ba = process.budget_analysis; - const items = []; - if (ba.current_distribution) items.push(`
    当前分布${ba.current_distribution}
    `); - if (ba.comparison_with_standard) items.push(`
    标准对比${ba.comparison_with_standard}
    `); - if (ba.adjustment_needed) items.push(`
    调整建议${ba.adjustment_needed}
    `); - if (items.length > 0) { - sections.push(`
    预算分析
    ${items.join('')}
    `); - } - } - - // 风险识别 - if (process.risk_identification) { - const ri = process.risk_identification; - const items = []; - if (ri.capital_pressure_check) items.push(`
    资金压力${ri.capital_pressure_check}
    `); - if (ri.over_replenishment_check) items.push(`
    过度补货${ri.over_replenishment_check}
    `); - if (ri.identified_risks && ri.identified_risks.length > 0) { - items.push(`
    识别风险${ri.identified_risks.join('; ')}
    `); - } - if (items.length > 0) { - sections.push(`
    风险识别
    ${items.join('')}
    `); - } - } - - if (sections.length === 0) return ''; - - return ` -
    - - 查看分析推理过程 -
    -
    - ${sections.join('')} -
    - `; - }, - - /** - * 格式化推理过程的key名称 - */ - _formatProcessKey(key) { - const keyMap = { - 'in_stock_ratio': '在库未锁占比', - 'on_way_ratio': '在途占比', - 'plan_ratio': '计划数占比', - 'avg_cost': '平均成本', - 'out_stock_ratio': '90天出库占比', - 'locked_ratio': '未关单已锁占比', - 'ongoing_ratio': '未关单出库占比', - 'buy_ratio': '订件占比', - 'sku_active_rate': 'SKU活跃率', - 'avg_sales_price': '平均销售金额', - 'urgent_count_ratio': '急需数量占比', - 'urgent_amount_ratio': '急需金额占比', - 'suggested_count_ratio': '建议数量占比', - 'suggested_amount_ratio': '建议金额占比', - 'optional_count_ratio': '可选数量占比', - 'optional_amount_ratio': '可选金额占比', - }; - return keyMap[key] || key; - }, - - /** - * 绑定推理过程折叠事件 - */ - _bindProcessToggle(container) { - const toggles = container.querySelectorAll('.analysis-process-toggle'); - toggles.forEach(toggle => { - toggle.addEventListener('click', () => { - const targetId = toggle.dataset.target; - const content = document.getElementById(targetId); - if (content) { - toggle.classList.toggle('expanded'); - content.classList.toggle('expanded'); - lucide.createIcons(); - } - }); - }); - }, /** * 初始化应用 @@ -1310,14 +427,13 @@ const App = { try { Components.showLoading(); - const [task, partSummaries, logs] = await Promise.all([ + const [task, partSummaries] = await Promise.all([ API.getTask(taskNo), API.getPartSummaries(taskNo, { page: 1, page_size: 100 }).catch(() => ({ items: [], total: 0 })), - API.getTaskLogs(taskNo).catch(() => ({ items: [] })), ]); Components.hideLoading(); - this.renderTaskDetail(task, partSummaries, logs); + this.renderTaskDetail(task, partSummaries); lucide.createIcons(); } catch (error) { Components.hideLoading(); @@ -1328,8 +444,7 @@ const App = { /** * 渲染任务详情 */ - renderTaskDetail(task, partSummaries, logs) { - this._currentLogs = logs; + renderTaskDetail(task, partSummaries) { this._partSummaries = partSummaries; const container = document.getElementById('task-detail-container'); @@ -1378,14 +493,6 @@ const App = { 配件明细 - -