Commit 25a8b07f6d8eb2c73251e4862df9503833215e8e

Authored by 朱焱飞
1 parent f52f1031

feat: 重构分析报告为库存总览、销售分析、库存健康和补货总结四个主要板块。

.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
... ...
ui/css/style.css
... ... @@ -2362,3 +2362,390 @@ tbody tr:last-child td {
2362 2362 font-size: 0.75rem;
2363 2363 color: var(--text-muted);
2364 2364 }
  2365 +
  2366 +
  2367 +/* =====================
  2368 + 新报告板块样式
  2369 + ===================== */
  2370 +
  2371 +/* 报告统计卡片网格 */
  2372 +.report-stat-cards {
  2373 + display: grid;
  2374 + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  2375 + gap: var(--spacing-md);
  2376 + margin-bottom: var(--spacing-xl);
  2377 +}
  2378 +
  2379 +.report-stat-cards-4 {
  2380 + grid-template-columns: repeat(4, 1fr);
  2381 +}
  2382 +
  2383 +@media (max-width: 768px) {
  2384 + .report-stat-cards-4 {
  2385 + grid-template-columns: repeat(2, 1fr);
  2386 + }
  2387 +}
  2388 +
  2389 +/* 报告统计卡片 */
  2390 +.report-stat-card {
  2391 + background: rgba(30, 41, 59, 0.6);
  2392 + border: 1px solid var(--glass-border);
  2393 + border-radius: var(--radius-lg);
  2394 + padding: var(--spacing-lg);
  2395 + transition: all var(--transition-base);
  2396 +}
  2397 +
  2398 +.report-stat-card:hover {
  2399 + transform: translateY(-2px);
  2400 + box-shadow: var(--shadow-md);
  2401 + border-color: var(--border-color-light);
  2402 +}
  2403 +
  2404 +.report-stat-label {
  2405 + font-size: 0.8rem;
  2406 + color: var(--text-secondary);
  2407 + margin-bottom: var(--spacing-xs);
  2408 + text-transform: uppercase;
  2409 + letter-spacing: 0.05em;
  2410 +}
  2411 +
  2412 +.report-stat-value {
  2413 + font-size: 1.5rem;
  2414 + font-weight: 700;
  2415 + color: var(--text-primary);
  2416 + line-height: 1.3;
  2417 +}
  2418 +
  2419 +.report-stat-sub {
  2420 + font-size: 0.85rem;
  2421 + font-weight: 400;
  2422 + color: var(--text-muted);
  2423 +}
  2424 +
  2425 +.report-stat-pct {
  2426 + font-size: 0.8rem;
  2427 + color: var(--text-secondary);
  2428 + margin-top: var(--spacing-xs);
  2429 +}
  2430 +
  2431 +/* 健康度卡片颜色变体 */
  2432 +.report-stat-card-danger {
  2433 + border-left: 3px solid var(--color-danger);
  2434 +}
  2435 +.report-stat-card-danger .report-stat-value {
  2436 + color: var(--color-danger-light);
  2437 +}
  2438 +
  2439 +.report-stat-card-warning {
  2440 + border-left: 3px solid var(--color-warning);
  2441 +}
  2442 +.report-stat-card-warning .report-stat-value {
  2443 + color: var(--color-warning-light);
  2444 +}
  2445 +
  2446 +.report-stat-card-info {
  2447 + border-left: 3px solid var(--color-info);
  2448 +}
  2449 +.report-stat-card-info .report-stat-value {
  2450 + color: var(--color-info-light);
  2451 +}
  2452 +
  2453 +.report-stat-card-success {
  2454 + border-left: 3px solid var(--color-success);
  2455 +}
  2456 +.report-stat-card-success .report-stat-value {
  2457 + color: var(--color-success-light);
  2458 +}
  2459 +
  2460 +/* 报告明细表格 */
  2461 +.report-detail-table {
  2462 + background: rgba(30, 41, 59, 0.4);
  2463 + border: 1px solid var(--glass-border);
  2464 + border-radius: var(--radius-lg);
  2465 + overflow: hidden;
  2466 + margin-bottom: var(--spacing-xl);
  2467 +}
  2468 +
  2469 +.report-detail-table table {
  2470 + width: 100%;
  2471 + border-collapse: collapse;
  2472 +}
  2473 +
  2474 +.report-detail-table th {
  2475 + background: var(--bg-surface);
  2476 + padding: var(--spacing-md) var(--spacing-lg);
  2477 + text-align: left;
  2478 + font-weight: 600;
  2479 + font-size: 0.8rem;
  2480 + text-transform: uppercase;
  2481 + letter-spacing: 0.05em;
  2482 + color: var(--text-secondary);
  2483 + border-bottom: 1px solid var(--border-color);
  2484 +}
  2485 +
  2486 +.report-detail-table td {
  2487 + padding: var(--spacing-md) var(--spacing-lg);
  2488 + border-bottom: 1px solid var(--border-color);
  2489 + color: var(--text-primary);
  2490 +}
  2491 +
  2492 +.report-detail-table tbody tr:last-child td {
  2493 + border-bottom: none;
  2494 +}
  2495 +
  2496 +.report-detail-table tbody tr:hover {
  2497 + background: rgba(255, 255, 255, 0.02);
  2498 +}
  2499 +
  2500 +/* 图表容器 */
  2501 +.report-charts-row {
  2502 + display: grid;
  2503 + grid-template-columns: 1fr 1fr;
  2504 + gap: var(--spacing-xl);
  2505 + margin-bottom: var(--spacing-xl);
  2506 +}
  2507 +
  2508 +@media (max-width: 768px) {
  2509 + .report-charts-row {
  2510 + grid-template-columns: 1fr;
  2511 + }
  2512 +}
  2513 +
  2514 +.report-chart-container {
  2515 + background: rgba(30, 41, 59, 0.4);
  2516 + border: 1px solid var(--glass-border);
  2517 + border-radius: var(--radius-lg);
  2518 + padding: var(--spacing-lg);
  2519 + display: flex;
  2520 + flex-direction: column;
  2521 + align-items: center;
  2522 +}
  2523 +
  2524 +.report-chart-title {
  2525 + font-size: 0.9rem;
  2526 + font-weight: 600;
  2527 + color: var(--text-secondary);
  2528 + margin-bottom: var(--spacing-md);
  2529 + text-align: center;
  2530 +}
  2531 +
  2532 +.report-chart-container canvas {
  2533 + max-width: 300px;
  2534 + max-height: 300px;
  2535 +}
  2536 +
  2537 +/* LLM 分析文本区域 */
  2538 +.report-analysis-text {
  2539 + background: rgba(30, 41, 59, 0.3);
  2540 + border: 1px solid var(--glass-border);
  2541 + border-radius: var(--radius-lg);
  2542 + padding: var(--spacing-xl);
  2543 +}
  2544 +
  2545 +.analysis-block {
  2546 + margin-bottom: var(--spacing-lg);
  2547 +}
  2548 +
  2549 +.analysis-block:last-child {
  2550 + margin-bottom: 0;
  2551 +}
  2552 +
  2553 +.analysis-block-title {
  2554 + font-size: 1rem;
  2555 + font-weight: 600;
  2556 + color: var(--text-primary);
  2557 + margin-bottom: var(--spacing-sm);
  2558 + display: flex;
  2559 + align-items: center;
  2560 + gap: var(--spacing-sm);
  2561 +}
  2562 +
  2563 +.analysis-block-title svg {
  2564 + width: 18px;
  2565 + height: 18px;
  2566 + color: var(--color-primary);
  2567 +}
  2568 +
  2569 +.analysis-block p {
  2570 + color: var(--text-secondary);
  2571 + line-height: 1.7;
  2572 + margin-bottom: var(--spacing-xs);
  2573 + font-size: 0.9rem;
  2574 +}
  2575 +
  2576 +.analysis-block p:empty {
  2577 + display: none;
  2578 +}
  2579 +
  2580 +.analysis-benchmark {
  2581 + font-style: italic;
  2582 + opacity: 0.8;
  2583 +}
  2584 +
  2585 +.analysis-rec-list {
  2586 + list-style: none;
  2587 + padding: 0;
  2588 + margin: 0;
  2589 +}
  2590 +
  2591 +.analysis-rec-list li {
  2592 + position: relative;
  2593 + padding-left: 20px;
  2594 + margin-bottom: var(--spacing-sm);
  2595 + color: var(--text-secondary);
  2596 + font-size: 0.9rem;
  2597 + line-height: 1.6;
  2598 +}
  2599 +
  2600 +.analysis-rec-list li::before {
  2601 + content: '→';
  2602 + position: absolute;
  2603 + left: 0;
  2604 + color: var(--color-primary);
  2605 +}
  2606 +
  2607 +ol.analysis-rec-list {
  2608 + counter-reset: rec-counter;
  2609 +}
  2610 +
  2611 +ol.analysis-rec-list li {
  2612 + counter-increment: rec-counter;
  2613 +}
  2614 +
  2615 +ol.analysis-rec-list li::before {
  2616 + content: counter(rec-counter) '.';
  2617 + font-weight: 600;
  2618 +}
  2619 +
  2620 +/* 风险标签 */
  2621 +.risk-tag {
  2622 + display: inline-flex;
  2623 + align-items: center;
  2624 + padding: 2px 8px;
  2625 + border-radius: var(--radius-full);
  2626 + font-size: 0.7rem;
  2627 + font-weight: 600;
  2628 + margin-left: var(--spacing-sm);
  2629 +}
  2630 +
  2631 +.risk-tag-high {
  2632 + background: rgba(239, 68, 68, 0.15);
  2633 + color: var(--color-danger-light);
  2634 +}
  2635 +
  2636 +.risk-tag-medium {
  2637 + background: rgba(245, 158, 11, 0.15);
  2638 + color: var(--color-warning-light);
  2639 +}
  2640 +
  2641 +.risk-tag-low {
  2642 + background: rgba(16, 185, 129, 0.15);
  2643 + color: var(--color-success-light);
  2644 +}
  2645 +
  2646 +/* 可折叠推理过程 */
  2647 +.analysis-process-toggle {
  2648 + display: flex;
  2649 + align-items: center;
  2650 + gap: var(--spacing-sm);
  2651 + padding: var(--spacing-sm) var(--spacing-md);
  2652 + margin-top: var(--spacing-md);
  2653 + background: rgba(99, 102, 241, 0.08);
  2654 + border: 1px solid rgba(99, 102, 241, 0.2);
  2655 + border-radius: var(--radius-md);
  2656 + cursor: pointer;
  2657 + transition: all var(--transition-fast);
  2658 + color: var(--color-primary-light);
  2659 + font-size: 0.85rem;
  2660 + font-weight: 500;
  2661 +}
  2662 +
  2663 +.analysis-process-toggle:hover {
  2664 + background: rgba(99, 102, 241, 0.15);
  2665 +}
  2666 +
  2667 +.analysis-process-toggle svg {
  2668 + width: 16px;
  2669 + height: 16px;
  2670 + transition: transform var(--transition-fast);
  2671 +}
  2672 +
  2673 +.analysis-process-toggle.expanded svg {
  2674 + transform: rotate(180deg);
  2675 +}
  2676 +
  2677 +.analysis-process-content {
  2678 + display: none;
  2679 + margin-top: var(--spacing-md);
  2680 + padding: var(--spacing-md);
  2681 + background: rgba(30, 41, 59, 0.5);
  2682 + border: 1px solid var(--border-color);
  2683 + border-radius: var(--radius-md);
  2684 + font-size: 0.85rem;
  2685 +}
  2686 +
  2687 +.analysis-process-content.expanded {
  2688 + display: block;
  2689 +}
  2690 +
  2691 +.process-section {
  2692 + margin-bottom: var(--spacing-md);
  2693 + padding-bottom: var(--spacing-md);
  2694 + border-bottom: 1px solid var(--border-color);
  2695 +}
  2696 +
  2697 +.process-section:last-child {
  2698 + margin-bottom: 0;
  2699 + padding-bottom: 0;
  2700 + border-bottom: none;
  2701 +}
  2702 +
  2703 +.process-section-title {
  2704 + font-size: 0.8rem;
  2705 + font-weight: 600;
  2706 + color: var(--text-muted);
  2707 + text-transform: uppercase;
  2708 + letter-spacing: 0.5px;
  2709 + margin-bottom: var(--spacing-sm);
  2710 +}
  2711 +
  2712 +.process-item {
  2713 + display: flex;
  2714 + margin-bottom: var(--spacing-xs);
  2715 + line-height: 1.5;
  2716 +}
  2717 +
  2718 +.process-item-label {
  2719 + color: var(--text-muted);
  2720 + min-width: 120px;
  2721 + flex-shrink: 0;
  2722 +}
  2723 +
  2724 +.process-item-value {
  2725 + color: var(--text-secondary);
  2726 + word-break: break-word;
  2727 +}
  2728 +
  2729 +.process-item-value.highlight {
  2730 + color: var(--color-primary-light);
  2731 + font-weight: 500;
  2732 +}
  2733 +
  2734 +/* 季节标签 */
  2735 +.season-tag {
  2736 + display: inline-flex;
  2737 + align-items: center;
  2738 + gap: 4px;
  2739 + padding: 2px 10px;
  2740 + border-radius: var(--radius-full);
  2741 + font-size: 0.75rem;
  2742 + font-weight: 500;
  2743 + background: rgba(59, 130, 246, 0.15);
  2744 + color: var(--color-info-light);
  2745 + margin-left: var(--spacing-sm);
  2746 +}
  2747 +
  2748 +.season-tag svg {
  2749 + width: 12px;
  2750 + height: 12px;
  2751 +}
