Commit 25a8b07f6d8eb2c73251e4862df9503833215e8e
1 parent
f52f1031
feat: 重构分析报告为库存总览、销售分析、库存健康和补货总结四个主要板块。
Showing
23 changed files
with
4788 additions
and
1096 deletions
Too many changes to show.
To preserve performance only 20 of 23 files are displayed.
.kiro/specs/refactor-analysis-report/design.md
0 → 100644
| 1 | +# 设计文档:重构分析报告功能 | |
| 2 | + | |
| 3 | +## 概述 | |
| 4 | + | |
| 5 | +重构 AI 补货建议系统的分析报告功能,将现有的四模块宏观决策报告(整体态势研判/风险预警/采购策略/效果预期)替换为四大数据驱动板块(库存总体概览/销量分析/库存构成健康度/补货建议生成情况)。每个板块包含精确的统计数据和 LLM 生成的分析文本。 | |
| 6 | + | |
| 7 | +核心设计变更: | |
| 8 | +- 从"LLM 主导分析"转变为"数据统计 + LLM 辅助分析"模式 | |
| 9 | +- 使用 LangGraph 动态节点并发生成四个板块的 LLM 分析 | |
| 10 | +- 前端新增 Chart.js 图表支持库存健康度可视化 | |
| 11 | +- 数据库表结构完全重建,四个 JSON 字段分别存储各板块数据 | |
| 12 | + | |
| 13 | +## 架构 | |
| 14 | + | |
| 15 | +### 整体数据流 | |
| 16 | + | |
| 17 | +```mermaid | |
| 18 | +graph TD | |
| 19 | + A[allocate_budget 节点完成] --> B[generate_analysis_report 节点] | |
| 20 | + B --> C[统计计算阶段] | |
| 21 | + C --> C1[库存概览统计] | |
| 22 | + C --> C2[销量分析统计] | |
| 23 | + C --> C3[健康度统计] | |
| 24 | + C --> C4[补货建议统计] | |
| 25 | + C1 & C2 & C3 & C4 --> D[LangGraph 并发 LLM 分析] | |
| 26 | + D --> D1[库存概览 LLM 节点] | |
| 27 | + D --> D2[销量分析 LLM 节点] | |
| 28 | + D --> D3[健康度 LLM 节点] | |
| 29 | + D --> D4[补货建议 LLM 节点] | |
| 30 | + D1 & D2 & D3 & D4 --> E[汇总报告] | |
| 31 | + E --> F[Result_Writer 写入数据库] | |
| 32 | + F --> G[API 返回前端] | |
| 33 | + G --> H[Report_UI 渲染] | |
| 34 | +``` | |
| 35 | + | |
| 36 | +### LangGraph 并发子图设计 | |
| 37 | + | |
| 38 | +在现有工作流 `allocate_budget → generate_analysis_report → END` 中,`generate_analysis_report` 节点内部使用一个 LangGraph 子图实现并发: | |
| 39 | + | |
| 40 | +```mermaid | |
| 41 | +graph TD | |
| 42 | + START[统计计算完成] --> FORK{并发分发} | |
| 43 | + FORK --> N1[inventory_overview_llm] | |
| 44 | + FORK --> N2[sales_analysis_llm] | |
| 45 | + FORK --> N3[inventory_health_llm] | |
| 46 | + FORK --> N4[replenishment_summary_llm] | |
| 47 | + N1 & N2 & N3 & N4 --> JOIN[汇总合并] | |
| 48 | + JOIN --> END_NODE[返回完整报告] | |
| 49 | +``` | |
| 50 | + | |
| 51 | +实现方式:使用 `langgraph.graph.StateGraph` 构建子图,通过 `add_node` 添加四个并发 LLM 节点,使用 fan-out/fan-in 模式(从 START 分发到四个节点,四个节点汇聚到 END)。每个节点独立调用 LLM,失败不影响其他节点。 | |
| 52 | + | |
| 53 | +## 组件与接口 | |
| 54 | + | |
| 55 | +### 1. 统计计算模块 (analysis_report_node.py) | |
| 56 | + | |
| 57 | +#### `calculate_inventory_overview(part_ratios: list[dict]) -> dict` | |
| 58 | +计算库存总体概览统计数据。有效库存使用 `valid_storage_cnt`(三项公式:in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt)。 | |
| 59 | + | |
| 60 | +输入:PartRatio 字典列表 | |
| 61 | +输出: | |
| 62 | +```python | |
| 63 | +{ | |
| 64 | + "total_valid_storage_cnt": Decimal, # 有效库存总数量(五项之和) | |
| 65 | + "total_valid_storage_amount": Decimal, # 有效库存总金额(资金占用) | |
| 66 | + "total_in_stock_unlocked_cnt": Decimal, # 在库未锁总数量 | |
| 67 | + "total_in_stock_unlocked_amount": Decimal, | |
| 68 | + "total_on_the_way_cnt": Decimal, # 在途总数量 | |
| 69 | + "total_on_the_way_amount": Decimal, | |
| 70 | + "total_has_plan_cnt": Decimal, # 计划数总数量 | |
| 71 | + "total_has_plan_amount": Decimal, | |
| 72 | + "total_transfer_cnt": Decimal, # 主动调拨在途总数量 | |
| 73 | + "total_transfer_amount": Decimal, | |
| 74 | + "total_gen_transfer_cnt": Decimal, # 自动调拨在途总数量 | |
| 75 | + "total_gen_transfer_amount": Decimal, | |
| 76 | + "total_avg_sales_cnt": Decimal, # 月均销量总数量 | |
| 77 | + "overall_ratio": Decimal, # 整体库销比 | |
| 78 | + "part_count": int, # 配件总种类数 | |
| 79 | +} | |
| 80 | +``` | |
| 81 | + | |
| 82 | +#### `calculate_sales_analysis(part_ratios: list[dict]) -> dict` | |
| 83 | +计算销量分析统计数据。 | |
| 84 | + | |
| 85 | +输入:PartRatio 字典列表 | |
| 86 | +输出: | |
| 87 | +```python | |
| 88 | +{ | |
| 89 | + "total_avg_sales_cnt": Decimal, # 月均销量总数量 | |
| 90 | + "total_avg_sales_amount": Decimal, # 月均销量总金额 | |
| 91 | + "total_out_stock_cnt": Decimal, # 90天出库数总量 | |
| 92 | + "total_storage_locked_cnt": Decimal, # 未关单已锁总量 | |
| 93 | + "total_out_stock_ongoing_cnt": Decimal, # 未关单出库总量 | |
| 94 | + "total_buy_cnt": int, # 订件总量 | |
| 95 | + "has_sales_part_count": int, # 有销量配件数 | |
| 96 | + "no_sales_part_count": int, # 无销量配件数 | |
| 97 | +} | |
| 98 | +``` | |
| 99 | + | |
| 100 | +#### `calculate_inventory_health(part_ratios: list[dict]) -> dict` | |
| 101 | +计算库存构成健康度统计数据。 | |
| 102 | + | |
| 103 | +输入:PartRatio 字典列表 | |
| 104 | +输出: | |
| 105 | +```python | |
| 106 | +{ | |
| 107 | + "shortage": {"count": int, "amount": Decimal, "count_pct": float, "amount_pct": float}, | |
| 108 | + "stagnant": {"count": int, "amount": Decimal, "count_pct": float, "amount_pct": float}, | |
| 109 | + "low_freq": {"count": int, "amount": Decimal, "count_pct": float, "amount_pct": float}, | |
| 110 | + "normal": {"count": int, "amount": Decimal, "count_pct": float, "amount_pct": float}, | |
| 111 | + "total_count": int, | |
| 112 | + "total_amount": Decimal, | |
| 113 | +} | |
| 114 | +``` | |
| 115 | + | |
| 116 | +#### `calculate_replenishment_summary(part_results: list) -> dict` | |
| 117 | +计算补货建议生成情况统计数据。 | |
| 118 | + | |
| 119 | +输入:part_results 列表(配件汇总结果) | |
| 120 | +输出: | |
| 121 | +```python | |
| 122 | +{ | |
| 123 | + "urgent": {"count": int, "amount": Decimal}, # 急需补货 (priority=1) | |
| 124 | + "suggested": {"count": int, "amount": Decimal}, # 建议补货 (priority=2) | |
| 125 | + "optional": {"count": int, "amount": Decimal}, # 可选补货 (priority=3) | |
| 126 | + "total_count": int, | |
| 127 | + "total_amount": Decimal, | |
| 128 | +} | |
| 129 | +``` | |
| 130 | + | |
| 131 | +### 2. LLM 分析节点 | |
| 132 | + | |
| 133 | +四个独立的 LLM 分析函数,每个接收对应板块的统计数据,调用 LLM 生成分析文本: | |
| 134 | + | |
| 135 | +- `llm_analyze_inventory_overview(stats: dict) -> str` — 返回 JSON 字符串 | |
| 136 | +- `llm_analyze_sales(stats: dict) -> str` — 返回 JSON 字符串 | |
| 137 | +- `llm_analyze_inventory_health(stats: dict) -> str` — 返回 JSON 字符串 | |
| 138 | +- `llm_analyze_replenishment_summary(stats: dict) -> str` — 返回 JSON 字符串 | |
| 139 | + | |
| 140 | +每个函数加载对应的提示词模板(统一放在 `prompts/analysis_report.md` 中,按板块分段),填充统计数据,调用 LLM,解析 JSON 响应。 | |
| 141 | + | |
| 142 | +### 3. 提示词文件 | |
| 143 | + | |
| 144 | +拆分为四个独立提示词文件,每个板块一个,确保分析专业且有实际决策价值: | |
| 145 | + | |
| 146 | +#### prompts/report_inventory_overview.md — 库存概览分析 | |
| 147 | + | |
| 148 | +角色:资深汽车配件库存管理专家。输入统计数据后,要求 LLM 分析: | |
| 149 | +- **资金占用评估**:总库存金额是否合理,各构成部分(在库未锁/在途/计划数/主动调拨在途/自动调拨在途)的资金分配比例是否健康 | |
| 150 | +- **库销比诊断**:当前整体库销比处于什么水平(<1 库存不足,1-2 合理,2-3 偏高,>3 严重积压),对比行业经验给出判断 | |
| 151 | +- **库存结构建议**:基于五项构成的比例,给出具体的库存结构优化方向 | |
| 152 | + | |
| 153 | +输出 JSON 格式: | |
| 154 | +```json | |
| 155 | +{ | |
| 156 | + "capital_assessment": { | |
| 157 | + "total_evaluation": "总资金占用评估", | |
| 158 | + "structure_ratio": "各构成部分的资金比例分析", | |
| 159 | + "risk_level": "high/medium/low" | |
| 160 | + }, | |
| 161 | + "ratio_diagnosis": { | |
| 162 | + "level": "不足/合理/偏高/严重积压", | |
| 163 | + "analysis": "库销比具体分析", | |
| 164 | + "benchmark": "行业参考值对比" | |
| 165 | + }, | |
| 166 | + "recommendations": ["具体建议1", "具体建议2"] | |
| 167 | +} | |
| 168 | +``` | |
| 169 | + | |
| 170 | +#### prompts/report_sales_analysis.md — 销量分析 | |
| 171 | + | |
| 172 | +角色:汽车配件销售数据分析师。输入统计数据后,要求 LLM 分析: | |
| 173 | +- **销量构成解读**:90天出库数占比最大说明正常销售为主,未关单已锁/出库占比高说明有大量待处理订单,订件占比高说明客户预订需求旺盛 | |
| 174 | +- **销售活跃度**:有销量 vs 无销量配件的比例反映 SKU 活跃度,无销量占比过高说明 SKU 管理需要优化 | |
| 175 | +- **需求趋势判断**:基于各组成部分的比例关系,判断当前需求是稳定、上升还是下降趋势 | |
| 176 | + | |
| 177 | +输出 JSON 格式: | |
| 178 | +```json | |
| 179 | +{ | |
| 180 | + "composition_analysis": { | |
| 181 | + "main_driver": "主要销量来源分析", | |
| 182 | + "pending_orders_impact": "未关单对销量的影响", | |
| 183 | + "booking_trend": "订件趋势分析" | |
| 184 | + }, | |
| 185 | + "activity_assessment": { | |
| 186 | + "active_ratio": "活跃SKU占比评估", | |
| 187 | + "optimization_suggestion": "SKU优化建议" | |
| 188 | + }, | |
| 189 | + "demand_trend": { | |
| 190 | + "direction": "上升/稳定/下降", | |
| 191 | + "evidence": "判断依据", | |
| 192 | + "forecast": "短期需求预测" | |
| 193 | + } | |
| 194 | +} | |
| 195 | +``` | |
| 196 | + | |
| 197 | +#### prompts/report_inventory_health.md — 库存健康度分析 | |
| 198 | + | |
| 199 | +角色:汽车配件库存健康度诊断专家。输入统计数据后,要求 LLM 分析: | |
| 200 | +- **健康度评分**:基于正常件占比给出整体健康度评分(正常件>70%为健康,50-70%为亚健康,<50%为不健康) | |
| 201 | +- **问题诊断**:呆滞件占比高说明采购决策需要优化,缺货件占比高说明补货不及时,低频件占比高说明 SKU 精简空间大 | |
| 202 | +- **资金释放机会**:呆滞件和低频件占用的资金可以通过促销、退货等方式释放,给出具体金额估算 | |
| 203 | +- **改善优先级**:按影响程度排序,给出最应优先处理的问题类型 | |
| 204 | + | |
| 205 | +输出 JSON 格式: | |
| 206 | +```json | |
| 207 | +{ | |
| 208 | + "health_score": { | |
| 209 | + "score": "健康/亚健康/不健康", | |
| 210 | + "normal_ratio_evaluation": "正常件占比评估" | |
| 211 | + }, | |
| 212 | + "problem_diagnosis": { | |
| 213 | + "stagnant_analysis": "呆滞件问题分析及原因", | |
| 214 | + "shortage_analysis": "缺货件问题分析及影响", | |
| 215 | + "low_freq_analysis": "低频件问题分析及建议" | |
| 216 | + }, | |
| 217 | + "capital_release": { | |
| 218 | + "stagnant_releasable": "呆滞件可释放资金估算", | |
| 219 | + "low_freq_releasable": "低频件可释放资金估算", | |
| 220 | + "action_plan": "资金释放行动方案" | |
| 221 | + }, | |
| 222 | + "priority_actions": ["最优先处理事项1", "最优先处理事项2"] | |
| 223 | +} | |
| 224 | +``` | |
| 225 | + | |
| 226 | +#### prompts/report_replenishment_summary.md — 补货建议分析 | |
| 227 | + | |
| 228 | +角色:汽车配件采购策略顾问。输入统计数据后,要求 LLM 分析: | |
| 229 | +- **紧迫度评估**:急需补货占比反映当前缺货风险程度,急需占比>30%说明库存管理存在较大问题 | |
| 230 | +- **资金分配建议**:基于各优先级的金额分布,给出资金分配的先后顺序和比例建议 | |
| 231 | +- **执行节奏建议**:急需补货应立即执行,建议补货可在1-2周内完成,可选补货可根据资金情况灵活安排 | |
| 232 | +- **风险提示**:如果可选补货金额占比过高,提示可能存在过度补货风险 | |
| 233 | + | |
| 234 | +输出 JSON 格式: | |
| 235 | +```json | |
| 236 | +{ | |
| 237 | + "urgency_assessment": { | |
| 238 | + "urgent_ratio_evaluation": "急需补货占比评估", | |
| 239 | + "risk_level": "high/medium/low", | |
| 240 | + "immediate_action_needed": true/false | |
| 241 | + }, | |
| 242 | + "budget_allocation": { | |
| 243 | + "recommended_order": "建议资金分配顺序", | |
| 244 | + "urgent_budget": "急需补货建议预算", | |
| 245 | + "suggested_budget": "建议补货建议预算", | |
| 246 | + "optional_budget": "可选补货建议预算" | |
| 247 | + }, | |
| 248 | + "execution_plan": { | |
| 249 | + "urgent_timeline": "急需补货执行时间建议", | |
| 250 | + "suggested_timeline": "建议补货执行时间建议", | |
| 251 | + "optional_timeline": "可选补货执行时间建议" | |
| 252 | + }, | |
| 253 | + "risk_warnings": ["风险提示1", "风险提示2"] | |
| 254 | +} | |
| 255 | +``` | |
| 256 | + | |
| 257 | +### 4. API 接口 (tasks.py) | |
| 258 | + | |
| 259 | +更新 `GET /api/tasks/{task_no}/analysis-report` 端点: | |
| 260 | + | |
| 261 | +响应模型 `AnalysisReportResponse`: | |
| 262 | +```python | |
| 263 | +class AnalysisReportResponse(BaseModel): | |
| 264 | + id: int | |
| 265 | + task_no: str | |
| 266 | + group_id: int | |
| 267 | + dealer_grouping_id: int | |
| 268 | + dealer_grouping_name: Optional[str] | |
| 269 | + report_type: str | |
| 270 | + | |
| 271 | + inventory_overview: Optional[Dict[str, Any]] # 库存概览(统计+分析) | |
| 272 | + sales_analysis: Optional[Dict[str, Any]] # 销量分析(统计+分析) | |
| 273 | + inventory_health: Optional[Dict[str, Any]] # 健康度(统计+分析+图表数据) | |
| 274 | + replenishment_summary: Optional[Dict[str, Any]] # 补货建议(统计+分析) | |
| 275 | + | |
| 276 | + llm_provider: Optional[str] | |
| 277 | + llm_model: Optional[str] | |
| 278 | + llm_tokens: int | |
| 279 | + execution_time_ms: int | |
| 280 | + statistics_date: Optional[str] | |
| 281 | + create_time: Optional[str] | |
| 282 | +``` | |
| 283 | + | |
| 284 | +### 5. 前端渲染 (app.js) | |
| 285 | + | |
| 286 | +`renderReportTab()` 重写,渲染四个板块: | |
| 287 | + | |
| 288 | +1. **库存概览板块**: 统计卡片(总数量、总金额、库销比)+ 五项构成明细表(在库未锁/在途/计划数/主动调拨在途/自动调拨在途)+ LLM 分析文本 | |
| 289 | +2. **销量分析板块**: 统计卡片(月均销量、总金额)+ 构成明细表 + LLM 分析文本 | |
| 290 | +3. **健康度板块**: 统计卡片 + Chart.js 环形图(数量占比 + 金额占比)+ LLM 分析文本 | |
| 291 | +4. **补货建议板块**: 优先级统计表 + LLM 分析文本 | |
| 292 | + | |
| 293 | +### 6. Chart.js 集成 | |
| 294 | + | |
| 295 | +在 `ui/index.html` 中通过 CDN 引入 Chart.js: | |
| 296 | +```html | |
| 297 | +<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script> | |
| 298 | +``` | |
| 299 | + | |
| 300 | +健康度板块使用两个环形图(Doughnut Chart): | |
| 301 | +- 数量占比图:缺货/呆滞/低频/正常 四类的数量百分比 | |
| 302 | +- 金额占比图:缺货/呆滞/低频/正常 四类的金额百分比 | |
| 303 | + | |
| 304 | +## 数据模型 | |
| 305 | + | |
| 306 | +### 数据库表 (ai_analysis_report) | |
| 307 | + | |
| 308 | +```sql | |
| 309 | +DROP TABLE IF EXISTS ai_analysis_report; | |
| 310 | +CREATE TABLE ai_analysis_report ( | |
| 311 | + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', | |
| 312 | + task_no VARCHAR(32) NOT NULL COMMENT '任务编号', | |
| 313 | + group_id BIGINT NOT NULL COMMENT '集团ID', | |
| 314 | + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID', | |
| 315 | + dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称', | |
| 316 | + brand_grouping_id BIGINT COMMENT '品牌组合ID', | |
| 317 | + report_type VARCHAR(32) DEFAULT 'replenishment' COMMENT '报告类型', | |
| 318 | + | |
| 319 | + -- 四大板块 (JSON 结构化存储,每个字段包含 stats + llm_analysis) | |
| 320 | + inventory_overview JSON COMMENT '库存总体概览(统计数据+LLM分析)', | |
| 321 | + sales_analysis JSON COMMENT '销量分析(统计数据+LLM分析)', | |
| 322 | + inventory_health JSON COMMENT '库存构成健康度(统计数据+图表数据+LLM分析)', | |
| 323 | + replenishment_summary JSON COMMENT '补货建议生成情况(统计数据+LLM分析)', | |
| 324 | + | |
| 325 | + -- LLM 元数据 | |
| 326 | + llm_provider VARCHAR(32) COMMENT 'LLM提供商', | |
| 327 | + llm_model VARCHAR(64) COMMENT 'LLM模型名称', | |
| 328 | + llm_tokens INT DEFAULT 0 COMMENT 'LLM Token消耗', | |
| 329 | + execution_time_ms INT DEFAULT 0 COMMENT '执行耗时(毫秒)', | |
| 330 | + | |
| 331 | + statistics_date VARCHAR(16) COMMENT '统计日期', | |
| 332 | + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', | |
| 333 | + | |
| 334 | + INDEX idx_task_no (task_no), | |
| 335 | + INDEX idx_group_date (group_id, statistics_date), | |
| 336 | + INDEX idx_dealer_grouping (dealer_grouping_id) | |
| 337 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议分析报告表-重构版'; | |
| 338 | +``` | |
| 339 | + | |
| 340 | +### Python 数据模型 (AnalysisReport) | |
| 341 | + | |
| 342 | +```python | |
| 343 | +@dataclass | |
| 344 | +class AnalysisReport: | |
| 345 | + task_no: str | |
| 346 | + group_id: int | |
| 347 | + dealer_grouping_id: int | |
| 348 | + | |
| 349 | + id: Optional[int] = None | |
| 350 | + dealer_grouping_name: Optional[str] = None | |
| 351 | + brand_grouping_id: Optional[int] = None | |
| 352 | + report_type: str = "replenishment" | |
| 353 | + | |
| 354 | + # 四大板块 | |
| 355 | + inventory_overview: Optional[Dict[str, Any]] = None | |
| 356 | + sales_analysis: Optional[Dict[str, Any]] = None | |
| 357 | + inventory_health: Optional[Dict[str, Any]] = None | |
| 358 | + replenishment_summary: Optional[Dict[str, Any]] = None | |
| 359 | + | |
| 360 | + # LLM 元数据 | |
| 361 | + llm_provider: str = "" | |
| 362 | + llm_model: str = "" | |
| 363 | + llm_tokens: int = 0 | |
| 364 | + execution_time_ms: int = 0 | |
| 365 | + | |
| 366 | + statistics_date: str = "" | |
| 367 | + create_time: Optional[datetime] = None | |
| 368 | +``` | |
| 369 | + | |
| 370 | +### 每个板块的 JSON 数据结构 | |
| 371 | + | |
| 372 | +每个板块的 JSON 包含 `stats`(统计数据)和 `llm_analysis`(LLM 分析文本)两部分: | |
| 373 | + | |
| 374 | +```python | |
| 375 | +# inventory_overview 示例 | |
| 376 | +{ | |
| 377 | + "stats": { | |
| 378 | + "total_valid_storage_cnt": 12500, | |
| 379 | + "total_valid_storage_amount": 3250000.00, | |
| 380 | + "total_in_stock_unlocked_cnt": 8000, | |
| 381 | + "total_in_stock_unlocked_amount": 2080000.00, | |
| 382 | + "total_on_the_way_cnt": 2500, | |
| 383 | + "total_on_the_way_amount": 650000.00, | |
| 384 | + "total_has_plan_cnt": 1000, | |
| 385 | + "total_has_plan_amount": 260000.00, | |
| 386 | + "total_transfer_cnt": 600, | |
| 387 | + "total_transfer_amount": 156000.00, | |
| 388 | + "total_gen_transfer_cnt": 400, | |
| 389 | + "total_gen_transfer_amount": 104000.00, | |
| 390 | + "total_avg_sales_cnt": 5000, | |
| 391 | + "overall_ratio": 2.5, | |
| 392 | + "part_count": 800 | |
| 393 | + }, | |
| 394 | + "llm_analysis": { ... } # LLM 返回的 JSON 分析对象 | |
| 395 | +} | |
| 396 | + | |
| 397 | +# inventory_health 示例(额外包含 chart_data) | |
| 398 | +{ | |
| 399 | + "stats": { | |
| 400 | + "shortage": {"count": 50, "amount": 125000, "count_pct": 6.25, "amount_pct": 3.85}, | |
| 401 | + "stagnant": {"count": 120, "amount": 480000, "count_pct": 15.0, "amount_pct": 14.77}, | |
| 402 | + "low_freq": {"count": 200, "amount": 300000, "count_pct": 25.0, "amount_pct": 9.23}, | |
| 403 | + "normal": {"count": 430, "amount": 2345000, "count_pct": 53.75, "amount_pct": 72.15}, | |
| 404 | + "total_count": 800, | |
| 405 | + "total_amount": 3250000 | |
| 406 | + }, | |
| 407 | + "chart_data": { | |
| 408 | + "labels": ["缺货件", "呆滞件", "低频件", "正常件"], | |
| 409 | + "count_values": [50, 120, 200, 430], | |
| 410 | + "amount_values": [125000, 480000, 300000, 2345000] | |
| 411 | + }, | |
| 412 | + "llm_analysis": { ... } | |
| 413 | +} | |
| 414 | +``` | |
| 415 | + | |
| 416 | + | |
| 417 | +## 正确性属性 | |
| 418 | + | |
| 419 | +*正确性属性是系统在所有合法执行中都应保持为真的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规范与机器可验证正确性保证之间的桥梁。* | |
| 420 | + | |
| 421 | +### Property 1: 库存概览统计一致性 | |
| 422 | + | |
| 423 | +*对于任意* PartRatio 字典列表,`calculate_inventory_overview` 的输出应满足: | |
| 424 | +- `total_valid_storage_cnt` = 所有配件 `(in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt)` 之和 | |
| 425 | +- `total_valid_storage_amount` = 所有配件 `(in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt) × cost_price` 之和 | |
| 426 | +- `total_in_stock_unlocked_cnt + total_on_the_way_cnt + total_has_plan_cnt` = `total_valid_storage_cnt`(构成不变量) | |
| 427 | +- 当 `total_avg_sales_cnt > 0` 时,`overall_ratio` = `total_valid_storage_cnt / total_avg_sales_cnt` | |
| 428 | + | |
| 429 | +**Validates: Requirements 2.1, 2.2, 2.3** | |
| 430 | + | |
| 431 | +### Property 2: 销量分析统计一致性 | |
| 432 | + | |
| 433 | +*对于任意* PartRatio 字典列表,`calculate_sales_analysis` 的输出应满足: | |
| 434 | +- `total_avg_sales_cnt` = 所有配件 `(out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3` 之和 | |
| 435 | +- `(total_out_stock_cnt + total_storage_locked_cnt + total_out_stock_ongoing_cnt + total_buy_cnt) / 3` = `total_avg_sales_cnt`(构成不变量) | |
| 436 | +- `has_sales_part_count + no_sales_part_count` = 总配件数 | |
| 437 | + | |
| 438 | +**Validates: Requirements 3.1, 3.2, 3.4** | |
| 439 | + | |
| 440 | +### Property 3: 健康度分类完备性与一致性 | |
| 441 | + | |
| 442 | +*对于任意* PartRatio 字典列表,`calculate_inventory_health` 的输出应满足: | |
| 443 | +- `shortage.count + stagnant.count + low_freq.count + normal.count` = `total_count`(分类完备) | |
| 444 | +- `shortage.amount + stagnant.amount + low_freq.amount + normal.amount` = `total_amount`(金额守恒) | |
| 445 | +- 每种类型的 `count_pct` = `count / total_count × 100` | |
| 446 | +- 所有 `count_pct` 之和 ≈ 100.0(浮点精度容差内) | |
| 447 | + | |
| 448 | +**Validates: Requirements 4.1, 4.2** | |
| 449 | + | |
| 450 | +### Property 4: 补货建议统计一致性 | |
| 451 | + | |
| 452 | +*对于任意* part_results 列表,`calculate_replenishment_summary` 的输出应满足: | |
| 453 | +- `urgent.count + suggested.count + optional.count` = `total_count` | |
| 454 | +- `urgent.amount + suggested.amount + optional.amount` = `total_amount` | |
| 455 | + | |
| 456 | +**Validates: Requirements 5.1, 5.2** | |
| 457 | + | |
| 458 | +### Property 5: 报告数据模型序列化 round-trip | |
| 459 | + | |
| 460 | +*对于任意* 合法的 AnalysisReport 对象,调用 `to_dict()` 后再用返回的字典构造新的 AnalysisReport 对象,两个对象的核心字段应相等。 | |
| 461 | + | |
| 462 | +**Validates: Requirements 10.2, 10.3** | |
| 463 | + | |
| 464 | +## 错误处理 | |
| 465 | + | |
| 466 | +| 错误场景 | 处理方式 | | |
| 467 | +|---------|---------| | |
| 468 | +| LLM 单板块调用失败 | 该板块 `llm_analysis` 置为 `{"error": "错误信息"}`,其他板块正常生成 | | |
| 469 | +| LLM 返回非法 JSON | 记录原始响应到日志,`llm_analysis` 置为 `{"error": "JSON解析失败", "raw": "原始文本前200字符"}` | | |
| 470 | +| PartRatio 列表为空 | 所有统计值为 0,LLM 分析说明无数据 | | |
| 471 | +| part_results 列表为空 | 补货建议板块统计值为 0,LLM 分析说明无补货建议 | | |
| 472 | +| 数据库写入失败 | 记录错误日志,不中断主工作流,返回包含错误信息的报告 | | |
| 473 | +| 提示词文件缺失 | 抛出 FileNotFoundError,由上层节点捕获并记录 | | |
| 474 | + | |
| 475 | +## 测试策略 | |
| 476 | + | |
| 477 | +### 属性测试 (Property-Based Testing) | |
| 478 | + | |
| 479 | +使用 `hypothesis` 库进行属性测试,每个属性至少运行 100 次迭代。 | |
| 480 | + | |
| 481 | +- **Property 1**: 生成随机 PartRatio 字典列表(随机数量、随机字段值),验证库存概览统计的不变量 | |
| 482 | + - Tag: **Feature: refactor-analysis-report, Property 1: 库存概览统计一致性** | |
| 483 | +- **Property 2**: 生成随机 PartRatio 字典列表,验证销量分析统计的不变量 | |
| 484 | + - Tag: **Feature: refactor-analysis-report, Property 2: 销量分析统计一致性** | |
| 485 | +- **Property 3**: 生成随机 PartRatio 字典列表,验证健康度分类的完备性和一致性 | |
| 486 | + - Tag: **Feature: refactor-analysis-report, Property 3: 健康度分类完备性与一致性** | |
| 487 | +- **Property 4**: 生成随机 part_results 列表(随机优先级和金额),验证补货建议统计的一致性 | |
| 488 | + - Tag: **Feature: refactor-analysis-report, Property 4: 补货建议统计一致性** | |
| 489 | +- **Property 5**: 生成随机 AnalysisReport 对象,验证 to_dict round-trip | |
| 490 | + - Tag: **Feature: refactor-analysis-report, Property 5: 报告数据模型序列化 round-trip** | |
| 491 | + | |
| 492 | +### 单元测试 | |
| 493 | + | |
| 494 | +- 边界情况:空列表输入、月均销量为零、所有配件同一类型 | |
| 495 | +- LLM 响应解析:合法 JSON、非法 JSON、空响应 | |
| 496 | +- API 端点:有报告数据、无报告数据 | |
| 497 | + | |
| 498 | +### 集成测试 | |
| 499 | + | |
| 500 | +- 完整报告生成流程(mock LLM) | |
| 501 | +- 数据库写入和读取一致性 | |
| 502 | +- 前端渲染(手动验证) | ... | ... |
.kiro/specs/refactor-analysis-report/requirements.md
0 → 100644
| 1 | +# 需求文档:重构分析报告功能 | |
| 2 | + | |
| 3 | +## 简介 | |
| 4 | + | |
| 5 | +重构 AI 补货建议系统的分析报告功能。先清理现有分析报告相关代码(仅限分析报告模块,不涉及其他模块),再基于新结构重建。新报告涵盖库存总体概览、销量分析、库存构成健康度、补货建议生成情况四大板块。每个板块包含统计数据和 LLM 生成的分析文本。考虑使用 LangGraph 动态节点并发生成各板块统计及分析,最后汇总。涉及全栈改动:数据库表结构、数据模型、Agent 节点、LLM 提示词、API 接口、前端页面。 | |
| 6 | + | |
| 7 | +## 术语表 | |
| 8 | + | |
| 9 | +- **Report_Generator**: 分析报告生成节点(analysis_report_node),负责统计计算和调用 LLM 生成报告 | |
| 10 | +- **Report_API**: 分析报告 API 接口,负责从数据库读取报告并返回给前端 | |
| 11 | +- **Report_UI**: 前端报告渲染模块,负责展示报告数据和图表 | |
| 12 | +- **PartRatio**: 配件库销比数据,包含库存、销量、成本等原始数据 | |
| 13 | +- **有效库存**: valid_storage_cnt = in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt(在库未锁 + 在途 + 计划数) | |
| 14 | +- **月均销量**: (90天出库数 + 未关单已锁 + 未关单出库 + 订件) / 3 | |
| 15 | +- **库销比**: 有效库存(valid_storage_cnt) / 月均销量 | |
| 16 | +- **呆滞件**: 有效库存(valid_storage_cnt) > 0 且 90天出库数 = 0 的配件 | |
| 17 | +- **低频件**: 月均销量 < 1 或 出库次数 < 3 或 出库间隔 >= 30天 的配件 | |
| 18 | +- **缺货件**: 有效库存(valid_storage_cnt) = 0 且 月均销量 >= 1 的配件 | |
| 19 | +- **正常件**: 不属于呆滞件、低频件、缺货件的配件 | |
| 20 | +- **急需补货**: 优先级 1,库销比 < 0.5 且月均销量 >= 1 | |
| 21 | +- **建议补货**: 优先级 2,库销比 0.5-1.0 且月均销量 >= 1 | |
| 22 | +- **可选补货**: 优先级 3,库销比 1.0-目标值 且月均销量 >= 1 | |
| 23 | +- **Result_Writer**: 数据库写入服务,负责将报告持久化到 MySQL | |
| 24 | + | |
| 25 | +## 需求 | |
| 26 | + | |
| 27 | +### 需求 1:清理现有分析报告代码 | |
| 28 | + | |
| 29 | +**用户故事:** 作为开发者,我希望先清理现有分析报告相关代码,以便在干净的基础上重构新报告功能。 | |
| 30 | + | |
| 31 | +#### 验收标准 | |
| 32 | + | |
| 33 | +1. WHEN 开始重构, THE 开发者 SHALL 清理 analysis_report_node.py 中的现有报告生成逻辑 | |
| 34 | +2. WHEN 开始重构, THE 开发者 SHALL 清理 analysis_report.py 中的现有数据模型 | |
| 35 | +3. WHEN 开始重构, THE 开发者 SHALL 清理 prompts/analysis_report.md 中的现有提示词 | |
| 36 | +4. WHEN 开始重构, THE 开发者 SHALL 清理前端 app.js 中的现有报告渲染代码(renderReportTab、renderOverallAssessment、renderRiskAlerts、renderStrategy、renderExpectedImpact) | |
| 37 | +5. WHEN 清理代码, THE 开发者 SHALL 仅清理分析报告相关代码,保持其他模块代码不变 | |
| 38 | + | |
| 39 | +### 需求 2:库存总体概览统计 | |
| 40 | + | |
| 41 | +**用户故事:** 作为采购决策者,我希望看到库存总体概览(总数量、总金额/资金占用、库销比及库存构成明细),以便快速了解当前库存状况和资金占用情况。 | |
| 42 | + | |
| 43 | +#### 验收标准 | |
| 44 | + | |
| 45 | +1. WHEN Report_Generator 接收到 PartRatio 列表, THE Report_Generator SHALL 使用 valid_storage_cnt(in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt)计算所有配件的有效库存总数量和总金额(有效库存 × 成本价之和) | |
| 46 | +2. WHEN Report_Generator 计算库存构成, THE Report_Generator SHALL 分别统计在库未锁(in_stock_unlocked_cnt)、在途(on_the_way_cnt)、计划数(has_plan_cnt)的总数量/总金额 | |
| 47 | +3. WHEN Report_Generator 计算库销比, THE Report_Generator SHALL 使用有效库存总数量除以月均销量总数量得到整体库销比 | |
| 48 | +4. WHEN 库存概览统计完成, THE Report_Generator SHALL 将统计数据作为上下文传递给 LLM 生成库存概览分析文本 | |
| 49 | +5. IF 月均销量总数量为零, THEN THE Report_Generator SHALL 将库销比标记为特殊值并在分析中说明无销量数据 | |
| 50 | + | |
| 51 | +### 需求 3:销量分析统计 | |
| 52 | + | |
| 53 | +**用户故事:** 作为采购决策者,我希望看到销量分析(月均销量及各组成部分明细),以便了解销售趋势和需求分布。 | |
| 54 | + | |
| 55 | +#### 验收标准 | |
| 56 | + | |
| 57 | +1. WHEN Report_Generator 接收到 PartRatio 列表, THE Report_Generator SHALL 计算所有配件的月均销量总数量和总金额(月均销量 × 成本价之和) | |
| 58 | +2. WHEN Report_Generator 计算销量构成, THE Report_Generator SHALL 分别统计90天出库数(out_stock_cnt)总量、未关单已锁(storage_locked_cnt)总量、未关单出库(out_stock_ongoing_cnt)总量、订件(buy_cnt)总量 | |
| 59 | +3. WHEN 销量统计完成, THE Report_Generator SHALL 将统计数据作为上下文传递给 LLM 生成销量分析文本 | |
| 60 | +4. WHEN Report_Generator 计算销量分布, THE Report_Generator SHALL 统计有销量配件数(月均销量 > 0)和无销量配件数(月均销量 = 0) | |
| 61 | + | |
| 62 | +### 需求 4:库存构成健康度统计 | |
| 63 | + | |
| 64 | +**用户故事:** 作为采购决策者,我希望看到库存健康度分析(缺货/呆滞/低频/正常各类型的数量和金额占比),以便识别库存结构问题。 | |
| 65 | + | |
| 66 | +#### 验收标准 | |
| 67 | + | |
| 68 | +1. WHEN Report_Generator 分类配件, THE Report_Generator SHALL 将每个配件归类为缺货件、呆滞件、低频件或正常件中的一种 | |
| 69 | +2. WHEN Report_Generator 统计各类型, THE Report_Generator SHALL 计算每种类型的配件数量、占总数量的百分比、涉及金额(有效库存 × 成本价)、占总金额的百分比 | |
| 70 | +3. WHEN 健康度统计完成, THE Report_Generator SHALL 将统计数据作为上下文传递给 LLM 生成健康度分析文本 | |
| 71 | +4. WHEN Report_API 返回健康度数据, THE Report_API SHALL 返回包含各类型数量和金额的结构化数据,供前端生成图表 | |
| 72 | +5. WHEN Report_UI 展示健康度数据, THE Report_UI SHALL 使用图表展示各类型配件的数量占比和金额占比 | |
| 73 | + | |
| 74 | +### 需求 5:补货建议生成情况统计 | |
| 75 | + | |
| 76 | +**用户故事:** 作为采购决策者,我希望看到本次补货建议的生成情况(急需/建议/可选各优先级的配件数和金额),以便了解补货的紧迫程度和资金需求。 | |
| 77 | + | |
| 78 | +#### 验收标准 | |
| 79 | + | |
| 80 | +1. WHEN Report_Generator 统计补货建议, THE Report_Generator SHALL 按优先级(急需补货/建议补货/可选补货)分别统计配件种类数和建议补货总金额 | |
| 81 | +2. WHEN Report_Generator 统计补货总量, THE Report_Generator SHALL 计算所有优先级的配件总种类数和总金额 | |
| 82 | +3. WHEN 补货统计完成, THE Report_Generator SHALL 将统计数据作为上下文传递给 LLM 生成补货建议分析文本 | |
| 83 | + | |
| 84 | +### 需求 6:数据库表结构重构 | |
| 85 | + | |
| 86 | +**用户故事:** 作为开发者,我希望数据库表结构能够存储新的报告模块数据,以便支持四大板块的数据持久化。 | |
| 87 | + | |
| 88 | +#### 验收标准 | |
| 89 | + | |
| 90 | +1. THE ai_analysis_report 表 SHALL 包含库存概览模块的 JSON 字段(inventory_overview)存储统计数据和 LLM 分析 | |
| 91 | +2. THE ai_analysis_report 表 SHALL 包含销量分析模块的 JSON 字段(sales_analysis)存储统计数据和 LLM 分析 | |
| 92 | +3. THE ai_analysis_report 表 SHALL 包含健康度模块的 JSON 字段(inventory_health)存储统计数据和 LLM 分析 | |
| 93 | +4. THE ai_analysis_report 表 SHALL 包含补货建议模块的 JSON 字段(replenishment_summary)存储统计数据和 LLM 分析 | |
| 94 | +5. THE ai_analysis_report 表 SHALL 保留 task_no、group_id、dealer_grouping_id 等基础字段和 LLM 元数据字段 | |
| 95 | + | |
| 96 | +### 需求 7:LLM 提示词重构与并发生成 | |
| 97 | + | |
| 98 | +**用户故事:** 作为开发者,我希望使用 LangGraph 动态节点并发生成各板块的 LLM 分析,提高报告生成效率。 | |
| 99 | + | |
| 100 | +#### 验收标准 | |
| 101 | + | |
| 102 | +1. WHEN Report_Generator 生成报告, THE Report_Generator SHALL 使用 LangGraph 动态节点并发调用 LLM 生成四个板块的分析文本 | |
| 103 | +2. WHEN 每个板块节点调用 LLM, THE 节点 SHALL 在提示词中仅包含该板块相关的统计数据 | |
| 104 | +3. WHEN LLM 生成分析, THE LLM 输出 SHALL 为合法的 JSON 对象 | |
| 105 | +4. WHEN 所有板块分析完成, THE Report_Generator SHALL 汇总四个板块的统计数据和 LLM 分析结果为一个完整报告 | |
| 106 | +5. IF 某个板块的 LLM 调用失败, THEN THE Report_Generator SHALL 记录错误日志,该板块分析文本置为错误提示,其他板块继续正常生成 | |
| 107 | + | |
| 108 | +### 需求 8:API 接口重构 | |
| 109 | + | |
| 110 | +**用户故事:** 作为前端开发者,我希望 API 返回新结构的报告数据,以便前端能够渲染四大板块。 | |
| 111 | + | |
| 112 | +#### 验收标准 | |
| 113 | + | |
| 114 | +1. WHEN 前端请求分析报告, THE Report_API SHALL 返回包含四个模块(inventory_overview、sales_analysis、inventory_health、replenishment_summary)的 JSON 响应 | |
| 115 | +2. WHEN 报告中包含 JSON 字符串字段, THE Report_API SHALL 将其解析为 JSON 对象后返回 | |
| 116 | +3. IF 指定 task_no 无报告数据, THEN THE Report_API SHALL 返回 null | |
| 117 | + | |
| 118 | +### 需求 9:前端报告页面重构 | |
| 119 | + | |
| 120 | +**用户故事:** 作为采购决策者,我希望在前端看到结构清晰的四大板块报告,包含统计数据卡片、图表和 LLM 分析文本。 | |
| 121 | + | |
| 122 | +#### 验收标准 | |
| 123 | + | |
| 124 | +1. WHEN Report_UI 渲染报告, THE Report_UI SHALL 按库存概览、销量分析、健康度、补货建议的顺序展示四个板块 | |
| 125 | +2. WHEN Report_UI 渲染库存概览板块, THE Report_UI SHALL 展示总库存数量、总金额(资金占用)、库销比的统计卡片,以及三项构成明细(在库未锁/在途/计划数)和 LLM 分析文本 | |
| 126 | +3. WHEN Report_UI 渲染销量分析板块, THE Report_UI SHALL 展示月均销量总量、销量构成明细(90天出库/未关单已锁/未关单出库/订件)和 LLM 分析文本 | |
| 127 | +4. WHEN Report_UI 渲染健康度板块, THE Report_UI SHALL 展示各类型配件的数量/金额占比图表和 LLM 分析文本 | |
| 128 | +5. WHEN Report_UI 渲染补货建议板块, THE Report_UI SHALL 展示各优先级的配件数/金额统计和 LLM 分析文本 | |
| 129 | +6. WHEN Report_UI 渲染健康度图表, THE Report_UI SHALL 使用 Chart.js 生成饼图或环形图展示数量占比和金额占比 | |
| 130 | + | |
| 131 | +### 需求 10:数据模型与写入服务重构 | |
| 132 | + | |
| 133 | +**用户故事:** 作为开发者,我希望 Python 数据模型和数据库写入服务能够适配新的报告结构。 | |
| 134 | + | |
| 135 | +#### 验收标准 | |
| 136 | + | |
| 137 | +1. THE AnalysisReport 数据模型 SHALL 包含 inventory_overview、sales_analysis、inventory_health、replenishment_summary 四个字典字段 | |
| 138 | +2. WHEN Result_Writer 保存报告, THE Result_Writer SHALL 将四个模块的字典数据序列化为 JSON 字符串写入对应数据库字段 | |
| 139 | +3. THE AnalysisReport 数据模型 SHALL 提供 to_dict 方法将报告转换为可序列化的字典 | ... | ... |
.kiro/specs/refactor-analysis-report/tasks.md
0 → 100644
| 1 | +# 实现计划:重构分析报告功能 | |
| 2 | + | |
| 3 | +## 概述 | |
| 4 | + | |
| 5 | +全栈重构分析报告功能,从清理现有代码开始,依次重建数据库表、数据模型、统计计算、LLM 提示词、并发节点、API 接口、前端页面。使用 Python + LangGraph + FastAPI + Chart.js 技术栈。 | |
| 6 | + | |
| 7 | +## 任务 | |
| 8 | + | |
| 9 | +- [x] 1. 清理现有分析报告专属文件 | |
| 10 | + - [x] 1.1 清空 `src/fw_pms_ai/models/analysis_report.py`,仅保留文件头注释和必要 import,移除现有 AnalysisReport dataclass 的所有字段和方法 | |
| 11 | + - [x] 1.2 清空 `src/fw_pms_ai/agent/analysis_report_node.py`,仅保留文件头注释和必要 import,移除 `_load_prompt`、`_calculate_suggestion_stats`、`_calculate_risk_stats`、`_build_suggestion_summary`、`generate_analysis_report_node` 等所有函数 | |
| 12 | + - [x] 1.3 清空 `prompts/analysis_report.md` 的内容 | |
| 13 | + - 注意:此步骤仅清理分析报告专属文件,不修改 `tasks.py`、`app.js`、`result_writer.py` 等共享文件(这些文件中的报告相关代码将在后续重建步骤中以替换方式更新) | |
| 14 | + - _Requirements: 1.1, 1.2, 1.3, 1.5_ | |
| 15 | + | |
| 16 | +- [x] 2. 重建数据库表和数据模型 | |
| 17 | + - [x] 2.1 重写 `sql/migrate_analysis_report.sql`,创建新表结构(inventory_overview、sales_analysis、inventory_health、replenishment_summary 四个 JSON 字段 + LLM 元数据字段) | |
| 18 | + - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5_ | |
| 19 | + - [x] 2.2 在已清空的 `src/fw_pms_ai/models/analysis_report.py` 中编写新的 AnalysisReport dataclass(四个 Dict 字段 + to_dict 方法) | |
| 20 | + - _Requirements: 10.1, 10.3_ | |
| 21 | + - [x] 2.3 替换 `src/fw_pms_ai/services/result_writer.py` 中的 `save_analysis_report` 方法为新版本,适配新表结构(四个 JSON 字段序列化写入),不修改该文件中的其他方法 | |
| 22 | + - _Requirements: 10.2_ | |
| 23 | + - [ ]* 2.4 编写属性测试:报告数据模型序列化 round-trip | |
| 24 | + - **Property 5: 报告数据模型序列化 round-trip** | |
| 25 | + - **Validates: Requirements 10.2, 10.3** | |
| 26 | + | |
| 27 | +- [x] 3. 实现四大板块统计计算函数 | |
| 28 | + - [x] 3.1 在已清空的 `src/fw_pms_ai/agent/analysis_report_node.py` 中实现 `calculate_inventory_overview(part_ratios)` 函数 | |
| 29 | + - 计算有效库存(valid_storage_cnt = in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt)总数量/总金额、三项构成明细、库销比 | |
| 30 | + - _Requirements: 2.1, 2.2, 2.3, 2.5_ | |
| 31 | + - [x] 3.2 实现 `calculate_sales_analysis(part_ratios)` 函数 | |
| 32 | + - 计算月均销量总数量/总金额、各组成部分(out_stock_cnt/storage_locked_cnt/out_stock_ongoing_cnt/buy_cnt)总量、有销量/无销量配件数 | |
| 33 | + - _Requirements: 3.1, 3.2, 3.4_ | |
| 34 | + - [x] 3.3 实现 `calculate_inventory_health(part_ratios)` 函数 | |
| 35 | + - 将配件分类为缺货/呆滞/低频/正常,计算各类型数量/金额/百分比,生成 chart_data | |
| 36 | + - _Requirements: 4.1, 4.2_ | |
| 37 | + - [x] 3.4 实现 `calculate_replenishment_summary(part_results)` 函数 | |
| 38 | + - 按优先级(1=急需/2=建议/3=可选)统计配件种类数和金额 | |
| 39 | + - _Requirements: 5.1, 5.2_ | |
| 40 | + - [ ]* 3.5 编写属性测试:库存概览统计一致性 | |
| 41 | + - **Property 1: 库存概览统计一致性** | |
| 42 | + - **Validates: Requirements 2.1, 2.2, 2.3** | |
| 43 | + - [ ]* 3.6 编写属性测试:销量分析统计一致性 | |
| 44 | + - **Property 2: 销量分析统计一致性** | |
| 45 | + - **Validates: Requirements 3.1, 3.2, 3.4** | |
| 46 | + - [ ]* 3.7 编写属性测试:健康度分类完备性与一致性 | |
| 47 | + - **Property 3: 健康度分类完备性与一致性** | |
| 48 | + - **Validates: Requirements 4.1, 4.2** | |
| 49 | + - [ ]* 3.8 编写属性测试:补货建议统计一致性 | |
| 50 | + - **Property 4: 补货建议统计一致性** | |
| 51 | + - **Validates: Requirements 5.1, 5.2** | |
| 52 | + | |
| 53 | +- [ ] 4. Checkpoint - 确保统计计算函数和属性测试通过 | |
| 54 | + - 确保所有测试通过,如有问题请向用户确认。 | |
| 55 | + | |
| 56 | +- [x] 5. 创建 LLM 提示词文件 | |
| 57 | + - [x] 5.1 创建 `prompts/report_inventory_overview.md`,包含库存概览分析提示词(资金占用评估、库销比诊断、库存结构建议),确保分析专业且有实际决策价值 | |
| 58 | + - _Requirements: 7.2_ | |
| 59 | + - [x] 5.2 创建 `prompts/report_sales_analysis.md`,包含销量分析提示词(销量构成解读、销售活跃度、需求趋势判断),确保分析专业且有实际决策价值 | |
| 60 | + - _Requirements: 7.2_ | |
| 61 | + - [x] 5.3 创建 `prompts/report_inventory_health.md`,包含健康度分析提示词(健康度评分、问题诊断、资金释放机会、改善优先级),确保分析专业且有实际决策价值 | |
| 62 | + - _Requirements: 7.2_ | |
| 63 | + - [x] 5.4 创建 `prompts/report_replenishment_summary.md`,包含补货建议分析提示词(紧迫度评估、资金分配建议、执行节奏、风险提示),确保分析专业且有实际决策价值 | |
| 64 | + - _Requirements: 7.2_ | |
| 65 | + - [x] 5.5 删除旧的 `prompts/analysis_report.md` 文件(已在步骤1.3清空,此处正式删除) | |
| 66 | + - _Requirements: 1.3_ | |
| 67 | + | |
| 68 | +- [x] 6. 实现 LangGraph 并发 LLM 分析节点 | |
| 69 | + - [x] 6.1 在 `src/fw_pms_ai/agent/analysis_report_node.py` 中实现四个 LLM 分析函数(`llm_analyze_inventory_overview`、`llm_analyze_sales`、`llm_analyze_inventory_health`、`llm_analyze_replenishment_summary`),每个函数加载对应提示词、填充统计数据、调用 LLM、解析 JSON 响应 | |
| 70 | + - _Requirements: 7.2, 7.3_ | |
| 71 | + - [x] 6.2 使用 LangGraph StateGraph 构建并发子图,四个 LLM 节点从 START fan-out 并发执行,结果 fan-in 汇总 | |
| 72 | + - _Requirements: 7.1, 7.4_ | |
| 73 | + - [x] 6.3 实现新的 `generate_analysis_report_node(state)` 主函数,串联统计计算 → 并发 LLM 分析 → 汇总报告 → 写入数据库,单板块 LLM 失败不影响其他板块 | |
| 74 | + - _Requirements: 7.4, 7.5_ | |
| 75 | + - [x] 6.4 确认 `src/fw_pms_ai/agent/replenishment.py` 中的工作流引用无需修改(`generate_analysis_report_node` 函数签名保持不变) | |
| 76 | + - _Requirements: 7.1_ | |
| 77 | + | |
| 78 | +- [ ] 7. Checkpoint - 确保后端报告生成流程完整 | |
| 79 | + - 确保所有测试通过,如有问题请向用户确认。 | |
| 80 | + | |
| 81 | +- [x] 8. 重建 API 接口 | |
| 82 | + - [x] 8.1 替换 `src/fw_pms_ai/api/routes/tasks.py` 中的 `AnalysisReportResponse` 模型为新版本(inventory_overview、sales_analysis、inventory_health、replenishment_summary 四个 Dict 字段),不修改该文件中的其他模型和端点 | |
| 83 | + - _Requirements: 8.1_ | |
| 84 | + - [x] 8.2 替换 `get_analysis_report` 端点实现,从新表读取数据并解析 JSON 字段,不修改该文件中的其他端点 | |
| 85 | + - _Requirements: 8.1, 8.2, 8.3_ | |
| 86 | + | |
| 87 | +- [x] 9. 重建前端报告页面 | |
| 88 | + - [x] 9.1 在 `ui/index.html` 中引入 Chart.js CDN | |
| 89 | + - _Requirements: 9.6_ | |
| 90 | + - [x] 9.2 替换 `ui/js/app.js` 中的 `renderReportTab` 方法为新版本,渲染四大板块框架,同时移除旧的 `renderOverallAssessment`、`renderRiskAlerts`、`renderStrategy`、`renderExpectedImpact` 方法,不修改该文件中的其他方法 | |
| 91 | + - _Requirements: 9.1, 1.4_ | |
| 92 | + - [x] 9.3 实现 `renderInventoryOverview` 方法,渲染库存概览板块(统计卡片 + 五项构成明细 + LLM 分析文本) | |
| 93 | + - _Requirements: 9.2_ | |
| 94 | + - [x] 9.4 实现 `renderSalesAnalysis` 方法,渲染销量分析板块(统计卡片 + 构成明细 + LLM 分析文本) | |
| 95 | + - _Requirements: 9.3_ | |
| 96 | + - [x] 9.5 实现 `renderInventoryHealth` 方法,渲染健康度板块(统计卡片 + Chart.js 环形图 + LLM 分析文本) | |
| 97 | + - _Requirements: 9.4, 9.6_ | |
| 98 | + - [x] 9.6 实现 `renderReplenishmentSummary` 方法,渲染补货建议板块(优先级统计表 + LLM 分析文本) | |
| 99 | + - _Requirements: 9.5_ | |
| 100 | + - [x] 9.7 在 `ui/css/style.css` 中添加新报告板块的样式(统计卡片、图表容器、分析文本区域),不修改现有样式 | |
| 101 | + - _Requirements: 9.1_ | |
| 102 | + | |
| 103 | +- [ ] 10. Final Checkpoint - 全栈集成验证 | |
| 104 | + - 确保所有测试通过,如有问题请向用户确认。 | |
| 105 | + | |
| 106 | +## 安全约束:不影响补货建议功能 | |
| 107 | + | |
| 108 | +本次重构严格限定在分析报告模块范围内,以下补货建议相关代码禁止修改: | |
| 109 | + | |
| 110 | +- `src/fw_pms_ai/agent/nodes.py` — 补货建议核心节点(fetch_part_ratio、sql_agent、allocate_budget) | |
| 111 | +- `src/fw_pms_ai/agent/replenishment.py` — 补货建议工作流(仅确认无需修改,不做任何改动) | |
| 112 | +- `src/fw_pms_ai/agent/sql_agent/` — SQL Agent 目录 | |
| 113 | +- `src/fw_pms_ai/models/part_ratio.py` — 配件库销比模型 | |
| 114 | +- `src/fw_pms_ai/models/replenishment_*.py` — 补货建议相关模型 | |
| 115 | +- `result_writer.py` 中的 `save_task`、`update_task`、`save_details`、`save_part_summaries`、`save_execution_log` 等方法 | |
| 116 | +- `tasks.py` 中的任务列表、任务详情、配件明细、配件汇总、执行日志等端点 | |
| 117 | +- `app.js` 中的任务列表、任务详情、配件明细等渲染方法 | |
| 118 | +- `prompts/` 中的 `part_shop_analysis*.md`、`suggestion*.md`、`sql_agent.md` 等提示词文件 | |
| 119 | + | |
| 120 | +## 备注 | |
| 121 | + | |
| 122 | +- 标记 `*` 的任务为可选任务,可跳过以加快 MVP 进度 | |
| 123 | +- 每个任务引用了具体的需求编号以保证可追溯性 | |
| 124 | +- 属性测试使用 `hypothesis` 库,每个属性至少 100 次迭代 | |
| 125 | +- Checkpoint 用于阶段性验证,确保增量正确 | |
| 126 | +- 共享文件(tasks.py、app.js、result_writer.py、style.css)中的修改均采用"替换特定函数/类"方式,明确不修改其他部分 | ... | ... |
README.md
| 1 | 1 | # fw-pms-ai |
| 2 | 2 | |
| 3 | -AI 配件系统 - 基于 Python + LangChain + LangGraph | |
| 3 | +fw-pms 配件管理系统 AI 扩展平台 — 基于 Python + LangChain + LangGraph | |
| 4 | 4 | |
| 5 | 5 | ## 项目简介 |
| 6 | 6 | |
| 7 | -本项目是 `fw-pms` 的 AI 扩展模块,使用大语言模型 (LLM) 和 Agent 技术,为配件管理系统提供智能化的补货建议能力。 | |
| 7 | +本项目是 `fw-pms` 配件管理系统的 **AI 能力扩展平台**,使用大语言模型 (LLM) 和 Agent 技术,为配件业务提供多种智能化功能。 | |
| 8 | 8 | |
| 9 | -## 核心技术 | |
| 9 | +### 功能模块 | |
| 10 | 10 | |
| 11 | -### LangChain + LangGraph | |
| 12 | - | |
| 13 | -| 技术 | 作用 | | |
| 14 | -|------|------| | |
| 15 | -| **LangChain** | LLM 框架,提供模型抽象、Prompt 管理、消息格式化 | | |
| 16 | -| **LangGraph** | Agent 工作流编排,管理状态机、定义节点和边、支持条件分支 | | |
| 17 | -| **SQL Agent** | 自定义 Text-to-SQL 实现,支持错误重试和 LLM 数据分析 | | |
| 11 | +| 状态 | 模块 | 说明 | | |
| 12 | +|------|------|------| | |
| 13 | +| ✅ 已实现 | **智能补货建议** | 分析库销比数据,LLM 逐配件分析各门店补货需求,生成结构化建议 | | |
| 14 | +| ✅ 已实现 | **分析报告** | 四大板块(库存概览/销量分析/健康度/补货建议)并发 LLM 分析 | | |
| 15 | +| 🚧 规划中 | **需求预测** | 基于历史销量预测未来配件需求 | | |
| 16 | +| 🚧 规划中 | **异常检测** | 识别库存和销量数据异常 | | |
| 17 | +| 🚧 规划中 | **智能定价** | AI 辅助配件定价建议 | | |
| 18 | + | |
| 19 | +## 技术栈 | |
| 20 | + | |
| 21 | +| 组件 | 技术 | 版本要求 | | |
| 22 | +|------|------|---------| | |
| 23 | +| 编程语言 | Python | ≥ 3.11 | | |
| 24 | +| Agent 框架 | LangChain + LangGraph | LangChain ≥ 0.3, LangGraph ≥ 0.2 | | |
| 25 | +| LLM 集成 | 智谱 GLM / 豆包 / OpenAI 兼容 / Anthropic 兼容 | — | | |
| 26 | +| Web API | FastAPI + Uvicorn | FastAPI ≥ 0.109 | | |
| 27 | +| 数据库 | MySQL (mysql-connector-python + SQLAlchemy) | SQLAlchemy ≥ 2.0 | | |
| 28 | +| 任务调度 | APScheduler | ≥ 3.10 | | |
| 29 | +| 配置管理 | Pydantic Settings + python-dotenv | Pydantic ≥ 2.0 | | |
| 30 | +| HTTP 客户端 | httpx | ≥ 0.25 | | |
| 31 | +| 重试机制 | tenacity | ≥ 8.0 | | |
| 32 | + | |
| 33 | +## 系统架构 | |
| 18 | 34 | |
| 19 | 35 | ```mermaid |
| 20 | -graph LR | |
| 21 | - A[用户请求] --> B[LangGraph Agent] | |
| 22 | - B --> C[FetchPartRatio] | |
| 23 | - C --> D[SQLAgent<br/>LLM分析] | |
| 24 | - D --> E[AllocateBudget] | |
| 25 | - E --> F[AnalysisReport] | |
| 26 | - F --> G[SaveResult] | |
| 36 | +flowchart TB | |
| 37 | + subgraph Scheduler ["定时调度 (APScheduler)"] | |
| 38 | + S[每日凌晨触发] | |
| 39 | + end | |
| 40 | + | |
| 41 | + subgraph API ["FastAPI API 层"] | |
| 42 | + A[/tasks, details, logs, reports/] | |
| 43 | + end | |
| 44 | + | |
| 45 | + subgraph Agent ["LangGraph 工作流"] | |
| 46 | + direction TB | |
| 47 | + B[1. fetch_part_ratio] --> C[2. sql_agent] | |
| 48 | + C --> D{需要重试?} | |
| 49 | + D -->|是| C | |
| 50 | + D -->|否| E[3. allocate_budget] | |
| 51 | + E --> F[4. generate_analysis_report] | |
| 52 | + F --> G[END] | |
| 53 | + end | |
| 54 | + | |
| 55 | + subgraph ReportSubgraph ["分析报告并发子图"] | |
| 56 | + direction LR | |
| 57 | + R1[库存概览 LLM] & R2[销量分析 LLM] & R3[健康度 LLM] & R4[补货建议 LLM] | |
| 58 | + end | |
| 59 | + | |
| 60 | + subgraph Services ["业务服务层"] | |
| 61 | + DS[DataService] | |
| 62 | + RW[ResultWriter] | |
| 63 | + RP[Repository] | |
| 64 | + end | |
| 65 | + | |
| 66 | + subgraph LLM ["LLM 适配层"] | |
| 67 | + L1[GLMClient] | |
| 68 | + L2[DoubaoClient] | |
| 69 | + L3[OpenAICompatClient] | |
| 70 | + L4[AnthropicCompatClient] | |
| 71 | + end | |
| 72 | + | |
| 73 | + subgraph DB ["数据存储"] | |
| 74 | + MySQL[(MySQL)] | |
| 75 | + end | |
| 76 | + | |
| 77 | + S --> Agent | |
| 78 | + A --> Services | |
| 79 | + B --> DS | |
| 80 | + C --> LLM | |
| 81 | + F --> ReportSubgraph | |
| 82 | + ReportSubgraph --> LLM | |
| 83 | + E --> RW | |
| 84 | + DS --> MySQL | |
| 85 | + RW --> MySQL | |
| 86 | + RP --> MySQL | |
| 27 | 87 | ``` |
| 28 | 88 | |
| 29 | 89 | 详细架构图见 [docs/architecture.md](docs/architecture.md) |
| 30 | 90 | |
| 31 | -## 功能模块 | |
| 91 | +> 平台采用模块化设计,新增 AI 功能模块只需添加对应的 Agent 工作流节点和提示词文件。 | |
| 32 | 92 | |
| 33 | -### ✅ 已实现 | |
| 93 | +## 补货建议工作流 | |
| 34 | 94 | |
| 35 | -| 模块 | 功能 | 说明 | | |
| 36 | -|------|------|------| | |
| 37 | -| **SQL Agent** | LLM 分析 | 直接分析 part_ratio 数据生成补货建议 | | |
| 38 | -| **补货分配** | Replenishment | 转换 LLM 建议为补货明细 | | |
| 95 | +补货建议模块的核心是一个 **4 节点 LangGraph 工作流**,按顺序执行: | |
| 39 | 96 | |
| 40 | -### 🚧 计划中 | |
| 97 | +| 序号 | 节点 | 功能 | 说明 | | |
| 98 | +|------|------|------|------| | |
| 99 | +| 1 | `fetch_part_ratio` | 获取库销比数据 | 通过 dealer_grouping_id 从 part_ratio 表查询配件数据 | | |
| 100 | +| 2 | `sql_agent` | LLM 分析 + 建议生成 | 按 part_code 分组,逐配件分析各门店补货需求,支持错误重试 | | |
| 101 | +| 3 | `allocate_budget` | 转换补货明细 | 将 LLM 建议转换为结构化的补货明细记录 | | |
| 102 | +| 4 | `generate_analysis_report` | 生成分析报告 | 统计计算 + 4 路并发 LLM 分析生成结构化报告 | | |
| 41 | 103 | |
| 42 | -| 模块 | 功能 | | |
| 43 | -|------|------| | |
| 44 | -| 预测引擎 | 基于历史销量预测未来需求 | | |
| 45 | -| 异常检测 | 识别数据异常 | | |
| 104 | +### 分析报告四大板块 | |
| 105 | + | |
| 106 | +| 板块 | 统计计算 | LLM 分析 | | |
| 107 | +|------|---------|---------| | |
| 108 | +| **库存概览** | 有效库存、资金占用、配件总数 | 库存状况综合评价 | | |
| 109 | +| **销量分析** | 月均销量、出库频次、销售趋势 | 销售趋势洞察 | | |
| 110 | +| **库存健康度** | 缺货/呆滞/低频/正常分类统计 | 健康度风险提示 | | |
| 111 | +| **补货建议汇总** | 按优先级分类统计补货数量和金额 | 补货策略建议 | | |
| 112 | + | |
| 113 | +> 四个 LLM 分析节点使用 LangGraph 子图 **并发执行**,单板块失败不影响其他板块。 | |
| 114 | + | |
| 115 | +## 业务术语 | |
| 116 | + | |
| 117 | +| 术语 | 定义 | 处理方式 | | |
| 118 | +|------|------|---------| | |
| 119 | +| **呆滞件** | 有效库存 > 0,90天出库数 = 0 | 不做计划 | | |
| 120 | +| **低频件** | 月均销量 < 1 或 出库次数 < 3 或 出库间隔 ≥ 30天 | 不做计划 | | |
| 121 | +| **缺货件** | 有效库存 = 0,月均销量 ≥ 1 | 需要补货 | | |
| 122 | +| **正常件** | 不属于以上三类 | 按需补货 | | |
| 46 | 123 | |
| 47 | 124 | ## 项目结构 |
| 48 | 125 | |
| 49 | 126 | ``` |
| 50 | 127 | fw-pms-ai/ |
| 51 | 128 | ├── src/fw_pms_ai/ |
| 52 | -│ ├── agent/ # LangGraph Agent | |
| 53 | -│ │ ├── state.py # Agent 状态定义 | |
| 54 | -│ │ ├── nodes.py # 工作流节点 | |
| 55 | -│ │ ├── sql_agent.py # SQL Agent(Text-to-SQL + 建议生成) | |
| 56 | -│ │ └── replenishment.py | |
| 57 | -│ ├── api/ # FastAPI 接口 | |
| 58 | -│ │ ├── app.py # 应用入口 | |
| 59 | -│ │ └── routes/ # 路由模块 | |
| 60 | -│ ├── config/ # 配置管理 | |
| 61 | -│ ├── llm/ # LLM 集成 | |
| 62 | -│ │ ├── base.py # 抽象基类 | |
| 63 | -│ │ ├── glm.py # 智谱 GLM | |
| 64 | -│ │ ├── doubao.py # 豆包 | |
| 65 | -│ │ ├── openai_compat.py | |
| 66 | -│ │ └── anthropic_compat.py | |
| 67 | -│ ├── models/ # 数据模型 | |
| 68 | -│ │ ├── task.py # 任务和明细模型 | |
| 69 | -│ │ ├── execution_log.py # 执行日志模型 | |
| 70 | -│ │ ├── part_ratio.py # 库销比模型 | |
| 71 | -│ │ ├── part_summary.py # 配件汇总模型 | |
| 72 | -│ │ ├── sql_result.py # SQL执行结果模型 | |
| 73 | -│ │ └── suggestion.py # 补货建议模型 | |
| 74 | -│ ├── services/ # 业务服务 | |
| 75 | -│ │ ├── db.py # 数据库连接 | |
| 76 | -│ │ ├── data_service.py # 数据查询服务 | |
| 77 | -│ │ └── result_writer.py # 结果写入服务 | |
| 78 | -│ ├── scheduler/ # 定时任务 | |
| 79 | -│ └── main.py | |
| 80 | -├── prompts/ # AI Prompt 文件 | |
| 81 | -│ ├── sql_agent.md # SQL Agent 系统提示词 | |
| 82 | -│ ├── suggestion.md # 补货建议提示词 | |
| 83 | -│ ├── suggestion_system.md | |
| 84 | -│ ├── part_shop_analysis.md | |
| 85 | -│ └── part_shop_analysis_system.md | |
| 86 | -├── ui/ # 前端静态文件 | |
| 87 | -├── sql/ # 数据库迁移脚本 | |
| 88 | -├── pyproject.toml | |
| 129 | +│ ├── main.py # 应用入口 | |
| 130 | +│ ├── agent/ # LangGraph Agent 工作流 | |
| 131 | +│ │ ├── state.py # Agent 状态定义 (TypedDict + Annotated reducer) | |
| 132 | +│ │ ├── nodes.py # 工作流节点 (fetch/sql_agent/allocate) | |
| 133 | +│ │ ├── analysis_report_node.py # 分析报告节点 (统计计算 + 并发 LLM 子图) | |
| 134 | +│ │ ├── replenishment.py # ReplenishmentAgent 主类 (构建图 + 运行) | |
| 135 | +│ │ └── sql_agent/ # SQL Agent 子包 | |
| 136 | +│ │ ├── agent.py # SQLAgent 主类 | |
| 137 | +│ │ ├── executor.py # SQL 执行器 | |
| 138 | +│ │ ├── analyzer.py # 配件分析器 (逐配件 LLM 分析) | |
| 139 | +│ │ └── prompts.py # 提示词加载 | |
| 140 | +│ ├── api/ # FastAPI REST API | |
| 141 | +│ │ ├── app.py # FastAPI 应用 (CORS + 静态文件 + 路由) | |
| 142 | +│ │ └── routes/ | |
| 143 | +│ │ └── tasks.py # 任务/明细/日志/汇总/报告 API | |
| 144 | +│ ├── config/ | |
| 145 | +│ │ └── settings.py # Pydantic Settings 配置管理 | |
| 146 | +│ ├── llm/ # LLM 适配层 | |
| 147 | +│ │ ├── base.py # BaseLLMClient 抽象基类 | |
| 148 | +│ │ ├── glm.py # 智谱 GLM 客户端 | |
| 149 | +│ │ ├── doubao.py # 豆包客户端 | |
| 150 | +│ │ ├── openai_compat.py # OpenAI 兼容客户端 (火山引擎等) | |
| 151 | +│ │ └── anthropic_compat.py # Anthropic 兼容客户端 | |
| 152 | +│ ├── models/ # 数据模型 (Pydantic) | |
| 153 | +│ │ ├── task.py # 任务模型 + 状态枚举 | |
| 154 | +│ │ ├── suggestion.py # 补货建议模型 | |
| 155 | +│ │ ├── part_ratio.py # 配件库销比模型 | |
| 156 | +│ │ ├── part_summary.py # 配件汇总模型 | |
| 157 | +│ │ ├── analysis_report.py # 分析报告模型 | |
| 158 | +│ │ ├── execution_log.py # 执行日志模型 | |
| 159 | +│ │ └── sql_result.py # SQL 执行结果模型 | |
| 160 | +│ ├── services/ # 业务服务层 | |
| 161 | +│ │ ├── db.py # 数据库连接管理 | |
| 162 | +│ │ ├── data_service.py # 数据查询服务 | |
| 163 | +│ │ ├── result_writer.py # 结果写入服务 | |
| 164 | +│ │ └── repository/ # 仓库模式 | |
| 165 | +│ │ ├── task_repo.py # 任务仓库 | |
| 166 | +│ │ ├── detail_repo.py # 明细仓库 | |
| 167 | +│ │ └── log_repo.py # 日志仓库 | |
| 168 | +│ └── scheduler/ | |
| 169 | +│ └── tasks.py # APScheduler 定时任务 + CLI 参数解析 | |
| 170 | +├── prompts/ # AI 提示词文件 | |
| 171 | +│ ├── sql_agent.md # SQL Agent 系统提示词 | |
| 172 | +│ ├── suggestion.md # 补货建议生成提示词 | |
| 173 | +│ ├── suggestion_system.md # 补货建议系统提示词 | |
| 174 | +│ ├── part_shop_analysis.md # 配件门店分析提示词 | |
| 175 | +│ ├── part_shop_analysis_system.md | |
| 176 | +│ ├── report_inventory_overview.md # 分析报告-库存概览提示词 | |
| 177 | +│ ├── report_sales_analysis.md # 分析报告-销量分析提示词 | |
| 178 | +│ ├── report_inventory_health.md # 分析报告-库存健康度提示词 | |
| 179 | +│ └── report_replenishment_summary.md # 分析报告-补货建议提示词 | |
| 180 | +├── ui/ # 前端静态文件 | |
| 181 | +│ ├── index.html # 主页面 | |
| 182 | +│ ├── css/ # 样式 | |
| 183 | +│ ├── js/ # JavaScript | |
| 184 | +│ └── merchant-report/ # 商家组合报告页面 | |
| 185 | +├── sql/ # 数据库脚本 | |
| 186 | +│ ├── init.sql # 初始化建表 (4张表) | |
| 187 | +│ └── migrate_analysis_report.sql # 分析报告表迁移 | |
| 188 | +├── docs/ # 文档 | |
| 189 | +│ ├── architecture.md # 系统架构文档 | |
| 190 | +│ └── 商家组合维度分析需求设计.md | |
| 191 | +├── pyproject.toml # 项目配置 (hatchling 构建) | |
| 192 | +├── .env # 环境变量 | |
| 89 | 193 | └── README.md |
| 90 | 194 | ``` |
| 91 | 195 | |
| 196 | +## 数据表说明 | |
| 92 | 197 | |
| 93 | -## 工作流程 | |
| 198 | +| 表名 | 说明 | SQL 文件 | | |
| 199 | +|------|------|---------| | |
| 200 | +| `part_ratio` | 配件库销比数据(来源表,只读) | — | | |
| 201 | +| `ai_replenishment_task` | 任务记录 | init.sql | | |
| 202 | +| `ai_replenishment_detail` | 配件级别补货建议明细 | init.sql | | |
| 203 | +| `ai_replenishment_part_summary` | 配件汇总表(按配件编码聚合) | init.sql | | |
| 204 | +| `ai_task_execution_log` | 任务执行日志(每步骤详情) | init.sql | | |
| 205 | +| `ai_analysis_report` | 结构化分析报告(四大板块 JSON) | migrate_analysis_report.sql | | |
| 94 | 206 | |
| 95 | -``` | |
| 96 | -1. FetchPartRatio - 从 part_ratio 表获取库销比数据 | |
| 97 | -2. SQLAgent - LLM 分析数据,生成补货建议 | |
| 98 | -3. AllocateBudget - 转换建议为补货明细 | |
| 99 | -4. AnalysisReport - 生成分析报告(风险评估、行动方案) | |
| 100 | -5. SaveResult - 写入数据库 | |
| 101 | -``` | |
| 207 | +## API 接口 | |
| 102 | 208 | |
| 103 | -### 业务术语 | |
| 209 | +基于 FastAPI 提供 REST API,支持前端 UI 对接。 | |
| 104 | 210 | |
| 105 | -| 术语 | 定义 | 处理 | | |
| 211 | +| 方法 | 路径 | 说明 | | |
| 106 | 212 | |------|------|------| |
| 107 | -| **呆滞件** | 有库存,90天无销量 | 不做计划 | | |
| 108 | -| **低频件** | 无库存,月均销量<1 | 不做计划 | | |
| 109 | -| **缺货件** | 无库存,月均销量≥1 | 需要补货 | | |
| 213 | +| GET | `/api/tasks` | 任务列表(分页、状态/组合/日期筛选) | | |
| 214 | +| GET | `/api/tasks/{task_no}` | 任务详情 | | |
| 215 | +| GET | `/api/tasks/{task_no}/details` | 配件建议明细(分页、排序、搜索) | | |
| 216 | +| GET | `/api/tasks/{task_no}/logs` | 执行日志 | | |
| 217 | +| GET | `/api/tasks/{task_no}/part-summaries` | 配件汇总列表(分页、优先级筛选) | | |
| 218 | +| GET | `/api/tasks/{task_no}/parts/{part_code}/shops` | 指定配件的门店明细 | | |
| 219 | +| GET | `/api/tasks/{task_no}/analysis-report` | 分析报告 | | |
| 220 | +| GET | `/health` | 健康检查 | | |
| 221 | +| GET | `/` | 主页面(静态文件) | | |
| 110 | 222 | |
| 111 | -## 数据表说明 | |
| 223 | +运行后访问 `/docs` 查看 Swagger 文档。 | |
| 112 | 224 | |
| 113 | -| 表名 | 说明 | | |
| 114 | -|------|------| | |
| 115 | -| `part_ratio` | 配件库销比数据(来源表) | | |
| 116 | -| `ai_replenishment_task` | 任务记录 | | |
| 117 | -| `ai_replenishment_detail` | 配件级别补货建议 | | |
| 118 | -| `ai_replenishment_part_summary` | 配件汇总表 | | |
| 119 | -| `ai_task_execution_log` | 任务执行日志 | | |
| 225 | +## LLM 集成 | |
| 226 | + | |
| 227 | +支持 4 种 LLM 客户端,通过环境变量自动选择: | |
| 228 | + | |
| 229 | +| 客户端 | 环境变量 | 说明 | | |
| 230 | +|--------|---------|------| | |
| 231 | +| `OpenAICompatClient` | `OPENAI_COMPAT_API_KEY` | OpenAI 兼容接口(火山引擎、智谱等) | | |
| 232 | +| `AnthropicCompatClient` | `ANTHROPIC_API_KEY` | Anthropic 兼容接口 | | |
| 233 | +| `GLMClient` | `GLM_API_KEY` | 智谱 GLM 原生 SDK | | |
| 234 | +| `DoubaoClient` | `DOUBAO_API_KEY` | 豆包 | | |
| 235 | + | |
| 236 | +优先级:`OpenAI Compat` > `Anthropic Compat` > `GLM` > `Doubao` | |
| 120 | 237 | |
| 121 | 238 | ## 快速开始 |
| 122 | 239 | |
| ... | ... | @@ -131,39 +248,53 @@ pip install -e . |
| 131 | 248 | |
| 132 | 249 | ```bash |
| 133 | 250 | cp .env.example .env |
| 251 | +# 编辑 .env 填写以下配置 | |
| 134 | 252 | ``` |
| 135 | 253 | |
| 136 | 254 | 必填配置项: |
| 137 | -- `GLM_API_KEY` / `ANTHROPIC_API_KEY` - LLM API Key | |
| 138 | -- `MYSQL_HOST`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE` | |
| 255 | + | |
| 256 | +```env | |
| 257 | +# LLM (至少配置一种) | |
| 258 | +OPENAI_COMPAT_API_KEY=your-key | |
| 259 | +OPENAI_COMPAT_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 | |
| 260 | +OPENAI_COMPAT_MODEL=glm-4-7-251222 | |
| 261 | + | |
| 262 | +# 数据库 | |
| 263 | +MYSQL_HOST=localhost | |
| 264 | +MYSQL_PORT=3306 | |
| 265 | +MYSQL_USER=root | |
| 266 | +MYSQL_PASSWORD=your-password | |
| 267 | +MYSQL_DATABASE=fw_pms | |
| 268 | +``` | |
| 139 | 269 | |
| 140 | 270 | ### 3. 初始化数据库 |
| 141 | 271 | |
| 142 | 272 | ```bash |
| 273 | +# 基础表结构 | |
| 143 | 274 | mysql -u root -p fw_pms < sql/init.sql |
| 275 | + | |
| 276 | +# 分析报告表 | |
| 277 | +mysql -u root -p fw_pms < sql/migrate_analysis_report.sql | |
| 144 | 278 | ``` |
| 145 | 279 | |
| 146 | 280 | ### 4. 运行 |
| 147 | 281 | |
| 148 | 282 | ```bash |
| 149 | -# 启动定时任务调度器 | |
| 283 | +# 启动定时任务调度器(默认每日 02:00 执行) | |
| 150 | 284 | fw-pms-ai |
| 151 | 285 | |
| 152 | -# 立即执行一次 | |
| 286 | +# 立即执行一次(所有商家组合) | |
| 153 | 287 | fw-pms-ai --run-once |
| 154 | 288 | |
| 155 | -# 指定参数 | |
| 156 | -fw-pms-ai --run-once --group-id 2 | |
| 289 | +# 指定集团和商家组合 | |
| 290 | +fw-pms-ai --run-once --group-id 2 --dealer-grouping-id 100 | |
| 157 | 291 | ``` |
| 158 | 292 | |
| 159 | -## AI Prompt 文件 | |
| 293 | +### 5. 启动 API 服务 | |
| 160 | 294 | |
| 161 | -Prompt 文件存放在 `prompts/` 目录: | |
| 162 | - | |
| 163 | -| 文件 | 用途 | | |
| 164 | -|------|------| | |
| 165 | -| `suggestion.md` | 补货建议生成(含业务术语定义) | | |
| 166 | -| `analyze_inventory.md` | 库存分析 | | |
| 295 | +```bash | |
| 296 | +uvicorn fw_pms_ai.api.app:app --host 0.0.0.0 --port 8000 --reload | |
| 297 | +``` | |
| 167 | 298 | |
| 168 | 299 | ## 开发 |
| 169 | 300 | |
| ... | ... | @@ -171,6 +302,26 @@ Prompt 文件存放在 `prompts/` 目录: |
| 171 | 302 | # 安装开发依赖 |
| 172 | 303 | pip install -e ".[dev]" |
| 173 | 304 | |
| 305 | +# 代码格式化 | |
| 306 | +black src/ | |
| 307 | +ruff check src/ | |
| 308 | + | |
| 174 | 309 | # 运行测试 |
| 175 | 310 | pytest tests/ -v |
| 176 | 311 | ``` |
| 312 | + | |
| 313 | +## 提示词文件 | |
| 314 | + | |
| 315 | +提示词文件存放在 `prompts/` 目录,供 LLM 调用时使用: | |
| 316 | + | |
| 317 | +| 文件 | 用途 | | |
| 318 | +|------|------| | |
| 319 | +| `sql_agent.md` | SQL Agent 生成 SQL 查询的系统提示词 | | |
| 320 | +| `suggestion.md` | 配件补货建议生成 | | |
| 321 | +| `suggestion_system.md` | 补货建议系统角色提示词 | | |
| 322 | +| `part_shop_analysis.md` | 配件门店级别分析 | | |
| 323 | +| `part_shop_analysis_system.md` | 门店分析系统角色提示词 | | |
| 324 | +| `report_inventory_overview.md` | 分析报告 — 库存概览板块 | | |
| 325 | +| `report_sales_analysis.md` | 分析报告 — 销量分析板块 | | |
| 326 | +| `report_inventory_health.md` | 分析报告 — 库存健康度板块 | | |
| 327 | +| `report_replenishment_summary.md` | 分析报告 — 补货建议板块 | | ... | ... |
docs/architecture.md deleted
| 1 | -# fw-pms-ai 系统架构 | |
| 2 | - | |
| 3 | -## 技术栈 | |
| 4 | - | |
| 5 | -| 组件 | 技术 | | |
| 6 | -|------|------| | |
| 7 | -| 编程语言 | Python 3.11+ | | |
| 8 | -| Agent 框架 | LangChain + LangGraph | | |
| 9 | -| LLM | 智谱 GLM / 豆包 / OpenAI 兼容接口 | | |
| 10 | -| 数据库 | MySQL | | |
| 11 | -| API 框架 | FastAPI | | |
| 12 | -| 任务调度 | APScheduler | | |
| 13 | - | |
| 14 | ---- | |
| 15 | - | |
| 16 | -## 系统架构图 | |
| 17 | - | |
| 18 | -```mermaid | |
| 19 | -flowchart TB | |
| 20 | - subgraph API ["FastAPI API 层"] | |
| 21 | - A[/tasks endpoint/] | |
| 22 | - end | |
| 23 | - | |
| 24 | - subgraph Agent ["LangGraph Agent"] | |
| 25 | - direction TB | |
| 26 | - B[fetch_part_ratio] --> C[sql_agent] | |
| 27 | - C --> D{需要重试?} | |
| 28 | - D -->|是| C | |
| 29 | - D -->|否| E[allocate_budget] | |
| 30 | - E --> E2[generate_analysis_report] | |
| 31 | - E2 --> F[END] | |
| 32 | - end | |
| 33 | - | |
| 34 | - subgraph Services ["业务服务层"] | |
| 35 | - G[DataService] | |
| 36 | - H[ResultWriter] | |
| 37 | - end | |
| 38 | - | |
| 39 | - subgraph LLM ["LLM 集成"] | |
| 40 | - I[GLM] | |
| 41 | - J[Doubao] | |
| 42 | - K[OpenAI Compat] | |
| 43 | - end | |
| 44 | - | |
| 45 | - subgraph DB ["数据存储"] | |
| 46 | - L[(MySQL)] | |
| 47 | - end | |
| 48 | - | |
| 49 | - A --> Agent | |
| 50 | - B --> G | |
| 51 | - C --> LLM | |
| 52 | - E --> H | |
| 53 | - G --> L | |
| 54 | - H --> L | |
| 55 | -``` | |
| 56 | - | |
| 57 | ---- | |
| 58 | - | |
| 59 | -## 工作流节点说明 | |
| 60 | - | |
| 61 | -| 节点 | 职责 | 输入 | 输出 | | |
| 62 | -|------|------|------|------| | |
| 63 | -| `fetch_part_ratio` | 获取商家组合的配件库销比数据 | dealer_grouping_id | part_ratios[] | | |
| 64 | -| `sql_agent` | LLM 分析配件数据,生成补货建议 | part_ratios[] | llm_suggestions[], part_results[] | | |
| 65 | -| `allocate_budget` | 转换 LLM 建议为补货明细 | llm_suggestions[] | details[] | | |
| 66 | -| `generate_analysis_report` | 生成分析报告 | part_ratios[], details[] | analysis_report | | |
| 67 | - | |
| 68 | ---- | |
| 69 | - | |
| 70 | -## 核心数据流 | |
| 71 | - | |
| 72 | -```mermaid | |
| 73 | -sequenceDiagram | |
| 74 | - participant API | |
| 75 | - participant Agent | |
| 76 | - participant SQLAgent | |
| 77 | - participant LLM | |
| 78 | - participant DB | |
| 79 | - | |
| 80 | - API->>Agent: 创建任务 | |
| 81 | - Agent->>DB: 保存任务记录 | |
| 82 | - Agent->>DB: 查询 part_ratio | |
| 83 | - Agent->>SQLAgent: 分析配件数据 | |
| 84 | - | |
| 85 | - loop 每个配件 | |
| 86 | - SQLAgent->>LLM: 发送分析请求 | |
| 87 | - LLM-->>SQLAgent: 返回补货建议 | |
| 88 | - end | |
| 89 | - | |
| 90 | - SQLAgent-->>Agent: 汇总建议 | |
| 91 | - Agent->>DB: 保存补货明细 | |
| 92 | - Agent->>DB: 更新任务状态 | |
| 93 | - Agent-->>API: 返回结果 | |
| 94 | -``` | |
| 95 | - | |
| 96 | ---- | |
| 97 | - | |
| 98 | -## 目录结构 | |
| 99 | - | |
| 100 | -``` | |
| 101 | -src/fw_pms_ai/ | |
| 102 | -├── agent/ # LangGraph 工作流 | |
| 103 | -│ ├── state.py # 状态定义 (TypedDict) | |
| 104 | -│ ├── nodes.py # 工作流节点 | |
| 105 | -│ ├── sql_agent.py # SQL Agent 实现 | |
| 106 | -│ └── replenishment.py # 主入口 | |
| 107 | -├── api/ # REST API | |
| 108 | -├── config/ # 配置管理 | |
| 109 | -├── llm/ # LLM 适配器 | |
| 110 | -├── models/ # 数据模型 | |
| 111 | -├── services/ # 业务服务 | |
| 112 | -└── scheduler/ # 定时任务 | |
| 113 | -``` | |
| 114 | - | |
| 115 | ---- | |
| 116 | - | |
| 117 | -## 数据库表结构 | |
| 118 | - | |
| 119 | -| 表名 | 用途 | | |
| 120 | -|------|------| | |
| 121 | -| `part_ratio` | 配件库销比数据(只读) | | |
| 122 | -| `ai_replenishment_task` | 任务记录 | | |
| 123 | -| `ai_replenishment_detail` | 补货明细 | | |
| 124 | -| `ai_replenishment_part_summary` | 配件级汇总 | | |
| 125 | -| `ai_task_execution_log` | 执行日志 | | |
| 126 | -| `ai_analysis_report` | 分析报告(JSON结构化) | |
docs/商家组合维度分析需求设计.md
0 → 100644
| 1 | +# 商家组合维度分析报告 - 需求分析与设计文档 | |
| 2 | + | |
| 3 | +> **版本**: 2.0.0 | |
| 4 | +> **日期**: 2026-02-12 | |
| 5 | +> **项目**: fw-pms-ai(AI配件补货建议系统) | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## 1. 业务背景与目标 | |
| 10 | + | |
| 11 | +### 1.1 业务痛点 | |
| 12 | + | |
| 13 | +汽车配件管理面临以下核心挑战: | |
| 14 | + | |
| 15 | +| 痛点 | 描述 | 影响 | | |
| 16 | +|------|------|------| | |
| 17 | +| **库存失衡** | 部分配件长期缺货,部分配件严重呆滞 | 缺货导致客户流失,呆滞占用资金 | | |
| 18 | +| **人工决策低效** | 传统补货依赖采购员经验判断 | 效率低、易出错、难以规模化 | | |
| 19 | +| **多门店协调困难** | 同一商家组合下的多门店库存无法统一调配 | 资源利用率低,部分门店过剩而另一部分缺货 | | |
| 20 | +| **数据利用不足** | 丰富的销售数据未能有效转化为决策依据 | 补货缺乏数据支撑,决策质量参差不齐 | | |
| 21 | + | |
| 22 | +### 1.2 项目目标 | |
| 23 | + | |
| 24 | +```mermaid | |
| 25 | +mindmap | |
| 26 | + root((商家组合维度分析)) | |
| 27 | + 智能补货建议 | |
| 28 | + LLM驱动分析 | |
| 29 | + 配件级决策 | |
| 30 | + 门店级分配 | |
| 31 | + 风险识别与预警 | |
| 32 | + 呆滞件识别 | |
| 33 | + 低频件过滤 | |
| 34 | + 缺货预警 | |
| 35 | + 数据驱动分析报告 | |
| 36 | + 库存概览 | |
| 37 | + 销量分析 | |
| 38 | + 库存健康度 | |
| 39 | + 补货建议汇总 | |
| 40 | + 可视化展示 | |
| 41 | + 分析报告 | |
| 42 | + 配件明细 | |
| 43 | + 执行日志 | |
| 44 | +``` | |
| 45 | + | |
| 46 | +**核心价值主张**: | |
| 47 | +1. **智能化**:通过 LLM 自动分析库销比数据,生成专业补货建议 | |
| 48 | +2. **精细化**:从商家组合维度统一分析,再下钻到配件级、门店级 | |
| 49 | +3. **专业化**:输出的分析理由贴合采购人员专业度,包含具体数据指标 | |
| 50 | + | |
| 51 | +--- | |
| 52 | + | |
| 53 | +## 2. 功能模块设计 | |
| 54 | + | |
| 55 | +### 2.1 功能架构 | |
| 56 | + | |
| 57 | +```mermaid | |
| 58 | +flowchart TB | |
| 59 | + subgraph 用户层["🖥️ 用户层"] | |
| 60 | + UI[Web管理界面] | |
| 61 | + end | |
| 62 | + | |
| 63 | + subgraph 应用层["⚙️ 应用层"] | |
| 64 | + API[FastAPI 接口层] | |
| 65 | + | |
| 66 | + subgraph Agent["🤖 LangGraph Agent"] | |
| 67 | + N1[获取配件库销比<br/>fetch_part_ratio] | |
| 68 | + N2[LLM分析生成建议<br/>sql_agent] | |
| 69 | + N3[转换补货明细<br/>allocate_budget] | |
| 70 | + N4[生成分析报告<br/>generate_analysis_report] | |
| 71 | + end | |
| 72 | + | |
| 73 | + subgraph ReportSubgraph["📊 分析报告并发子图"] | |
| 74 | + R1[库存概览 LLM] | |
| 75 | + R2[销量分析 LLM] | |
| 76 | + R3[健康度 LLM] | |
| 77 | + R4[补货建议 LLM] | |
| 78 | + end | |
| 79 | + end | |
| 80 | + | |
| 81 | + subgraph 基础设施["🔧 基础设施"] | |
| 82 | + LLM[LLM服务<br/>智谱GLM/豆包/OpenAI/Anthropic] | |
| 83 | + DB[(MySQL数据库)] | |
| 84 | + end | |
| 85 | + | |
| 86 | + UI --> API | |
| 87 | + API --> Agent | |
| 88 | + N1 --> N2 --> N3 --> N4 | |
| 89 | + N4 --> ReportSubgraph | |
| 90 | + N1 -.-> DB | |
| 91 | + N2 -.-> LLM | |
| 92 | + N3 -.-> DB | |
| 93 | + R1 & R2 & R3 & R4 -.-> LLM | |
| 94 | +``` | |
| 95 | + | |
| 96 | +### 2.2 功能模块清单 | |
| 97 | + | |
| 98 | +| 模块 | 功能 | 输入 | 输出 | | |
| 99 | +|------|------|------|------| | |
| 100 | +| **数据获取** | 获取商家组合内所有配件的库销比数据 | dealer_grouping_id | part_ratios[] | | |
| 101 | +| **LLM分析** | 按配件分组分析,生成补货建议和决策理由 | part_ratios, base_ratio | llm_suggestions[] | | |
| 102 | +| **建议转换** | 将LLM建议转换为结构化的补货明细 | llm_suggestions[] | details[], part_summaries[] | | |
| 103 | +| **分析报告** | 四大板块统计计算 + 并发LLM分析 | part_ratios, part_results | analysis_report | | |
| 104 | + | |
| 105 | +--- | |
| 106 | + | |
| 107 | +## 3. 系统架构设计 | |
| 108 | + | |
| 109 | +### 3.1 整体架构 | |
| 110 | + | |
| 111 | +```mermaid | |
| 112 | +C4Component | |
| 113 | + title 商家组合维度分析系统 - 组件架构 | |
| 114 | + | |
| 115 | + Container_Boundary(web, "Web层") { | |
| 116 | + Component(ui, "前端UI", "HTML/CSS/JS", "任务管理、结果展示") | |
| 117 | + } | |
| 118 | + | |
| 119 | + Container_Boundary(api, "API层") { | |
| 120 | + Component(routes, "路由模块", "FastAPI", "REST API接口") | |
| 121 | + Component(scheduler, "定时调度", "APScheduler", "任务调度") | |
| 122 | + } | |
| 123 | + | |
| 124 | + Container_Boundary(agent, "Agent层") { | |
| 125 | + Component(workflow, "工作流引擎", "LangGraph", "状态机编排") | |
| 126 | + Component(nodes, "节点实现", "Python", "业务逻辑") | |
| 127 | + Component(report_subgraph, "报告子图", "LangGraph 并发子图", "4路并发LLM分析") | |
| 128 | + Component(prompts, "提示词", "Markdown", "LLM指令") | |
| 129 | + } | |
| 130 | + | |
| 131 | + Container_Boundary(service, "服务层") { | |
| 132 | + Component(data, "数据服务", "Python", "数据查询") | |
| 133 | + Component(writer, "写入服务", "Python", "结果持久化") | |
| 134 | + } | |
| 135 | + | |
| 136 | + Container_Boundary(infra, "基础设施") { | |
| 137 | + ComponentDb(mysql, "MySQL", "数据库", "业务数据存储") | |
| 138 | + Component(llm, "LLM", "GLM/Doubao/OpenAI/Anthropic", "大语言模型") | |
| 139 | + } | |
| 140 | + | |
| 141 | + Rel(ui, routes, "HTTP请求") | |
| 142 | + Rel(routes, workflow, "触发任务") | |
| 143 | + Rel(workflow, nodes, "执行") | |
| 144 | + Rel(nodes, report_subgraph, "fan-out/fan-in") | |
| 145 | + Rel(nodes, prompts, "加载") | |
| 146 | + Rel(nodes, llm, "调用") | |
| 147 | + Rel(nodes, data, "查询") | |
| 148 | + Rel(nodes, writer, "写入") | |
| 149 | + Rel(data, mysql, "SQL") | |
| 150 | + Rel(writer, mysql, "SQL") | |
| 151 | +``` | |
| 152 | + | |
| 153 | +### 3.2 工作流状态机 | |
| 154 | + | |
| 155 | +```mermaid | |
| 156 | +stateDiagram-v2 | |
| 157 | + [*] --> FetchPartRatio: 启动任务 | |
| 158 | + | |
| 159 | + FetchPartRatio --> SQLAgent: 获取库销比数据 | |
| 160 | + FetchPartRatio --> [*]: 无数据 | |
| 161 | + | |
| 162 | + SQLAgent --> SQLAgent: 重试(错误 & 次数<3) | |
| 163 | + SQLAgent --> AllocateBudget: 分析完成 | |
| 164 | + SQLAgent --> [*]: 重试失败 | |
| 165 | + | |
| 166 | + AllocateBudget --> GenerateReport: 转换完成 | |
| 167 | + | |
| 168 | + GenerateReport --> [*]: 生成报告 | |
| 169 | + | |
| 170 | + state GenerateReport { | |
| 171 | + [*] --> 统计计算 | |
| 172 | + 统计计算 --> 并发LLM子图 | |
| 173 | + | |
| 174 | + state 并发LLM子图 { | |
| 175 | + [*] --> 库存概览LLM | |
| 176 | + [*] --> 销量分析LLM | |
| 177 | + [*] --> 健康度LLM | |
| 178 | + [*] --> 补货建议LLM | |
| 179 | + 库存概览LLM --> [*] | |
| 180 | + 销量分析LLM --> [*] | |
| 181 | + 健康度LLM --> [*] | |
| 182 | + 补货建议LLM --> [*] | |
| 183 | + } | |
| 184 | + | |
| 185 | + 并发LLM子图 --> 汇总写入 | |
| 186 | + 汇总写入 --> [*] | |
| 187 | + } | |
| 188 | +``` | |
| 189 | + | |
| 190 | +--- | |
| 191 | + | |
| 192 | +## 4. 核心算法说明 | |
| 193 | + | |
| 194 | +### 4.1 三层决策逻辑 | |
| 195 | + | |
| 196 | +```mermaid | |
| 197 | +flowchart LR | |
| 198 | + subgraph L1["第一层: 配件级判断"] | |
| 199 | + A1[汇总商家组合内<br/>所有门店数据] | |
| 200 | + A2[计算整体库销比] | |
| 201 | + A3{是否需要补货?} | |
| 202 | + A4[生成配件级理由] | |
| 203 | + end | |
| 204 | + | |
| 205 | + subgraph L2["第二层: 门店级分配"] | |
| 206 | + B1[按库销比从低到高排序] | |
| 207 | + B2[计算各门店缺口] | |
| 208 | + B3[分配补货数量] | |
| 209 | + end | |
| 210 | + | |
| 211 | + subgraph L3["第三层: 决策理由生成"] | |
| 212 | + C1[状态判定标签] | |
| 213 | + C2[关键指标数据] | |
| 214 | + C3[缺口分析] | |
| 215 | + C4[天数说明] | |
| 216 | + end | |
| 217 | + | |
| 218 | + A1 --> A2 --> A3 | |
| 219 | + A3 -->|是| L2 | |
| 220 | + A3 -->|否| A4 | |
| 221 | + B1 --> B2 --> B3 | |
| 222 | + B3 --> L3 | |
| 223 | + C1 --> C2 --> C3 --> C4 | |
| 224 | +``` | |
| 225 | + | |
| 226 | +### 4.2 补货数量计算公式 | |
| 227 | + | |
| 228 | +``` | |
| 229 | +suggest_cnt = ceil(目标库销比 × 月均销量 - 当前库存) | |
| 230 | +``` | |
| 231 | + | |
| 232 | +其中: | |
| 233 | +- **有效库存** = `in_stock_unlocked_cnt` + `on_the_way_cnt` + `has_plan_cnt` | |
| 234 | +- **月均销量** = (`out_stock_cnt` + `storage_locked_cnt` + `out_stock_ongoing_cnt` + `buy_cnt`) / 3 | |
| 235 | +- **资金占用** = (`in_stock_unlocked_cnt` + `on_the_way_cnt`) × `cost_price` | |
| 236 | + | |
| 237 | +### 4.3 配件分类与处理规则 | |
| 238 | + | |
| 239 | +| 分类 | 判定条件 | 处理策略 | | |
| 240 | +|------|----------|----------| | |
| 241 | +| **缺货件** | 有效库存 = 0 且 月均销量 ≥ 1 | 优先补货 | | |
| 242 | +| **呆滞件** | 有效库存 > 0 且 90天出库数 = 0 | 不补货,建议清理 | | |
| 243 | +| **低频件** | 月均销量 < 1 或 出库次数 < 3 或 出库间隔 ≥ 30天 | 不补货 | | |
| 244 | +| **正常件** | 不属于以上三类 | 按缺口补货 | | |
| 245 | + | |
| 246 | +> 分类优先级:缺货件 > 呆滞件 > 低频件 > 正常件(按顺序判断,命中即止) | |
| 247 | + | |
| 248 | +### 4.4 优先级判定标准 | |
| 249 | + | |
| 250 | +```mermaid | |
| 251 | +flowchart TD | |
| 252 | + A{库存状态} -->|库存=0 且 销量活跃| H[高优先级<br/>急需补货] | |
| 253 | + A -->|库销比<0.5| M[中优先级<br/>建议补货] | |
| 254 | + A -->|0.5≤库销比<目标值| L[低优先级<br/>可选补货] | |
| 255 | + A -->|库销比≥目标值| N[无需补货<br/>库存充足] | |
| 256 | + | |
| 257 | + style H fill:#ff6b6b | |
| 258 | + style M fill:#feca57 | |
| 259 | + style L fill:#48dbfb | |
| 260 | + style N fill:#2ecc71 | |
| 261 | +``` | |
| 262 | + | |
| 263 | +--- | |
| 264 | + | |
| 265 | +## 5. 数据模型设计 | |
| 266 | + | |
| 267 | +### 5.1 ER图 | |
| 268 | + | |
| 269 | +```mermaid | |
| 270 | +erDiagram | |
| 271 | + AI_REPLENISHMENT_TASK ||--o{ AI_REPLENISHMENT_DETAIL : contains | |
| 272 | + AI_REPLENISHMENT_TASK ||--o{ AI_REPLENISHMENT_PART_SUMMARY : contains | |
| 273 | + AI_REPLENISHMENT_TASK ||--o{ AI_TASK_EXECUTION_LOG : logs | |
| 274 | + AI_REPLENISHMENT_TASK ||--o| AI_ANALYSIS_REPORT : generates | |
| 275 | + PART_RATIO }o--|| AI_REPLENISHMENT_DETAIL : references | |
| 276 | + | |
| 277 | + AI_REPLENISHMENT_TASK { | |
| 278 | + bigint id PK | |
| 279 | + varchar task_no UK "AI-开头" | |
| 280 | + bigint group_id "集团ID" | |
| 281 | + bigint dealer_grouping_id | |
| 282 | + varchar dealer_grouping_name | |
| 283 | + bigint brand_grouping_id "品牌组合ID" | |
| 284 | + decimal plan_amount "计划采购金额" | |
| 285 | + decimal actual_amount "实际分配金额" | |
| 286 | + int part_count "配件数量" | |
| 287 | + decimal base_ratio "基准库销比" | |
| 288 | + tinyint status "0运行/1成功/2失败" | |
| 289 | + varchar llm_provider | |
| 290 | + varchar llm_model | |
| 291 | + int llm_total_tokens | |
| 292 | + varchar statistics_date | |
| 293 | + datetime start_time | |
| 294 | + datetime end_time | |
| 295 | + } | |
| 296 | + | |
| 297 | + AI_REPLENISHMENT_DETAIL { | |
| 298 | + bigint id PK | |
| 299 | + varchar task_no FK | |
| 300 | + bigint group_id | |
| 301 | + bigint dealer_grouping_id | |
| 302 | + bigint brand_grouping_id | |
| 303 | + bigint shop_id "库房ID" | |
| 304 | + varchar shop_name | |
| 305 | + varchar part_code "配件编码" | |
| 306 | + varchar part_name | |
| 307 | + varchar unit | |
| 308 | + decimal cost_price | |
| 309 | + decimal current_ratio "当前库销比" | |
| 310 | + decimal base_ratio "基准库销比" | |
| 311 | + decimal post_plan_ratio "计划后库销比" | |
| 312 | + decimal valid_storage_cnt "有效库存" | |
| 313 | + decimal avg_sales_cnt "月均销量" | |
| 314 | + int suggest_cnt "建议数量" | |
| 315 | + decimal suggest_amount "建议金额" | |
| 316 | + text suggestion_reason "决策理由" | |
| 317 | + int priority "1高/2中/3低" | |
| 318 | + float llm_confidence "LLM置信度" | |
| 319 | + } | |
| 320 | + | |
| 321 | + AI_REPLENISHMENT_PART_SUMMARY { | |
| 322 | + bigint id PK | |
| 323 | + varchar task_no FK | |
| 324 | + bigint group_id | |
| 325 | + bigint dealer_grouping_id | |
| 326 | + varchar part_code "配件编码" | |
| 327 | + varchar part_name | |
| 328 | + varchar unit | |
| 329 | + decimal cost_price | |
| 330 | + decimal total_storage_cnt "总库存" | |
| 331 | + decimal total_avg_sales_cnt "总月均销量" | |
| 332 | + decimal group_current_ratio "商家组合库销比" | |
| 333 | + int total_suggest_cnt "总建议数量" | |
| 334 | + decimal total_suggest_amount "总建议金额" | |
| 335 | + int shop_count "涉及门店数" | |
| 336 | + int need_replenishment_shop_count "需补货门店数" | |
| 337 | + text part_decision_reason "配件级理由" | |
| 338 | + int priority "1高/2中/3低" | |
| 339 | + float llm_confidence | |
| 340 | + } | |
| 341 | + | |
| 342 | + AI_TASK_EXECUTION_LOG { | |
| 343 | + bigint id PK | |
| 344 | + varchar task_no FK | |
| 345 | + bigint group_id | |
| 346 | + bigint brand_grouping_id | |
| 347 | + varchar brand_grouping_name | |
| 348 | + bigint dealer_grouping_id | |
| 349 | + varchar dealer_grouping_name | |
| 350 | + varchar step_name "步骤名称" | |
| 351 | + int step_order "步骤顺序" | |
| 352 | + tinyint status "0进行/1成功/2失败/3跳过" | |
| 353 | + text input_data "输入JSON" | |
| 354 | + text output_data "输出JSON" | |
| 355 | + text error_message | |
| 356 | + int retry_count | |
| 357 | + text sql_query | |
| 358 | + text llm_prompt | |
| 359 | + text llm_response | |
| 360 | + int llm_tokens "Token消耗" | |
| 361 | + int execution_time_ms "耗时" | |
| 362 | + } | |
| 363 | + | |
| 364 | + AI_ANALYSIS_REPORT { | |
| 365 | + bigint id PK | |
| 366 | + varchar task_no FK | |
| 367 | + bigint group_id | |
| 368 | + bigint dealer_grouping_id | |
| 369 | + varchar dealer_grouping_name | |
| 370 | + bigint brand_grouping_id | |
| 371 | + varchar report_type "默认replenishment" | |
| 372 | + json inventory_overview "库存概览" | |
| 373 | + json sales_analysis "销量分析" | |
| 374 | + json inventory_health "健康度" | |
| 375 | + json replenishment_summary "补货建议" | |
| 376 | + varchar llm_provider | |
| 377 | + varchar llm_model | |
| 378 | + int llm_tokens | |
| 379 | + int execution_time_ms | |
| 380 | + } | |
| 381 | + | |
| 382 | + PART_RATIO { | |
| 383 | + bigint id PK | |
| 384 | + bigint shop_id | |
| 385 | + varchar part_code | |
| 386 | + decimal in_stock_unlocked_cnt "在库未锁定" | |
| 387 | + decimal on_the_way_cnt "在途" | |
| 388 | + decimal has_plan_cnt "已有计划" | |
| 389 | + decimal out_stock_cnt "出库数" | |
| 390 | + decimal storage_locked_cnt "库存锁定" | |
| 391 | + decimal out_stock_ongoing_cnt "出库在途" | |
| 392 | + decimal buy_cnt "采购数" | |
| 393 | + decimal cost_price "成本价" | |
| 394 | + int out_times "出库次数" | |
| 395 | + int out_duration "平均出库间隔" | |
| 396 | + } | |
| 397 | +``` | |
| 398 | + | |
| 399 | +### 5.2 核心表结构 | |
| 400 | + | |
| 401 | +#### ai_replenishment_task(任务主表) | |
| 402 | +| 字段 | 类型 | 说明 | | |
| 403 | +|------|------|------| | |
| 404 | +| task_no | VARCHAR(32) | 任务编号,AI-开头,唯一 | | |
| 405 | +| group_id | BIGINT | 集团ID | | |
| 406 | +| dealer_grouping_id | BIGINT | 商家组合ID | | |
| 407 | +| dealer_grouping_name | VARCHAR(128) | 商家组合名称 | | |
| 408 | +| brand_grouping_id | BIGINT | 品牌组合ID | | |
| 409 | +| plan_amount | DECIMAL(14,2) | 计划采购金额(预算) | | |
| 410 | +| actual_amount | DECIMAL(14,2) | 实际分配金额 | | |
| 411 | +| part_count | INT | 配件数量 | | |
| 412 | +| base_ratio | DECIMAL(10,4) | 基准库销比 | | |
| 413 | +| status | TINYINT | 状态: 0运行中/1成功/2失败 | | |
| 414 | +| llm_provider | VARCHAR(32) | LLM提供商 | | |
| 415 | +| llm_model | VARCHAR(64) | LLM模型名称 | | |
| 416 | +| statistics_date | VARCHAR(16) | 统计日期 | | |
| 417 | +| start_time / end_time | DATETIME | 任务执行起止时间 | | |
| 418 | + | |
| 419 | +#### ai_replenishment_part_summary(配件汇总表) | |
| 420 | +| 字段 | 类型 | 说明 | | |
| 421 | +|------|------|------| | |
| 422 | +| task_no | VARCHAR(32) | 任务编号 | | |
| 423 | +| group_id | BIGINT | 集团ID | | |
| 424 | +| dealer_grouping_id | BIGINT | 商家组合ID | | |
| 425 | +| part_code | VARCHAR(64) | 配件编码 | | |
| 426 | +| part_name | VARCHAR(256) | 配件名称 | | |
| 427 | +| cost_price | DECIMAL(14,2) | 成本价 | | |
| 428 | +| total_storage_cnt | DECIMAL(14,2) | 商家组合内总库存 | | |
| 429 | +| total_avg_sales_cnt | DECIMAL(14,2) | 总月均销量 | | |
| 430 | +| group_current_ratio | DECIMAL(10,4) | 商家组合级库销比 | | |
| 431 | +| total_suggest_cnt | INT | 总建议数量 | | |
| 432 | +| total_suggest_amount | DECIMAL(14,2) | 总建议金额 | | |
| 433 | +| shop_count | INT | 涉及门店数 | | |
| 434 | +| need_replenishment_shop_count | INT | 需补货门店数 | | |
| 435 | +| part_decision_reason | TEXT | 配件级补货理由 | | |
| 436 | +| priority | INT | 优先级: 1高/2中/3低 | | |
| 437 | + | |
| 438 | +#### ai_analysis_report(分析报告表) | |
| 439 | +| 字段 | 类型 | 说明 | | |
| 440 | +|------|------|------| | |
| 441 | +| task_no | VARCHAR(32) | 任务编号 | | |
| 442 | +| group_id | BIGINT | 集团ID | | |
| 443 | +| dealer_grouping_id | BIGINT | 商家组合ID | | |
| 444 | +| report_type | VARCHAR(32) | 报告类型(默认 replenishment) | | |
| 445 | +| inventory_overview | JSON | 库存总体概览(stats + llm_analysis) | | |
| 446 | +| sales_analysis | JSON | 销量分析(stats + llm_analysis) | | |
| 447 | +| inventory_health | JSON | 库存健康度(stats + chart_data + llm_analysis) | | |
| 448 | +| replenishment_summary | JSON | 补货建议(stats + llm_analysis) | | |
| 449 | +| llm_provider | VARCHAR(32) | LLM提供商 | | |
| 450 | +| llm_model | VARCHAR(64) | LLM模型名称 | | |
| 451 | +| llm_tokens | INT | LLM Token总消耗 | | |
| 452 | +| execution_time_ms | INT | 执行耗时(毫秒) | | |
| 453 | + | |
| 454 | +--- | |
| 455 | + | |
| 456 | +## 6. API 接口设计 | |
| 457 | + | |
| 458 | +### 6.1 接口总览 | |
| 459 | + | |
| 460 | +```mermaid | |
| 461 | +flowchart LR | |
| 462 | + subgraph Tasks["任务管理"] | |
| 463 | + T1["GET /api/tasks"] | |
| 464 | + T2["GET /api/tasks/:task_no"] | |
| 465 | + end | |
| 466 | + | |
| 467 | + subgraph Details["明细查询"] | |
| 468 | + D1["GET /api/tasks/:task_no/details"] | |
| 469 | + D2["GET /api/tasks/:task_no/part-summaries"] | |
| 470 | + D3["GET /api/tasks/:task_no/parts/:part_code/shops"] | |
| 471 | + end | |
| 472 | + | |
| 473 | + subgraph Reports["报告模块"] | |
| 474 | + R1["GET /api/tasks/:task_no/analysis-report"] | |
| 475 | + R2["GET /api/tasks/:task_no/logs"] | |
| 476 | + end | |
| 477 | + | |
| 478 | + subgraph Health["健康检查"] | |
| 479 | + H1["GET /health"] | |
| 480 | + end | |
| 481 | +``` | |
| 482 | + | |
| 483 | +### 6.2 核心接口定义 | |
| 484 | + | |
| 485 | +#### 1. 获取任务列表 | |
| 486 | +``` | |
| 487 | +GET /api/tasks?page=1&page_size=20&status=1&dealer_grouping_id=100&statistics_date=20260212 | |
| 488 | +``` | |
| 489 | + | |
| 490 | +**响应示例**: | |
| 491 | +```json | |
| 492 | +{ | |
| 493 | + "items": [ | |
| 494 | + { | |
| 495 | + "id": 1, | |
| 496 | + "task_no": "AI-ABC12345", | |
| 497 | + "group_id": 2, | |
| 498 | + "dealer_grouping_id": 100, | |
| 499 | + "dealer_grouping_name": "华东区商家组合", | |
| 500 | + "brand_grouping_id": 50, | |
| 501 | + "plan_amount": 100000.00, | |
| 502 | + "actual_amount": 89520.50, | |
| 503 | + "part_count": 156, | |
| 504 | + "base_ratio": 1.5000, | |
| 505 | + "status": 1, | |
| 506 | + "status_text": "成功", | |
| 507 | + "llm_provider": "openai_compat", | |
| 508 | + "llm_model": "glm-4-7-251222", | |
| 509 | + "llm_total_tokens": 8500, | |
| 510 | + "statistics_date": "20260212", | |
| 511 | + "start_time": "2026-02-12 02:00:00", | |
| 512 | + "end_time": "2026-02-12 02:05:30", | |
| 513 | + "duration_seconds": 330, | |
| 514 | + "create_time": "2026-02-12 02:00:00" | |
| 515 | + } | |
| 516 | + ], | |
| 517 | + "total": 100, | |
| 518 | + "page": 1, | |
| 519 | + "page_size": 20 | |
| 520 | +} | |
| 521 | +``` | |
| 522 | + | |
| 523 | +#### 2. 获取配件汇总(商家组合维度) | |
| 524 | +``` | |
| 525 | +GET /api/tasks/{task_no}/part-summaries?sort_by=total_suggest_amount&sort_order=desc&priority=1 | |
| 526 | +``` | |
| 527 | + | |
| 528 | +**响应示例**: | |
| 529 | +```json | |
| 530 | +{ | |
| 531 | + "items": [ | |
| 532 | + { | |
| 533 | + "id": 1, | |
| 534 | + "task_no": "AI-ABC12345", | |
| 535 | + "part_code": "C211F280503", | |
| 536 | + "part_name": "机油滤芯", | |
| 537 | + "unit": "个", | |
| 538 | + "cost_price": 140.00, | |
| 539 | + "total_storage_cnt": 25, | |
| 540 | + "total_avg_sales_cnt": 18.5, | |
| 541 | + "group_current_ratio": 1.35, | |
| 542 | + "group_post_plan_ratio": 2.0, | |
| 543 | + "total_suggest_cnt": 12, | |
| 544 | + "total_suggest_amount": 1680.00, | |
| 545 | + "shop_count": 5, | |
| 546 | + "need_replenishment_shop_count": 3, | |
| 547 | + "part_decision_reason": "【配件决策】该配件在商家组合内总库存25件...", | |
| 548 | + "priority": 1, | |
| 549 | + "llm_confidence": 0.85 | |
| 550 | + } | |
| 551 | + ], | |
| 552 | + "total": 50, | |
| 553 | + "page": 1, | |
| 554 | + "page_size": 50 | |
| 555 | +} | |
| 556 | +``` | |
| 557 | + | |
| 558 | +#### 3. 获取门店级明细 | |
| 559 | +``` | |
| 560 | +GET /api/tasks/{task_no}/parts/{part_code}/shops | |
| 561 | +``` | |
| 562 | + | |
| 563 | +**响应示例**: | |
| 564 | +```json | |
| 565 | +{ | |
| 566 | + "total": 3, | |
| 567 | + "items": [ | |
| 568 | + { | |
| 569 | + "id": 101, | |
| 570 | + "task_no": "AI-ABC12345", | |
| 571 | + "shop_id": 1001, | |
| 572 | + "shop_name": "杭州西湖店", | |
| 573 | + "part_code": "C211F280503", | |
| 574 | + "part_name": "机油滤芯", | |
| 575 | + "cost_price": 140.00, | |
| 576 | + "valid_storage_cnt": 5, | |
| 577 | + "avg_sales_cnt": 6.2, | |
| 578 | + "current_ratio": 0.81, | |
| 579 | + "post_plan_ratio": 1.61, | |
| 580 | + "suggest_cnt": 5, | |
| 581 | + "suggest_amount": 700.00, | |
| 582 | + "suggestion_reason": "「建议补货」当前库存5件,月均销量6.2件...", | |
| 583 | + "priority": 1 | |
| 584 | + } | |
| 585 | + ] | |
| 586 | +} | |
| 587 | +``` | |
| 588 | + | |
| 589 | +#### 4. 获取配件建议明细 | |
| 590 | +``` | |
| 591 | +GET /api/tasks/{task_no}/details?page=1&page_size=50&sort_by=suggest_amount&sort_order=desc&part_code=C211 | |
| 592 | +``` | |
| 593 | + | |
| 594 | +#### 5. 获取分析报告 | |
| 595 | +``` | |
| 596 | +GET /api/tasks/{task_no}/analysis-report | |
| 597 | +``` | |
| 598 | + | |
| 599 | +**响应示例**: | |
| 600 | +```json | |
| 601 | +{ | |
| 602 | + "id": 1, | |
| 603 | + "task_no": "AI-ABC12345", | |
| 604 | + "group_id": 2, | |
| 605 | + "dealer_grouping_id": 100, | |
| 606 | + "report_type": "replenishment", | |
| 607 | + "inventory_overview": { | |
| 608 | + "stats": { | |
| 609 | + "total_valid_storage_cnt": 2500, | |
| 610 | + "total_valid_storage_amount": 350000.0, | |
| 611 | + "total_capital_occupation": 280000.0, | |
| 612 | + "overall_ratio": 1.35, | |
| 613 | + "part_count": 156 | |
| 614 | + }, | |
| 615 | + "llm_analysis": { "..." : "LLM生成的分析结论" } | |
| 616 | + }, | |
| 617 | + "sales_analysis": { | |
| 618 | + "stats": { "total_avg_sales_cnt": 1850, "..." : "..." }, | |
| 619 | + "llm_analysis": { "..." : "..." } | |
| 620 | + }, | |
| 621 | + "inventory_health": { | |
| 622 | + "stats": { "shortage": { "count": 12, "amount": 5000 }, "..." : "..." }, | |
| 623 | + "chart_data": { "labels": ["缺货件","呆滞件","低频件","正常件"], "..." : "..." }, | |
| 624 | + "llm_analysis": { "..." : "..." } | |
| 625 | + }, | |
| 626 | + "replenishment_summary": { | |
| 627 | + "stats": { "urgent": { "count": 15, "amount": 25000 }, "..." : "..." }, | |
| 628 | + "llm_analysis": { "..." : "..." } | |
| 629 | + }, | |
| 630 | + "llm_tokens": 3200, | |
| 631 | + "execution_time_ms": 12000 | |
| 632 | +} | |
| 633 | +``` | |
| 634 | + | |
| 635 | +#### 6. 获取执行日志 | |
| 636 | +``` | |
| 637 | +GET /api/tasks/{task_no}/logs | |
| 638 | +``` | |
| 639 | + | |
| 640 | +--- | |
| 641 | + | |
| 642 | +## 7. 前端交互设计 | |
| 643 | + | |
| 644 | +### 7.1 页面结构 | |
| 645 | + | |
| 646 | +```mermaid | |
| 647 | +flowchart TB | |
| 648 | + subgraph Dashboard["仪表盘"] | |
| 649 | + S1[统计卡片] | |
| 650 | + S2[最近任务列表] | |
| 651 | + end | |
| 652 | + | |
| 653 | + subgraph TaskList["任务列表页"] | |
| 654 | + L1[筛选条件] | |
| 655 | + L2[任务表格] | |
| 656 | + L3[分页控件] | |
| 657 | + end | |
| 658 | + | |
| 659 | + subgraph TaskDetail["任务详情页"] | |
| 660 | + D1[任务头部信息] | |
| 661 | + D2[统计卡片] | |
| 662 | + | |
| 663 | + subgraph Tabs["标签页"] | |
| 664 | + T1[配件明细] | |
| 665 | + T2[分析报告] | |
| 666 | + T3[执行日志] | |
| 667 | + T4[任务信息] | |
| 668 | + end | |
| 669 | + end | |
| 670 | + | |
| 671 | + Dashboard --> TaskList --> TaskDetail | |
| 672 | +``` | |
| 673 | + | |
| 674 | +### 7.2 配件明细交互 | |
| 675 | + | |
| 676 | +```mermaid | |
| 677 | +sequenceDiagram | |
| 678 | + participant U as 用户 | |
| 679 | + participant UI as 前端UI | |
| 680 | + participant API as 后端API | |
| 681 | + | |
| 682 | + U->>UI: 点击任务详情 | |
| 683 | + UI->>API: GET /api/tasks/{task_no}/part-summaries | |
| 684 | + API-->>UI: 返回配件汇总列表 | |
| 685 | + UI->>UI: 渲染配件表格(可排序/筛选/优先级) | |
| 686 | + | |
| 687 | + U->>UI: 点击展开某配件 | |
| 688 | + UI->>API: GET /api/tasks/{task_no}/parts/{part_code}/shops | |
| 689 | + API-->>UI: 返回门店级明细 | |
| 690 | + UI->>UI: 展开子表格显示门店数据 | |
| 691 | + | |
| 692 | + Note over UI: 门店数据包含:<br/>库存、销量、库销比<br/>建议数量、建议理由<br/>计划后库销比 | |
| 693 | +``` | |
| 694 | + | |
| 695 | +### 7.3 关键UI组件 | |
| 696 | + | |
| 697 | +| 组件 | 功能 | 交互方式 | | |
| 698 | +|------|------|----------| | |
| 699 | +| **配件汇总表格** | 展示商家组合维度的配件建议 | 支持排序、筛选、分页、优先级筛选 | | |
| 700 | +| **可展开行** | 展示配件下的门店明细 | 点击行展开/收起 | | |
| 701 | +| **配件决策卡片** | 显示LLM生成的配件级理由 | 展开配件时显示 | | |
| 702 | +| **库销比指示器** | 直观显示库销比健康度 | 颜色渐变(红/黄/绿) | | |
| 703 | +| **分析报告面板** | 四大板块数据驱动展示 | 统计数据 + LLM 分析 + 图表 | | |
| 704 | + | |
| 705 | +--- | |
| 706 | + | |
| 707 | +## 8. 分析报告设计 | |
| 708 | + | |
| 709 | +### 8.1 报告模块结构 | |
| 710 | + | |
| 711 | +分析报告由 **统计计算** + **4路并发 LLM 分析** 的 LangGraph 子图生成。每个板块包含 `stats`(统计数据)和 `llm_analysis`(LLM 分析结论)。 | |
| 712 | + | |
| 713 | +```mermaid | |
| 714 | +flowchart TB | |
| 715 | + subgraph Report["分析报告四大板块"] | |
| 716 | + M1["板块1: 库存总体概览<br/>inventory_overview"] | |
| 717 | + M2["板块2: 销量分析<br/>sales_analysis"] | |
| 718 | + M3["板块3: 库存构成健康度<br/>inventory_health"] | |
| 719 | + M4["板块4: 补货建议生成情况<br/>replenishment_summary"] | |
| 720 | + end | |
| 721 | + | |
| 722 | + M1 --> S1[有效库存/资金占用] | |
| 723 | + M1 --> S2[在库/在途/已有计划] | |
| 724 | + M1 --> S3[整体库销比] | |
| 725 | + | |
| 726 | + M2 --> R1[月均销量/销售金额] | |
| 727 | + M2 --> R2[有销量/无销量配件数] | |
| 728 | + M2 --> R3[出库/锁定/采购统计] | |
| 729 | + | |
| 730 | + M3 --> P1[缺货件统计] | |
| 731 | + M3 --> P2[呆滞件统计] | |
| 732 | + M3 --> P3[低频件统计] | |
| 733 | + M3 --> P4[正常件统计] | |
| 734 | + M3 --> P5[chart_data图表数据] | |
| 735 | + | |
| 736 | + M4 --> E1[急需补货统计] | |
| 737 | + M4 --> E2[建议补货统计] | |
| 738 | + M4 --> E3[可选补货统计] | |
| 739 | +``` | |
| 740 | + | |
| 741 | +### 8.2 各板块统计计算与LLM分析 | |
| 742 | + | |
| 743 | +| 板块 | 统计计算 | LLM 分析 | 提示词文件 | | |
| 744 | +|------|---------|---------|-----------| | |
| 745 | +| **库存概览** | 有效库存、资金占用、配件总数、整体库销比 | 库存状况综合评价 | `report_inventory_overview.md` | | |
| 746 | +| **销量分析** | 月均销量、出库频次、有/无销量配件数 | 销售趋势洞察 | `report_sales_analysis.md` | | |
| 747 | +| **库存健康度** | 缺货/呆滞/低频/正常分类统计(数量/金额/占比) | 健康度风险提示 | `report_inventory_health.md` | | |
| 748 | +| **补货建议汇总** | 按优先级(急需/建议/可选)分类统计 | 补货策略建议 | `report_replenishment_summary.md` | | |
| 749 | + | |
| 750 | +> 四个 LLM 分析节点使用 LangGraph 子图 **并发执行**(fan-out / fan-in),单板块失败不影响其他板块。 | |
| 751 | + | |
| 752 | +### 8.3 并发子图实现 | |
| 753 | + | |
| 754 | +```mermaid | |
| 755 | +flowchart LR | |
| 756 | + START --> A[库存概览LLM] --> END2[END] | |
| 757 | + START --> B[销量分析LLM] --> END2 | |
| 758 | + START --> C[健康度LLM] --> END2 | |
| 759 | + START --> D[补货建议LLM] --> END2 | |
| 760 | +``` | |
| 761 | + | |
| 762 | +子图采用 `ReportLLMState` TypedDict 定义状态,使用 `Annotated` reducer 合并并发结果: | |
| 763 | +- 分析结果:`_merge_dict`(保留非 None) | |
| 764 | +- Token 用量:`_sum_int`(累加) | |
| 765 | + | |
| 766 | +--- | |
| 767 | + | |
| 768 | +## 9. 技术选型 | |
| 769 | + | |
| 770 | +| 组件 | 技术 | 选型理由 | | |
| 771 | +|------|------|----------| | |
| 772 | +| **编程语言** | Python 3.11+ | 丰富的AI/ML生态 | | |
| 773 | +| **Agent框架** | LangChain + LangGraph | 成熟的LLM编排框架,支持并发子图 | | |
| 774 | +| **API框架** | FastAPI | 高性能、自动文档 | | |
| 775 | +| **数据库** | MySQL | 与主系统保持一致 | | |
| 776 | +| **LLM** | 智谱GLM / 豆包 / OpenAI兼容 / Anthropic兼容 | 多模型支持,优先级自动选择 | | |
| 777 | +| **前端** | 原生HTML+CSS+JS | 轻量级,无构建依赖 | | |
| 778 | + | |
| 779 | +### LLM 客户端优先级 | |
| 780 | + | |
| 781 | +| 优先级 | 客户端 | 触发条件 | | |
| 782 | +|--------|--------|----------| | |
| 783 | +| 1 | `OpenAICompatClient` | `OPENAI_COMPAT_API_KEY` 已配置 | | |
| 784 | +| 2 | `AnthropicCompatClient` | `ANTHROPIC_API_KEY` 已配置 | | |
| 785 | +| 3 | `GLMClient` | `GLM_API_KEY` 已配置 | | |
| 786 | +| 4 | `DoubaoClient` | `DOUBAO_API_KEY` 已配置 | | |
| 787 | + | |
| 788 | +--- | |
| 789 | + | |
| 790 | +## 10. 部署与运维 | |
| 791 | + | |
| 792 | +### 10.1 部署架构 | |
| 793 | + | |
| 794 | +```mermaid | |
| 795 | +flowchart LR | |
| 796 | + subgraph Client["客户端"] | |
| 797 | + Browser[浏览器] | |
| 798 | + end | |
| 799 | + | |
| 800 | + subgraph Server["服务器"] | |
| 801 | + Nginx[Nginx<br/>静态资源/反向代理] | |
| 802 | + API[FastAPI<br/>API服务] | |
| 803 | + Scheduler[APScheduler<br/>定时任务] | |
| 804 | + end | |
| 805 | + | |
| 806 | + subgraph External["外部服务"] | |
| 807 | + LLM[LLM API] | |
| 808 | + DB[(MySQL)] | |
| 809 | + end | |
| 810 | + | |
| 811 | + Browser --> Nginx | |
| 812 | + Nginx --> API | |
| 813 | + API --> LLM | |
| 814 | + API --> DB | |
| 815 | + Scheduler --> API | |
| 816 | +``` | |
| 817 | + | |
| 818 | +### 10.2 关键监控指标 | |
| 819 | + | |
| 820 | +| 指标 | 阈值 | 告警方式 | | |
| 821 | +|------|------|----------| | |
| 822 | +| 任务成功率 | < 95% | 邮件 | | |
| 823 | +| LLM响应时间 | > 30s | 日志 | | |
| 824 | +| Token消耗 | > 10000/任务 | 日志 | | |
| 825 | +| API响应时间 | > 2s | 监控 | | |
| 826 | + | |
| 827 | +--- | |
| 828 | + | |
| 829 | +## 附录 | |
| 830 | + | |
| 831 | +### A. 术语表 | |
| 832 | + | |
| 833 | +| 术语 | 定义 | | |
| 834 | +|------|------| | |
| 835 | +| 商家组合 | 多个经销商/门店的逻辑分组 | | |
| 836 | +| 库销比 | 库存数量 / 月均销量,衡量库存健康度 | | |
| 837 | +| 呆滞件 | 有库存但90天无出库数的配件 | | |
| 838 | +| 低频件 | 月均销量<1 或 出库次数<3 或 出库间隔≥30天的配件 | | |
| 839 | +| 有效库存 | 在库未锁定 + 在途 + 已有计划 | | |
| 840 | +| 资金占用 | (在库未锁定 + 在途) × 成本价 | | |
| 841 | + | |
| 842 | +### B. 参考文档 | |
| 843 | + | |
| 844 | +- [docs/architecture.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/docs/architecture.md) - 系统架构文档 | |
| 845 | +- [prompts/part_shop_analysis.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/part_shop_analysis.md) - 配件分析提示词 | |
| 846 | +- [prompts/report_inventory_overview.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/report_inventory_overview.md) - 库存概览提示词 | |
| 847 | +- [prompts/report_sales_analysis.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/report_sales_analysis.md) - 销量分析提示词 | |
| 848 | +- [prompts/report_inventory_health.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/report_inventory_health.md) - 库存健康度提示词 | |
| 849 | +- [prompts/report_replenishment_summary.md](file:///Users/gunieason/Studio/Code/FeeWee/fw-pms-ai/prompts/report_replenishment_summary.md) - 补货建议提示词 | |
| 850 | + | |
| 851 | +### C. 版本变更记录 | |
| 852 | + | |
| 853 | +| 版本 | 日期 | 变更说明 | | |
| 854 | +|------|------|----------| | |
| 855 | +| 1.0.0 | 2026-02-09 | 初始版本 | | |
| 856 | +| 2.0.0 | 2026-02-12 | 根据实际实现更新:分析报告重构为四大数据驱动板块、ER图更新、API路径和字段对齐、新增LLM客户端等 | | ... | ... |
prompts/analysis_report.md deleted
| 1 | -# 智能补货建议分析报告 | |
| 2 | - | |
| 3 | -你是一位资深汽车配件采购顾问。AI系统已经基于全量数据生成了补货建议的**多维统计分布数据**,现在需要你站在更宏观的视角,为采购决策者提供**整体性分析**。 | |
| 4 | - | |
| 5 | -> **核心定位**: 统计数据揭示了补货的宏观特征与分布情况,本报告聚焦于"整体策略、风险预警、资金规划"等**决策层面**的洞察。 | |
| 6 | - | |
| 7 | ---- | |
| 8 | - | |
| 9 | -## 商家组合信息 | |
| 10 | - | |
| 11 | -| 项目 | 数值 | | |
| 12 | -|------|------| | |
| 13 | -| 商家组合ID | {dealer_grouping_id} | | |
| 14 | -| 商家组合名称 | {dealer_grouping_name} | | |
| 15 | -| 报告生成日期 | {statistics_date} | | |
| 16 | - | |
| 17 | ---- | |
| 18 | - | |
| 19 | -## 本期补货建议概览 (统计摘要) | |
| 20 | - | |
| 21 | -{suggestion_summary} | |
| 22 | - | |
| 23 | ---- | |
| 24 | - | |
| 25 | -## 库存健康度参考 | |
| 26 | - | |
| 27 | -| 状态分类 | 配件数量 | 涉及金额 | | |
| 28 | -|----------|----------|----------| | |
| 29 | -| 缺货件 | {shortage_cnt} | {shortage_amount} | | |
| 30 | -| 呆滞件 | {stagnant_cnt} | {stagnant_amount} | | |
| 31 | -| 低频件 | {low_freq_cnt} | {low_freq_amount} | | |
| 32 | - | |
| 33 | ---- | |
| 34 | - | |
| 35 | -## 分析任务 | |
| 36 | - | |
| 37 | -请严格按以下4个模块输出 **JSON格式** 的整体分析报告。 | |
| 38 | - | |
| 39 | -**注意**: 分析应基于提供的统计分布数据(如优先级、价格区间、周转频次等),按以下**宏观维度**展开。 | |
| 40 | - | |
| 41 | -### 模块1: 整体态势研判 (overall_assessment) | |
| 42 | - | |
| 43 | -从全局视角评估本次补货建议: | |
| 44 | - | |
| 45 | -1. **补货规模评估**: 结合涉及配件数和总金额,评估本期补货的力度和资金需求规模。 | |
| 46 | -2. **结构特征分析**: 基于价格区间和周转频次分布,分析补货建议的结构特征(如是否偏向高频低价件,或存在大量低频高价件)。 | |
| 47 | -3. **时机判断**: 当前是否处于补货的有利时机?需要考虑哪些时间因素(如节假日、促销季、供应商备货周期)? | |
| 48 | - | |
| 49 | -### 模块2: 风险预警与应对 (risk_alerts) | |
| 50 | - | |
| 51 | -识别本次补货可能面临的风险并给出应对建议: | |
| 52 | - | |
| 53 | -1. **供应风险**: 高频或高优先级配件的供应保障是否关键? | |
| 54 | -2. **资金风险**: 大额补货配件(≥5000元)的占比是否过高?是否构成资金压力? | |
| 55 | -3. **库存结构风险**: 低频配件的补货比例是否合理?是否存在积压风险? | |
| 56 | -4. **执行重点**: 针对高优先级或大额补货的配件,建议采取什么复核策略? | |
| 57 | - | |
| 58 | -### 模块3: 采购策略建议 (procurement_strategy) | |
| 59 | - | |
| 60 | -提供整体性的采购执行策略: | |
| 61 | - | |
| 62 | -1. **优先级排序原则**: 结合优先级分布数据,给出资金分配和采购执行的先后顺序建议。 | |
| 63 | -2. **批量采购机会**: 对于低价高频的配件,是否建议采用批量采购策略以优化成本? | |
| 64 | -3. **分批采购建议**: 对于大额或低频配件,是否建议分批次补货以控制风险? | |
| 65 | -4. **供应商协调要点**: 针对本期补货的结构特征(如大额占比高或高频占比高),与供应商沟通的侧重点是什么? | |
| 66 | - | |
| 67 | -### 模块4: 效果预期与建议 (expected_impact) | |
| 68 | - | |
| 69 | -预估按建议执行后的整体效果: | |
| 70 | - | |
| 71 | -1. **库存健康度改善**: 补货后整体库存结构预计如何变化?缺货率预计下降多少? | |
| 72 | -2. **资金效率预估**: 本期补货的预计投入产出如何?资金周转是否会改善? | |
| 73 | -3. **后续关注点**: 补货完成后需要持续关注哪些指标或配件?下一步建议行动是什么? | |
| 74 | - | |
| 75 | ---- | |
| 76 | - | |
| 77 | -## 输出格式 | |
| 78 | - | |
| 79 | -直接输出JSON对象,**不要**包含 ```json 标记: | |
| 80 | - | |
| 81 | -{{ | |
| 82 | - "overall_assessment": {{ | |
| 83 | - "scale_evaluation": {{ | |
| 84 | - "current_vs_historical": "与历史同期对比结论", | |
| 85 | - "possible_reasons": "规模变化的可能原因" | |
| 86 | - }}, | |
| 87 | - "structure_analysis": {{ | |
| 88 | - "category_distribution": "品类分布特征", | |
| 89 | - "price_range_distribution": "价格区间分布特征", | |
| 90 | - "turnover_distribution": "周转频次分布特征", | |
| 91 | - "imbalance_warning": "是否存在失衡及说明" | |
| 92 | - }}, | |
| 93 | - "timing_judgment": {{ | |
| 94 | - "is_favorable": true或false, | |
| 95 | - "timing_factors": "需要考虑的时间因素", | |
| 96 | - "recommendation": "时机相关建议" | |
| 97 | - }} | |
| 98 | - }}, | |
| 99 | - "risk_alerts": {{ | |
| 100 | - "supply_risks": [ | |
| 101 | - {{ | |
| 102 | - "risk_type": "风险类型(缺货/涨价/交期延长等)", | |
| 103 | - "affected_scope": "影响范围描述", | |
| 104 | - "likelihood": "可能性评估(高/中/低)", | |
| 105 | - "mitigation": "应对建议" | |
| 106 | - }} | |
| 107 | - ], | |
| 108 | - "capital_risks": {{ | |
| 109 | - "cash_flow_pressure": "资金压力评估", | |
| 110 | - "stagnation_warning": "呆滞风险提示", | |
| 111 | - "recommendation": "资金风险应对建议" | |
| 112 | - }}, | |
| 113 | - "market_risks": [ | |
| 114 | - {{ | |
| 115 | - "risk_description": "市场风险描述", | |
| 116 | - "affected_parts": "影响配件范围", | |
| 117 | - "recommendation": "应对建议" | |
| 118 | - }} | |
| 119 | - ], | |
| 120 | - "execution_anomalies": [ | |
| 121 | - {{ | |
| 122 | - "anomaly_type": "异常类型", | |
| 123 | - "description": "异常描述", | |
| 124 | - "review_suggestion": "复核建议" | |
| 125 | - }} | |
| 126 | - ] | |
| 127 | - }}, | |
| 128 | - "procurement_strategy": {{ | |
| 129 | - "priority_principle": {{ | |
| 130 | - "tier1_criteria": "第一优先级标准及说明", | |
| 131 | - "tier2_criteria": "第二优先级标准及说明", | |
| 132 | - "tier3_criteria": "可延后采购的标准及说明" | |
| 133 | - }}, | |
| 134 | - "batch_opportunities": {{ | |
| 135 | - "potential_savings": "潜在节省金额或比例", | |
| 136 | - "applicable_categories": "适用品类或供应商", | |
| 137 | - "execution_suggestion": "具体操作建议" | |
| 138 | - }}, | |
| 139 | - "phased_procurement": {{ | |
| 140 | - "recommended_parts": "建议分批采购的配件范围", | |
| 141 | - "suggested_rhythm": "建议的采购节奏" | |
| 142 | - }}, | |
| 143 | - "supplier_coordination": {{ | |
| 144 | - "key_communications": "关键沟通事项", | |
| 145 | - "timing_suggestions": "沟通时机建议" | |
| 146 | - }} | |
| 147 | - }}, | |
| 148 | - "expected_impact": {{ | |
| 149 | - "inventory_health": {{ | |
| 150 | - "structure_improvement": "库存结构改善预期", | |
| 151 | - "shortage_reduction": "缺货率预计下降幅度" | |
| 152 | - }}, | |
| 153 | - "capital_efficiency": {{ | |
| 154 | - "investment_amount": 本期补货投入金额, | |
| 155 | - "expected_return": "预期收益描述", | |
| 156 | - "turnover_improvement": "周转改善预期" | |
| 157 | - }}, | |
| 158 | - "follow_up_actions": {{ | |
| 159 | - "key_metrics_to_watch": "需持续关注的指标", | |
| 160 | - "next_steps": "下一步建议行动" | |
| 161 | - }} | |
| 162 | - }} | |
| 163 | -}} | |
| 164 | - | |
| 165 | ---- | |
| 166 | - | |
| 167 | -## 重要约束 | |
| 168 | - | |
| 169 | -1. **输出必须是合法的JSON对象** | |
| 170 | -2. **所有金额单位为元,保留2位小数** | |
| 171 | -3. **聚焦宏观分析,不要重复明细中已有的配件级别信息** | |
| 172 | -4. **风险和效果预估尽量量化** | |
| 173 | -5. **策略建议要具体可执行,避免空泛描述** | |
| 174 | -6. **分析基于提供的汇总数据,保持客观理性** |
prompts/report_inventory_health.md
0 → 100644
| 1 | +# 库存健康度分析提示词 | |
| 2 | + | |
| 3 | +你是一位汽车配件库存健康度诊断专家,擅长从库存结构数据中识别问题并提出改善方案。请基于以下健康度统计数据,进行专业的库存健康度诊断。 | |
| 4 | + | |
| 5 | +--- | |
| 6 | + | |
| 7 | +## 统计数据 | |
| 8 | + | |
| 9 | +| 指标 | 数值 | | |
| 10 | +|------|------| | |
| 11 | +| 配件总种类数 | {total_count} | | |
| 12 | +| 库存总金额 | {total_amount} 元 | | |
| 13 | + | |
| 14 | +### 各类型配件统计 | |
| 15 | + | |
| 16 | +| 类型 | 数量 | 数量占比 | 金额(元) | 金额占比 | | |
| 17 | +|------|------|----------|------------|----------| | |
| 18 | +| 缺货件 | {shortage_count} | {shortage_count_pct}% | {shortage_amount} | {shortage_amount_pct}% | | |
| 19 | +| 呆滞件 | {stagnant_count} | {stagnant_count_pct}% | {stagnant_amount} | {stagnant_amount_pct}% | | |
| 20 | +| 低频件 | {low_freq_count} | {low_freq_count_pct}% | {low_freq_amount} | {low_freq_amount_pct}% | | |
| 21 | +| 正常件 | {normal_count} | {normal_count_pct}% | {normal_amount} | {normal_amount_pct}% | | |
| 22 | + | |
| 23 | +--- | |
| 24 | + | |
| 25 | +## 术语说明 | |
| 26 | + | |
| 27 | +- **缺货件**: 有效库存 = 0 且月均销量 >= 1,有需求但无库存 | |
| 28 | +- **呆滞件**: 有效库存 > 0 且90天出库数 = 0,有库存但无销售 | |
| 29 | +- **低频件**: 月均销量 < 1 或出库次数 < 3 或出库间隔 >= 30天 | |
| 30 | +- **正常件**: 不属于以上三类的配件 | |
| 31 | + | |
| 32 | +--- | |
| 33 | + | |
| 34 | +## 当前季节信息 | |
| 35 | + | |
| 36 | +- **当前季节**: {current_season} | |
| 37 | +- **统计日期**: {statistics_date} | |
| 38 | + | |
| 39 | +--- | |
| 40 | + | |
| 41 | +## 季节性因素参考 | |
| 42 | + | |
| 43 | +| 季节 | 健康度评估调整 | 特别关注 | | |
| 44 | +|------|--------------|---------| | |
| 45 | +| 春季(3-5月) | 呆滞件中可能包含冬季配件,属正常现象 | 关注冬季配件是否及时清理 | | |
| 46 | +| 夏季(6-8月) | 制冷配件缺货风险高,需重点关注 | 空调、冷却系统配件缺货影响大 | | |
| 47 | +| 秋季(9-11月) | 夏季配件可能转为低频,需提前处理 | 关注夏季配件库存消化 | | |
| 48 | +| 冬季(12-2月) | 电瓶、暖风配件缺货影响大 | 春节前缺货损失更大,需提前备货 | | |
| 49 | + | |
| 50 | +--- | |
| 51 | + | |
| 52 | +## 分析框架与判断标准 | |
| 53 | + | |
| 54 | +### 健康度评分标准 | |
| 55 | +| 正常件数量占比 | 健康度等级 | 说明 | | |
| 56 | +|---------------|-----------|------| | |
| 57 | +| > 70% | 健康 | 库存结构良好,继续保持 | | |
| 58 | +| 50% - 70% | 亚健康 | 存在优化空间,需关注问题件 | | |
| 59 | +| < 50% | 不健康 | 库存结构严重失衡,需立即改善 | | |
| 60 | + | |
| 61 | +### 各类型问题件风险评估标准 | |
| 62 | +| 类型 | 数量占比阈值 | 金额占比阈值 | 风险等级 | | |
| 63 | +|------|-------------|-------------|---------| | |
| 64 | +| 缺货件 | > 10% | - | 高风险(影响销售) | | |
| 65 | +| 呆滞件 | > 15% | > 20% | 高风险(资金占用) | | |
| 66 | +| 低频件 | > 25% | > 30% | 中风险(周转效率) | | |
| 67 | + | |
| 68 | +### 资金释放潜力评估 | |
| 69 | +| 类型 | 可释放比例 | 释放方式 | | |
| 70 | +|------|-----------|---------| | |
| 71 | +| 呆滞件 | 60%-80% | 促销清仓、退货供应商、调拨其他门店 | | |
| 72 | +| 低频件 | 30%-50% | 降价促销、减少补货、逐步淘汰 | | |
| 73 | + | |
| 74 | +--- | |
| 75 | + | |
| 76 | +## 分析任务 | |
| 77 | + | |
| 78 | +请严格按照以下步骤进行分析,每一步都要展示推理过程: | |
| 79 | + | |
| 80 | +### 步骤1:健康度评分 | |
| 81 | +- 读取正常件数量占比 | |
| 82 | +- 对照健康度评分标准,确定健康度等级 | |
| 83 | +- 说明判断依据 | |
| 84 | + | |
| 85 | +### 步骤2:问题件诊断 | |
| 86 | +对每类问题件进行分析: | |
| 87 | + | |
| 88 | +**缺货件分析:** | |
| 89 | +- 对照风险阈值(数量占比>10%),判断风险等级 | |
| 90 | +- 分析缺货对业务的影响(销售损失、客户流失) | |
| 91 | +- 推断可能原因(补货不及时、需求预测不准、供应链问题) | |
| 92 | + | |
| 93 | +**呆滞件分析:** | |
| 94 | +- 对照风险阈值(数量占比>15%或金额占比>20%),判断风险等级 | |
| 95 | +- 分析呆滞对资金的影响 | |
| 96 | +- 推断可能原因(采购决策失误、市场变化、产品更新换代) | |
| 97 | + | |
| 98 | +**低频件分析:** | |
| 99 | +- 对照风险阈值(数量占比>25%或金额占比>30%),判断风险等级 | |
| 100 | +- 分析低频件对SKU效率的影响 | |
| 101 | +- 推断可能原因(长尾需求、季节性产品、新品导入) | |
| 102 | + | |
| 103 | +### 步骤3:资金释放机会评估 | |
| 104 | +- 计算呆滞件可释放资金 = 呆滞件金额 × 可释放比例(60%-80%) | |
| 105 | +- 计算低频件可释放资金 = 低频件金额 × 可释放比例(30%-50%) | |
| 106 | +- 给出具体的资金释放行动方案 | |
| 107 | + | |
| 108 | +### 步骤4:改善优先级排序 | |
| 109 | +- 根据风险等级和影响程度,排序问题类型 | |
| 110 | +- 给出2-3条优先级最高的改善行动 | |
| 111 | + | |
| 112 | +--- | |
| 113 | + | |
| 114 | +## 输出格式 | |
| 115 | + | |
| 116 | +直接输出JSON对象,**不要**包含 ```json 标记: | |
| 117 | + | |
| 118 | +{{ | |
| 119 | + "analysis_process": {{ | |
| 120 | + "health_score_diagnosis": {{ | |
| 121 | + "normal_ratio": "正常件数量占比(直接读取:{normal_count_pct}%)", | |
| 122 | + "score": "健康/亚健康/不健康", | |
| 123 | + "reasoning": "判断依据:对照标准xxx,当前正常件占比为xxx%,因此判断为xxx" | |
| 124 | + }}, | |
| 125 | + "problem_diagnosis": {{ | |
| 126 | + "shortage": {{ | |
| 127 | + "risk_level": "高/中/低", | |
| 128 | + "threshold_comparison": "对照阈值>10%,当前{shortage_count_pct}%,结论", | |
| 129 | + "business_impact": "对业务的具体影响分析", | |
| 130 | + "possible_causes": ["可能原因1", "可能原因2"] | |
| 131 | + }}, | |
| 132 | + "stagnant": {{ | |
| 133 | + "risk_level": "高/中/低", | |
| 134 | + "threshold_comparison": "对照阈值(数量>15%或金额>20%),当前数量{stagnant_count_pct}%/金额{stagnant_amount_pct}%,结论", | |
| 135 | + "capital_impact": "对资金的具体影响分析", | |
| 136 | + "possible_causes": ["可能原因1", "可能原因2"] | |
| 137 | + }}, | |
| 138 | + "low_freq": {{ | |
| 139 | + "risk_level": "高/中/低", | |
| 140 | + "threshold_comparison": "对照阈值(数量>25%或金额>30%),当前数量{low_freq_count_pct}%/金额{low_freq_amount_pct}%,结论", | |
| 141 | + "efficiency_impact": "对SKU效率的具体影响分析", | |
| 142 | + "possible_causes": ["可能原因1", "可能原因2"] | |
| 143 | + }} | |
| 144 | + }}, | |
| 145 | + "capital_release_calculation": {{ | |
| 146 | + "stagnant_calculation": "呆滞件可释放资金 = {stagnant_amount} × 70% = xxx元(取中间值70%)", | |
| 147 | + "low_freq_calculation": "低频件可释放资金 = {low_freq_amount} × 40% = xxx元(取中间值40%)", | |
| 148 | + "total_releasable": "总可释放资金 = xxx元" | |
| 149 | + }}, | |
| 150 | + "seasonal_analysis": {{ | |
| 151 | + "current_season": "当前季节", | |
| 152 | + "seasonal_stagnant_items": "呆滞件中是否包含季节性配件(如冬季的空调配件)", | |
| 153 | + "seasonal_shortage_risk": "当季高需求配件的缺货风险评估", | |
| 154 | + "upcoming_season_alert": "下一季节需要关注的配件类型" | |
| 155 | + }} | |
| 156 | + }}, | |
| 157 | + "conclusion": {{ | |
| 158 | + "health_score": {{ | |
| 159 | + "score": "健康/亚健康/不健康", | |
| 160 | + "normal_ratio_evaluation": "正常件占比评估结论(基于分析得出)" | |
| 161 | + }}, | |
| 162 | + "problem_diagnosis": {{ | |
| 163 | + "stagnant_analysis": "呆滞件问题分析及原因(基于分析得出)", | |
| 164 | + "shortage_analysis": "缺货件问题分析及影响(基于分析得出)", | |
| 165 | + "low_freq_analysis": "低频件问题分析及建议(基于分析得出)" | |
| 166 | + }}, | |
| 167 | + "capital_release": {{ | |
| 168 | + "stagnant_releasable": "呆滞件可释放资金估算(基于计算得出)", | |
| 169 | + "low_freq_releasable": "低频件可释放资金估算(基于计算得出)", | |
| 170 | + "action_plan": "资金释放行动方案(具体步骤)" | |
| 171 | + }}, | |
| 172 | + "priority_actions": [ | |
| 173 | + {{ | |
| 174 | + "priority": 1, | |
| 175 | + "action": "最优先处理事项", | |
| 176 | + "reason": "优先原因", | |
| 177 | + "expected_effect": "预期效果" | |
| 178 | + }}, | |
| 179 | + {{ | |
| 180 | + "priority": 2, | |
| 181 | + "action": "次优先处理事项", | |
| 182 | + "reason": "优先原因", | |
| 183 | + "expected_effect": "预期效果" | |
| 184 | + }} | |
| 185 | + ] | |
| 186 | + }} | |
| 187 | +}} | |
| 188 | + | |
| 189 | +--- | |
| 190 | + | |
| 191 | +## 重要约束 | |
| 192 | + | |
| 193 | +1. **输出必须是合法的JSON对象** | |
| 194 | +2. **分析必须基于提供的数据,不要编造数据** | |
| 195 | +3. **每个结论都必须有明确的推理依据和数据支撑** | |
| 196 | +4. **资金释放估算应基于实际数据和给定的释放比例范围** | |
| 197 | +5. **score 只能是"健康"、"亚健康"、"不健康"三个值之一** | |
| 198 | +6. **priority_actions 数组至少包含2条,最多3条** | |
| 199 | +7. **如果数据全为零,请在分析中说明无有效数据,并给出相应建议** | |
| 200 | +8. **所有金额计算结果保留两位小数** | ... | ... |
prompts/report_inventory_overview.md
0 → 100644
| 1 | +# 库存概览分析提示词 | |
| 2 | + | |
| 3 | +你是一位资深汽车配件库存管理专家,拥有20年以上的汽车后市场库存管理经验。请基于以下库存概览统计数据,进行专业的库存分析。 | |
| 4 | + | |
| 5 | +--- | |
| 6 | + | |
| 7 | +## 统计数据 | |
| 8 | + | |
| 9 | +| 指标 | 数值 | | |
| 10 | +|------|------| | |
| 11 | +| 配件总种类数 | {part_count} | | |
| 12 | +| 有效库存总数量 | {total_valid_storage_cnt} | | |
| 13 | +| 有效库存总金额(资金占用) | {total_valid_storage_amount} 元 | | |
| 14 | +| 月均销量总数量 | {total_avg_sales_cnt} | | |
| 15 | +| 整体库销比 | {overall_ratio} | | |
| 16 | + | |
| 17 | +### 库存三项构成明细 | |
| 18 | + | |
| 19 | +| 构成项 | 数量 | 金额(元) | 数量占比 | 金额占比 | | |
| 20 | +|--------|------|------------|----------|----------| | |
| 21 | +| 在库未锁 | {total_in_stock_unlocked_cnt} | {total_in_stock_unlocked_amount} | - | - | | |
| 22 | +| 在途 | {total_on_the_way_cnt} | {total_on_the_way_amount} | - | - | | |
| 23 | +| 计划数 | {total_has_plan_cnt} | {total_has_plan_amount} | - | - | | |
| 24 | + | |
| 25 | +--- | |
| 26 | + | |
| 27 | +## 术语说明 | |
| 28 | + | |
| 29 | +- **有效库存**: 在库未锁 + 在途 + 计划数 | |
| 30 | +- **月均销量**: (90天出库数 + 未关单已锁 + 未关单出库 + 订件) / 3 | |
| 31 | +- **库销比**: 有效库存总数量 / 月均销量总数量,反映库存周转效率 | |
| 32 | + | |
| 33 | +--- | |
| 34 | + | |
| 35 | +## 当前季节信息 | |
| 36 | + | |
| 37 | +- **当前季节**: {current_season} | |
| 38 | +- **统计日期**: {statistics_date} | |
| 39 | + | |
| 40 | +--- | |
| 41 | + | |
| 42 | +## 季节性因素参考 | |
| 43 | + | |
| 44 | +| 季节 | 需求特征 | 库存策略建议 | | |
| 45 | +|------|---------|-------------| | |
| 46 | +| 春季(3-5月) | 需求回暖,维修保养高峰前期 | 适当增加库存,为旺季做准备 | | |
| 47 | +| 夏季(6-8月) | 空调、冷却系统配件需求旺盛 | 重点备货制冷相关配件,库销比可适当放宽至2.5 | | |
| 48 | +| 秋季(9-11月) | 需求平稳,换季保养需求 | 保持正常库存水平,关注轮胎、刹车片等 | | |
| 49 | +| 冬季(12-2月) | 电瓶、暖风系统需求增加,春节前备货期 | 提前备货,库销比可适当放宽至2.5-3.0 | | |
| 50 | + | |
| 51 | +--- | |
| 52 | + | |
| 53 | +## 分析框架与判断标准 | |
| 54 | + | |
| 55 | +### 库销比判断标准 | |
| 56 | +| 库销比范围 | 判断等级 | 含义 | | |
| 57 | +|-----------|---------|------| | |
| 58 | +| < 1.0 | 库存不足 | 可能面临缺货风险,需要加快补货 | | |
| 59 | +| 1.0 - 2.0 | 合理 | 库存水平健康,周转效率良好 | | |
| 60 | +| 2.0 - 3.0 | 偏高 | 库存积压风险,需关注周转 | | |
| 61 | +| > 3.0 | 严重积压 | 资金占用过高,需立即优化 | | |
| 62 | +| = 999 | 无销量 | 月均销量为零,需特别关注 | | |
| 63 | + | |
| 64 | +### 库存结构健康标准 | |
| 65 | +| 构成项 | 健康占比范围 | 风险提示 | | |
| 66 | +|--------|-------------|---------| | |
| 67 | +| 在库未锁 | 60%-80% | 过高说明周转慢,过低说明库存不足 | | |
| 68 | +| 在途 | 10%-25% | 过高说明到货延迟风险,过低说明补货不及时 | | |
| 69 | +| 计划数 | 5%-15% | 过高说明计划执行滞后 | | |
| 70 | + | |
| 71 | +### 资金占用风险等级 | |
| 72 | +| 条件 | 风险等级 | | |
| 73 | +|------|---------| | |
| 74 | +| 库销比 > 3.0 或 在库未锁占比 > 85% | high | | |
| 75 | +| 库销比 2.0-3.0 或 在库未锁占比 80%-85% | medium | | |
| 76 | +| 库销比 < 2.0 且 结构合理 | low | | |
| 77 | + | |
| 78 | +--- | |
| 79 | + | |
| 80 | +## 分析任务 | |
| 81 | + | |
| 82 | +请严格按照以下步骤进行分析,每一步都要展示推理过程: | |
| 83 | + | |
| 84 | +### 步骤1:计算关键指标 | |
| 85 | +首先计算以下指标(请在分析中展示计算过程): | |
| 86 | +- 各构成项的数量占比 = 构成项数量 / 有效库存总数量 × 100% | |
| 87 | +- 各构成项的金额占比 = 构成项金额 / 有效库存总金额 × 100% | |
| 88 | +- 单件平均成本 = 有效库存总金额 / 有效库存总数量 | |
| 89 | + | |
| 90 | +### 步骤2:库销比诊断 | |
| 91 | +- 对照判断标准,确定当前库销比所处等级 | |
| 92 | +- 说明该等级的业务含义 | |
| 93 | +- 与行业经验值(1.5-2.5)进行对比 | |
| 94 | + | |
| 95 | +### 步骤3:库存结构分析 | |
| 96 | +- 对照健康标准,评估各构成项占比是否合理 | |
| 97 | +- 识别偏离健康范围的构成项 | |
| 98 | +- 分析偏离的可能原因 | |
| 99 | + | |
| 100 | +### 步骤4:风险评估 | |
| 101 | +- 根据风险等级判断条件,确定当前风险等级 | |
| 102 | +- 列出具体的风险点 | |
| 103 | + | |
| 104 | +### 步骤5:季节性考量 | |
| 105 | +- 结合当前季节特征,评估库存水平是否适合当前季节 | |
| 106 | +- 考虑即将到来的季节变化,是否需要提前调整 | |
| 107 | + | |
| 108 | +### 步骤6:形成建议 | |
| 109 | +- 基于以上分析,提出2-3条具体可操作的改善建议 | |
| 110 | +- 每条建议需说明预期效果 | |
| 111 | +- 建议需考虑季节性因素 | |
| 112 | + | |
| 113 | +--- | |
| 114 | + | |
| 115 | +## 输出格式 | |
| 116 | + | |
| 117 | +直接输出JSON对象,**不要**包含 ```json 标记: | |
| 118 | + | |
| 119 | +{{ | |
| 120 | + "analysis_process": {{ | |
| 121 | + "calculated_metrics": {{ | |
| 122 | + "in_stock_ratio": "在库未锁数量占比(计算过程:xxx / xxx = xx%)", | |
| 123 | + "on_way_ratio": "在途数量占比(计算过程)", | |
| 124 | + "plan_ratio": "计划数占比(计算过程)", | |
| 125 | + "avg_cost": "单件平均成本(计算过程)" | |
| 126 | + }}, | |
| 127 | + "ratio_diagnosis": {{ | |
| 128 | + "current_value": "当前库销比数值", | |
| 129 | + "level": "不足/合理/偏高/严重积压/无销量", | |
| 130 | + "reasoning": "判断依据:对照标准xxx,当前值xxx,因此判断为xxx", | |
| 131 | + "benchmark_comparison": "与行业经验值1.5-2.5对比的结论" | |
| 132 | + }}, | |
| 133 | + "structure_analysis": {{ | |
| 134 | + "in_stock_evaluation": "在库未锁占比评估(对照标准60%-80%,当前xx%,结论)", | |
| 135 | + "on_way_evaluation": "在途占比评估(对照标准10%-25%,当前xx%,结论)", | |
| 136 | + "plan_evaluation": "计划数占比评估(对照标准5%-15%,当前xx%,结论)", | |
| 137 | + "abnormal_items": ["偏离健康范围的项目及原因分析"] | |
| 138 | + }}, | |
| 139 | + "seasonal_analysis": {{ | |
| 140 | + "current_season": "当前季节", | |
| 141 | + "season_demand_feature": "当前季节需求特征", | |
| 142 | + "inventory_fitness": "当前库存水平是否适合本季节(结合季节性因素评估)", | |
| 143 | + "upcoming_season_preparation": "对即将到来季节的准备建议" | |
| 144 | + }} | |
| 145 | + }}, | |
| 146 | + "conclusion": {{ | |
| 147 | + "capital_assessment": {{ | |
| 148 | + "total_evaluation": "总资金占用评估(基于以上分析得出的一句话结论)", | |
| 149 | + "structure_ratio": "各构成部分的资金比例分析结论", | |
| 150 | + "risk_level": "high/medium/low(基于风险等级判断条件得出)" | |
| 151 | + }}, | |
| 152 | + "ratio_diagnosis": {{ | |
| 153 | + "level": "不足/合理/偏高/严重积压", | |
| 154 | + "analysis": "库销比分析结论", | |
| 155 | + "benchmark": "行业参考值对比结论" | |
| 156 | + }}, | |
| 157 | + "recommendations": [ | |
| 158 | + {{ | |
| 159 | + "action": "具体建议1", | |
| 160 | + "reason": "建议依据", | |
| 161 | + "expected_effect": "预期效果" | |
| 162 | + }}, | |
| 163 | + {{ | |
| 164 | + "action": "具体建议2", | |
| 165 | + "reason": "建议依据", | |
| 166 | + "expected_effect": "预期效果" | |
| 167 | + }} | |
| 168 | + ] | |
| 169 | + }} | |
| 170 | +}} | |
| 171 | + | |
| 172 | +--- | |
| 173 | + | |
| 174 | +## 重要约束 | |
| 175 | + | |
| 176 | +1. **输出必须是合法的JSON对象** | |
| 177 | +2. **分析必须基于提供的数据,不要编造数据** | |
| 178 | +3. **每个结论都必须有明确的推理依据** | |
| 179 | +4. **建议必须具体可操作,避免空泛的表述** | |
| 180 | +5. **risk_level 只能是 high、medium、low 三个值之一** | |
| 181 | +6. **如果数据全为零,请在分析中说明无有效数据,并给出相应建议** | |
| 182 | +7. **所有百分比计算结果保留两位小数** | ... | ... |
prompts/report_replenishment_summary.md
0 → 100644
| 1 | +# 补货建议分析提示词 | |
| 2 | + | |
| 3 | +你是一位汽车配件采购策略顾问,擅长制定科学的补货计划和资金分配方案。请基于以下补货建议统计数据,进行专业的补货策略分析。 | |
| 4 | + | |
| 5 | +--- | |
| 6 | + | |
| 7 | +## 统计数据 | |
| 8 | + | |
| 9 | +| 指标 | 数值 | | |
| 10 | +|------|------| | |
| 11 | +| 补货配件总种类数 | {total_count} | | |
| 12 | +| 补货总金额 | {total_amount} 元 | | |
| 13 | + | |
| 14 | +### 各优先级统计 | |
| 15 | + | |
| 16 | +| 优先级 | 配件种类数 | 金额(元) | | |
| 17 | +|--------|-----------|------------| | |
| 18 | +| 急需补货(优先级1) | {urgent_count} | {urgent_amount} | | |
| 19 | +| 建议补货(优先级2) | {suggested_count} | {suggested_amount} | | |
| 20 | +| 可选补货(优先级3) | {optional_count} | {optional_amount} | | |
| 21 | + | |
| 22 | +--- | |
| 23 | + | |
| 24 | +## 术语说明 | |
| 25 | + | |
| 26 | +- **急需补货(优先级1)**: 库销比 < 0.5 且月均销量 >= 1,库存严重不足,面临断货风险 | |
| 27 | +- **建议补货(优先级2)**: 库销比 0.5-1.0 且月均销量 >= 1,库存偏低,建议及时补充 | |
| 28 | +- **可选补货(优先级3)**: 库销比 1.0-目标值 且月均销量 >= 1,库存尚可,可灵活安排 | |
| 29 | + | |
| 30 | +--- | |
| 31 | + | |
| 32 | +## 当前季节信息 | |
| 33 | + | |
| 34 | +- **当前季节**: {current_season} | |
| 35 | +- **统计日期**: {statistics_date} | |
| 36 | + | |
| 37 | +--- | |
| 38 | + | |
| 39 | +## 季节性因素参考 | |
| 40 | + | |
| 41 | +| 季节 | 补货策略调整 | 重点补货品类 | | |
| 42 | +|------|-------------|-------------| | |
| 43 | +| 春季(3-5月) | 为夏季旺季提前备货 | 空调、冷却系统配件 | | |
| 44 | +| 夏季(6-8月) | 制冷配件紧急补货优先级更高 | 空调压缩机、冷凝器、制冷剂 | | |
| 45 | +| 秋季(9-11月) | 为冬季备货,减少夏季配件补货 | 电瓶、暖风系统、防冻液 | | |
| 46 | +| 冬季(12-2月) | 春节前加快补货节奏 | 电瓶、启动机、暖风配件 | | |
| 47 | + | |
| 48 | +--- | |
| 49 | + | |
| 50 | +## 分析框架与判断标准 | |
| 51 | + | |
| 52 | +### 紧迫度评估标准 | |
| 53 | +| 急需补货占比 | 紧迫度等级 | 风险等级 | 建议 | | |
| 54 | +|-------------|-----------|---------|------| | |
| 55 | +| > 30% | 非常紧迫 | high | 立即启动紧急补货流程 | | |
| 56 | +| 15% - 30% | 较紧迫 | medium | 优先处理急需补货 | | |
| 57 | +| < 15% | 一般 | low | 按正常流程处理 | | |
| 58 | + | |
| 59 | +### 资金分配优先级原则 | |
| 60 | +| 优先级 | 建议预算占比 | 执行时间 | | |
| 61 | +|--------|-------------|---------| | |
| 62 | +| 急需补货 | 50%-70% | 1-3天内 | | |
| 63 | +| 建议补货 | 20%-35% | 1-2周内 | | |
| 64 | +| 可选补货 | 10%-15% | 2-4周内 | | |
| 65 | + | |
| 66 | +### 风险预警阈值 | |
| 67 | +| 风险类型 | 触发条件 | 预警等级 | | |
| 68 | +|---------|---------|---------| | |
| 69 | +| 资金压力 | 急需补货金额占比 > 60% | 高 | | |
| 70 | +| 过度补货 | 可选补货金额占比 > 40% | 中 | | |
| 71 | + | |
| 72 | +--- | |
| 73 | + | |
| 74 | +## 分析任务 | |
| 75 | + | |
| 76 | +请严格按照以下步骤进行分析,展示推理过程: | |
| 77 | + | |
| 78 | +### 步骤1:计算关键指标 | |
| 79 | +- 各优先级数量占比 = 数量 / 总数量 × 100% | |
| 80 | +- 各优先级金额占比 = 金额 / 总金额 × 100% | |
| 81 | + | |
| 82 | +### 步骤2:紧迫度评估 | |
| 83 | +- 对照标准确定紧迫度等级和风险等级 | |
| 84 | +- 判断是否需要立即行动 | |
| 85 | + | |
| 86 | +### 步骤3:资金分配建议 | |
| 87 | +- 对照建议预算占比,判断当前分布是否合理 | |
| 88 | +- 给出具体资金分配建议 | |
| 89 | + | |
| 90 | +### 步骤4:执行节奏规划 | |
| 91 | +- 规划各类补货的执行时间 | |
| 92 | + | |
| 93 | +### 步骤5:风险识别 | |
| 94 | +- 对照风险预警阈值,识别潜在风险 | |
| 95 | + | |
| 96 | +--- | |
| 97 | + | |
| 98 | +## 输出格式 | |
| 99 | + | |
| 100 | +直接输出JSON对象,**不要**包含 ```json 标记: | |
| 101 | + | |
| 102 | +{{ | |
| 103 | + "analysis_process": {{ | |
| 104 | + "calculated_metrics": {{ | |
| 105 | + "urgent_count_ratio": "急需补货数量占比(计算:xxx / xxx = xx%)", | |
| 106 | + "urgent_amount_ratio": "急需补货金额占比(计算)", | |
| 107 | + "suggested_count_ratio": "建议补货数量占比(计算)", | |
| 108 | + "suggested_amount_ratio": "建议补货金额占比(计算)", | |
| 109 | + "optional_count_ratio": "可选补货数量占比(计算)", | |
| 110 | + "optional_amount_ratio": "可选补货金额占比(计算)" | |
| 111 | + }}, | |
| 112 | + "urgency_diagnosis": {{ | |
| 113 | + "urgent_ratio": "急需补货数量占比", | |
| 114 | + "level": "非常紧迫/较紧迫/一般", | |
| 115 | + "reasoning": "判断依据:对照标准xxx,当前占比xxx%,因此判断为xxx" | |
| 116 | + }}, | |
| 117 | + "budget_analysis": {{ | |
| 118 | + "current_distribution": "当前各优先级金额分布情况", | |
| 119 | + "comparison_with_standard": "与建议预算占比对比分析", | |
| 120 | + "adjustment_needed": "是否需要调整及原因" | |
| 121 | + }}, | |
| 122 | + "risk_identification": {{ | |
| 123 | + "capital_pressure_check": "资金压力检查(急需占比是否>60%)", | |
| 124 | + "over_replenishment_check": "过度补货检查(可选占比是否>40%)", | |
| 125 | + "identified_risks": ["识别到的风险1", "识别到的风险2"] | |
| 126 | + }}, | |
| 127 | + "seasonal_analysis": {{ | |
| 128 | + "current_season": "当前季节", | |
| 129 | + "seasonal_priority_items": "当季重点补货品类是否在急需列表中", | |
| 130 | + "timeline_adjustment": "是否需要根据季节调整补货时间(如春节前加快)", | |
| 131 | + "next_season_preparation": "为下一季节需要提前准备的配件" | |
| 132 | + }} | |
| 133 | + }}, | |
| 134 | + "conclusion": {{ | |
| 135 | + "urgency_assessment": {{ | |
| 136 | + "urgent_ratio_evaluation": "急需补货占比评估结论", | |
| 137 | + "risk_level": "high/medium/low", | |
| 138 | + "immediate_action_needed": true或false | |
| 139 | + }}, | |
| 140 | + "budget_allocation": {{ | |
| 141 | + "recommended_order": "建议资金分配顺序(基于分析得出)", | |
| 142 | + "urgent_budget": "急需补货建议预算(具体金额或比例)", | |
| 143 | + "suggested_budget": "建议补货建议预算", | |
| 144 | + "optional_budget": "可选补货建议预算" | |
| 145 | + }}, | |
| 146 | + "execution_plan": {{ | |
| 147 | + "urgent_timeline": "急需补货执行时间(1-3天内)", | |
| 148 | + "suggested_timeline": "建议补货执行时间(1-2周内)", | |
| 149 | + "optional_timeline": "可选补货执行时间(2-4周内)" | |
| 150 | + }}, | |
| 151 | + "risk_warnings": [ | |
| 152 | + {{ | |
| 153 | + "risk_type": "风险类型", | |
| 154 | + "description": "风险描述", | |
| 155 | + "mitigation": "应对建议" | |
| 156 | + }} | |
| 157 | + ] | |
| 158 | + }} | |
| 159 | +}} | |
| 160 | + | |
| 161 | +--- | |
| 162 | + | |
| 163 | +## 重要约束 | |
| 164 | + | |
| 165 | +1. **输出必须是合法的JSON对象** | |
| 166 | +2. **分析必须基于提供的数据,不要编造数据** | |
| 167 | +3. **每个结论都必须有明确的推理依据和数据支撑** | |
| 168 | +4. **建议必须具体可操作,包含时间和金额参考** | |
| 169 | +5. **risk_level 只能是 high、medium、low 三个值之一** | |
| 170 | +6. **immediate_action_needed 必须是布尔值 true 或 false** | |
| 171 | +7. **risk_warnings 数组至少包含1条,最多3条** | |
| 172 | +8. **如果数据全为零,请在分析中说明无补货建议数据** | |
| 173 | +9. **所有百分比计算结果保留两位小数** | ... | ... |
prompts/report_sales_analysis.md
0 → 100644
| 1 | +# 销量分析提示词 | |
| 2 | + | |
| 3 | +你是一位汽车配件销售数据分析师,擅长从销量数据中洞察需求趋势和业务机会。请基于以下销量统计数据,进行专业的销量分析。 | |
| 4 | + | |
| 5 | +--- | |
| 6 | + | |
| 7 | +## 统计数据 | |
| 8 | + | |
| 9 | +| 指标 | 数值 | | |
| 10 | +|------|------| | |
| 11 | +| 月均销量总数量 | {total_avg_sales_cnt} | | |
| 12 | +| 月均销量总金额 | {total_avg_sales_amount} 元 | | |
| 13 | +| 有销量配件数 | {has_sales_part_count} | | |
| 14 | +| 无销量配件数 | {no_sales_part_count} | | |
| 15 | + | |
| 16 | +### 销量构成明细 | |
| 17 | + | |
| 18 | +| 构成项 | 数量 | 说明 | | |
| 19 | +|--------|------|------| | |
| 20 | +| 90天出库数 | {total_out_stock_cnt} | 近90天实际出库,反映正常销售 | | |
| 21 | +| 未关单已锁 | {total_storage_locked_cnt} | 已锁定库存但订单未关闭,反映待处理订单 | | |
| 22 | +| 未关单出库 | {total_out_stock_ongoing_cnt} | 已出库但订单未关闭,反映在途交付 | | |
| 23 | +| 订件 | {total_buy_cnt} | 客户预订的配件数量,反映预订需求 | | |
| 24 | + | |
| 25 | +--- | |
| 26 | + | |
| 27 | +## 术语说明 | |
| 28 | + | |
| 29 | +- **月均销量**: (90天出库数 + 未关单已锁 + 未关单出库 + 订件) / 3 | |
| 30 | +- **有销量配件**: 月均销量 > 0 的配件 | |
| 31 | +- **无销量配件**: 月均销量 = 0 的配件 | |
| 32 | +- **SKU活跃率**: 有销量配件数 / 总配件数 × 100% | |
| 33 | + | |
| 34 | +--- | |
| 35 | + | |
| 36 | +## 当前季节信息 | |
| 37 | + | |
| 38 | +- **当前季节**: {current_season} | |
| 39 | +- **统计日期**: {statistics_date} | |
| 40 | + | |
| 41 | +--- | |
| 42 | + | |
| 43 | +## 季节性因素参考 | |
| 44 | + | |
| 45 | +| 季节 | 销量特征 | 关注重点 | | |
| 46 | +|------|---------|---------| | |
| 47 | +| 春季(3-5月) | 销量逐步回升,保养类配件需求增加 | 关注机油、滤芯等保养件销量变化 | | |
| 48 | +| 夏季(6-8月) | 空调、冷却系统配件销量高峰 | 制冷配件销量应明显上升,否则需关注 | | |
| 49 | +| 秋季(9-11月) | 销量平稳,换季保养需求 | 轮胎、刹车片等安全件需求增加 | | |
| 50 | +| 冬季(12-2月) | 电瓶、暖风配件需求增加,春节前订单高峰 | 订件占比可能上升,属正常现象 | | |
| 51 | + | |
| 52 | +--- | |
| 53 | + | |
| 54 | +## 分析框架与判断标准 | |
| 55 | + | |
| 56 | +### 销量构成健康标准 | |
| 57 | +| 构成项 | 健康占比范围 | 异常信号 | | |
| 58 | +|--------|-------------|---------| | |
| 59 | +| 90天出库数 | > 70% | 占比过低说明正常销售不足,可能存在订单积压 | | |
| 60 | +| 未关单已锁 | < 15% | 占比过高说明订单处理效率低,需关注 | | |
| 61 | +| 未关单出库 | < 10% | 占比过高说明交付周期长,客户体验受影响 | | |
| 62 | +| 订件 | 5%-15% | 过高说明预订需求旺盛但库存不足,过低说明预订渠道不畅 | | |
| 63 | + | |
| 64 | +### SKU活跃度判断标准 | |
| 65 | +| 活跃率范围 | 判断等级 | 建议 | | |
| 66 | +|-----------|---------|------| | |
| 67 | +| > 80% | 优秀 | SKU管理良好,保持现状 | | |
| 68 | +| 70%-80% | 良好 | 可适当优化无销量SKU | | |
| 69 | +| 50%-70% | 一般 | 需要重点关注SKU精简 | | |
| 70 | +| < 50% | 较差 | SKU管理存在严重问题,需立即优化 | | |
| 71 | + | |
| 72 | +### 需求趋势判断依据 | |
| 73 | +| 信号 | 趋势判断 | | |
| 74 | +|------|---------| | |
| 75 | +| 订件占比上升 + 未关单占比上升 | 上升(需求增长但供应跟不上) | | |
| 76 | +| 90天出库占比稳定 + 各项占比均衡 | 稳定(供需平衡) | | |
| 77 | +| 90天出库占比下降 + 订件占比下降 | 下降(需求萎缩) | | |
| 78 | + | |
| 79 | +--- | |
| 80 | + | |
| 81 | +## 分析任务 | |
| 82 | + | |
| 83 | +请严格按照以下步骤进行分析,每一步都要展示推理过程: | |
| 84 | + | |
| 85 | +### 步骤1:计算关键指标 | |
| 86 | +首先计算以下指标(请在分析中展示计算过程): | |
| 87 | +- 各构成项占比 = 构成项数量 / (90天出库数 + 未关单已锁 + 未关单出库 + 订件) × 100% | |
| 88 | +- SKU活跃率 = 有销量配件数 / (有销量配件数 + 无销量配件数) × 100% | |
| 89 | +- 单件平均销售金额 = 月均销量总金额 / 月均销量总数量 | |
| 90 | + | |
| 91 | +### 步骤2:销量构成分析 | |
| 92 | +- 对照健康标准,评估各构成项占比是否合理 | |
| 93 | +- 识别主要销量来源 | |
| 94 | +- 分析未关单(已锁+出库)对整体销量的影响 | |
| 95 | + | |
| 96 | +### 步骤3:SKU活跃度评估 | |
| 97 | +- 对照活跃度标准,确定当前活跃率等级 | |
| 98 | +- 分析无销量配件占比的业务影响 | |
| 99 | +- 提出SKU优化方向 | |
| 100 | + | |
| 101 | +### 步骤4:季节性分析 | |
| 102 | +- 结合当前季节特征,评估销量表现是否符合季节预期 | |
| 103 | +- 分析季节性配件的销量是否正常 | |
| 104 | + | |
| 105 | +### 步骤5:需求趋势判断 | |
| 106 | +- 根据各构成项的占比关系,判断需求趋势 | |
| 107 | +- 结合季节因素,说明判断依据 | |
| 108 | +- 给出短期需求预测(考虑季节变化) | |
| 109 | + | |
| 110 | +--- | |
| 111 | + | |
| 112 | +## 输出格式 | |
| 113 | + | |
| 114 | +直接输出JSON对象,**不要**包含 ```json 标记: | |
| 115 | + | |
| 116 | +{{ | |
| 117 | + "analysis_process": {{ | |
| 118 | + "calculated_metrics": {{ | |
| 119 | + "out_stock_ratio": "90天出库占比(计算过程:xxx / xxx = xx%)", | |
| 120 | + "locked_ratio": "未关单已锁占比(计算过程)", | |
| 121 | + "ongoing_ratio": "未关单出库占比(计算过程)", | |
| 122 | + "buy_ratio": "订件占比(计算过程)", | |
| 123 | + "sku_active_rate": "SKU活跃率(计算过程:xxx / xxx = xx%)", | |
| 124 | + "avg_sales_price": "单件平均销售金额(计算过程)" | |
| 125 | + }}, | |
| 126 | + "composition_diagnosis": {{ | |
| 127 | + "out_stock_evaluation": "90天出库占比评估(对照标准>70%,当前xx%,结论)", | |
| 128 | + "locked_evaluation": "未关单已锁占比评估(对照标准<15%,当前xx%,结论)", | |
| 129 | + "ongoing_evaluation": "未关单出库占比评估(对照标准<10%,当前xx%,结论)", | |
| 130 | + "buy_evaluation": "订件占比评估(对照标准5%-15%,当前xx%,结论)", | |
| 131 | + "abnormal_items": ["偏离健康范围的项目及原因分析"] | |
| 132 | + }}, | |
| 133 | + "activity_diagnosis": {{ | |
| 134 | + "current_rate": "当前SKU活跃率", | |
| 135 | + "level": "优秀/良好/一般/较差", | |
| 136 | + "reasoning": "判断依据:对照标准xxx,当前值xxx,因此判断为xxx" | |
| 137 | + }}, | |
| 138 | + "trend_diagnosis": {{ | |
| 139 | + "signals": ["观察到的趋势信号1", "观察到的趋势信号2"], | |
| 140 | + "reasoning": "基于以上信号,判断需求趋势为xxx,因为xxx" | |
| 141 | + }}, | |
| 142 | + "seasonal_analysis": {{ | |
| 143 | + "current_season": "当前季节", | |
| 144 | + "expected_performance": "本季节预期销量特征", | |
| 145 | + "actual_vs_expected": "实际表现与季节预期对比", | |
| 146 | + "seasonal_items_status": "季节性配件销量状态评估" | |
| 147 | + }} | |
| 148 | + }}, | |
| 149 | + "conclusion": {{ | |
| 150 | + "composition_analysis": {{ | |
| 151 | + "main_driver": "主要销量来源分析(基于占比计算得出)", | |
| 152 | + "pending_orders_impact": "未关单对销量的影响(基于占比计算得出)", | |
| 153 | + "booking_trend": "订件趋势分析(基于占比计算得出)" | |
| 154 | + }}, | |
| 155 | + "activity_assessment": {{ | |
| 156 | + "active_ratio": "活跃SKU占比评估结论", | |
| 157 | + "optimization_suggestion": "SKU优化建议(基于活跃度等级给出)" | |
| 158 | + }}, | |
| 159 | + "demand_trend": {{ | |
| 160 | + "direction": "上升/稳定/下降", | |
| 161 | + "evidence": "判断依据(列出具体数据支撑)", | |
| 162 | + "seasonal_factor": "季节因素对趋势的影响", | |
| 163 | + "forecast": "短期需求预测(考虑季节变化)" | |
| 164 | + }} | |
| 165 | + }} | |
| 166 | +}} | |
| 167 | + | |
| 168 | +--- | |
| 169 | + | |
| 170 | +## 重要约束 | |
| 171 | + | |
| 172 | +1. **输出必须是合法的JSON对象** | |
| 173 | +2. **分析必须基于提供的数据,不要编造数据** | |
| 174 | +3. **每个结论都必须有明确的推理依据和数据支撑** | |
| 175 | +4. **建议必须具体可操作,避免空泛的表述** | |
| 176 | +5. **direction 只能是"上升"、"稳定"、"下降"三个值之一** | |
| 177 | +6. **如果数据全为零,请在分析中说明无有效数据,并给出相应建议** | |
| 178 | +7. **所有百分比计算结果保留两位小数** | ... | ... |
prompts/sql_agent.md
| ... | ... | @@ -60,7 +60,7 @@ CREATE TABLE part_ratio ( |
| 60 | 60 | |
| 61 | 61 | | 指标 | 公式 | 说明 | |
| 62 | 62 | |------|------|------| |
| 63 | -| 有效库存 | `in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt + transfer_cnt + gen_transfer_cnt` | 可用库存总量 | | |
| 63 | +| 有效库存 | `in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt` | 可用库存总量 | | |
| 64 | 64 | | 月均销量 | `(out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3` | 基于90天数据计算 | |
| 65 | 65 | | 库销比 | `有效库存 / 月均销量` | 当月均销量 > 0 时有效 | |
| 66 | 66 | ... | ... |
sql/migrate_analysis_report.sql
| 1 | 1 | -- ============================================================================ |
| 2 | 2 | -- AI 补货建议分析报告表 |
| 3 | 3 | -- ============================================================================ |
| 4 | --- 版本: 2.0.0 | |
| 5 | --- 更新日期: 2026-02-05 | |
| 6 | --- 变更说明: 重构报告模块,聚焦补货决策支持(区别于传统库销分析) | |
| 4 | +-- 版本: 3.0.0 | |
| 5 | +-- 更新日期: 2026-02-10 | |
| 6 | +-- 变更说明: 重构为四大数据驱动板块(库存概览/销量分析/健康度/补货建议) | |
| 7 | 7 | -- ============================================================================ |
| 8 | 8 | |
| 9 | 9 | DROP TABLE IF EXISTS ai_analysis_report; |
| ... | ... | @@ -15,33 +15,23 @@ CREATE TABLE ai_analysis_report ( |
| 15 | 15 | dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称', |
| 16 | 16 | brand_grouping_id BIGINT COMMENT '品牌组合ID', |
| 17 | 17 | report_type VARCHAR(32) DEFAULT 'replenishment' COMMENT '报告类型', |
| 18 | - | |
| 19 | - -- 报告各模块 (JSON 结构化存储) - 宏观决策分析 | |
| 20 | - -- 注:字段名保持兼容,实际存储内容已更新为新模块 | |
| 21 | - replenishment_insights JSON COMMENT '整体态势研判(规模评估/结构分析/时机判断) - 原overall_assessment', | |
| 22 | - urgency_assessment JSON COMMENT '风险预警与应对(供应/资金/市场/执行风险) - 原risk_alerts', | |
| 23 | - strategy_recommendations JSON COMMENT '采购策略建议(优先级/批量机会/分批/供应商协调) - 原procurement_strategy', | |
| 24 | - execution_guide JSON COMMENT '已废弃,置为NULL', | |
| 25 | - expected_outcomes JSON COMMENT '效果预期与建议(库存健康/资金效率/后续行动) - 原expected_impact', | |
| 26 | - | |
| 27 | - -- 统计信息 | |
| 28 | - total_suggest_cnt INT DEFAULT 0 COMMENT '总建议数量', | |
| 29 | - total_suggest_amount DECIMAL(14,2) DEFAULT 0 COMMENT '总建议金额', | |
| 30 | - shortage_risk_cnt INT DEFAULT 0 COMMENT '缺货风险配件数', | |
| 31 | - excess_risk_cnt INT DEFAULT 0 COMMENT '过剩风险配件数', | |
| 32 | - stagnant_cnt INT DEFAULT 0 COMMENT '呆滞件数量', | |
| 33 | - low_freq_cnt INT DEFAULT 0 COMMENT '低频件数量', | |
| 34 | - | |
| 18 | + | |
| 19 | + -- 四大板块 (JSON 结构化存储,每个字段包含 stats + llm_analysis) | |
| 20 | + inventory_overview JSON COMMENT '库存总体概览(统计数据+LLM分析)', | |
| 21 | + sales_analysis JSON COMMENT '销量分析(统计数据+LLM分析)', | |
| 22 | + inventory_health JSON COMMENT '库存构成健康度(统计数据+图表数据+LLM分析)', | |
| 23 | + replenishment_summary JSON COMMENT '补货建议生成情况(统计数据+LLM分析)', | |
| 24 | + | |
| 35 | 25 | -- LLM 元数据 |
| 36 | 26 | llm_provider VARCHAR(32) COMMENT 'LLM提供商', |
| 37 | 27 | llm_model VARCHAR(64) COMMENT 'LLM模型名称', |
| 38 | 28 | llm_tokens INT DEFAULT 0 COMMENT 'LLM Token消耗', |
| 39 | 29 | execution_time_ms INT DEFAULT 0 COMMENT '执行耗时(毫秒)', |
| 40 | - | |
| 30 | + | |
| 41 | 31 | statistics_date VARCHAR(16) COMMENT '统计日期', |
| 42 | 32 | create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', |
| 43 | - | |
| 33 | + | |
| 44 | 34 | INDEX idx_task_no (task_no), |
| 45 | 35 | INDEX idx_group_date (group_id, statistics_date), |
| 46 | 36 | INDEX idx_dealer_grouping (dealer_grouping_id) |
| 47 | -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议分析报告表-结构化补货决策支持报告'; | |
| 37 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议分析报告表-重构版'; | ... | ... |
src/fw_pms_ai/agent/analysis_report_node.py
| 1 | 1 | """ |
| 2 | 2 | 分析报告生成节点 |
| 3 | 3 | |
| 4 | -在补货建议工作流的最后一个节点执行,生成结构化分析报告 | |
| 4 | +在补货建议工作流的最后一个节点执行,生成结构化分析报告。 | |
| 5 | +包含四大板块的统计计算函数:库存概览、销量分析、库存健康度、补货建议。 | |
| 5 | 6 | """ |
| 6 | 7 | |
| 7 | 8 | import logging |
| 8 | -import time | |
| 9 | -import json | |
| 10 | -import os | |
| 11 | -from typing import Dict, Any | |
| 12 | -from decimal import Decimal | |
| 13 | -from datetime import datetime | |
| 9 | +from decimal import Decimal, ROUND_HALF_UP | |
| 14 | 10 | |
| 15 | -from langchain_core.messages import HumanMessage | |
| 11 | +logger = logging.getLogger(__name__) | |
| 16 | 12 | |
| 17 | -from ..llm import get_llm_client | |
| 18 | -from ..models import AnalysisReport | |
| 19 | -from ..services.result_writer import ResultWriter | |
| 20 | 13 | |
| 21 | -logger = logging.getLogger(__name__) | |
| 14 | +def _to_decimal(value) -> Decimal: | |
| 15 | + """安全转换为 Decimal""" | |
| 16 | + if value is None: | |
| 17 | + return Decimal("0") | |
| 18 | + return Decimal(str(value)) | |
| 22 | 19 | |
| 23 | 20 | |
| 24 | -def _load_prompt(filename: str) -> str: | |
| 25 | - """从prompts目录加载提示词文件""" | |
| 26 | - prompts_dir = os.path.join( | |
| 27 | - os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))), | |
| 28 | - "prompts" | |
| 21 | +def calculate_inventory_overview(part_ratios: list[dict]) -> dict: | |
| 22 | + """ | |
| 23 | + 计算库存总体概览统计数据 | |
| 24 | + | |
| 25 | + 有效库存 = in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt | |
| 26 | + 资金占用 = in_stock_unlocked_cnt + on_the_way_cnt(仅计算实际占用资金的库存) | |
| 27 | + | |
| 28 | + Args: | |
| 29 | + part_ratios: PartRatio 字典列表 | |
| 30 | + | |
| 31 | + Returns: | |
| 32 | + 库存概览统计字典 | |
| 33 | + """ | |
| 34 | + total_in_stock_unlocked_cnt = Decimal("0") | |
| 35 | + total_in_stock_unlocked_amount = Decimal("0") | |
| 36 | + total_on_the_way_cnt = Decimal("0") | |
| 37 | + total_on_the_way_amount = Decimal("0") | |
| 38 | + total_has_plan_cnt = Decimal("0") | |
| 39 | + total_has_plan_amount = Decimal("0") | |
| 40 | + total_avg_sales_cnt = Decimal("0") | |
| 41 | + # 资金占用合计 = (在库未锁 + 在途) * 成本价 | |
| 42 | + total_capital_occupation = Decimal("0") | |
| 43 | + | |
| 44 | + for p in part_ratios: | |
| 45 | + cost_price = _to_decimal(p.get("cost_price", 0)) | |
| 46 | + | |
| 47 | + in_stock = _to_decimal(p.get("in_stock_unlocked_cnt", 0)) | |
| 48 | + on_way = _to_decimal(p.get("on_the_way_cnt", 0)) | |
| 49 | + has_plan = _to_decimal(p.get("has_plan_cnt", 0)) | |
| 50 | + | |
| 51 | + total_in_stock_unlocked_cnt += in_stock | |
| 52 | + total_in_stock_unlocked_amount += in_stock * cost_price | |
| 53 | + total_on_the_way_cnt += on_way | |
| 54 | + total_on_the_way_amount += on_way * cost_price | |
| 55 | + total_has_plan_cnt += has_plan | |
| 56 | + total_has_plan_amount += has_plan * cost_price | |
| 57 | + | |
| 58 | + # 资金占用 = 在库未锁 + 在途 | |
| 59 | + total_capital_occupation += (in_stock + on_way) * cost_price | |
| 60 | + | |
| 61 | + # 月均销量 | |
| 62 | + out_stock = _to_decimal(p.get("out_stock_cnt", 0)) | |
| 63 | + locked = _to_decimal(p.get("storage_locked_cnt", 0)) | |
| 64 | + ongoing = _to_decimal(p.get("out_stock_ongoing_cnt", 0)) | |
| 65 | + buy = _to_decimal(p.get("buy_cnt", 0)) | |
| 66 | + avg_sales = (out_stock + locked + ongoing + buy) / Decimal("3") | |
| 67 | + total_avg_sales_cnt += avg_sales | |
| 68 | + | |
| 69 | + total_valid_storage_cnt = ( | |
| 70 | + total_in_stock_unlocked_cnt | |
| 71 | + + total_on_the_way_cnt | |
| 72 | + + total_has_plan_cnt | |
| 29 | 73 | ) |
| 30 | - filepath = os.path.join(prompts_dir, filename) | |
| 31 | - | |
| 32 | - if not os.path.exists(filepath): | |
| 33 | - raise FileNotFoundError(f"Prompt文件未找到: {filepath}") | |
| 34 | - | |
| 35 | - with open(filepath, "r", encoding="utf-8") as f: | |
| 36 | - return f.read() | |
| 74 | + total_valid_storage_amount = ( | |
| 75 | + total_in_stock_unlocked_amount | |
| 76 | + + total_on_the_way_amount | |
| 77 | + + total_has_plan_amount | |
| 78 | + ) | |
| 79 | + | |
| 80 | + # 库销比:月均销量为零时标记为特殊值 | |
| 81 | + if total_avg_sales_cnt > 0: | |
| 82 | + overall_ratio = total_valid_storage_cnt / total_avg_sales_cnt | |
| 83 | + else: | |
| 84 | + overall_ratio = Decimal("999") | |
| 37 | 85 | |
| 86 | + return { | |
| 87 | + "total_valid_storage_cnt": total_valid_storage_cnt, | |
| 88 | + "total_valid_storage_amount": total_valid_storage_amount, | |
| 89 | + "total_capital_occupation": total_capital_occupation, | |
| 90 | + "total_in_stock_unlocked_cnt": total_in_stock_unlocked_cnt, | |
| 91 | + "total_in_stock_unlocked_amount": total_in_stock_unlocked_amount, | |
| 92 | + "total_on_the_way_cnt": total_on_the_way_cnt, | |
| 93 | + "total_on_the_way_amount": total_on_the_way_amount, | |
| 94 | + "total_has_plan_cnt": total_has_plan_cnt, | |
| 95 | + "total_has_plan_amount": total_has_plan_amount, | |
| 96 | + "total_avg_sales_cnt": total_avg_sales_cnt, | |
| 97 | + "overall_ratio": overall_ratio, | |
| 98 | + "part_count": len(part_ratios), | |
| 99 | + } | |
| 38 | 100 | |
| 39 | -def _calculate_suggestion_stats(part_results: list) -> dict: | |
| 101 | + | |
| 102 | +def calculate_sales_analysis(part_ratios: list[dict]) -> dict: | |
| 40 | 103 | """ |
| 41 | - 基于完整数据计算补货建议统计 | |
| 42 | - | |
| 43 | - 统计维度: | |
| 44 | - 1. 总体统计:总数量、总金额 | |
| 45 | - 2. 优先级分布:高/中/低优先级配件数及金额 | |
| 46 | - 3. 价格区间分布:低价/中价/高价配件分布 | |
| 47 | - 4. 周转频次分布:高频/中频/低频配件分布 | |
| 48 | - 5. 补货规模分布:大额/中额/小额补货配件分布 | |
| 104 | + 计算销量分析统计数据 | |
| 105 | + | |
| 106 | + 月均销量 = (out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3 | |
| 107 | + | |
| 108 | + Args: | |
| 109 | + part_ratios: PartRatio 字典列表 | |
| 110 | + | |
| 111 | + Returns: | |
| 112 | + 销量分析统计字典 | |
| 49 | 113 | """ |
| 50 | - stats = { | |
| 51 | - # 总体统计 | |
| 52 | - "total_parts_cnt": 0, | |
| 53 | - "total_suggest_cnt": 0, | |
| 54 | - "total_suggest_amount": Decimal("0"), | |
| 55 | - | |
| 56 | - # 优先级分布: 1=高, 2=中, 3=低 | |
| 57 | - "priority_high_cnt": 0, | |
| 58 | - "priority_high_amount": Decimal("0"), | |
| 59 | - "priority_medium_cnt": 0, | |
| 60 | - "priority_medium_amount": Decimal("0"), | |
| 61 | - "priority_low_cnt": 0, | |
| 62 | - "priority_low_amount": Decimal("0"), | |
| 63 | - | |
| 64 | - # 价格区间分布 (成本价) | |
| 65 | - "price_low_cnt": 0, | |
| 66 | - "price_low_amount": Decimal("0"), | |
| 67 | - "price_medium_cnt": 0, | |
| 68 | - "price_medium_amount": Decimal("0"), | |
| 69 | - "price_high_cnt": 0, | |
| 70 | - "price_high_amount": Decimal("0"), | |
| 71 | - | |
| 72 | - # 周转频次分布 (月均销量) | |
| 73 | - "turnover_high_cnt": 0, | |
| 74 | - "turnover_high_amount": Decimal("0"), | |
| 75 | - "turnover_medium_cnt": 0, | |
| 76 | - "turnover_medium_amount": Decimal("0"), | |
| 77 | - "turnover_low_cnt": 0, | |
| 78 | - "turnover_low_amount": Decimal("0"), | |
| 79 | - | |
| 80 | - # 补货金额分布 | |
| 81 | - "replenish_large_cnt": 0, | |
| 82 | - "replenish_large_amount": Decimal("0"), | |
| 83 | - "replenish_medium_cnt": 0, | |
| 84 | - "replenish_medium_amount": Decimal("0"), | |
| 85 | - "replenish_small_cnt": 0, | |
| 86 | - "replenish_small_amount": Decimal("0"), | |
| 114 | + total_out_stock_cnt = Decimal("0") | |
| 115 | + total_storage_locked_cnt = Decimal("0") | |
| 116 | + total_out_stock_ongoing_cnt = Decimal("0") | |
| 117 | + total_buy_cnt = Decimal("0") | |
| 118 | + total_avg_sales_amount = Decimal("0") | |
| 119 | + has_sales_part_count = 0 | |
| 120 | + no_sales_part_count = 0 | |
| 121 | + | |
| 122 | + for p in part_ratios: | |
| 123 | + cost_price = _to_decimal(p.get("cost_price", 0)) | |
| 124 | + | |
| 125 | + out_stock = _to_decimal(p.get("out_stock_cnt", 0)) | |
| 126 | + locked = _to_decimal(p.get("storage_locked_cnt", 0)) | |
| 127 | + ongoing = _to_decimal(p.get("out_stock_ongoing_cnt", 0)) | |
| 128 | + buy = _to_decimal(p.get("buy_cnt", 0)) | |
| 129 | + | |
| 130 | + total_out_stock_cnt += out_stock | |
| 131 | + total_storage_locked_cnt += locked | |
| 132 | + total_out_stock_ongoing_cnt += ongoing | |
| 133 | + total_buy_cnt += buy | |
| 134 | + | |
| 135 | + avg_sales = (out_stock + locked + ongoing + buy) / Decimal("3") | |
| 136 | + total_avg_sales_amount += avg_sales * cost_price | |
| 137 | + | |
| 138 | + if avg_sales > 0: | |
| 139 | + has_sales_part_count += 1 | |
| 140 | + else: | |
| 141 | + no_sales_part_count += 1 | |
| 142 | + | |
| 143 | + total_avg_sales_cnt = ( | |
| 144 | + total_out_stock_cnt + total_storage_locked_cnt + total_out_stock_ongoing_cnt + total_buy_cnt | |
| 145 | + ) / Decimal("3") | |
| 146 | + | |
| 147 | + return { | |
| 148 | + "total_avg_sales_cnt": total_avg_sales_cnt, | |
| 149 | + "total_avg_sales_amount": total_avg_sales_amount, | |
| 150 | + "total_out_stock_cnt": total_out_stock_cnt, | |
| 151 | + "total_storage_locked_cnt": total_storage_locked_cnt, | |
| 152 | + "total_out_stock_ongoing_cnt": total_out_stock_ongoing_cnt, | |
| 153 | + "total_buy_cnt": total_buy_cnt, | |
| 154 | + "has_sales_part_count": has_sales_part_count, | |
| 155 | + "no_sales_part_count": no_sales_part_count, | |
| 87 | 156 | } |
| 88 | - | |
| 89 | - if not part_results: | |
| 90 | - return stats | |
| 91 | - | |
| 92 | - for pr in part_results: | |
| 93 | - # 兼容对象和字典两种形式 | |
| 94 | - if hasattr(pr, "total_suggest_cnt"): | |
| 95 | - suggest_cnt = pr.total_suggest_cnt | |
| 96 | - suggest_amount = pr.total_suggest_amount | |
| 97 | - cost_price = pr.cost_price | |
| 98 | - avg_sales = pr.total_avg_sales_cnt | |
| 99 | - priority = pr.priority | |
| 157 | + | |
| 158 | + | |
| 159 | +def _classify_part(p: dict) -> str: | |
| 160 | + """ | |
| 161 | + 将配件分类为缺货/呆滞/低频/正常 | |
| 162 | + | |
| 163 | + 分类规则(按优先级顺序判断): | |
| 164 | + - 缺货件: 有效库存 = 0 且 月均销量 >= 1 | |
| 165 | + - 呆滞件: 有效库存 > 0 且 90天出库数 = 0 | |
| 166 | + - 低频件: 月均销量 < 1 或 出库次数 < 3 或 出库间隔 >= 30天 | |
| 167 | + - 正常件: 不属于以上三类 | |
| 168 | + """ | |
| 169 | + in_stock = _to_decimal(p.get("in_stock_unlocked_cnt", 0)) | |
| 170 | + on_way = _to_decimal(p.get("on_the_way_cnt", 0)) | |
| 171 | + has_plan = _to_decimal(p.get("has_plan_cnt", 0)) | |
| 172 | + valid_storage = in_stock + on_way + has_plan | |
| 173 | + | |
| 174 | + out_stock = _to_decimal(p.get("out_stock_cnt", 0)) | |
| 175 | + locked = _to_decimal(p.get("storage_locked_cnt", 0)) | |
| 176 | + ongoing = _to_decimal(p.get("out_stock_ongoing_cnt", 0)) | |
| 177 | + buy = _to_decimal(p.get("buy_cnt", 0)) | |
| 178 | + avg_sales = (out_stock + locked + ongoing + buy) / Decimal("3") | |
| 179 | + | |
| 180 | + out_times = int(p.get("out_times", 0) or 0) | |
| 181 | + out_duration = int(p.get("out_duration", 0) or 0) | |
| 182 | + | |
| 183 | + # 缺货件 | |
| 184 | + if valid_storage == 0 and avg_sales >= 1: | |
| 185 | + return "shortage" | |
| 186 | + | |
| 187 | + # 呆滞件 | |
| 188 | + if valid_storage > 0 and out_stock == 0: | |
| 189 | + return "stagnant" | |
| 190 | + | |
| 191 | + # 低频件 | |
| 192 | + if avg_sales < 1 or out_times < 3 or out_duration >= 30: | |
| 193 | + return "low_freq" | |
| 194 | + | |
| 195 | + return "normal" | |
| 196 | + | |
| 197 | + | |
| 198 | +def calculate_inventory_health(part_ratios: list[dict]) -> dict: | |
| 199 | + """ | |
| 200 | + 计算库存构成健康度统计数据 | |
| 201 | + | |
| 202 | + 将每个配件归类为缺货件/呆滞件/低频件/正常件,统计各类型数量/金额/百分比, | |
| 203 | + 并生成 chart_data 供前端图表使用。 | |
| 204 | + | |
| 205 | + Args: | |
| 206 | + part_ratios: PartRatio 字典列表 | |
| 207 | + | |
| 208 | + Returns: | |
| 209 | + 健康度统计字典(含 chart_data) | |
| 210 | + """ | |
| 211 | + categories = { | |
| 212 | + "shortage": {"count": 0, "amount": Decimal("0")}, | |
| 213 | + "stagnant": {"count": 0, "amount": Decimal("0")}, | |
| 214 | + "low_freq": {"count": 0, "amount": Decimal("0")}, | |
| 215 | + "normal": {"count": 0, "amount": Decimal("0")}, | |
| 216 | + } | |
| 217 | + | |
| 218 | + for p in part_ratios: | |
| 219 | + cat = _classify_part(p) | |
| 220 | + cost_price = _to_decimal(p.get("cost_price", 0)) | |
| 221 | + | |
| 222 | + # 有效库存金额 | |
| 223 | + in_stock = _to_decimal(p.get("in_stock_unlocked_cnt", 0)) | |
| 224 | + on_way = _to_decimal(p.get("on_the_way_cnt", 0)) | |
| 225 | + has_plan = _to_decimal(p.get("has_plan_cnt", 0)) | |
| 226 | + valid_storage = in_stock + on_way + has_plan | |
| 227 | + amount = valid_storage * cost_price | |
| 228 | + | |
| 229 | + categories[cat]["count"] += 1 | |
| 230 | + categories[cat]["amount"] += amount | |
| 231 | + | |
| 232 | + total_count = len(part_ratios) | |
| 233 | + total_amount = sum(c["amount"] for c in categories.values()) | |
| 234 | + | |
| 235 | + # 计算百分比 | |
| 236 | + result = {} | |
| 237 | + for cat_name, data in categories.items(): | |
| 238 | + count_pct = (data["count"] / total_count * 100) if total_count > 0 else 0.0 | |
| 239 | + amount_pct = (float(data["amount"]) / float(total_amount) * 100) if total_amount > 0 else 0.0 | |
| 240 | + result[cat_name] = { | |
| 241 | + "count": data["count"], | |
| 242 | + "amount": data["amount"], | |
| 243 | + "count_pct": round(count_pct, 2), | |
| 244 | + "amount_pct": round(amount_pct, 2), | |
| 245 | + } | |
| 246 | + | |
| 247 | + result["total_count"] = total_count | |
| 248 | + result["total_amount"] = total_amount | |
| 249 | + | |
| 250 | + # chart_data 供前端 Chart.js 使用 | |
| 251 | + labels = ["缺货件", "呆滞件", "低频件", "正常件"] | |
| 252 | + cat_keys = ["shortage", "stagnant", "low_freq", "normal"] | |
| 253 | + result["chart_data"] = { | |
| 254 | + "labels": labels, | |
| 255 | + "count_values": [categories[k]["count"] for k in cat_keys], | |
| 256 | + "amount_values": [float(categories[k]["amount"]) for k in cat_keys], | |
| 257 | + } | |
| 258 | + | |
| 259 | + return result | |
| 260 | + | |
| 261 | + | |
| 262 | +def calculate_replenishment_summary(part_results: list) -> dict: | |
| 263 | + """ | |
| 264 | + 计算补货建议生成情况统计数据 | |
| 265 | + | |
| 266 | + 按优先级分类统计: | |
| 267 | + - priority=1: 急需补货 | |
| 268 | + - priority=2: 建议补货 | |
| 269 | + - priority=3: 可选补货 | |
| 270 | + | |
| 271 | + Args: | |
| 272 | + part_results: 配件汇总结果列表(字典或 ReplenishmentPartSummary 对象) | |
| 273 | + | |
| 274 | + Returns: | |
| 275 | + 补货建议统计字典 | |
| 276 | + """ | |
| 277 | + urgent = {"count": 0, "amount": Decimal("0")} | |
| 278 | + suggested = {"count": 0, "amount": Decimal("0")} | |
| 279 | + optional = {"count": 0, "amount": Decimal("0")} | |
| 280 | + | |
| 281 | + for item in part_results: | |
| 282 | + # 兼容字典和对象两种形式 | |
| 283 | + if isinstance(item, dict): | |
| 284 | + priority = int(item.get("priority", 0)) | |
| 285 | + amount = _to_decimal(item.get("total_suggest_amount", 0)) | |
| 100 | 286 | else: |
| 101 | - suggest_cnt = int(pr.get("total_suggest_cnt", 0)) | |
| 102 | - suggest_amount = Decimal(str(pr.get("total_suggest_amount", 0))) | |
| 103 | - cost_price = Decimal(str(pr.get("cost_price", 0))) | |
| 104 | - avg_sales = Decimal(str(pr.get("total_avg_sales_cnt", 0))) | |
| 105 | - priority = int(pr.get("priority", 2)) | |
| 106 | - | |
| 107 | - # 总体统计 | |
| 108 | - stats["total_parts_cnt"] += 1 | |
| 109 | - stats["total_suggest_cnt"] += suggest_cnt | |
| 110 | - stats["total_suggest_amount"] += suggest_amount | |
| 111 | - | |
| 112 | - # 优先级分布 | |
| 287 | + priority = getattr(item, "priority", 0) | |
| 288 | + amount = _to_decimal(getattr(item, "total_suggest_amount", 0)) | |
| 289 | + | |
| 113 | 290 | if priority == 1: |
| 114 | - stats["priority_high_cnt"] += 1 | |
| 115 | - stats["priority_high_amount"] += suggest_amount | |
| 291 | + urgent["count"] += 1 | |
| 292 | + urgent["amount"] += amount | |
| 116 | 293 | elif priority == 2: |
| 117 | - stats["priority_medium_cnt"] += 1 | |
| 118 | - stats["priority_medium_amount"] += suggest_amount | |
| 119 | - else: | |
| 120 | - stats["priority_low_cnt"] += 1 | |
| 121 | - stats["priority_low_amount"] += suggest_amount | |
| 122 | - | |
| 123 | - # 价格区间分布: <50低价, 50-200中价, >200高价 | |
| 124 | - if cost_price < 50: | |
| 125 | - stats["price_low_cnt"] += 1 | |
| 126 | - stats["price_low_amount"] += suggest_amount | |
| 127 | - elif cost_price <= 200: | |
| 128 | - stats["price_medium_cnt"] += 1 | |
| 129 | - stats["price_medium_amount"] += suggest_amount | |
| 130 | - else: | |
| 131 | - stats["price_high_cnt"] += 1 | |
| 132 | - stats["price_high_amount"] += suggest_amount | |
| 133 | - | |
| 134 | - # 周转频次分布: 月均销量 >=5高频, 1-5中频, <1低频 | |
| 135 | - if avg_sales >= 5: | |
| 136 | - stats["turnover_high_cnt"] += 1 | |
| 137 | - stats["turnover_high_amount"] += suggest_amount | |
| 138 | - elif avg_sales >= 1: | |
| 139 | - stats["turnover_medium_cnt"] += 1 | |
| 140 | - stats["turnover_medium_amount"] += suggest_amount | |
| 141 | - else: | |
| 142 | - stats["turnover_low_cnt"] += 1 | |
| 143 | - stats["turnover_low_amount"] += suggest_amount | |
| 144 | - | |
| 145 | - # 补货金额分布: >=5000大额, 1000-5000中额, <1000小额 | |
| 146 | - if suggest_amount >= 5000: | |
| 147 | - stats["replenish_large_cnt"] += 1 | |
| 148 | - stats["replenish_large_amount"] += suggest_amount | |
| 149 | - elif suggest_amount >= 1000: | |
| 150 | - stats["replenish_medium_cnt"] += 1 | |
| 151 | - stats["replenish_medium_amount"] += suggest_amount | |
| 294 | + suggested["count"] += 1 | |
| 295 | + suggested["amount"] += amount | |
| 296 | + elif priority == 3: | |
| 297 | + optional["count"] += 1 | |
| 298 | + optional["amount"] += amount | |
| 299 | + | |
| 300 | + total_count = urgent["count"] + suggested["count"] + optional["count"] | |
| 301 | + total_amount = urgent["amount"] + suggested["amount"] + optional["amount"] | |
| 302 | + | |
| 303 | + return { | |
| 304 | + "urgent": urgent, | |
| 305 | + "suggested": suggested, | |
| 306 | + "optional": optional, | |
| 307 | + "total_count": total_count, | |
| 308 | + "total_amount": total_amount, | |
| 309 | + } | |
| 310 | + | |
| 311 | + | |
| 312 | +# ============================================================ | |
| 313 | +# LLM 分析函数 | |
| 314 | +# ============================================================ | |
| 315 | + | |
| 316 | +import os | |
| 317 | +import json | |
| 318 | +import time | |
| 319 | +from langchain_core.messages import SystemMessage, HumanMessage | |
| 320 | + | |
| 321 | + | |
| 322 | +def _load_prompt(filename: str) -> str: | |
| 323 | + """从 prompts 目录加载提示词文件""" | |
| 324 | + prompt_path = os.path.join( | |
| 325 | + os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))), | |
| 326 | + "prompts", | |
| 327 | + filename, | |
| 328 | + ) | |
| 329 | + with open(prompt_path, "r", encoding="utf-8") as f: | |
| 330 | + return f.read() | |
| 331 | + | |
| 332 | + | |
| 333 | +def _format_decimal(value) -> str: | |
| 334 | + """将 Decimal 格式化为字符串,用于填充提示词""" | |
| 335 | + if value is None: | |
| 336 | + return "0" | |
| 337 | + return str(round(float(value), 2)) | |
| 338 | + | |
| 339 | + | |
| 340 | +def _get_season_from_date(date_str: str) -> str: | |
| 341 | + """ | |
| 342 | + 根据日期字符串获取季节 | |
| 343 | + | |
| 344 | + Args: | |
| 345 | + date_str: 日期字符串,格式如 "2024-01-15" 或 "20240115" | |
| 346 | + | |
| 347 | + Returns: | |
| 348 | + 季节名称:春季/夏季/秋季/冬季 | |
| 349 | + """ | |
| 350 | + from datetime import datetime | |
| 351 | + | |
| 352 | + try: | |
| 353 | + # 尝试解析不同格式的日期 | |
| 354 | + if "-" in date_str: | |
| 355 | + dt = datetime.strptime(date_str[:10], "%Y-%m-%d") | |
| 152 | 356 | else: |
| 153 | - stats["replenish_small_cnt"] += 1 | |
| 154 | - stats["replenish_small_amount"] += suggest_amount | |
| 155 | - | |
| 156 | - return stats | |
| 157 | - | |
| 158 | - | |
| 159 | -def _calculate_risk_stats(part_ratios: list) -> dict: | |
| 160 | - """计算风险统计数据""" | |
| 161 | - stats = { | |
| 162 | - "shortage_cnt": 0, | |
| 163 | - "shortage_amount": Decimal("0"), | |
| 164 | - "stagnant_cnt": 0, | |
| 165 | - "stagnant_amount": Decimal("0"), | |
| 166 | - "low_freq_cnt": 0, | |
| 167 | - "low_freq_amount": Decimal("0"), | |
| 357 | + dt = datetime.strptime(date_str[:8], "%Y%m%d") | |
| 358 | + month = dt.month | |
| 359 | + except (ValueError, TypeError): | |
| 360 | + # 解析失败时使用当前月份 | |
| 361 | + month = datetime.now().month | |
| 362 | + | |
| 363 | + if month in (3, 4, 5): | |
| 364 | + return "春季(3-5月)" | |
| 365 | + elif month in (6, 7, 8): | |
| 366 | + return "夏季(6-8月)" | |
| 367 | + elif month in (9, 10, 11): | |
| 368 | + return "秋季(9-11月)" | |
| 369 | + else: | |
| 370 | + return "冬季(12-2月)" | |
| 371 | + | |
| 372 | + | |
| 373 | +def _parse_llm_json(content: str) -> dict: | |
| 374 | + """ | |
| 375 | + 解析 LLM 返回的 JSON 内容 | |
| 376 | + | |
| 377 | + 尝试直接解析,如果失败则尝试提取 ```json 代码块中的内容。 | |
| 378 | + """ | |
| 379 | + text = content.strip() | |
| 380 | + | |
| 381 | + # 尝试直接解析 | |
| 382 | + try: | |
| 383 | + return json.loads(text) | |
| 384 | + except json.JSONDecodeError: | |
| 385 | + pass | |
| 386 | + | |
| 387 | + # 尝试提取 ```json ... ``` 代码块 | |
| 388 | + import re | |
| 389 | + match = re.search(r"```json\s*(.*?)\s*```", text, re.DOTALL) | |
| 390 | + if match: | |
| 391 | + try: | |
| 392 | + return json.loads(match.group(1)) | |
| 393 | + except json.JSONDecodeError: | |
| 394 | + pass | |
| 395 | + | |
| 396 | + # 尝试提取 { ... } 块 | |
| 397 | + start = text.find("{") | |
| 398 | + end = text.rfind("}") | |
| 399 | + if start != -1 and end != -1 and end > start: | |
| 400 | + try: | |
| 401 | + return json.loads(text[start : end + 1]) | |
| 402 | + except json.JSONDecodeError: | |
| 403 | + pass | |
| 404 | + | |
| 405 | + # 解析失败 | |
| 406 | + raise json.JSONDecodeError("无法从 LLM 响应中解析 JSON", text, 0) | |
| 407 | + | |
| 408 | + | |
| 409 | +def llm_analyze_inventory_overview(stats: dict, statistics_date: str = "", llm_client=None) -> tuple[dict, dict]: | |
| 410 | + """ | |
| 411 | + LLM 分析库存概览 | |
| 412 | + | |
| 413 | + Args: | |
| 414 | + stats: calculate_inventory_overview 的输出 | |
| 415 | + statistics_date: 统计日期 | |
| 416 | + llm_client: LLM 客户端实例,为 None 时自动获取 | |
| 417 | + | |
| 418 | + Returns: | |
| 419 | + (llm_analysis_dict, usage_dict) | |
| 420 | + """ | |
| 421 | + from ..llm import get_llm_client | |
| 422 | + | |
| 423 | + if llm_client is None: | |
| 424 | + llm_client = get_llm_client() | |
| 425 | + | |
| 426 | + current_season = _get_season_from_date(statistics_date) | |
| 427 | + | |
| 428 | + prompt_template = _load_prompt("report_inventory_overview.md") | |
| 429 | + prompt = prompt_template.format( | |
| 430 | + part_count=stats.get("part_count", 0), | |
| 431 | + total_valid_storage_cnt=_format_decimal(stats.get("total_valid_storage_cnt")), | |
| 432 | + total_valid_storage_amount=_format_decimal(stats.get("total_valid_storage_amount")), | |
| 433 | + total_avg_sales_cnt=_format_decimal(stats.get("total_avg_sales_cnt")), | |
| 434 | + overall_ratio=_format_decimal(stats.get("overall_ratio")), | |
| 435 | + total_in_stock_unlocked_cnt=_format_decimal(stats.get("total_in_stock_unlocked_cnt")), | |
| 436 | + total_in_stock_unlocked_amount=_format_decimal(stats.get("total_in_stock_unlocked_amount")), | |
| 437 | + total_on_the_way_cnt=_format_decimal(stats.get("total_on_the_way_cnt")), | |
| 438 | + total_on_the_way_amount=_format_decimal(stats.get("total_on_the_way_amount")), | |
| 439 | + total_has_plan_cnt=_format_decimal(stats.get("total_has_plan_cnt")), | |
| 440 | + total_has_plan_amount=_format_decimal(stats.get("total_has_plan_amount")), | |
| 441 | + current_season=current_season, | |
| 442 | + statistics_date=statistics_date or "未知", | |
| 443 | + ) | |
| 444 | + | |
| 445 | + messages = [HumanMessage(content=prompt)] | |
| 446 | + response = llm_client.invoke(messages) | |
| 447 | + | |
| 448 | + try: | |
| 449 | + analysis = _parse_llm_json(response.content) | |
| 450 | + except json.JSONDecodeError: | |
| 451 | + logger.warning(f"库存概览 LLM JSON 解析失败,原始响应: {response.content[:200]}") | |
| 452 | + analysis = {"error": "JSON解析失败", "raw": response.content[:200]} | |
| 453 | + | |
| 454 | + usage = { | |
| 455 | + "provider": response.usage.provider, | |
| 456 | + "model": response.usage.model, | |
| 457 | + "prompt_tokens": response.usage.prompt_tokens, | |
| 458 | + "completion_tokens": response.usage.completion_tokens, | |
| 168 | 459 | } |
| 169 | - | |
| 170 | - for pr in part_ratios: | |
| 171 | - valid_storage = Decimal(str(pr.get("valid_storage_cnt", 0) or 0)) | |
| 172 | - avg_sales = Decimal(str(pr.get("avg_sales_cnt", 0) or 0)) | |
| 173 | - out_stock = Decimal(str(pr.get("out_stock_cnt", 0) or 0)) | |
| 174 | - cost_price = Decimal(str(pr.get("cost_price", 0) or 0)) | |
| 175 | - | |
| 176 | - # 呆滞件: 有库存但90天无出库 | |
| 177 | - if valid_storage > 0 and out_stock == 0: | |
| 178 | - stats["stagnant_cnt"] += 1 | |
| 179 | - stats["stagnant_amount"] += valid_storage * cost_price | |
| 180 | - | |
| 181 | - # 低频件: 无库存且月均销量<1 | |
| 182 | - elif valid_storage == 0 and avg_sales < 1: | |
| 183 | - stats["low_freq_cnt"] += 1 | |
| 184 | - | |
| 185 | - # 缺货件: 无库存且月均销量>=1 | |
| 186 | - elif valid_storage == 0 and avg_sales >= 1: | |
| 187 | - stats["shortage_cnt"] += 1 | |
| 188 | - # 缺货损失估算:月均销量 * 成本价 | |
| 189 | - stats["shortage_amount"] += avg_sales * cost_price | |
| 190 | - | |
| 191 | - return stats | |
| 460 | + return analysis, usage | |
| 192 | 461 | |
| 193 | 462 | |
| 194 | -def _build_suggestion_summary(suggestion_stats: dict) -> str: | |
| 463 | +def llm_analyze_sales(stats: dict, statistics_date: str = "", llm_client=None) -> tuple[dict, dict]: | |
| 195 | 464 | """ |
| 196 | - 基于预计算的统计数据构建结构化补货建议摘要 | |
| 197 | - | |
| 198 | - 摘要包含: | |
| 199 | - - 补货总体规模 | |
| 200 | - - 优先级分布 | |
| 201 | - - 价格区间分布 | |
| 202 | - - 周转频次分布 | |
| 203 | - - 补货金额分布 | |
| 465 | + LLM 分析销量 | |
| 466 | + | |
| 467 | + Args: | |
| 468 | + stats: calculate_sales_analysis 的输出 | |
| 469 | + statistics_date: 统计日期 | |
| 470 | + llm_client: LLM 客户端实例 | |
| 471 | + | |
| 472 | + Returns: | |
| 473 | + (llm_analysis_dict, usage_dict) | |
| 204 | 474 | """ |
| 205 | - if suggestion_stats["total_parts_cnt"] == 0: | |
| 206 | - return "暂无补货建议" | |
| 207 | - | |
| 208 | - lines = [] | |
| 209 | - | |
| 210 | - # 总体规模 | |
| 211 | - lines.append(f"### 补货总体规模") | |
| 212 | - lines.append(f"- 涉及配件种类: {suggestion_stats['total_parts_cnt']}种") | |
| 213 | - lines.append(f"- 建议补货总数量: {suggestion_stats['total_suggest_cnt']}件") | |
| 214 | - lines.append(f"- 建议补货总金额: {suggestion_stats['total_suggest_amount']:.2f}元") | |
| 215 | - lines.append("") | |
| 216 | - | |
| 217 | - # 优先级分布 | |
| 218 | - lines.append(f"### 优先级分布") | |
| 219 | - lines.append(f"| 优先级 | 配件数 | 金额(元) | 占比 |") | |
| 220 | - lines.append(f"|--------|--------|----------|------|") | |
| 221 | - total_amount = suggestion_stats['total_suggest_amount'] or Decimal("1") | |
| 222 | - | |
| 223 | - if suggestion_stats['priority_high_cnt'] > 0: | |
| 224 | - pct = suggestion_stats['priority_high_amount'] / total_amount * 100 | |
| 225 | - lines.append(f"| 高优先级 | {suggestion_stats['priority_high_cnt']} | {suggestion_stats['priority_high_amount']:.2f} | {pct:.1f}% |") | |
| 226 | - if suggestion_stats['priority_medium_cnt'] > 0: | |
| 227 | - pct = suggestion_stats['priority_medium_amount'] / total_amount * 100 | |
| 228 | - lines.append(f"| 中优先级 | {suggestion_stats['priority_medium_cnt']} | {suggestion_stats['priority_medium_amount']:.2f} | {pct:.1f}% |") | |
| 229 | - if suggestion_stats['priority_low_cnt'] > 0: | |
| 230 | - pct = suggestion_stats['priority_low_amount'] / total_amount * 100 | |
| 231 | - lines.append(f"| 低优先级 | {suggestion_stats['priority_low_cnt']} | {suggestion_stats['priority_low_amount']:.2f} | {pct:.1f}% |") | |
| 232 | - lines.append("") | |
| 233 | - | |
| 234 | - # 价格区间分布 | |
| 235 | - lines.append(f"### 价格区间分布 (按成本价)") | |
| 236 | - lines.append(f"| 价格区间 | 配件数 | 金额(元) | 占比 |") | |
| 237 | - lines.append(f"|----------|--------|----------|------|") | |
| 238 | - if suggestion_stats['price_low_cnt'] > 0: | |
| 239 | - pct = suggestion_stats['price_low_amount'] / total_amount * 100 | |
| 240 | - lines.append(f"| 低价(<50元) | {suggestion_stats['price_low_cnt']} | {suggestion_stats['price_low_amount']:.2f} | {pct:.1f}% |") | |
| 241 | - if suggestion_stats['price_medium_cnt'] > 0: | |
| 242 | - pct = suggestion_stats['price_medium_amount'] / total_amount * 100 | |
| 243 | - lines.append(f"| 中价(50-200元) | {suggestion_stats['price_medium_cnt']} | {suggestion_stats['price_medium_amount']:.2f} | {pct:.1f}% |") | |
| 244 | - if suggestion_stats['price_high_cnt'] > 0: | |
| 245 | - pct = suggestion_stats['price_high_amount'] / total_amount * 100 | |
| 246 | - lines.append(f"| 高价(>200元) | {suggestion_stats['price_high_cnt']} | {suggestion_stats['price_high_amount']:.2f} | {pct:.1f}% |") | |
| 247 | - lines.append("") | |
| 248 | - | |
| 249 | - # 周转频次分布 | |
| 250 | - lines.append(f"### 周转频次分布 (按月均销量)") | |
| 251 | - lines.append(f"| 周转频次 | 配件数 | 金额(元) | 占比 |") | |
| 252 | - lines.append(f"|----------|--------|----------|------|") | |
| 253 | - if suggestion_stats['turnover_high_cnt'] > 0: | |
| 254 | - pct = suggestion_stats['turnover_high_amount'] / total_amount * 100 | |
| 255 | - lines.append(f"| 高频(≥5件/月) | {suggestion_stats['turnover_high_cnt']} | {suggestion_stats['turnover_high_amount']:.2f} | {pct:.1f}% |") | |
| 256 | - if suggestion_stats['turnover_medium_cnt'] > 0: | |
| 257 | - pct = suggestion_stats['turnover_medium_amount'] / total_amount * 100 | |
| 258 | - lines.append(f"| 中频(1-5件/月) | {suggestion_stats['turnover_medium_cnt']} | {suggestion_stats['turnover_medium_amount']:.2f} | {pct:.1f}% |") | |
| 259 | - if suggestion_stats['turnover_low_cnt'] > 0: | |
| 260 | - pct = suggestion_stats['turnover_low_amount'] / total_amount * 100 | |
| 261 | - lines.append(f"| 低频(<1件/月) | {suggestion_stats['turnover_low_cnt']} | {suggestion_stats['turnover_low_amount']:.2f} | {pct:.1f}% |") | |
| 262 | - lines.append("") | |
| 263 | - | |
| 264 | - # 补货金额分布 | |
| 265 | - lines.append(f"### 单配件补货金额分布") | |
| 266 | - lines.append(f"| 补货规模 | 配件数 | 金额(元) | 占比 |") | |
| 267 | - lines.append(f"|----------|--------|----------|------|") | |
| 268 | - if suggestion_stats['replenish_large_cnt'] > 0: | |
| 269 | - pct = suggestion_stats['replenish_large_amount'] / total_amount * 100 | |
| 270 | - lines.append(f"| 大额(≥5000元) | {suggestion_stats['replenish_large_cnt']} | {suggestion_stats['replenish_large_amount']:.2f} | {pct:.1f}% |") | |
| 271 | - if suggestion_stats['replenish_medium_cnt'] > 0: | |
| 272 | - pct = suggestion_stats['replenish_medium_amount'] / total_amount * 100 | |
| 273 | - lines.append(f"| 中额(1000-5000元) | {suggestion_stats['replenish_medium_cnt']} | {suggestion_stats['replenish_medium_amount']:.2f} | {pct:.1f}% |") | |
| 274 | - if suggestion_stats['replenish_small_cnt'] > 0: | |
| 275 | - pct = suggestion_stats['replenish_small_amount'] / total_amount * 100 | |
| 276 | - lines.append(f"| 小额(<1000元) | {suggestion_stats['replenish_small_cnt']} | {suggestion_stats['replenish_small_amount']:.2f} | {pct:.1f}% |") | |
| 277 | - | |
| 278 | - return "\n".join(lines) | |
| 475 | + from ..llm import get_llm_client | |
| 279 | 476 | |
| 477 | + if llm_client is None: | |
| 478 | + llm_client = get_llm_client() | |
| 280 | 479 | |
| 281 | -def generate_analysis_report_node(state: dict) -> dict: | |
| 480 | + current_season = _get_season_from_date(statistics_date) | |
| 481 | + | |
| 482 | + prompt_template = _load_prompt("report_sales_analysis.md") | |
| 483 | + prompt = prompt_template.format( | |
| 484 | + total_avg_sales_cnt=_format_decimal(stats.get("total_avg_sales_cnt")), | |
| 485 | + total_avg_sales_amount=_format_decimal(stats.get("total_avg_sales_amount")), | |
| 486 | + has_sales_part_count=stats.get("has_sales_part_count", 0), | |
| 487 | + no_sales_part_count=stats.get("no_sales_part_count", 0), | |
| 488 | + total_out_stock_cnt=_format_decimal(stats.get("total_out_stock_cnt")), | |
| 489 | + total_storage_locked_cnt=_format_decimal(stats.get("total_storage_locked_cnt")), | |
| 490 | + total_out_stock_ongoing_cnt=_format_decimal(stats.get("total_out_stock_ongoing_cnt")), | |
| 491 | + total_buy_cnt=_format_decimal(stats.get("total_buy_cnt")), | |
| 492 | + current_season=current_season, | |
| 493 | + statistics_date=statistics_date or "未知", | |
| 494 | + ) | |
| 495 | + | |
| 496 | + messages = [HumanMessage(content=prompt)] | |
| 497 | + response = llm_client.invoke(messages) | |
| 498 | + | |
| 499 | + try: | |
| 500 | + analysis = _parse_llm_json(response.content) | |
| 501 | + except json.JSONDecodeError: | |
| 502 | + logger.warning(f"销量分析 LLM JSON 解析失败,原始响应: {response.content[:200]}") | |
| 503 | + analysis = {"error": "JSON解析失败", "raw": response.content[:200]} | |
| 504 | + | |
| 505 | + usage = { | |
| 506 | + "provider": response.usage.provider, | |
| 507 | + "model": response.usage.model, | |
| 508 | + "prompt_tokens": response.usage.prompt_tokens, | |
| 509 | + "completion_tokens": response.usage.completion_tokens, | |
| 510 | + } | |
| 511 | + return analysis, usage | |
| 512 | + | |
| 513 | + | |
| 514 | +def llm_analyze_inventory_health(stats: dict, statistics_date: str = "", llm_client=None) -> tuple[dict, dict]: | |
| 282 | 515 | """ |
| 283 | - 生成分析报告节点 | |
| 284 | - | |
| 285 | - 输入: part_ratios, llm_suggestions, allocated_details, part_results | |
| 286 | - 输出: analysis_report | |
| 516 | + LLM 分析库存健康度 | |
| 517 | + | |
| 518 | + Args: | |
| 519 | + stats: calculate_inventory_health 的输出 | |
| 520 | + statistics_date: 统计日期 | |
| 521 | + llm_client: LLM 客户端实例 | |
| 522 | + | |
| 523 | + Returns: | |
| 524 | + (llm_analysis_dict, usage_dict) | |
| 287 | 525 | """ |
| 288 | - start_time = time.time() | |
| 289 | - | |
| 290 | - task_no = state.get("task_no", "") | |
| 291 | - group_id = state.get("group_id", 0) | |
| 292 | - dealer_grouping_id = state.get("dealer_grouping_id", 0) | |
| 293 | - dealer_grouping_name = state.get("dealer_grouping_name", "") | |
| 294 | - brand_grouping_id = state.get("brand_grouping_id") | |
| 295 | - statistics_date = state.get("statistics_date", "") | |
| 296 | - | |
| 297 | - part_ratios = state.get("part_ratios", []) | |
| 298 | - part_results = state.get("part_results", []) | |
| 299 | - allocated_details = state.get("allocated_details", []) | |
| 300 | - | |
| 301 | - logger.info(f"[{task_no}] 开始生成分析报告: dealer={dealer_grouping_name}") | |
| 302 | - | |
| 526 | + from ..llm import get_llm_client | |
| 527 | + | |
| 528 | + if llm_client is None: | |
| 529 | + llm_client = get_llm_client() | |
| 530 | + | |
| 531 | + current_season = _get_season_from_date(statistics_date) | |
| 532 | + | |
| 533 | + prompt_template = _load_prompt("report_inventory_health.md") | |
| 534 | + prompt = prompt_template.format( | |
| 535 | + total_count=stats.get("total_count", 0), | |
| 536 | + total_amount=_format_decimal(stats.get("total_amount")), | |
| 537 | + shortage_count=stats.get("shortage", {}).get("count", 0), | |
| 538 | + shortage_count_pct=stats.get("shortage", {}).get("count_pct", 0), | |
| 539 | + shortage_amount=_format_decimal(stats.get("shortage", {}).get("amount")), | |
| 540 | + shortage_amount_pct=stats.get("shortage", {}).get("amount_pct", 0), | |
| 541 | + stagnant_count=stats.get("stagnant", {}).get("count", 0), | |
| 542 | + stagnant_count_pct=stats.get("stagnant", {}).get("count_pct", 0), | |
| 543 | + stagnant_amount=_format_decimal(stats.get("stagnant", {}).get("amount")), | |
| 544 | + stagnant_amount_pct=stats.get("stagnant", {}).get("amount_pct", 0), | |
| 545 | + low_freq_count=stats.get("low_freq", {}).get("count", 0), | |
| 546 | + low_freq_count_pct=stats.get("low_freq", {}).get("count_pct", 0), | |
| 547 | + low_freq_amount=_format_decimal(stats.get("low_freq", {}).get("amount")), | |
| 548 | + low_freq_amount_pct=stats.get("low_freq", {}).get("amount_pct", 0), | |
| 549 | + normal_count=stats.get("normal", {}).get("count", 0), | |
| 550 | + normal_count_pct=stats.get("normal", {}).get("count_pct", 0), | |
| 551 | + normal_amount=_format_decimal(stats.get("normal", {}).get("amount")), | |
| 552 | + normal_amount_pct=stats.get("normal", {}).get("amount_pct", 0), | |
| 553 | + current_season=current_season, | |
| 554 | + statistics_date=statistics_date or "未知", | |
| 555 | + ) | |
| 556 | + | |
| 557 | + messages = [HumanMessage(content=prompt)] | |
| 558 | + response = llm_client.invoke(messages) | |
| 559 | + | |
| 303 | 560 | try: |
| 304 | - # 计算风险统计 | |
| 305 | - risk_stats = _calculate_risk_stats(part_ratios) | |
| 306 | - | |
| 307 | - # 计算补货建议统计 (基于完整数据) | |
| 308 | - suggestion_stats = _calculate_suggestion_stats(part_results) | |
| 309 | - | |
| 310 | - # 构建结构化建议汇总 | |
| 311 | - suggestion_summary = _build_suggestion_summary(suggestion_stats) | |
| 312 | - | |
| 313 | - # 加载 Prompt | |
| 314 | - prompt_template = _load_prompt("analysis_report.md") | |
| 315 | - | |
| 316 | - # 填充 Prompt 变量 | |
| 317 | - prompt = prompt_template.format( | |
| 318 | - dealer_grouping_id=dealer_grouping_id, | |
| 319 | - dealer_grouping_name=dealer_grouping_name, | |
| 320 | - statistics_date=statistics_date, | |
| 321 | - suggestion_summary=suggestion_summary, | |
| 322 | - shortage_cnt=risk_stats["shortage_cnt"], | |
| 323 | - shortage_amount=f"{risk_stats['shortage_amount']:.2f}", | |
| 324 | - stagnant_cnt=risk_stats["stagnant_cnt"], | |
| 325 | - stagnant_amount=f"{risk_stats['stagnant_amount']:.2f}", | |
| 326 | - low_freq_cnt=risk_stats["low_freq_cnt"], | |
| 327 | - low_freq_amount="0.00", # 低频件无库存 | |
| 328 | - ) | |
| 329 | - | |
| 330 | - # 调用 LLM | |
| 561 | + analysis = _parse_llm_json(response.content) | |
| 562 | + except json.JSONDecodeError: | |
| 563 | + logger.warning(f"健康度 LLM JSON 解析失败,原始响应: {response.content[:200]}") | |
| 564 | + analysis = {"error": "JSON解析失败", "raw": response.content[:200]} | |
| 565 | + | |
| 566 | + usage = { | |
| 567 | + "provider": response.usage.provider, | |
| 568 | + "model": response.usage.model, | |
| 569 | + "prompt_tokens": response.usage.prompt_tokens, | |
| 570 | + "completion_tokens": response.usage.completion_tokens, | |
| 571 | + } | |
| 572 | + return analysis, usage | |
| 573 | + | |
| 574 | + | |
| 575 | +def llm_analyze_replenishment_summary(stats: dict, statistics_date: str = "", llm_client=None) -> tuple[dict, dict]: | |
| 576 | + """ | |
| 577 | + LLM 分析补货建议 | |
| 578 | + | |
| 579 | + Args: | |
| 580 | + stats: calculate_replenishment_summary 的输出 | |
| 581 | + statistics_date: 统计日期 | |
| 582 | + llm_client: LLM 客户端实例 | |
| 583 | + | |
| 584 | + Returns: | |
| 585 | + (llm_analysis_dict, usage_dict) | |
| 586 | + """ | |
| 587 | + from ..llm import get_llm_client | |
| 588 | + | |
| 589 | + if llm_client is None: | |
| 331 | 590 | llm_client = get_llm_client() |
| 332 | - response = llm_client.invoke( | |
| 333 | - messages=[HumanMessage(content=prompt)], | |
| 334 | - ) | |
| 335 | - | |
| 336 | - # 解析 JSON 响应 | |
| 337 | - response_text = response.content.strip() | |
| 338 | - # 移除可能的 markdown 代码块 | |
| 339 | - if response_text.startswith("```"): | |
| 340 | - lines = response_text.split("\n") | |
| 341 | - response_text = "\n".join(lines[1:-1]) | |
| 342 | - | |
| 343 | - report_data = json.loads(response_text) | |
| 344 | - | |
| 345 | - # 复用已计算的统计数据 | |
| 346 | - total_suggest_cnt = suggestion_stats["total_suggest_cnt"] | |
| 347 | - total_suggest_amount = suggestion_stats["total_suggest_amount"] | |
| 348 | - | |
| 349 | - execution_time_ms = int((time.time() - start_time) * 1000) | |
| 350 | - | |
| 351 | - # 创建报告对象 | |
| 352 | - # 新 prompt 字段名映射到现有数据库字段: | |
| 353 | - # overall_assessment -> replenishment_insights | |
| 354 | - # risk_alerts -> urgency_assessment | |
| 355 | - # procurement_strategy -> strategy_recommendations | |
| 356 | - # expected_impact -> expected_outcomes | |
| 357 | - # execution_guide 已移除,置为 None | |
| 358 | - report = AnalysisReport( | |
| 359 | - task_no=task_no, | |
| 360 | - group_id=group_id, | |
| 361 | - dealer_grouping_id=dealer_grouping_id, | |
| 362 | - dealer_grouping_name=dealer_grouping_name, | |
| 363 | - brand_grouping_id=brand_grouping_id, | |
| 364 | - report_type="replenishment", | |
| 365 | - replenishment_insights=report_data.get("overall_assessment"), | |
| 366 | - urgency_assessment=report_data.get("risk_alerts"), | |
| 367 | - strategy_recommendations=report_data.get("procurement_strategy"), | |
| 368 | - execution_guide=None, | |
| 369 | - expected_outcomes=report_data.get("expected_impact"), | |
| 370 | - total_suggest_cnt=total_suggest_cnt, | |
| 371 | - total_suggest_amount=total_suggest_amount, | |
| 372 | - shortage_risk_cnt=risk_stats["shortage_cnt"], | |
| 373 | - excess_risk_cnt=risk_stats["stagnant_cnt"], | |
| 374 | - stagnant_cnt=risk_stats["stagnant_cnt"], | |
| 375 | - low_freq_cnt=risk_stats["low_freq_cnt"], | |
| 376 | - llm_provider=getattr(llm_client, "provider", ""), | |
| 377 | - llm_model=getattr(llm_client, "model", ""), | |
| 378 | - llm_tokens=response.usage.total_tokens, | |
| 379 | - execution_time_ms=execution_time_ms, | |
| 380 | - statistics_date=statistics_date, | |
| 381 | - ) | |
| 382 | - | |
| 383 | - # 保存到数据库 | |
| 384 | - result_writer = ResultWriter() | |
| 385 | - try: | |
| 386 | - result_writer.save_analysis_report(report) | |
| 387 | - finally: | |
| 388 | - result_writer.close() | |
| 389 | - | |
| 390 | - logger.info( | |
| 391 | - f"[{task_no}] 分析报告生成完成: " | |
| 392 | - f"shortage={risk_stats['shortage_cnt']}, " | |
| 393 | - f"stagnant={risk_stats['stagnant_cnt']}, " | |
| 394 | - f"time={execution_time_ms}ms" | |
| 395 | - ) | |
| 396 | - | |
| 591 | + | |
| 592 | + current_season = _get_season_from_date(statistics_date) | |
| 593 | + | |
| 594 | + prompt_template = _load_prompt("report_replenishment_summary.md") | |
| 595 | + prompt = prompt_template.format( | |
| 596 | + total_count=stats.get("total_count", 0), | |
| 597 | + total_amount=_format_decimal(stats.get("total_amount")), | |
| 598 | + urgent_count=stats.get("urgent", {}).get("count", 0), | |
| 599 | + urgent_amount=_format_decimal(stats.get("urgent", {}).get("amount")), | |
| 600 | + suggested_count=stats.get("suggested", {}).get("count", 0), | |
| 601 | + suggested_amount=_format_decimal(stats.get("suggested", {}).get("amount")), | |
| 602 | + optional_count=stats.get("optional", {}).get("count", 0), | |
| 603 | + optional_amount=_format_decimal(stats.get("optional", {}).get("amount")), | |
| 604 | + current_season=current_season, | |
| 605 | + statistics_date=statistics_date or "未知", | |
| 606 | + ) | |
| 607 | + | |
| 608 | + messages = [HumanMessage(content=prompt)] | |
| 609 | + response = llm_client.invoke(messages) | |
| 610 | + | |
| 611 | + try: | |
| 612 | + analysis = _parse_llm_json(response.content) | |
| 613 | + except json.JSONDecodeError: | |
| 614 | + logger.warning(f"补货建议 LLM JSON 解析失败,原始响应: {response.content[:200]}") | |
| 615 | + analysis = {"error": "JSON解析失败", "raw": response.content[:200]} | |
| 616 | + | |
| 617 | + usage = { | |
| 618 | + "provider": response.usage.provider, | |
| 619 | + "model": response.usage.model, | |
| 620 | + "prompt_tokens": response.usage.prompt_tokens, | |
| 621 | + "completion_tokens": response.usage.completion_tokens, | |
| 622 | + } | |
| 623 | + return analysis, usage | |
| 624 | + | |
| 625 | + | |
| 626 | +# ============================================================ | |
| 627 | +# LangGraph 并发子图 | |
| 628 | +# ============================================================ | |
| 629 | + | |
| 630 | +from typing import TypedDict, Optional, Any, Annotated, Dict | |
| 631 | + | |
| 632 | +from langgraph.graph import StateGraph, START, END | |
| 633 | + | |
| 634 | + | |
| 635 | +def _merge_dict(left: Optional[dict], right: Optional[dict]) -> Optional[dict]: | |
| 636 | + """合并字典,保留非 None 的值""" | |
| 637 | + if right is not None: | |
| 638 | + return right | |
| 639 | + return left | |
| 640 | + | |
| 641 | + | |
| 642 | +def _sum_int(left: int, right: int) -> int: | |
| 643 | + """累加整数""" | |
| 644 | + return (left or 0) + (right or 0) | |
| 645 | + | |
| 646 | + | |
| 647 | +def _merge_str(left: Optional[str], right: Optional[str]) -> Optional[str]: | |
| 648 | + """合并字符串,保留非 None 的值""" | |
| 649 | + if right is not None: | |
| 650 | + return right | |
| 651 | + return left | |
| 652 | + | |
| 653 | + | |
| 654 | +class ReportLLMState(TypedDict, total=False): | |
| 655 | + """并发 LLM 分析子图的状态""" | |
| 656 | + | |
| 657 | + # 输入:四大板块的统计数据(只读,由主函数写入) | |
| 658 | + inventory_overview_stats: Annotated[Optional[dict], _merge_dict] | |
| 659 | + sales_analysis_stats: Annotated[Optional[dict], _merge_dict] | |
| 660 | + inventory_health_stats: Annotated[Optional[dict], _merge_dict] | |
| 661 | + replenishment_summary_stats: Annotated[Optional[dict], _merge_dict] | |
| 662 | + | |
| 663 | + # 输入:统计日期(用于季节判断) | |
| 664 | + statistics_date: Annotated[Optional[str], _merge_str] | |
| 665 | + | |
| 666 | + # 输出:四大板块的 LLM 分析结果(各节点独立写入) | |
| 667 | + inventory_overview_analysis: Annotated[Optional[dict], _merge_dict] | |
| 668 | + sales_analysis_analysis: Annotated[Optional[dict], _merge_dict] | |
| 669 | + inventory_health_analysis: Annotated[Optional[dict], _merge_dict] | |
| 670 | + replenishment_summary_analysis: Annotated[Optional[dict], _merge_dict] | |
| 671 | + | |
| 672 | + # LLM 使用量(累加) | |
| 673 | + total_prompt_tokens: Annotated[int, _sum_int] | |
| 674 | + total_completion_tokens: Annotated[int, _sum_int] | |
| 675 | + llm_provider: Annotated[Optional[str], _merge_dict] | |
| 676 | + llm_model: Annotated[Optional[str], _merge_dict] | |
| 677 | + | |
| 678 | + | |
| 679 | +def _node_inventory_overview(state: ReportLLMState) -> ReportLLMState: | |
| 680 | + """并发节点:库存概览 LLM 分析""" | |
| 681 | + stats = state.get("inventory_overview_stats") | |
| 682 | + statistics_date = state.get("statistics_date", "") | |
| 683 | + if not stats: | |
| 684 | + return {"inventory_overview_analysis": {"error": "无统计数据"}} | |
| 685 | + | |
| 686 | + try: | |
| 687 | + analysis, usage = llm_analyze_inventory_overview(stats, statistics_date) | |
| 397 | 688 | return { |
| 398 | - "analysis_report": report.to_dict(), | |
| 399 | - "end_time": time.time(), | |
| 689 | + "inventory_overview_analysis": analysis, | |
| 690 | + "total_prompt_tokens": usage.get("prompt_tokens", 0), | |
| 691 | + "total_completion_tokens": usage.get("completion_tokens", 0), | |
| 692 | + "llm_provider": usage.get("provider", ""), | |
| 693 | + "llm_model": usage.get("model", ""), | |
| 400 | 694 | } |
| 401 | - | |
| 402 | 695 | except Exception as e: |
| 403 | - logger.error(f"[{task_no}] 分析报告生成失败: {e}", exc_info=True) | |
| 404 | - | |
| 405 | - # 返回空报告,不中断整个流程 | |
| 696 | + logger.error(f"库存概览 LLM 分析失败: {e}") | |
| 697 | + return {"inventory_overview_analysis": {"error": str(e)}} | |
| 698 | + | |
| 699 | + | |
| 700 | +def _node_sales_analysis(state: ReportLLMState) -> ReportLLMState: | |
| 701 | + """并发节点:销量分析 LLM 分析""" | |
| 702 | + stats = state.get("sales_analysis_stats") | |
| 703 | + statistics_date = state.get("statistics_date", "") | |
| 704 | + if not stats: | |
| 705 | + return {"sales_analysis_analysis": {"error": "无统计数据"}} | |
| 706 | + | |
| 707 | + try: | |
| 708 | + analysis, usage = llm_analyze_sales(stats, statistics_date) | |
| 709 | + return { | |
| 710 | + "sales_analysis_analysis": analysis, | |
| 711 | + "total_prompt_tokens": usage.get("prompt_tokens", 0), | |
| 712 | + "total_completion_tokens": usage.get("completion_tokens", 0), | |
| 713 | + "llm_provider": usage.get("provider", ""), | |
| 714 | + "llm_model": usage.get("model", ""), | |
| 715 | + } | |
| 716 | + except Exception as e: | |
| 717 | + logger.error(f"销量分析 LLM 分析失败: {e}") | |
| 718 | + return {"sales_analysis_analysis": {"error": str(e)}} | |
| 719 | + | |
| 720 | + | |
| 721 | +def _node_inventory_health(state: ReportLLMState) -> ReportLLMState: | |
| 722 | + """并发节点:健康度 LLM 分析""" | |
| 723 | + stats = state.get("inventory_health_stats") | |
| 724 | + statistics_date = state.get("statistics_date", "") | |
| 725 | + if not stats: | |
| 726 | + return {"inventory_health_analysis": {"error": "无统计数据"}} | |
| 727 | + | |
| 728 | + try: | |
| 729 | + analysis, usage = llm_analyze_inventory_health(stats, statistics_date) | |
| 730 | + return { | |
| 731 | + "inventory_health_analysis": analysis, | |
| 732 | + "total_prompt_tokens": usage.get("prompt_tokens", 0), | |
| 733 | + "total_completion_tokens": usage.get("completion_tokens", 0), | |
| 734 | + "llm_provider": usage.get("provider", ""), | |
| 735 | + "llm_model": usage.get("model", ""), | |
| 736 | + } | |
| 737 | + except Exception as e: | |
| 738 | + logger.error(f"健康度 LLM 分析失败: {e}") | |
| 739 | + return {"inventory_health_analysis": {"error": str(e)}} | |
| 740 | + | |
| 741 | + | |
| 742 | +def _node_replenishment_summary(state: ReportLLMState) -> ReportLLMState: | |
| 743 | + """并发节点:补货建议 LLM 分析""" | |
| 744 | + stats = state.get("replenishment_summary_stats") | |
| 745 | + statistics_date = state.get("statistics_date", "") | |
| 746 | + if not stats: | |
| 747 | + return {"replenishment_summary_analysis": {"error": "无统计数据"}} | |
| 748 | + | |
| 749 | + try: | |
| 750 | + analysis, usage = llm_analyze_replenishment_summary(stats, statistics_date) | |
| 406 | 751 | return { |
| 407 | - "analysis_report": { | |
| 408 | - "error": str(e), | |
| 409 | - "task_no": task_no, | |
| 410 | - }, | |
| 411 | - "end_time": time.time(), | |
| 752 | + "replenishment_summary_analysis": analysis, | |
| 753 | + "total_prompt_tokens": usage.get("prompt_tokens", 0), | |
| 754 | + "total_completion_tokens": usage.get("completion_tokens", 0), | |
| 755 | + "llm_provider": usage.get("provider", ""), | |
| 756 | + "llm_model": usage.get("model", ""), | |
| 412 | 757 | } |
| 758 | + except Exception as e: | |
| 759 | + logger.error(f"补货建议 LLM 分析失败: {e}") | |
| 760 | + return {"replenishment_summary_analysis": {"error": str(e)}} | |
| 761 | + | |
| 762 | + | |
| 763 | +def build_report_llm_subgraph() -> StateGraph: | |
| 764 | + """ | |
| 765 | + 构建并发 LLM 分析子图 | |
| 766 | + | |
| 767 | + 四个 LLM 节点从 START fan-out 并发执行,结果 fan-in 汇总到 END。 | |
| 768 | + """ | |
| 769 | + graph = StateGraph(ReportLLMState) | |
| 770 | + | |
| 771 | + # 添加四个并发节点 | |
| 772 | + graph.add_node("inventory_overview_llm", _node_inventory_overview) | |
| 773 | + graph.add_node("sales_analysis_llm", _node_sales_analysis) | |
| 774 | + graph.add_node("inventory_health_llm", _node_inventory_health) | |
| 775 | + graph.add_node("replenishment_summary_llm", _node_replenishment_summary) | |
| 776 | + | |
| 777 | + # fan-out: START → 四个节点 | |
| 778 | + graph.add_edge(START, "inventory_overview_llm") | |
| 779 | + graph.add_edge(START, "sales_analysis_llm") | |
| 780 | + graph.add_edge(START, "inventory_health_llm") | |
| 781 | + graph.add_edge(START, "replenishment_summary_llm") | |
| 782 | + | |
| 783 | + # fan-in: 四个节点 → END | |
| 784 | + graph.add_edge("inventory_overview_llm", END) | |
| 785 | + graph.add_edge("sales_analysis_llm", END) | |
| 786 | + graph.add_edge("inventory_health_llm", END) | |
| 787 | + graph.add_edge("replenishment_summary_llm", END) | |
| 788 | + | |
| 789 | + return graph.compile() | |
| 790 | + | |
| 791 | + | |
| 792 | +# ============================================================ | |
| 793 | +# 主节点函数 | |
| 794 | +# ============================================================ | |
| 795 | + | |
| 796 | + | |
| 797 | +def _serialize_stats(stats: dict) -> dict: | |
| 798 | + """将统计数据中的 Decimal 转换为 float,以便 JSON 序列化""" | |
| 799 | + result = {} | |
| 800 | + for k, v in stats.items(): | |
| 801 | + if isinstance(v, Decimal): | |
| 802 | + result[k] = float(v) | |
| 803 | + elif isinstance(v, dict): | |
| 804 | + result[k] = _serialize_stats(v) | |
| 805 | + elif isinstance(v, list): | |
| 806 | + result[k] = [ | |
| 807 | + _serialize_stats(item) if isinstance(item, dict) else (float(item) if isinstance(item, Decimal) else item) | |
| 808 | + for item in v | |
| 809 | + ] | |
| 810 | + else: | |
| 811 | + result[k] = v | |
| 812 | + return result | |
| 813 | + | |
| 814 | + | |
| 815 | +def generate_analysis_report_node(state: dict) -> dict: | |
| 816 | + """ | |
| 817 | + 分析报告生成主节点 | |
| 818 | + | |
| 819 | + 串联流程: | |
| 820 | + 1. 统计计算(四大板块) | |
| 821 | + 2. 并发 LLM 分析(LangGraph 子图) | |
| 822 | + 3. 汇总报告 | |
| 823 | + 4. 写入数据库 | |
| 824 | + | |
| 825 | + 单板块 LLM 失败不影响其他板块。 | |
| 826 | + | |
| 827 | + Args: | |
| 828 | + state: AgentState 字典 | |
| 829 | + | |
| 830 | + Returns: | |
| 831 | + 更新后的 state 字典 | |
| 832 | + """ | |
| 833 | + from .state import AgentState | |
| 834 | + from ..models import AnalysisReport | |
| 835 | + from ..services.result_writer import ResultWriter | |
| 836 | + | |
| 837 | + logger.info("[AnalysisReport] ========== 开始生成分析报告 ==========") | |
| 838 | + start_time = time.time() | |
| 839 | + | |
| 840 | + part_ratios = state.get("part_ratios", []) | |
| 841 | + part_results = state.get("part_results", []) | |
| 842 | + | |
| 843 | + # ---- 1. 统计计算 ---- | |
| 844 | + logger.info(f"[AnalysisReport] 统计计算: part_ratios={len(part_ratios)}, part_results={len(part_results)}") | |
| 845 | + | |
| 846 | + inventory_overview_stats = calculate_inventory_overview(part_ratios) | |
| 847 | + sales_analysis_stats = calculate_sales_analysis(part_ratios) | |
| 848 | + inventory_health_stats = calculate_inventory_health(part_ratios) | |
| 849 | + replenishment_summary_stats = calculate_replenishment_summary(part_results) | |
| 850 | + | |
| 851 | + # 序列化统计数据(Decimal → float) | |
| 852 | + io_stats_serialized = _serialize_stats(inventory_overview_stats) | |
| 853 | + sa_stats_serialized = _serialize_stats(sales_analysis_stats) | |
| 854 | + ih_stats_serialized = _serialize_stats(inventory_health_stats) | |
| 855 | + rs_stats_serialized = _serialize_stats(replenishment_summary_stats) | |
| 856 | + | |
| 857 | + # ---- 2. 并发 LLM 分析 ---- | |
| 858 | + logger.info("[AnalysisReport] 启动并发 LLM 分析子图") | |
| 859 | + | |
| 860 | + statistics_date = state.get("statistics_date", "") | |
| 861 | + | |
| 862 | + subgraph = build_report_llm_subgraph() | |
| 863 | + llm_state: ReportLLMState = { | |
| 864 | + "inventory_overview_stats": io_stats_serialized, | |
| 865 | + "sales_analysis_stats": sa_stats_serialized, | |
| 866 | + "inventory_health_stats": ih_stats_serialized, | |
| 867 | + "replenishment_summary_stats": rs_stats_serialized, | |
| 868 | + "statistics_date": statistics_date, | |
| 869 | + "inventory_overview_analysis": None, | |
| 870 | + "sales_analysis_analysis": None, | |
| 871 | + "inventory_health_analysis": None, | |
| 872 | + "replenishment_summary_analysis": None, | |
| 873 | + "total_prompt_tokens": 0, | |
| 874 | + "total_completion_tokens": 0, | |
| 875 | + "llm_provider": None, | |
| 876 | + "llm_model": None, | |
| 877 | + } | |
| 878 | + | |
| 879 | + try: | |
| 880 | + llm_result = subgraph.invoke(llm_state) | |
| 881 | + except Exception as e: | |
| 882 | + logger.error(f"[AnalysisReport] LLM 子图执行异常: {e}") | |
| 883 | + llm_result = llm_state # 使用初始状态(所有分析为 None) | |
| 884 | + | |
| 885 | + # ---- 3. 汇总报告 ---- | |
| 886 | + inventory_overview_data = { | |
| 887 | + "stats": io_stats_serialized, | |
| 888 | + "llm_analysis": llm_result.get("inventory_overview_analysis") or {"error": "未生成"}, | |
| 889 | + } | |
| 890 | + sales_analysis_data = { | |
| 891 | + "stats": sa_stats_serialized, | |
| 892 | + "llm_analysis": llm_result.get("sales_analysis_analysis") or {"error": "未生成"}, | |
| 893 | + } | |
| 894 | + inventory_health_data = { | |
| 895 | + "stats": ih_stats_serialized, | |
| 896 | + "chart_data": ih_stats_serialized.get("chart_data"), | |
| 897 | + "llm_analysis": llm_result.get("inventory_health_analysis") or {"error": "未生成"}, | |
| 898 | + } | |
| 899 | + replenishment_summary_data = { | |
| 900 | + "stats": rs_stats_serialized, | |
| 901 | + "llm_analysis": llm_result.get("replenishment_summary_analysis") or {"error": "未生成"}, | |
| 902 | + } | |
| 903 | + | |
| 904 | + total_tokens = ( | |
| 905 | + (llm_result.get("total_prompt_tokens") or 0) | |
| 906 | + + (llm_result.get("total_completion_tokens") or 0) | |
| 907 | + ) | |
| 908 | + execution_time_ms = int((time.time() - start_time) * 1000) | |
| 909 | + | |
| 910 | + # ---- 4. 写入数据库 ---- | |
| 911 | + report = AnalysisReport( | |
| 912 | + task_no=state.get("task_no", ""), | |
| 913 | + group_id=state.get("group_id", 0), | |
| 914 | + dealer_grouping_id=state.get("dealer_grouping_id", 0), | |
| 915 | + dealer_grouping_name=state.get("dealer_grouping_name"), | |
| 916 | + brand_grouping_id=state.get("brand_grouping_id"), | |
| 917 | + inventory_overview=inventory_overview_data, | |
| 918 | + sales_analysis=sales_analysis_data, | |
| 919 | + inventory_health=inventory_health_data, | |
| 920 | + replenishment_summary=replenishment_summary_data, | |
| 921 | + llm_provider=llm_result.get("llm_provider") or "", | |
| 922 | + llm_model=llm_result.get("llm_model") or "", | |
| 923 | + llm_tokens=total_tokens, | |
| 924 | + execution_time_ms=execution_time_ms, | |
| 925 | + statistics_date=state.get("statistics_date", ""), | |
| 926 | + ) | |
| 927 | + | |
| 928 | + try: | |
| 929 | + writer = ResultWriter() | |
| 930 | + report_id = writer.save_analysis_report(report) | |
| 931 | + writer.close() | |
| 932 | + logger.info(f"[AnalysisReport] 报告已保存: id={report_id}, tokens={total_tokens}, 耗时={execution_time_ms}ms") | |
| 933 | + except Exception as e: | |
| 934 | + logger.error(f"[AnalysisReport] 报告写入数据库失败: {e}") | |
| 935 | + | |
| 936 | + # 返回更新后的状态 | |
| 937 | + return { | |
| 938 | + "analysis_report": report.to_dict(), | |
| 939 | + "llm_provider": llm_result.get("llm_provider") or state.get("llm_provider", ""), | |
| 940 | + "llm_model": llm_result.get("llm_model") or state.get("llm_model", ""), | |
| 941 | + "llm_prompt_tokens": llm_result.get("total_prompt_tokens") or 0, | |
| 942 | + "llm_completion_tokens": llm_result.get("total_completion_tokens") or 0, | |
| 943 | + "current_node": "generate_analysis_report", | |
| 944 | + "next_node": "end", | |
| 945 | + } | ... | ... |
src/fw_pms_ai/agent/sql_agent/agent.py
| ... | ... | @@ -140,7 +140,7 @@ class SQLAgent: |
| 140 | 140 | out_stock_ongoing_cnt, stock_age, out_times, out_duration, |
| 141 | 141 | transfer_cnt, gen_transfer_cnt, |
| 142 | 142 | part_biz_type, statistics_date, |
| 143 | - (in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt + transfer_cnt + gen_transfer_cnt) as valid_storage_cnt, | |
| 143 | + (in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt) as valid_storage_cnt, | |
| 144 | 144 | ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) as avg_sales_cnt |
| 145 | 145 | FROM part_ratio |
| 146 | 146 | WHERE group_id = %s |
| ... | ... | @@ -159,7 +159,7 @@ class SQLAgent: |
| 159 | 159 | # 优先处理有销量的配件 |
| 160 | 160 | sql += """ ORDER BY |
| 161 | 161 | CASE WHEN ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) > 0 THEN 0 ELSE 1 END, |
| 162 | - (in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt + transfer_cnt + gen_transfer_cnt) / NULLIF((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3, 0) ASC, | |
| 162 | + (in_stock_unlocked_cnt + on_the_way_cnt + has_plan_cnt) / NULLIF((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3, 0) ASC, | |
| 163 | 163 | ((out_stock_cnt + storage_locked_cnt + out_stock_ongoing_cnt + buy_cnt) / 3) DESC |
| 164 | 164 | """ |
| 165 | 165 | ... | ... |
src/fw_pms_ai/agent/state.py
| ... | ... | @@ -53,8 +53,8 @@ class AgentState(TypedDict, total=False): |
| 53 | 53 | dealer_grouping_name: Annotated[str, keep_last] |
| 54 | 54 | statistics_date: Annotated[str, keep_last] |
| 55 | 55 | |
| 56 | - # part_ratio 原始数据 | |
| 57 | - part_ratios: Annotated[List[dict], merge_dicts] | |
| 56 | + # part_ratio 原始数据(使用 keep_last,因为只在 fetch_part_ratio 节点写入一次) | |
| 57 | + part_ratios: Annotated[List[dict], keep_last] | |
| 58 | 58 | |
| 59 | 59 | # SQL Agent 相关 |
| 60 | 60 | sql_queries: Annotated[List[str], merge_lists] | ... | ... |
src/fw_pms_ai/api/routes/tasks.py
| ... | ... | @@ -619,23 +619,14 @@ class AnalysisReportResponse(BaseModel): |
| 619 | 619 | group_id: int |
| 620 | 620 | dealer_grouping_id: int |
| 621 | 621 | dealer_grouping_name: Optional[str] = None |
| 622 | - brand_grouping_id: Optional[int] = None | |
| 623 | 622 | report_type: str |
| 624 | - | |
| 625 | - # JSON 字段,使用 Any 或 Dict 来接收解析后的对象 | |
| 626 | - replenishment_insights: Optional[Dict[str, Any]] = None | |
| 627 | - urgency_assessment: Optional[Dict[str, Any]] = None | |
| 628 | - strategy_recommendations: Optional[Dict[str, Any]] = None | |
| 629 | - execution_guide: Optional[Dict[str, Any]] = None | |
| 630 | - expected_outcomes: Optional[Dict[str, Any]] = None | |
| 631 | - | |
| 632 | - total_suggest_cnt: int = 0 | |
| 633 | - total_suggest_amount: float = 0 | |
| 634 | - shortage_risk_cnt: int = 0 | |
| 635 | - excess_risk_cnt: int = 0 | |
| 636 | - stagnant_cnt: int = 0 | |
| 637 | - low_freq_cnt: int = 0 | |
| 638 | - | |
| 623 | + | |
| 624 | + # 四大板块(统计数据 + LLM 分析) | |
| 625 | + inventory_overview: Optional[Dict[str, Any]] = None | |
| 626 | + sales_analysis: Optional[Dict[str, Any]] = None | |
| 627 | + inventory_health: Optional[Dict[str, Any]] = None | |
| 628 | + replenishment_summary: Optional[Dict[str, Any]] = None | |
| 629 | + | |
| 639 | 630 | llm_provider: Optional[str] = None |
| 640 | 631 | llm_model: Optional[str] = None |
| 641 | 632 | llm_tokens: int = 0 |
| ... | ... | @@ -664,17 +655,19 @@ async def get_analysis_report(task_no: str): |
| 664 | 655 | |
| 665 | 656 | if not row: |
| 666 | 657 | return None |
| 667 | - | |
| 668 | - # 辅助函数:解析 JSON 字符串 | |
| 658 | + | |
| 659 | + # 解析 JSON 字段 | |
| 669 | 660 | def parse_json(value): |
| 670 | - if not value: | |
| 661 | + if value is None: | |
| 671 | 662 | return None |
| 663 | + if isinstance(value, dict): | |
| 664 | + return value | |
| 672 | 665 | if isinstance(value, str): |
| 673 | 666 | try: |
| 674 | 667 | return json.loads(value) |
| 675 | - except json.JSONDecodeError: | |
| 668 | + except (json.JSONDecodeError, TypeError): | |
| 676 | 669 | return None |
| 677 | - return value | |
| 670 | + return None | |
| 678 | 671 | |
| 679 | 672 | return AnalysisReportResponse( |
| 680 | 673 | id=row["id"], |
| ... | ... | @@ -682,22 +675,11 @@ async def get_analysis_report(task_no: str): |
| 682 | 675 | group_id=row["group_id"], |
| 683 | 676 | dealer_grouping_id=row["dealer_grouping_id"], |
| 684 | 677 | dealer_grouping_name=row.get("dealer_grouping_name"), |
| 685 | - brand_grouping_id=row.get("brand_grouping_id"), | |
| 686 | 678 | report_type=row.get("report_type", "replenishment"), |
| 687 | - | |
| 688 | - replenishment_insights=parse_json(row.get("replenishment_insights")), | |
| 689 | - urgency_assessment=parse_json(row.get("urgency_assessment")), | |
| 690 | - strategy_recommendations=parse_json(row.get("strategy_recommendations")), | |
| 691 | - execution_guide=parse_json(row.get("execution_guide")), | |
| 692 | - expected_outcomes=parse_json(row.get("expected_outcomes")), | |
| 693 | - | |
| 694 | - total_suggest_cnt=row.get("total_suggest_cnt") or 0, | |
| 695 | - total_suggest_amount=float(row.get("total_suggest_amount") or 0), | |
| 696 | - shortage_risk_cnt=row.get("shortage_risk_cnt") or 0, | |
| 697 | - excess_risk_cnt=row.get("excess_risk_cnt") or 0, | |
| 698 | - stagnant_cnt=row.get("stagnant_cnt") or 0, | |
| 699 | - low_freq_cnt=row.get("low_freq_cnt") or 0, | |
| 700 | - | |
| 679 | + inventory_overview=parse_json(row.get("inventory_overview")), | |
| 680 | + sales_analysis=parse_json(row.get("sales_analysis")), | |
| 681 | + inventory_health=parse_json(row.get("inventory_health")), | |
| 682 | + replenishment_summary=parse_json(row.get("replenishment_summary")), | |
| 701 | 683 | llm_provider=row.get("llm_provider"), |
| 702 | 684 | llm_model=row.get("llm_model"), |
| 703 | 685 | llm_tokens=row.get("llm_tokens") or 0, | ... | ... |
src/fw_pms_ai/models/analysis_report.py
| 1 | 1 | """ |
| 2 | 2 | 数据模型 - 分析报告 |
| 3 | +四大板块:库存概览、销量分析、库存健康度、补货建议 | |
| 3 | 4 | """ |
| 4 | 5 | |
| 5 | 6 | from dataclasses import dataclass, field |
| 6 | -from decimal import Decimal | |
| 7 | 7 | from datetime import datetime |
| 8 | -from typing import Optional, Dict, Any | |
| 8 | +from typing import Any, Dict, Optional | |
| 9 | 9 | |
| 10 | 10 | |
| 11 | 11 | @dataclass |
| 12 | 12 | class AnalysisReport: |
| 13 | - """AI补货建议分析报告""" | |
| 14 | - | |
| 13 | + """分析报告数据模型""" | |
| 14 | + | |
| 15 | 15 | task_no: str |
| 16 | 16 | group_id: int |
| 17 | 17 | dealer_grouping_id: int |
| 18 | - | |
| 18 | + | |
| 19 | 19 | id: Optional[int] = None |
| 20 | 20 | dealer_grouping_name: Optional[str] = None |
| 21 | 21 | brand_grouping_id: Optional[int] = None |
| 22 | 22 | report_type: str = "replenishment" |
| 23 | - | |
| 24 | - # 报告各模块 (字典结构) | |
| 25 | - replenishment_insights: Optional[Dict[str, Any]] = None | |
| 26 | - urgency_assessment: Optional[Dict[str, Any]] = None | |
| 27 | - strategy_recommendations: Optional[Dict[str, Any]] = None | |
| 28 | - execution_guide: Optional[Dict[str, Any]] = None | |
| 29 | - expected_outcomes: Optional[Dict[str, Any]] = None | |
| 30 | - | |
| 31 | - # 统计信息 | |
| 32 | - total_suggest_cnt: int = 0 | |
| 33 | - total_suggest_amount: Decimal = Decimal("0") | |
| 34 | - shortage_risk_cnt: int = 0 | |
| 35 | - excess_risk_cnt: int = 0 | |
| 36 | - stagnant_cnt: int = 0 | |
| 37 | - low_freq_cnt: int = 0 | |
| 38 | - | |
| 23 | + | |
| 24 | + # 四大板块 | |
| 25 | + inventory_overview: Optional[Dict[str, Any]] = field(default=None) | |
| 26 | + sales_analysis: Optional[Dict[str, Any]] = field(default=None) | |
| 27 | + inventory_health: Optional[Dict[str, Any]] = field(default=None) | |
| 28 | + replenishment_summary: Optional[Dict[str, Any]] = field(default=None) | |
| 29 | + | |
| 39 | 30 | # LLM 元数据 |
| 40 | 31 | llm_provider: str = "" |
| 41 | 32 | llm_model: str = "" |
| 42 | 33 | llm_tokens: int = 0 |
| 43 | 34 | execution_time_ms: int = 0 |
| 44 | - | |
| 35 | + | |
| 45 | 36 | statistics_date: str = "" |
| 46 | 37 | create_time: Optional[datetime] = None |
| 47 | 38 | |
| 48 | - def to_dict(self) -> dict: | |
| 49 | - """转换为字典""" | |
| 39 | + def to_dict(self) -> Dict[str, Any]: | |
| 40 | + """将报告转换为可序列化的字典""" | |
| 50 | 41 | return { |
| 42 | + "id": self.id, | |
| 51 | 43 | "task_no": self.task_no, |
| 52 | 44 | "group_id": self.group_id, |
| 53 | 45 | "dealer_grouping_id": self.dealer_grouping_id, |
| 54 | 46 | "dealer_grouping_name": self.dealer_grouping_name, |
| 55 | 47 | "brand_grouping_id": self.brand_grouping_id, |
| 56 | 48 | "report_type": self.report_type, |
| 57 | - "replenishment_insights": self.replenishment_insights, | |
| 58 | - "urgency_assessment": self.urgency_assessment, | |
| 59 | - "strategy_recommendations": self.strategy_recommendations, | |
| 60 | - "execution_guide": self.execution_guide, | |
| 61 | - "expected_outcomes": self.expected_outcomes, | |
| 62 | - "total_suggest_cnt": self.total_suggest_cnt, | |
| 63 | - "total_suggest_amount": float(self.total_suggest_amount), | |
| 64 | - "shortage_risk_cnt": self.shortage_risk_cnt, | |
| 65 | - "excess_risk_cnt": self.excess_risk_cnt, | |
| 66 | - "stagnant_cnt": self.stagnant_cnt, | |
| 67 | - "low_freq_cnt": self.low_freq_cnt, | |
| 49 | + "inventory_overview": self.inventory_overview, | |
| 50 | + "sales_analysis": self.sales_analysis, | |
| 51 | + "inventory_health": self.inventory_health, | |
| 52 | + "replenishment_summary": self.replenishment_summary, | |
| 68 | 53 | "llm_provider": self.llm_provider, |
| 69 | 54 | "llm_model": self.llm_model, |
| 70 | 55 | "llm_tokens": self.llm_tokens, |
| 71 | 56 | "execution_time_ms": self.execution_time_ms, |
| 72 | 57 | "statistics_date": self.statistics_date, |
| 58 | + "create_time": self.create_time.isoformat() if self.create_time else None, | |
| 73 | 59 | } | ... | ... |
src/fw_pms_ai/models/part_ratio.py
| ... | ... | @@ -46,9 +46,8 @@ class PartRatio: |
| 46 | 46 | |
| 47 | 47 | @property |
| 48 | 48 | def valid_storage_cnt(self) -> Decimal: |
| 49 | - """有效库存数量 = 在库未锁 + 在途 + 计划数 + 主动调拨在途 + 自动调拨在途""" | |
| 50 | - return (self.in_stock_unlocked_cnt + self.on_the_way_cnt + self.has_plan_cnt + | |
| 51 | - Decimal(str(self.transfer_cnt)) + Decimal(str(self.gen_transfer_cnt))) | |
| 49 | + """有效库存数量 = 在库未锁 + 在途 + 计划数""" | |
| 50 | + return self.in_stock_unlocked_cnt + self.on_the_way_cnt + self.has_plan_cnt | |
| 52 | 51 | |
| 53 | 52 | @property |
| 54 | 53 | def valid_storage_amount(self) -> Decimal: | ... | ... |
src/fw_pms_ai/services/result_writer.py
| ... | ... | @@ -325,8 +325,8 @@ class ResultWriter: |
| 325 | 325 | |
| 326 | 326 | def save_analysis_report(self, report: AnalysisReport) -> int: |
| 327 | 327 | """ |
| 328 | - 保存分析报告 | |
| 329 | - | |
| 328 | + 保存分析报告(四大板块 JSON 结构) | |
| 329 | + | |
| 330 | 330 | Returns: |
| 331 | 331 | 插入的报告ID |
| 332 | 332 | """ |
| ... | ... | @@ -338,20 +338,18 @@ class ResultWriter: |
| 338 | 338 | INSERT INTO ai_analysis_report ( |
| 339 | 339 | task_no, group_id, dealer_grouping_id, dealer_grouping_name, |
| 340 | 340 | brand_grouping_id, report_type, |
| 341 | - replenishment_insights, urgency_assessment, strategy_recommendations, | |
| 342 | - execution_guide, expected_outcomes, | |
| 343 | - total_suggest_cnt, total_suggest_amount, shortage_risk_cnt, | |
| 344 | - excess_risk_cnt, stagnant_cnt, low_freq_cnt, | |
| 341 | + inventory_overview, sales_analysis, | |
| 342 | + inventory_health, replenishment_summary, | |
| 345 | 343 | llm_provider, llm_model, llm_tokens, execution_time_ms, |
| 346 | 344 | statistics_date, create_time |
| 347 | 345 | ) VALUES ( |
| 348 | 346 | %s, %s, %s, %s, %s, %s, |
| 349 | - %s, %s, %s, %s, %s, | |
| 350 | - %s, %s, %s, %s, %s, %s, | |
| 351 | - %s, %s, %s, %s, %s, NOW() | |
| 347 | + %s, %s, %s, %s, | |
| 348 | + %s, %s, %s, %s, | |
| 349 | + %s, NOW() | |
| 352 | 350 | ) |
| 353 | 351 | """ |
| 354 | - | |
| 352 | + | |
| 355 | 353 | values = ( |
| 356 | 354 | report.task_no, |
| 357 | 355 | report.group_id, |
| ... | ... | @@ -359,27 +357,20 @@ class ResultWriter: |
| 359 | 357 | report.dealer_grouping_name, |
| 360 | 358 | report.brand_grouping_id, |
| 361 | 359 | report.report_type, |
| 362 | - json.dumps(report.replenishment_insights, ensure_ascii=False) if report.replenishment_insights else None, | |
| 363 | - json.dumps(report.urgency_assessment, ensure_ascii=False) if report.urgency_assessment else None, | |
| 364 | - json.dumps(report.strategy_recommendations, ensure_ascii=False) if report.strategy_recommendations else None, | |
| 365 | - json.dumps(report.execution_guide, ensure_ascii=False) if report.execution_guide else None, | |
| 366 | - json.dumps(report.expected_outcomes, ensure_ascii=False) if report.expected_outcomes else None, | |
| 367 | - report.total_suggest_cnt, | |
| 368 | - float(report.total_suggest_amount), | |
| 369 | - report.shortage_risk_cnt, | |
| 370 | - report.excess_risk_cnt, | |
| 371 | - report.stagnant_cnt, | |
| 372 | - report.low_freq_cnt, | |
| 360 | + json.dumps(report.inventory_overview, ensure_ascii=False) if report.inventory_overview else None, | |
| 361 | + json.dumps(report.sales_analysis, ensure_ascii=False) if report.sales_analysis else None, | |
| 362 | + json.dumps(report.inventory_health, ensure_ascii=False) if report.inventory_health else None, | |
| 363 | + json.dumps(report.replenishment_summary, ensure_ascii=False) if report.replenishment_summary else None, | |
| 373 | 364 | report.llm_provider, |
| 374 | 365 | report.llm_model, |
| 375 | 366 | report.llm_tokens, |
| 376 | 367 | report.execution_time_ms, |
| 377 | 368 | report.statistics_date, |
| 378 | 369 | ) |
| 379 | - | |
| 370 | + | |
| 380 | 371 | cursor.execute(sql, values) |
| 381 | 372 | conn.commit() |
| 382 | - | |
| 373 | + | |
| 383 | 374 | report_id = cursor.lastrowid |
| 384 | 375 | logger.info(f"保存分析报告: task_no={report.task_no}, id={report_id}") |
| 385 | 376 | return report_id | ... | ... |