... ...
ui/index.html
... ... @@ -17,6 +17,9 @@
17 17 <!-- Markdown Parser -->
18 18 <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
19 19  
  20 + <!-- Chart.js -->
  21 + <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
  22 +
20 23 <!-- Styles -->
21 24 <link rel="stylesheet" href="/css/style.css">
22 25 </head>
... ...
ui/js/app.js
... ... @@ -98,7 +98,7 @@ const App = {
98 98  
99 99  
100 100 /**
101   - * 渲染分析报告标签页
  101 + * 渲染分析报告标签页(四大板块:库存概览/销量分析/健康度/补货建议)
102 102 */
103 103 async renderReportTab(container, taskNo) {
104 104 container.innerHTML = '<div class="loading-shops">加载分析报告...</div>';
... ... @@ -116,46 +116,28 @@ const App = {
116 116 }
117 117  
118 118 container.innerHTML = `
119   - <div class="report-module">
120   - <div class="report-section-title">
121   - <i data-lucide="layout-dashboard"></i>
122   - 核心经营综述
123   - </div>
124   - <div class="report-grid">
125   - ${this.renderOverallAssessment(report.replenishment_insights)}
126   - </div>
127   - </div>
128   -
129   - <div class="report-module">
130   - <div class="report-section-title">
131   - <i data-lucide="alert-triangle"></i>
132   - 风险管控预警
133   - </div>
134   - <div class="report-grid">
135   - ${this.renderRiskAlerts(report.urgency_assessment)}
136   - </div>
137   - </div>
138   -
139   - <div class="report-module">
140   - <div class="report-section-title">
141   - <i data-lucide="target"></i>
142   - 补货策略建议
143   - </div>
144   - <div class="report-grid">
145   - ${this.renderStrategy(report.strategy_recommendations)}
146   - </div>
147   - </div>
148   -
149   - <div class="report-module">
150   - <div class="report-section-title">
151   - <i data-lucide="trending-up"></i>
152   - 效果预期与建议
153   - </div>
154   - <div class="report-grid">
155   - ${this.renderExpectedImpact(report.expected_outcomes)}
156   - </div>
157   - </div>
  119 + <div id="report-inventory-overview" class="report-module"></div>
  120 + <div id="report-sales-analysis" class="report-module"></div>
  121 + <div id="report-inventory-health" class="report-module"></div>
  122 + <div id="report-replenishment-summary" class="report-module"></div>
158 123 `;
  124 +
  125 + this.renderInventoryOverview(
  126 + document.getElementById('report-inventory-overview'),
  127 + report.inventory_overview
  128 + );
  129 + this.renderSalesAnalysis(
  130 + document.getElementById('report-sales-analysis'),
  131 + report.sales_analysis
  132 + );
  133 + this.renderInventoryHealth(
  134 + document.getElementById('report-inventory-health'),
  135 + report.inventory_health
  136 + );
  137 + this.renderReplenishmentSummary(
  138 + document.getElementById('report-replenishment-summary'),
  139 + report.replenishment_summary
  140 + );
159 141  
160 142 lucide.createIcons();
161 143 } catch (error) {
... ... @@ -169,203 +151,835 @@ const App = {
169 151 }
170 152 },
171 153  
172   - renderOverallAssessment(insights) {
173   - if (!insights) return '';
174   -
175   - let heroHtml = '';
  154 + /**
  155 + * 渲染库存概览板块
  156 + */
  157 + renderInventoryOverview(container, data) {
  158 + if (!data) {
  159 + container.innerHTML = '';
  160 + return;
  161 + }
  162 + const stats = data.stats || {};
  163 + const analysis = data.llm_analysis || {};
176 164  
177   - // Scale (Hero Main)
178   - if (insights.scale_evaluation) {
179   - heroHtml += `
180   - <div class="assessment-item">
181   - <div class="assessment-label">补货规模</div>
182   - <div class="assessment-main">${insights.scale_evaluation.current_vs_historical || '-'}</div>
183   - <div class="assessment-sub">${insights.scale_evaluation.possible_reasons || ''}</div>
184   - </div>`;
  165 + // 兼容新旧数据结构
  166 + const conclusion = analysis.conclusion || analysis;
  167 + const process = analysis.analysis_process || null;
  168 +
  169 + const ratio = stats.overall_ratio;
  170 + const ratioDisplay = (ratio === 999 || ratio === null || ratio === undefined) ? '无销量' : Components.formatNumber(ratio);
  171 +
  172 + // LLM 分析文本渲染
  173 + let analysisHtml = '';
  174 + if (analysis.error) {
  175 + analysisHtml = `<div class="report-analysis-text"><span style="color:var(--color-warning);">分析生成失败: ${analysis.error}</span></div>`;
  176 + } else {
  177 + const sections = [];
  178 +
  179 + // 季节信息(如果有)
  180 + if (process && process.seasonal_analysis) {
  181 + const sa = process.seasonal_analysis;
  182 + sections.push(`<div class="analysis-block">
  183 + <div class="analysis-block-title"><i data-lucide="sun"></i> 季节性分析 <span class="season-tag"><i data-lucide="calendar"></i>${sa.current_season || ''}</span></div>
  184 + <p>${sa.season_demand_feature || ''}</p>
  185 + <p>${sa.inventory_fitness || ''}</p>
  186 + ${sa.upcoming_season_preparation ? `<p>下季准备: ${sa.upcoming_season_preparation}</p>` : ''}
  187 + </div>`);
  188 + }
  189 +
  190 + if (conclusion.capital_assessment) {
  191 + const ca = conclusion.capital_assessment;
  192 + sections.push(`<div class="analysis-block">
  193 + <div class="analysis-block-title"><i data-lucide="wallet"></i> 资金占用评估 <span class="risk-tag risk-tag-${ca.risk_level || 'medium'}">${ca.risk_level === 'high' ? '高风险' : ca.risk_level === 'low' ? '低风险' : '中风险'}</span></div>
  194 + <p>${ca.total_evaluation || ''}</p>
  195 + <p>${ca.structure_ratio || ''}</p>
  196 + </div>`);
  197 + }
  198 + if (conclusion.ratio_diagnosis) {
  199 + const rd = conclusion.ratio_diagnosis;
  200 + sections.push(`<div class="analysis-block">
  201 + <div class="analysis-block-title"><i data-lucide="gauge"></i> 库销比诊断 — ${rd.level || ''}</div>
  202 + <p>${rd.analysis || ''}</p>
  203 + <p class="analysis-benchmark">${rd.benchmark || ''}</p>
  204 + </div>`);
  205 + }
  206 + if (conclusion.recommendations && conclusion.recommendations.length > 0) {
  207 + const recHtml = conclusion.recommendations.map(r => {
  208 + if (typeof r === 'object') {
  209 + return `<li><strong>${r.action || ''}</strong>${r.reason ? ` - ${r.reason}` : ''}${r.expected_effect ? `<br><small>预期效果: ${r.expected_effect}</small>` : ''}</li>`;
  210 + }
  211 + return `<li>${r}</li>`;
  212 + }).join('');
  213 + sections.push(`<div class="analysis-block">
  214 + <div class="analysis-block-title"><i data-lucide="lightbulb"></i> 库存结构建议</div>
  215 + <ul class="analysis-rec-list">${recHtml}</ul>
  216 + </div>`);
  217 + }
  218 +
  219 + // 推理过程(可折叠)
  220 + let processHtml = '';
  221 + if (process) {
  222 + processHtml = this._renderAnalysisProcess(process, 'inventory-overview');
  223 + }
  224 +
  225 + analysisHtml = sections.length > 0 ? `<div class="report-analysis-text">${sections.join('')}${processHtml}</div>` : '';
185 226 }
  227 +
  228 + container.innerHTML = `
  229 + <div class="report-section-title">
  230 + <i data-lucide="warehouse"></i>
  231 + 库存总体概览
  232 + </div>
  233 + <div class="report-stat-cards">
  234 + <div class="report-stat-card">
  235 + <div class="report-stat-label">有效库存总数量</div>
  236 + <div class="report-stat-value">${Components.formatNumber(stats.total_valid_storage_cnt)}</div>
  237 + </div>
  238 + <div class="report-stat-card">
  239 + <div class="report-stat-label">资金占用(总金额)</div>
  240 + <div class="report-stat-value">${Components.formatAmount(stats.total_valid_storage_amount)}</div>
  241 + </div>
  242 + <div class="report-stat-card">
  243 + <div class="report-stat-label">整体库销比</div>
  244 + <div class="report-stat-value">${ratioDisplay}</div>
  245 + </div>
  246 + <div class="report-stat-card">
  247 + <div class="report-stat-label">配件种类数</div>
  248 + <div class="report-stat-value">${stats.part_count || 0}</div>
  249 + </div>
  250 + </div>
  251 + <div class="report-detail-table">
  252 + <table>
  253 + <thead>
  254 + <tr>
  255 + <th>构成项</th>
  256 + <th>数量</th>
  257 + <th>金额</th>
  258 + </tr>
  259 + </thead>
  260 + <tbody>
  261 + <tr>
  262 + <td>在库未锁</td>
  263 + <td>${Components.formatNumber(stats.total_in_stock_unlocked_cnt)}</td>
  264 + <td>${Components.formatAmount(stats.total_in_stock_unlocked_amount)}</td>
  265 + </tr>
  266 + <tr>
  267 + <td>在途</td>
  268 + <td>${Components.formatNumber(stats.total_on_the_way_cnt)}</td>
  269 + <td>${Components.formatAmount(stats.total_on_the_way_amount)}</td>
  270 + </tr>
  271 + <tr>
  272 + <td>计划数</td>
  273 + <td>${Components.formatNumber(stats.total_has_plan_cnt)}</td>
  274 + <td>${Components.formatAmount(stats.total_has_plan_amount)}</td>
  275 + </tr>
  276 + </tbody>
  277 + </table>
  278 + </div>
  279 + ${analysisHtml}
  280 + `;
186 281  
187   - // Structure (Hero Middle)
188   - if (insights.structure_analysis) {
189   - const data = insights.structure_analysis;
190   - const details = [
191   - data.category_distribution ? `• ${data.category_distribution}` : '',
192   - data.price_range_distribution ? `• ${data.price_range_distribution}` : '',
193   - data.turnover_distribution ? `• ${data.turnover_distribution}` : ''
194   - ].filter(Boolean).join('<br>');
195   -
196   - heroHtml += `
197   - <div class="assessment-item">
198   - <div class="assessment-label">结构特征</div>
199   - <div class="assessment-main">${data.imbalance_warning || '结构均衡'}</div>
200   - <div class="assessment-sub">${details}</div>
201   - </div>`;
  282 + // 绑定折叠事件
  283 + this._bindProcessToggle(container);
  284 + },
  285 +
  286 + /**
  287 + * 渲染销量分析板块
  288 + */
  289 + renderSalesAnalysis(container, data) {
  290 + if (!data) {
  291 + container.innerHTML = '';
  292 + return;
202 293 }
  294 + const stats = data.stats || {};
  295 + const analysis = data.llm_analysis || {};
203 296  
204   - // Timing (Hero End)
205   - if (insights.timing_judgment) {
206   - const data = insights.timing_judgment;
207   - const isPos = data.is_favorable;
208   - heroHtml += `
209   - <div class="assessment-item">
210   - <div class="assessment-label">时机判断</div>
211   - <div class="assessment-main" style="color:${isPos ? 'var(--color-success)' : 'var(--color-warning)'}">
212   - ${isPos ? '有利时机' : '建议观望'}
  297 + // 兼容新旧数据结构
  298 + const conclusion = analysis.conclusion || analysis;
  299 + const process = analysis.analysis_process || null;
  300 +
  301 + // LLM 分析文本
  302 + let analysisHtml = '';
  303 + if (analysis.error) {
  304 + analysisHtml = `<div class="report-analysis-text"><span style="color:var(--color-warning);">分析生成失败: ${analysis.error}</span></div>`;
  305 + } else {
  306 + const sections = [];
  307 +
  308 + // 季节信息(如果有)
  309 + if (process && process.seasonal_analysis) {
  310 + const sa = process.seasonal_analysis;
  311 + sections.push(`<div class="analysis-block">
  312 + <div class="analysis-block-title"><i data-lucide="sun"></i> 季节性分析 <span class="season-tag"><i data-lucide="calendar"></i>${sa.current_season || ''}</span></div>
  313 + <p>${sa.expected_performance || ''}</p>
  314 + <p>${sa.actual_vs_expected || ''}</p>
  315 + ${sa.seasonal_items_status ? `<p>${sa.seasonal_items_status}</p>` : ''}
  316 + </div>`);
  317 + }
  318 +
  319 + if (conclusion.composition_analysis) {
  320 + const ca = conclusion.composition_analysis;
  321 + sections.push(`<div class="analysis-block">
  322 + <div class="analysis-block-title"><i data-lucide="pie-chart"></i> 销量构成解读</div>
  323 + <p>${ca.main_driver || ''}</p>
  324 + <p>${ca.pending_orders_impact || ''}</p>
  325 + <p>${ca.booking_trend || ''}</p>
  326 + </div>`);
  327 + }
  328 + if (conclusion.activity_assessment) {
  329 + const aa = conclusion.activity_assessment;
  330 + sections.push(`<div class="analysis-block">
  331 + <div class="analysis-block-title"><i data-lucide="activity"></i> 销售活跃度</div>
  332 + <p>${aa.active_ratio || ''}</p>
  333 + <p>${aa.optimization_suggestion || ''}</p>
  334 + </div>`);
  335 + }
  336 + if (conclusion.demand_trend) {
  337 + const dt = conclusion.demand_trend;
  338 + const dirIcon = dt.direction === '上升' ? 'trending-up' : dt.direction === '下降' ? 'trending-down' : 'minus';
  339 + sections.push(`<div class="analysis-block">
  340 + <div class="analysis-block-title"><i data-lucide="${dirIcon}"></i> 需求趋势 — ${dt.direction || ''}</div>
  341 + <p>${dt.evidence || ''}</p>
  342 + ${dt.seasonal_factor ? `<p>季节因素: ${dt.seasonal_factor}</p>` : ''}
  343 + <p>${dt.forecast || ''}</p>
  344 + </div>`);
  345 + }
  346 +
  347 + // 推理过程(可折叠)
  348 + let processHtml = '';
  349 + if (process) {
  350 + processHtml = this._renderAnalysisProcess(process, 'sales-analysis');
  351 + }
  352 +
  353 + analysisHtml = sections.length > 0 ? `<div class="report-analysis-text">${sections.join('')}${processHtml}</div>` : '';
  354 + }
  355 +
  356 + const totalParts = (stats.has_sales_part_count || 0) + (stats.no_sales_part_count || 0);
  357 +
  358 + container.innerHTML = `
  359 + <div class="report-section-title">
  360 + <i data-lucide="bar-chart-3"></i>
  361 + 销量分析
  362 + </div>
  363 + <div class="report-stat-cards">
  364 + <div class="report-stat-card">
  365 + <div class="report-stat-label">月均销量总数量</div>
  366 + <div class="report-stat-value">${Components.formatNumber(stats.total_avg_sales_cnt)}</div>
213 367 </div>
214   - <div class="assessment-sub">
215   - ${data.recommendation}<br>
216   - <span style="opacity:0.7;font-size:0.85em;display:block;margin-top:4px;">${data.timing_factors || ''}</span>
  368 + <div class="report-stat-card">
  369 + <div class="report-stat-label">月均销量总金额</div>
  370 + <div class="report-stat-value">${Components.formatAmount(stats.total_avg_sales_amount)}</div>
217 371 </div>
218   - </div>`;
219   - }
  372 + <div class="report-stat-card">
  373 + <div class="report-stat-label">有销量配件</div>
  374 + <div class="report-stat-value">${stats.has_sales_part_count || 0} <span class="report-stat-sub">/ ${totalParts}</span></div>
  375 + </div>
  376 + <div class="report-stat-card">
  377 + <div class="report-stat-label">无销量配件</div>
  378 + <div class="report-stat-value">${stats.no_sales_part_count || 0} <span class="report-stat-sub">/ ${totalParts}</span></div>
  379 + </div>
  380 + </div>
  381 + <div class="report-detail-table">
  382 + <table>
  383 + <thead>
  384 + <tr>
  385 + <th>构成项</th>
  386 + <th>总量</th>
  387 + </tr>
  388 + </thead>
  389 + <tbody>
  390 + <tr>
  391 + <td>90天出库数</td>
  392 + <td>${Components.formatNumber(stats.total_out_stock_cnt)}</td>
  393 + </tr>
  394 + <tr>
  395 + <td>未关单已锁</td>
  396 + <td>${Components.formatNumber(stats.total_storage_locked_cnt)}</td>
  397 + </tr>
  398 + <tr>
  399 + <td>未关单出库</td>
  400 + <td>${Components.formatNumber(stats.total_out_stock_ongoing_cnt)}</td>
  401 + </tr>
  402 + <tr>
  403 + <td>订件</td>
  404 + <td>${Components.formatNumber(stats.total_buy_cnt)}</td>
  405 + </tr>
  406 + </tbody>
  407 + </table>
  408 + </div>
  409 + ${analysisHtml}
  410 + `;
220 411  
221   - return `<div class="assessment-grid">${heroHtml}</div>`;
  412 + // 绑定折叠事件
  413 + this._bindProcessToggle(container);
222 414 },
223 415  
224   - renderRiskAlerts(risks) {
225   - if (!risks) return '';
226   -
227   - let feedHtml = '<div class="risk-feed">';
  416 + /**
  417 + * 渲染库存健康度板块(含 Chart.js 环形图)
  418 + */
  419 + renderInventoryHealth(container, data) {
  420 + if (!data) {
  421 + container.innerHTML = '';
  422 + return;
  423 + }
  424 + const stats = data.stats || {};
  425 + const chartData = data.chart_data || {};
  426 + const analysis = data.llm_analysis || {};
228 427  
229   - const addRiskItem = (level, type, desc, action) => {
230   - let icon = 'alert-circle';
231   - if (level === 'high') icon = 'alert-octagon';
232   - if (level === 'low') icon = 'info';
  428 + // 兼容新旧数据结构
  429 + const conclusion = analysis.conclusion || analysis;
  430 + const process = analysis.analysis_process || null;
  431 +
  432 + const shortage = stats.shortage || {};
  433 + const stagnant = stats.stagnant || {};
  434 + const low_freq = stats.low_freq || {};
  435 + const normal = stats.normal || {};
  436 +
  437 + // LLM 分析文本
  438 + let analysisHtml = '';
  439 + if (analysis.error) {
  440 + analysisHtml = `<div class="report-analysis-text"><span style="color:var(--color-warning);">分析生成失败: ${analysis.error}</span></div>`;
  441 + } else {
  442 + const sections = [];
233 443  
234   - feedHtml += `
235   - <div class="risk-item ${level}">
236   - <div class="risk-icon">
237   - <i data-lucide="${icon}"></i>
  444 + // 季节信息(如果有)
  445 + if (process && process.seasonal_analysis) {
  446 + const sa = process.seasonal_analysis;
  447 + sections.push(`<div class="analysis-block">
  448 + <div class="analysis-block-title"><i data-lucide="sun"></i> 季节性分析 <span class="season-tag"><i data-lucide="calendar"></i>${sa.current_season || ''}</span></div>
  449 + ${sa.seasonal_stagnant_items ? `<p>${sa.seasonal_stagnant_items}</p>` : ''}
  450 + ${sa.seasonal_shortage_risk ? `<p>${sa.seasonal_shortage_risk}</p>` : ''}
  451 + ${sa.upcoming_season_alert ? `<p>下季关注: ${sa.upcoming_season_alert}</p>` : ''}
  452 + </div>`);
  453 + }
  454 +
  455 + if (conclusion.health_score) {
  456 + const hs = conclusion.health_score;
  457 + sections.push(`<div class="analysis-block">
  458 + <div class="analysis-block-title"><i data-lucide="heart-pulse"></i> 健康度评分 — ${hs.score || ''}</div>
  459 + <p>${hs.normal_ratio_evaluation || ''}</p>
  460 + </div>`);
  461 + }
  462 + if (conclusion.problem_diagnosis) {
  463 + const pd = conclusion.problem_diagnosis;
  464 + sections.push(`<div class="analysis-block">
  465 + <div class="analysis-block-title"><i data-lucide="stethoscope"></i> 问题诊断</div>
  466 + ${pd.stagnant_analysis ? `<p>呆滞件: ${pd.stagnant_analysis}</p>` : ''}
  467 + ${pd.shortage_analysis ? `<p>缺货件: ${pd.shortage_analysis}</p>` : ''}
  468 + ${pd.low_freq_analysis ? `<p>低频件: ${pd.low_freq_analysis}</p>` : ''}
  469 + </div>`);
  470 + }
  471 + if (conclusion.capital_release) {
  472 + const cr = conclusion.capital_release;
  473 + sections.push(`<div class="analysis-block">
  474 + <div class="analysis-block-title"><i data-lucide="banknote"></i> 资金释放机会</div>
  475 + ${cr.stagnant_releasable ? `<p>呆滞件可释放: ${cr.stagnant_releasable}</p>` : ''}
  476 + ${cr.low_freq_releasable ? `<p>低频件可释放: ${cr.low_freq_releasable}</p>` : ''}
  477 + ${cr.action_plan ? `<p>${cr.action_plan}</p>` : ''}
  478 + </div>`);
  479 + }
  480 + if (conclusion.priority_actions && conclusion.priority_actions.length > 0) {
  481 + const actHtml = conclusion.priority_actions.map(a => {
  482 + if (typeof a === 'object') {
  483 + return `<li><strong>${a.action || ''}</strong>${a.reason ? ` - ${a.reason}` : ''}${a.expected_effect ? `<br><small>预期效果: ${a.expected_effect}</small>` : ''}</li>`;
  484 + }
  485 + return `<li>${a}</li>`;
  486 + }).join('');
  487 + sections.push(`<div class="analysis-block">
  488 + <div class="analysis-block-title"><i data-lucide="list-ordered"></i> 改善优先级</div>
  489 + <ol class="analysis-rec-list">${actHtml}</ol>
  490 + </div>`);
  491 + }
  492 +
  493 + // 推理过程(可折叠)
  494 + let processHtml = '';
  495 + if (process) {
  496 + processHtml = this._renderAnalysisProcess(process, 'inventory-health');
  497 + }
  498 +
  499 + analysisHtml = sections.length > 0 ? `<div class="report-analysis-text">${sections.join('')}${processHtml}</div>` : '';
  500 + }
  501 +
  502 + container.innerHTML = `
  503 + <div class="report-section-title">
  504 + <i data-lucide="heart-pulse"></i>
  505 + 库存构成健康度
  506 + </div>
  507 + <div class="report-stat-cards report-stat-cards-4">
  508 + <div class="report-stat-card report-stat-card-danger">
  509 + <div class="report-stat-label">缺货件</div>
  510 + <div class="report-stat-value">${shortage.count || 0}</div>
  511 + <div class="report-stat-pct">${Components.formatNumber(shortage.count_pct)}% · ${Components.formatAmount(shortage.amount)}</div>
238 512 </div>
239   - <div class="risk-content">
240   - <div class="risk-title">
241   - ${type}
242   - <span class="badgex">${level.toUpperCase()}</span>
243   - </div>
244   - <div class="risk-desc">${desc}</div>
245   - ${action ? `<div class="risk-action"><i data-lucide="arrow-right-circle" style="width:14px;"></i> ${action}</div>` : ''}
  513 + <div class="report-stat-card report-stat-card-warning">
  514 + <div class="report-stat-label">呆滞件</div>
  515 + <div class="report-stat-value">${stagnant.count || 0}</div>
  516 + <div class="report-stat-pct">${Components.formatNumber(stagnant.count_pct)}% · ${Components.formatAmount(stagnant.amount)}</div>
246 517 </div>
247   - </div>`;
248   - };
  518 + <div class="report-stat-card report-stat-card-info">
  519 + <div class="report-stat-label">低频件</div>
  520 + <div class="report-stat-value">${low_freq.count || 0}</div>
  521 + <div class="report-stat-pct">${Components.formatNumber(low_freq.count_pct)}% · ${Components.formatAmount(low_freq.amount)}</div>
  522 + </div>
  523 + <div class="report-stat-card report-stat-card-success">
  524 + <div class="report-stat-label">正常件</div>
  525 + <div class="report-stat-value">${normal.count || 0}</div>
  526 + <div class="report-stat-pct">${Components.formatNumber(normal.count_pct)}% · ${Components.formatAmount(normal.amount)}</div>
  527 + </div>
  528 + </div>
  529 + <div class="report-charts-row">
  530 + <div class="report-chart-container">
  531 + <div class="report-chart-title">数量占比</div>
  532 + <canvas id="health-count-chart"></canvas>
  533 + </div>
  534 + <div class="report-chart-container">
  535 + <div class="report-chart-title">金额占比</div>
  536 + <canvas id="health-amount-chart"></canvas>
  537 + </div>
  538 + </div>
  539 + ${analysisHtml}
  540 + `;
  541 +
  542 + // 渲染 Chart.js 环形图
  543 + this._renderHealthCharts(chartData);
249 544  
250   - // Supply Risks
251   - if (risks.supply_risks && Array.isArray(risks.supply_risks)) {
252   - risks.supply_risks.forEach(r => addRiskItem(
253   - r.likelihood === '高' ? 'high' : 'medium',
254   - r.risk_type || '供应风险',
255   - r.affected_scope,
256   - r.mitigation
257   - ));
  545 + // 绑定折叠事件
  546 + this._bindProcessToggle(container);
  547 + },
  548 +
  549 + /**
  550 + * 渲染健康度环形图
  551 + */
  552 + _renderHealthCharts(chartData) {
  553 + if (!chartData || !chartData.labels) return;
  554 + if (typeof Chart === 'undefined') return;
  555 +
  556 + const colors = ['#ef4444', '#f59e0b', '#3b82f6', '#10b981'];
  557 + const borderColors = ['#dc2626', '#d97706', '#2563eb', '#059669'];
  558 +
  559 + const chartOptions = {
  560 + responsive: true,
  561 + maintainAspectRatio: true,
  562 + plugins: {
  563 + legend: {
  564 + position: 'bottom',
  565 + labels: {
  566 + color: '#94a3b8',
  567 + padding: 16,
  568 + usePointStyle: true,
  569 + pointStyleWidth: 10,
  570 + font: { size: 12 }
  571 + }
  572 + },
  573 + tooltip: {
  574 + backgroundColor: '#1e293b',
  575 + titleColor: '#f8fafc',
  576 + bodyColor: '#94a3b8',
  577 + borderColor: 'rgba(148,163,184,0.2)',
  578 + borderWidth: 1
  579 + }
  580 + },
  581 + cutout: '60%'
  582 + };
  583 +
  584 + // 数量占比图
  585 + const countCtx = document.getElementById('health-count-chart');
  586 + if (countCtx) {
  587 + new Chart(countCtx, {
  588 + type: 'doughnut',
  589 + data: {
  590 + labels: chartData.labels,
  591 + datasets: [{
  592 + data: chartData.count_values,
  593 + backgroundColor: colors,
  594 + borderColor: borderColors,
  595 + borderWidth: 2
  596 + }]
  597 + },
  598 + options: chartOptions
  599 + });
258 600 }
259   -
260   - // Capital Risks
261   - if (risks.capital_risks) {
262   - const data = risks.capital_risks;
263   - addRiskItem('medium', '资金风险', data.cash_flow_pressure, data.recommendation);
  601 +
  602 + // 金额占比图
  603 + const amountCtx = document.getElementById('health-amount-chart');
  604 + if (amountCtx) {
  605 + new Chart(amountCtx, {
  606 + type: 'doughnut',
  607 + data: {
  608 + labels: chartData.labels,
  609 + datasets: [{
  610 + data: chartData.amount_values,
  611 + backgroundColor: colors,
  612 + borderColor: borderColors,
  613 + borderWidth: 2
  614 + }]
  615 + },
  616 + options: {
  617 + ...chartOptions,
  618 + plugins: {
  619 + ...chartOptions.plugins,
  620 + tooltip: {
  621 + ...chartOptions.plugins.tooltip,
  622 + callbacks: {
  623 + label: function(context) {
  624 + const value = context.parsed;
  625 + return ` ${context.label}: ¥${Number(value).toLocaleString('zh-CN', {minimumFractionDigits: 2})}`;
  626 + }
  627 + }
  628 + }
  629 + }
  630 + }
  631 + });
264 632 }
  633 + },
265 634  
266   - // Market Risks
267   - if (risks.market_risks && Array.isArray(risks.market_risks)) {
268   - risks.market_risks.forEach(r => addRiskItem('medium', '市场风险', r.risk_description, r.recommendation));
  635 + /**
  636 + * 渲染补货建议板块
  637 + */
  638 + renderReplenishmentSummary(container, data) {
  639 + if (!data) {
  640 + container.innerHTML = '';
  641 + return;
269 642 }
  643 + const stats = data.stats || {};
  644 + const analysis = data.llm_analysis || {};
270 645  
271   - // Execution
272   - if (risks.execution_anomalies && Array.isArray(risks.execution_anomalies)) {
273   - risks.execution_anomalies.forEach(a => addRiskItem('high', a.anomaly_type || '执行异常', a.description, a.review_suggestion));
  646 + // 兼容新旧数据结构
  647 + const conclusion = analysis.conclusion || analysis;
  648 + const process = analysis.analysis_process || null;
  649 +
  650 + const urgent = stats.urgent || {};
  651 + const suggested = stats.suggested || {};
  652 + const optional = stats.optional || {};
  653 +
  654 + // LLM 分析文本
  655 + let analysisHtml = '';
  656 + if (analysis.error) {
  657 + analysisHtml = `<div class="report-analysis-text"><span style="color:var(--color-warning);">分析生成失败: ${analysis.error}</span></div>`;
  658 + } else {
  659 + const sections = [];
  660 +
  661 + // 季节信息(如果有)
  662 + if (process && process.seasonal_analysis) {
  663 + const sa = process.seasonal_analysis;
  664 + sections.push(`<div class="analysis-block">
  665 + <div class="analysis-block-title"><i data-lucide="sun"></i> 季节性分析 <span class="season-tag"><i data-lucide="calendar"></i>${sa.current_season || ''}</span></div>
  666 + ${sa.seasonal_priority_items ? `<p>${sa.seasonal_priority_items}</p>` : ''}
  667 + ${sa.timeline_adjustment ? `<p>${sa.timeline_adjustment}</p>` : ''}
  668 + ${sa.next_season_preparation ? `<p>下季准备: ${sa.next_season_preparation}</p>` : ''}
  669 + </div>`);
  670 + }
  671 +
  672 + if (conclusion.urgency_assessment) {
  673 + const ua = conclusion.urgency_assessment;
  674 + const riskTag = ua.risk_level === 'high' ? '高风险' : ua.risk_level === 'low' ? '低风险' : '中风险';
  675 + sections.push(`<div class="analysis-block">
  676 + <div class="analysis-block-title"><i data-lucide="alert-triangle"></i> 紧迫度评估 <span class="risk-tag risk-tag-${ua.risk_level || 'medium'}">${riskTag}</span></div>
  677 + <p>${ua.urgent_ratio_evaluation || ''}</p>
  678 + ${ua.immediate_action_needed ? '<p style="color:var(--color-danger);font-weight:600;">需要立即采取行动</p>' : ''}
  679 + </div>`);
  680 + }
  681 + if (conclusion.budget_allocation) {
  682 + const ba = conclusion.budget_allocation;
  683 + sections.push(`<div class="analysis-block">
  684 + <div class="analysis-block-title"><i data-lucide="wallet"></i> 资金分配建议</div>
  685 + <p>${ba.recommended_order || ''}</p>
  686 + ${ba.urgent_budget ? `<p>急需补货预算: ${ba.urgent_budget}</p>` : ''}
  687 + ${ba.suggested_budget ? `<p>建议补货预算: ${ba.suggested_budget}</p>` : ''}
  688 + ${ba.optional_budget ? `<p>可选补货预算: ${ba.optional_budget}</p>` : ''}
  689 + </div>`);
  690 + }
  691 + if (conclusion.execution_plan) {
  692 + const ep = conclusion.execution_plan;
  693 + sections.push(`<div class="analysis-block">
  694 + <div class="analysis-block-title"><i data-lucide="calendar-clock"></i> 执行节奏建议</div>
  695 + ${ep.urgent_timeline ? `<p>急需: ${ep.urgent_timeline}</p>` : ''}
  696 + ${ep.suggested_timeline ? `<p>建议: ${ep.suggested_timeline}</p>` : ''}
  697 + ${ep.optional_timeline ? `<p>可选: ${ep.optional_timeline}</p>` : ''}
  698 + </div>`);
  699 + }
  700 + if (conclusion.risk_warnings && conclusion.risk_warnings.length > 0) {
  701 + const warnHtml = conclusion.risk_warnings.map(w => {
  702 + if (typeof w === 'object') {
  703 + return `<li><strong>${w.risk_type || ''}</strong>: ${w.description || ''}${w.mitigation ? `<br><small>应对: ${w.mitigation}</small>` : ''}</li>`;
  704 + }
  705 + return `<li>${w}</li>`;
  706 + }).join('');
  707 + sections.push(`<div class="analysis-block">
  708 + <div class="analysis-block-title"><i data-lucide="shield-alert"></i> 风险提示</div>
  709 + <ul class="analysis-rec-list">${warnHtml}</ul>
  710 + </div>`);
  711 + }
  712 +
  713 + // 推理过程(可折叠)
  714 + let processHtml = '';
  715 + if (process) {
  716 + processHtml = this._renderAnalysisProcess(process, 'replenishment-summary');
  717 + }
  718 +
  719 + analysisHtml = sections.length > 0 ? `<div class="report-analysis-text">${sections.join('')}${processHtml}</div>` : '';
274 720 }
  721 +
  722 + container.innerHTML = `
  723 + <div class="report-section-title">
  724 + <i data-lucide="shopping-cart"></i>
  725 + 补货建议生成情况
  726 + </div>
  727 + <div class="report-detail-table">
  728 + <table>
  729 + <thead>
  730 + <tr>
  731 + <th>优先级</th>
  732 + <th>配件种类数</th>
  733 + <th>建议补货金额</th>
  734 + </tr>
  735 + </thead>
  736 + <tbody>
  737 + <tr>
  738 + <td><span class="priority-badge priority-high">急需补货</span></td>
  739 + <td>${urgent.count || 0}</td>
  740 + <td class="table-cell-amount">${Components.formatAmount(urgent.amount)}</td>
  741 + </tr>
  742 + <tr>
  743 + <td><span class="priority-badge priority-medium">建议补货</span></td>
  744 + <td>${suggested.count || 0}</td>
  745 + <td class="table-cell-amount">${Components.formatAmount(suggested.amount)}</td>
  746 + </tr>
  747 + <tr>
  748 + <td><span class="priority-badge priority-low">可选补货</span></td>
  749 + <td>${optional.count || 0}</td>
  750 + <td class="table-cell-amount">${Components.formatAmount(optional.amount)}</td>
  751 + </tr>
  752 + <tr style="font-weight:600; border-top: 2px solid var(--border-color-light);">
  753 + <td>合计</td>
  754 + <td>${stats.total_count || 0}</td>
  755 + <td class="table-cell-amount">${Components.formatAmount(stats.total_amount)}</td>
  756 + </tr>
  757 + </tbody>
  758 + </table>
  759 + </div>
  760 + ${analysisHtml}
  761 + `;
275 762  
276   - feedHtml += '</div>';
277   - return feedHtml;
  763 + // 绑定折叠事件
  764 + this._bindProcessToggle(container);
278 765 },
279 766  
280   - renderStrategy(strategy) {
281   - if (!strategy) return '';
  767 + /**
  768 + * 渲染推理过程(可折叠)
  769 + */
  770 + _renderAnalysisProcess(process, moduleId) {
  771 + if (!process) return '';
282 772  
283   - let html = '<div class="strategy-steps">';
  773 + const sections = [];
284 774  
285   - const addStep = (num, title, items) => {
286   - const listItems = Array.isArray(items) ? items : [items];
287   - const listHtml = listItems.map(i => `<li>${i}</li>`).join('');
288   - html += `
289   - <div class="strategy-step">
290   - <div class="strategy-number">0${num}</div>
291   - <div class="strategy-title">${title}</div>
292   - <ul class="strategy-list">${listHtml}</ul>
293   - </div>`;
294   - };
  775 + // 计算指标
  776 + if (process.calculated_metrics) {
  777 + const items = Object.entries(process.calculated_metrics)
  778 + .filter(([k, v]) => v && v !== '')
  779 + .map(([k, v]) => `<div class="process-item"><span class="process-item-label">${this._formatProcessKey(k)}</span><span class="process-item-value">${v}</span></div>`)
  780 + .join('');
  781 + if (items) {
  782 + sections.push(`<div class="process-section"><div class="process-section-title">计算指标</div>${items}</div>`);
  783 + }
  784 + }
295 785  
296   - // 1. Priority
297   - if (strategy.priority_principle) {
298   - const p = strategy.priority_principle;
299   - addStep(1, '优先级排序', [
300   - `<strong style="color:var(--color-danger)">P1:</strong> ${p.tier1_criteria}`,
301   - `<strong style="color:var(--color-warning)">P2:</strong> ${p.tier2_criteria}`,
302   - `<span style="opacity:0.7">P3: ${p.tier3_criteria}</span>`
303   - ]);
  786 + // 库销比诊断
  787 + if (process.ratio_diagnosis) {
  788 + const rd = process.ratio_diagnosis;
  789 + const items = [];
  790 + if (rd.current_value) items.push(`<div class="process-item"><span class="process-item-label">当前值</span><span class="process-item-value highlight">${rd.current_value}</span></div>`);
  791 + if (rd.level) items.push(`<div class="process-item"><span class="process-item-label">判断等级</span><span class="process-item-value highlight">${rd.level}</span></div>`);
  792 + if (rd.reasoning) items.push(`<div class="process-item"><span class="process-item-label">判断依据</span><span class="process-item-value">${rd.reasoning}</span></div>`);
  793 + if (rd.benchmark_comparison) items.push(`<div class="process-item"><span class="process-item-label">行业对比</span><span class="process-item-value">${rd.benchmark_comparison}</span></div>`);
  794 + if (items.length > 0) {
  795 + sections.push(`<div class="process-section"><div class="process-section-title">库销比诊断</div>${items.join('')}</div>`);
  796 + }
304 797 }
305 798  
306   - // 2. Phased
307   - if (strategy.phased_procurement) {
308   - addStep(2, '分批节奏', [
309   - `节奏: ${strategy.phased_procurement.suggested_rhythm}`,
310   - `范围: ${strategy.phased_procurement.recommended_parts}`
311   - ]);
  799 + // 结构分析
  800 + if (process.structure_analysis) {
  801 + const sa = process.structure_analysis;
  802 + const items = [];
  803 + if (sa.in_stock_evaluation) items.push(`<div class="process-item"><span class="process-item-label">在库未锁</span><span class="process-item-value">${sa.in_stock_evaluation}</span></div>`);
  804 + if (sa.on_way_evaluation) items.push(`<div class="process-item"><span class="process-item-label">在途</span><span class="process-item-value">${sa.on_way_evaluation}</span></div>`);
  805 + if (sa.plan_evaluation) items.push(`<div class="process-item"><span class="process-item-label">计划数</span><span class="process-item-value">${sa.plan_evaluation}</span></div>`);
  806 + if (sa.abnormal_items && sa.abnormal_items.length > 0) {
  807 + items.push(`<div class="process-item"><span class="process-item-label">异常项</span><span class="process-item-value">${sa.abnormal_items.join('; ')}</span></div>`);
  808 + }
  809 + if (items.length > 0) {
  810 + sections.push(`<div class="process-section"><div class="process-section-title">结构分析</div>${items.join('')}</div>`);
  811 + }
312 812 }
313   -
314   - // 3. Coordination
315   - if (strategy.supplier_coordination) {
316   - addStep(3, '供应商协同', [
317   - strategy.supplier_coordination.key_communications,
318   - `时机: ${strategy.supplier_coordination.timing_suggestions}`
319   - ]);
  813 +
  814 + // 构成诊断(销量分析)
  815 + if (process.composition_diagnosis) {
  816 + const cd = process.composition_diagnosis;
  817 + const items = [];
  818 + if (cd.out_stock_evaluation) items.push(`<div class="process-item"><span class="process-item-label">90天出库</span><span class="process-item-value">${cd.out_stock_evaluation}</span></div>`);
  819 + if (cd.locked_evaluation) items.push(`<div class="process-item"><span class="process-item-label">未关单已锁</span><span class="process-item-value">${cd.locked_evaluation}</span></div>`);
  820 + if (cd.ongoing_evaluation) items.push(`<div class="process-item"><span class="process-item-label">未关单出库</span><span class="process-item-value">${cd.ongoing_evaluation}</span></div>`);
  821 + if (cd.buy_evaluation) items.push(`<div class="process-item"><span class="process-item-label">订件</span><span class="process-item-value">${cd.buy_evaluation}</span></div>`);
  822 + if (items.length > 0) {
  823 + sections.push(`<div class="process-section"><div class="process-section-title">构成诊断</div>${items.join('')}</div>`);
  824 + }
320 825 }
321 826  
322   - html += '</div>';
323   - return html;
324   - },
325   -
326   - renderExpectedImpact(impact) {
327   - if (!impact) return '';
  827 + // 活跃度诊断
  828 + if (process.activity_diagnosis) {
  829 + const ad = process.activity_diagnosis;
  830 + const items = [];
  831 + if (ad.current_rate) items.push(`<div class="process-item"><span class="process-item-label">当前活跃率</span><span class="process-item-value highlight">${ad.current_rate}</span></div>`);
  832 + if (ad.level) items.push(`<div class="process-item"><span class="process-item-label">判断等级</span><span class="process-item-value highlight">${ad.level}</span></div>`);
  833 + if (ad.reasoning) items.push(`<div class="process-item"><span class="process-item-label">判断依据</span><span class="process-item-value">${ad.reasoning}</span></div>`);
  834 + if (items.length > 0) {
  835 + sections.push(`<div class="process-section"><div class="process-section-title">活跃度诊断</div>${items.join('')}</div>`);
  836 + }
  837 + }
328 838  
329   - let html = '<div class="impact-panel">';
  839 + // 趋势诊断
  840 + if (process.trend_diagnosis) {
  841 + const td = process.trend_diagnosis;
  842 + const items = [];
  843 + if (td.signals && td.signals.length > 0) items.push(`<div class="process-item"><span class="process-item-label">趋势信号</span><span class="process-item-value">${td.signals.join('; ')}</span></div>`);
  844 + if (td.reasoning) items.push(`<div class="process-item"><span class="process-item-label">判断依据</span><span class="process-item-value">${td.reasoning}</span></div>`);
  845 + if (items.length > 0) {
  846 + sections.push(`<div class="process-section"><div class="process-section-title">趋势诊断</div>${items.join('')}</div>`);
  847 + }
  848 + }
330 849  
331   - // Inventory
332   - if (impact.inventory_health) {
333   - html += `
334   - <div class="kpi-item">
335   - <div class="kpi-label">库存健康度</div>
336   - <div class="kpi-value">${Components.formatAmount(impact.inventory_health.shortage_reduction || 0)}</div>
337   - <div class="kpi-desc">${impact.inventory_health.structure_improvement}</div>
338   - </div>`;
  850 + // 健康度诊断
  851 + if (process.health_score_diagnosis) {
  852 + const hsd = process.health_score_diagnosis;
  853 + const items = [];
  854 + if (hsd.normal_ratio) items.push(`<div class="process-item"><span class="process-item-label">正常件占比</span><span class="process-item-value highlight">${hsd.normal_ratio}</span></div>`);
  855 + if (hsd.score) items.push(`<div class="process-item"><span class="process-item-label">健康度评分</span><span class="process-item-value highlight">${hsd.score}</span></div>`);
  856 + if (hsd.reasoning) items.push(`<div class="process-item"><span class="process-item-label">判断依据</span><span class="process-item-value">${hsd.reasoning}</span></div>`);
  857 + if (items.length > 0) {
  858 + sections.push(`<div class="process-section"><div class="process-section-title">健康度诊断</div>${items.join('')}</div>`);
  859 + }
339 860 }
340 861  
341   - // Efficiency
342   - if (impact.capital_efficiency) {
343   - html += `
344   - <div class="kpi-item">
345   - <div class="kpi-label">资金效率</div>
346   - <div class="kpi-value">${Components.formatAmount(impact.capital_efficiency.investment_amount)}</div>
347   - <div class="kpi-desc">${impact.capital_efficiency.expected_return}</div>
348   - </div>`;
  862 + // 问题诊断(健康度)
  863 + if (process.problem_diagnosis) {
  864 + const pd = process.problem_diagnosis;
  865 + const items = [];
  866 + ['shortage', 'stagnant', 'low_freq'].forEach(key => {
  867 + const item = pd[key];
  868 + if (item) {
  869 + const label = key === 'shortage' ? '缺货件' : key === 'stagnant' ? '呆滞件' : '低频件';
  870 + if (item.threshold_comparison) items.push(`<div class="process-item"><span class="process-item-label">${label}</span><span class="process-item-value">${item.threshold_comparison}</span></div>`);
  871 + }
  872 + });
  873 + if (items.length > 0) {
  874 + sections.push(`<div class="process-section"><div class="process-section-title">问题诊断</div>${items.join('')}</div>`);
  875 + }
349 876 }
350   -
351   - // Next
352   - if (impact.follow_up_actions) {
353   - html += `
354   - <div class="kpi-item">
355   - <div class="kpi-label">下一步关注</div>
356   - <div class="kpi-value" style="font-size:1.5rem;background:none;-webkit-text-fill-color:var(--text-primary);">Key Actions</div>
357   - <div class="kpi-desc" style="text-align:left;display:inline-block;">${impact.follow_up_actions.next_steps}</div>
358   - </div>`;
  877 +
  878 + // 资金释放计算
  879 + if (process.capital_release_calculation) {
  880 + const crc = process.capital_release_calculation;
  881 + const items = [];
  882 + if (crc.stagnant_calculation) items.push(`<div class="process-item"><span class="process-item-label">呆滞件</span><span class="process-item-value">${crc.stagnant_calculation}</span></div>`);
  883 + if (crc.low_freq_calculation) items.push(`<div class="process-item"><span class="process-item-label">低频件</span><span class="process-item-value">${crc.low_freq_calculation}</span></div>`);
  884 + if (crc.total_releasable) items.push(`<div class="process-item"><span class="process-item-label">总可释放</span><span class="process-item-value highlight">${crc.total_releasable}</span></div>`);
  885 + if (items.length > 0) {
  886 + sections.push(`<div class="process-section"><div class="process-section-title">资金释放计算</div>${items.join('')}</div>`);
  887 + }
359 888 }
360 889  
361   - html += '</div>';
362   - return html;
  890 + // 紧迫度诊断(补货建议)
  891 + if (process.urgency_diagnosis) {
  892 + const ud = process.urgency_diagnosis;
  893 + const items = [];
  894 + if (ud.urgent_ratio) items.push(`<div class="process-item"><span class="process-item-label">急需占比</span><span class="process-item-value highlight">${ud.urgent_ratio}</span></div>`);
  895 + if (ud.level) items.push(`<div class="process-item"><span class="process-item-label">紧迫等级</span><span class="process-item-value highlight">${ud.level}</span></div>`);
  896 + if (ud.reasoning) items.push(`<div class="process-item"><span class="process-item-label">判断依据</span><span class="process-item-value">${ud.reasoning}</span></div>`);
  897 + if (items.length > 0) {
  898 + sections.push(`<div class="process-section"><div class="process-section-title">紧迫度诊断</div>${items.join('')}</div>`);
  899 + }
  900 + }
  901 +
  902 + // 预算分析
  903 + if (process.budget_analysis) {
  904 + const ba = process.budget_analysis;
  905 + const items = [];
  906 + if (ba.current_distribution) items.push(`<div class="process-item"><span class="process-item-label">当前分布</span><span class="process-item-value">${ba.current_distribution}</span></div>`);
  907 + if (ba.comparison_with_standard) items.push(`<div class="process-item"><span class="process-item-label">标准对比</span><span class="process-item-value">${ba.comparison_with_standard}</span></div>`);
  908 + if (ba.adjustment_needed) items.push(`<div class="process-item"><span class="process-item-label">调整建议</span><span class="process-item-value">${ba.adjustment_needed}</span></div>`);
  909 + if (items.length > 0) {
  910 + sections.push(`<div class="process-section"><div class="process-section-title">预算分析</div>${items.join('')}</div>`);
  911 + }
  912 + }
  913 +
  914 + // 风险识别
  915 + if (process.risk_identification) {
  916 + const ri = process.risk_identification;
  917 + const items = [];
  918 + if (ri.capital_pressure_check) items.push(`<div class="process-item"><span class="process-item-label">资金压力</span><span class="process-item-value">${ri.capital_pressure_check}</span></div>`);
  919 + if (ri.over_replenishment_check) items.push(`<div class="process-item"><span class="process-item-label">过度补货</span><span class="process-item-value">${ri.over_replenishment_check}</span></div>`);
  920 + if (ri.identified_risks && ri.identified_risks.length > 0) {
  921 + items.push(`<div class="process-item"><span class="process-item-label">识别风险</span><span class="process-item-value">${ri.identified_risks.join('; ')}</span></div>`);
  922 + }
  923 + if (items.length > 0) {
  924 + sections.push(`<div class="process-section"><div class="process-section-title">风险识别</div>${items.join('')}</div>`);
  925 + }
  926 + }
  927 +
  928 + if (sections.length === 0) return '';
  929 +
  930 + return `
  931 + <div class="analysis-process-toggle" data-target="${moduleId}-process">
  932 + <i data-lucide="chevron-down"></i>
  933 + <span>查看分析推理过程</span>
  934 + </div>
  935 + <div class="analysis-process-content" id="${moduleId}-process">
  936 + ${sections.join('')}
  937 + </div>
  938 + `;
  939 + },
  940 +
  941 + /**
  942 + * 格式化推理过程的key名称
  943 + */
  944 + _formatProcessKey(key) {
  945 + const keyMap = {
  946 + 'in_stock_ratio': '在库未锁占比',
  947 + 'on_way_ratio': '在途占比',
  948 + 'plan_ratio': '计划数占比',
  949 + 'avg_cost': '平均成本',
  950 + 'out_stock_ratio': '90天出库占比',
  951 + 'locked_ratio': '未关单已锁占比',
  952 + 'ongoing_ratio': '未关单出库占比',
  953 + 'buy_ratio': '订件占比',
  954 + 'sku_active_rate': 'SKU活跃率',
  955 + 'avg_sales_price': '平均销售金额',
  956 + 'urgent_count_ratio': '急需数量占比',
  957 + 'urgent_amount_ratio': '急需金额占比',
  958 + 'suggested_count_ratio': '建议数量占比',
  959 + 'suggested_amount_ratio': '建议金额占比',
  960 + 'optional_count_ratio': '可选数量占比',
  961 + 'optional_amount_ratio': '可选金额占比',
  962 + };
  963 + return keyMap[key] || key;
  964 + },
  965 +
  966 + /**
  967 + * 绑定推理过程折叠事件
  968 + */
  969 + _bindProcessToggle(container) {
  970 + const toggles = container.querySelectorAll('.analysis-process-toggle');
  971 + toggles.forEach(toggle => {
  972 + toggle.addEventListener('click', () => {
  973 + const targetId = toggle.dataset.target;
  974 + const content = document.getElementById(targetId);
  975 + if (content) {
  976 + toggle.classList.toggle('expanded');
  977 + content.classList.toggle('expanded');
  978 + lucide.createIcons();
  979 + }
  980 + });
  981 + });
363 982 },
364   -
365   - // 辅助方法:renderReportCard, renderRiskCard, renderImpactCard 已被新的独立渲染逻辑取代,保留为空或删除
366   - renderReportCard(title, data) { return ''; },
367   - renderRiskCard(title, data, level) { return ''; },
368   - renderImpactCard(title, data) { return ''; },
369 983  
370 984 /**
371 985 * 初始化应用
... ...