Gogs 3 месяцев назад
Родитель
Сommit
63939d4ba4
29 измененных файлов с 3432 добавлено и 26 удалено
  1. 9 4
      dtm-admin/pom.xml
  2. 6 1
      dtm-admin/src/main/resources/application.yml
  3. BIN
      dtm-storage/data/入库数据/2025采购入库单_1119.xlsx
  4. BIN
      dtm-storage/data/入库数据/产品资料.xlsx
  5. BIN
      dtm-storage/data/半成品组装/半成品匹配成品编码明细.xlsx
  6. BIN
      dtm-storage/data/半成品组装/组装明细 - 1107.xlsx
  7. BIN
      dtm-storage/data/销售数据/2025年订单数据.xlsx
  8. 40 0
      dtm-storage/pom.xml
  9. 116 0
      dtm-storage/src/main/java/com/dtm/storage/config/StorageSettings.java
  10. 95 0
      dtm-storage/src/main/java/com/dtm/storage/controller/InventoryController.java
  11. 63 0
      dtm-storage/src/main/java/com/dtm/storage/controller/ProductController.java
  12. 90 0
      dtm-storage/src/main/java/com/dtm/storage/controller/RiskController.java
  13. 79 0
      dtm-storage/src/main/java/com/dtm/storage/controller/SemiProductController.java
  14. 29 0
      dtm-storage/src/main/java/com/dtm/storage/model/AssemblyRecord.java
  15. 45 0
      dtm-storage/src/main/java/com/dtm/storage/model/ProductInfo.java
  16. 35 0
      dtm-storage/src/main/java/com/dtm/storage/model/PurchaseRecord.java
  17. 29 0
      dtm-storage/src/main/java/com/dtm/storage/model/SalesRecord.java
  18. 486 0
      dtm-storage/src/main/java/com/dtm/storage/service/InventoryService.java
  19. 694 0
      dtm-storage/src/main/java/com/dtm/storage/service/ProductService.java
  20. 652 0
      dtm-storage/src/main/java/com/dtm/storage/service/RiskService.java
  21. 317 0
      dtm-storage/src/main/java/com/dtm/storage/service/SemiProductService.java
  22. 482 0
      dtm-storage/src/main/java/com/dtm/storage/service/StorageDataLoader.java
  23. 23 0
      dtm-storage/src/main/java/com/dtm/storage/util/ExcelSheet.java
  24. 113 0
      dtm-storage/src/main/java/com/dtm/storage/util/ExcelUtils.java
  25. 29 21
      pom.xml
  26. BIN
      tmpcheck/BOOT-INF/lib/dtm-storage-3.9.0.jar
  27. BIN
      tmpcheck2/BOOT-INF/lib/dtm-storage-3.9.0.jar
  28. BIN
      tmpcheck3/BOOT-INF/lib/dtm-storage-3.9.0.jar
  29. BIN
      tmpcheck4/BOOT-INF/lib/dtm-storage-3.9.0.jar

+ 9 - 4
dtm-admin/pom.xml

@@ -1,4 +1,4 @@
-<?xml version="1.0" encoding="UTF-8"?>
+嚜�<?xml version="1.0" encoding="UTF-8"?>
 <project xmlns="http://maven.apache.org/POM/4.0.0"
          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
@@ -21,7 +21,7 @@
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-devtools</artifactId>
-            <optional>true</optional> <!-- 銵函內靘肽�銝滢�隡𣳇�?-->
+            <optional>true</optional> <!-- 銵函內靘肽�銝滢�隡𩤃蕭?-->
         </dependency>
 
         <!-- swagger3-->
@@ -30,14 +30,14 @@
             <artifactId>springfox-boot-starter</artifactId>
         </dependency>
 
-        <!-- �脫迫餈𥕦�swagger憿菟𢒰�亦掩�贝蓮�a�霂荔��㘾膄3.0.0銝剔�撘閧鍂嚗峕��典��?.6.2��𧋦 -->
+        <!-- �脫迫餈𥕦�swagger憿菟𢒰�亦掩�贝蓮�a�霂荔��㘾膄3.0.0銝剔�撘閧鍂嚗峕��典��?.6.2��𧋦 -->
         <dependency>
             <groupId>io.swagger</groupId>
             <artifactId>swagger-models</artifactId>
             <version>1.6.2</version>
         </dependency>
 
-         <!-- Mysql撽勗𢆡�?-->
+         <!-- Mysql撽勗𢆡�?-->
         <dependency>
             <groupId>mysql</groupId>
             <artifactId>mysql-connector-java</artifactId>
@@ -55,6 +55,11 @@
             <artifactId>dtm-order</artifactId>
         </dependency>
 
+        <dependency>
+            <groupId>com.dtm</groupId>
+            <artifactId>dtm-storage</artifactId>
+        </dependency>
+
         <dependency>
             <groupId>com.dtm</groupId>
             <artifactId>dtm-quartz</artifactId>

+ 6 - 1
dtm-admin/src/main/resources/application.yml

@@ -1,4 +1,4 @@
-# 项目相关配置
+# 项目相关配置
 ruoyi:
   # 名称
   name: RuoYi
@@ -149,3 +149,8 @@ shop:
   data:
     folder:
       path: C:/Users/akiby/Documents/shopdata
+
+# 仓储分析数据目录
+storage:
+  data:
+    path: E:/code/sudtm1/dtm2/dtm-storage/data

BIN
dtm-storage/data/入库数据/2025采购入库单_1119.xlsx


BIN
dtm-storage/data/入库数据/产品资料.xlsx


BIN
dtm-storage/data/半成品组装/半成品匹配成品编码明细.xlsx


BIN
dtm-storage/data/半成品组装/组装明细 - 1107.xlsx


BIN
dtm-storage/data/销售数据/2025年订单数据.xlsx


+ 40 - 0
dtm-storage/pom.xml

@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>dtm_java</artifactId>
+        <groupId>com.dtm</groupId>
+        <version>3.9.0</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>dtm-storage</artifactId>
+
+    <description>
+        ²Ö´¢¿â´æ·ÖÎöÄ£¿é
+    </description>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.dtm</groupId>
+            <artifactId>dtm-common</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework</groupId>
+            <artifactId>spring-web</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework</groupId>
+            <artifactId>spring-context</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.poi</groupId>
+            <artifactId>poi-ooxml</artifactId>
+        </dependency>
+    </dependencies>
+
+</project>

+ 116 - 0
dtm-storage/src/main/java/com/dtm/storage/config/StorageSettings.java

@@ -0,0 +1,116 @@
+package com.dtm.storage.config;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+public final class StorageSettings {
+    public static final Map<String, Double> DEFAULT_ANALYSIS_WEIGHTS;
+    public static final Map<String, Double> DEFAULT_RISK_WEIGHTS;
+    public static final List<Integer> TURNOVER_WINDOWS = Collections.unmodifiableList(Arrays.asList(7, 14, 30));
+
+    private static final Map<String, Double> ANALYSIS_WEIGHTS;
+    private static final Map<String, Double> RISK_WEIGHTS;
+
+    static {
+        Map<String, Double> analysisDefaults = new LinkedHashMap<>();
+        analysisDefaults.put("sales_growth_weight", 0.35);
+        analysisDefaults.put("inventory_variance_weight", 0.25);
+        analysisDefaults.put("turnover_weight", 0.2);
+        analysisDefaults.put("lifecycle_window_days", 14.0);
+        analysisDefaults.put("state_signal_weight", 0.3);
+
+        analysisDefaults.put("feature_inventory_weight", 0.4);
+        analysisDefaults.put("feature_sales_weight", 0.25);
+        analysisDefaults.put("feature_purchase_weight", 0.15);
+        analysisDefaults.put("feature_net_flow_weight", 0.1);
+        analysisDefaults.put("feature_stable_gap_weight", 0.1);
+
+        analysisDefaults.put("state_low_weight", 0.3);
+        analysisDefaults.put("state_overstock_weight", 0.3);
+        analysisDefaults.put("state_slow_weight", 0.2);
+        analysisDefaults.put("state_spike_weight", 0.15);
+        analysisDefaults.put("state_normal_weight", 0.1);
+
+        analysisDefaults.put("stable_window_days", 60.0);
+        analysisDefaults.put("forecast_drift_weight", 0.15);
+
+        DEFAULT_ANALYSIS_WEIGHTS = Collections.unmodifiableMap(analysisDefaults);
+        ANALYSIS_WEIGHTS = new ConcurrentHashMap<>(analysisDefaults);
+
+        Map<String, Double> riskDefaults = new LinkedHashMap<>();
+        riskDefaults.put("coverageWeight", 40.0);
+        riskDefaults.put("turnoverWeight", 30.0);
+        riskDefaults.put("trendWeight", 20.0);
+        riskDefaults.put("capitalWeight", 10.0);
+        DEFAULT_RISK_WEIGHTS = Collections.unmodifiableMap(riskDefaults);
+        RISK_WEIGHTS = new ConcurrentHashMap<>(riskDefaults);
+    }
+
+    private StorageSettings() {
+    }
+
+    public static Map<String, Double> getAnalysisWeights() {
+        return new LinkedHashMap<>(ANALYSIS_WEIGHTS);
+    }
+
+    public static Map<String, Double> getRiskWeights() {
+        return new LinkedHashMap<>(RISK_WEIGHTS);
+    }
+
+    public static void updateAnalysisWeights(Map<String, Object> payload) {
+        if (payload == null) {
+            return;
+        }
+        for (Map.Entry<String, Object> entry : payload.entrySet()) {
+            String key = entry.getKey();
+            Object value = entry.getValue();
+            if (!ANALYSIS_WEIGHTS.containsKey(key)) {
+                continue;
+            }
+            Double number = toDouble(value);
+            if (number != null) {
+                ANALYSIS_WEIGHTS.put(key, number);
+            }
+        }
+    }
+
+    public static void updateRiskWeights(Map<String, Object> payload) {
+        if (payload == null) {
+            return;
+        }
+        for (Map.Entry<String, Object> entry : payload.entrySet()) {
+            String key = entry.getKey();
+            Object value = entry.getValue();
+            if (!RISK_WEIGHTS.containsKey(key)) {
+                continue;
+            }
+            Double number = toDouble(value);
+            if (number != null) {
+                RISK_WEIGHTS.put(key, number);
+            }
+        }
+    }
+
+    private static Double toDouble(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).doubleValue();
+        }
+        if (value instanceof String) {
+            try {
+                return Double.parseDouble(((String) value).trim());
+            } catch (NumberFormatException ignored) {
+                return null;
+            }
+        }
+        return null;
+    }
+}
+
+

+ 95 - 0
dtm-storage/src/main/java/com/dtm/storage/controller/InventoryController.java

@@ -0,0 +1,95 @@
+package com.dtm.storage.controller;
+
+import com.dtm.common.annotation.Anonymous;
+import com.dtm.common.core.domain.AjaxResult;
+import com.dtm.storage.service.InventoryService;
+import com.dtm.storage.service.ProductService;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+@Anonymous
+@RestController
+@RequestMapping("/api/inventory")
+public class InventoryController {
+    private final InventoryService inventoryService;
+    private final ProductService productService;
+
+    public InventoryController(InventoryService inventoryService, ProductService productService) {
+        this.inventoryService = inventoryService;
+        this.productService = productService;
+    }
+
+    @GetMapping("/overview")
+    public AjaxResult getOverview() {
+        return AjaxResult.success(inventoryService.getOverviewData());
+    }
+
+    @GetMapping("/health-index")
+    public AjaxResult getHealthIndex() {
+        return AjaxResult.success(inventoryService.getHealthIndex());
+    }
+
+    @GetMapping("/fsm-states")
+    public AjaxResult getFsmStates() {
+        return AjaxResult.success(inventoryService.getFsmStates());
+    }
+
+    @GetMapping("/lifecycle-distribution")
+    public AjaxResult getLifecycleDistribution() {
+        return AjaxResult.success(inventoryService.getLifecycleDistribution());
+    }
+
+    @GetMapping("/structure")
+    public AjaxResult getStructure() {
+        return AjaxResult.success(inventoryService.getStructureData());
+    }
+
+    @GetMapping("/monthly-turnover")
+    public AjaxResult getMonthlyTurnover() {
+        return AjaxResult.success(inventoryService.getMonthlyTurnoverTable());
+    }
+
+    @GetMapping("/monthly-comparison")
+    public AjaxResult getMonthlyComparison() {
+        return AjaxResult.success(inventoryService.getMonthlyComparisonData());
+    }
+
+    @GetMapping("/sku-summary")
+    public AjaxResult getSkuSummary() {
+        return AjaxResult.success(inventoryService.getSkuSummaryTable());
+    }
+
+    @GetMapping("/spu-summary")
+    public AjaxResult getSpuSummary() {
+        return AjaxResult.success(inventoryService.getSpuSummaryTable());
+    }
+
+    @GetMapping("/settings")
+    public AjaxResult getSettings() {
+        AjaxResult result = AjaxResult.success();
+        Map<String, Object> settings = inventoryService.getSettings();
+        result.put("data", settings.get("data"));
+        result.put("defaults", settings.get("defaults"));
+        return result;
+    }
+
+    @PostMapping("/settings")
+    public AjaxResult updateSettings(@RequestBody(required = false) Map<String, Object> payload) {
+        AjaxResult result = AjaxResult.success();
+        Map<String, Object> updated = inventoryService.updateSettings(payload);
+        result.put("data", updated.get("data"));
+        return result;
+    }
+
+    @GetMapping("/product-trend")
+    public AjaxResult getProductTrend(@RequestParam("sku") String sku) {
+        return AjaxResult.success(productService.getProductTrend(sku));
+    }
+}
+

+ 63 - 0
dtm-storage/src/main/java/com/dtm/storage/controller/ProductController.java

@@ -0,0 +1,63 @@
+package com.dtm.storage.controller;
+
+import com.dtm.common.annotation.Anonymous;
+import com.dtm.common.core.domain.AjaxResult;
+import com.dtm.storage.service.ProductService;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Anonymous
+@RestController
+@RequestMapping("/api/product")
+public class ProductController {
+    private final ProductService productService;
+
+    public ProductController(ProductService productService) {
+        this.productService = productService;
+    }
+
+    @GetMapping("/list")
+    public AjaxResult getProductList() {
+        return AjaxResult.success(productService.getProductList());
+    }
+
+    @GetMapping("/trend/{sku}")
+    public AjaxResult getProductTrend(@PathVariable("sku") String sku) {
+        return AjaxResult.success(productService.getProductTrend(sku));
+    }
+
+    @GetMapping("/{productId}")
+    public AjaxResult getProductDetail(@PathVariable("productId") int productId) {
+        return AjaxResult.success(productService.getProductDetail(productId));
+    }
+
+    @GetMapping("/{productId}/metrics")
+    public AjaxResult getProductMetrics(@PathVariable("productId") int productId) {
+        return AjaxResult.success(productService.getProductMetrics(productId));
+    }
+
+    @GetMapping("/{productId}/growth-trend")
+    public AjaxResult getGrowthTrend(@PathVariable("productId") int productId,
+                                     @RequestParam(value = "range", required = false) String range) {
+        return AjaxResult.success(productService.getGrowthTrend(productId, range));
+    }
+
+    @GetMapping("/{productId}/lifecycle")
+    public AjaxResult getLifecycleStages(@PathVariable("productId") int productId) {
+        return AjaxResult.success(productService.getLifecycleStages(productId));
+    }
+
+    @GetMapping("/{productId}/fsm-state")
+    public AjaxResult getFsmState(@PathVariable("productId") int productId) {
+        return AjaxResult.success(productService.getFsmState(productId));
+    }
+
+    @GetMapping("/{productId}/forecast")
+    public AjaxResult getForecast(@PathVariable("productId") int productId) {
+        return AjaxResult.success(productService.getForecastData(productId));
+    }
+}
+

+ 90 - 0
dtm-storage/src/main/java/com/dtm/storage/controller/RiskController.java

@@ -0,0 +1,90 @@
+package com.dtm.storage.controller;
+
+import com.dtm.common.annotation.Anonymous;
+import com.dtm.common.core.domain.AjaxResult;
+import com.dtm.storage.service.RiskService;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+@Anonymous
+@RestController
+@RequestMapping("/api/risk")
+public class RiskController {
+    private final RiskService riskService;
+
+    public RiskController(RiskService riskService) {
+        this.riskService = riskService;
+    }
+
+    @GetMapping("/statistics")
+    public AjaxResult getRiskStatistics() {
+        return AjaxResult.success(riskService.getRiskStatistics());
+    }
+
+    @GetMapping("/list")
+    public AjaxResult getRiskList(@RequestParam(value = "page", defaultValue = "1") int page,
+                                  @RequestParam(value = "pageSize", defaultValue = "10") int pageSize,
+                                  @RequestParam(value = "riskLevel", required = false) String riskLevel,
+                                  @RequestParam(value = "riskType", required = false) String riskType) {
+        return AjaxResult.success(riskService.getRiskList(page, pageSize, riskLevel, riskType));
+    }
+
+    @GetMapping("/trend")
+    public AjaxResult getRiskTrend() {
+        return AjaxResult.success(riskService.getRiskTrend());
+    }
+
+    @GetMapping("/rules")
+    public AjaxResult getWarningRules() {
+        return AjaxResult.success(riskService.getWarningRules());
+    }
+
+    @PostMapping("/rules")
+    public AjaxResult createWarningRule(@RequestBody(required = false) Map<String, Object> payload) {
+        return AjaxResult.success(riskService.createWarningRule(payload));
+    }
+
+    @PutMapping("/rules/{ruleId}")
+    public AjaxResult updateWarningRule(@PathVariable("ruleId") int ruleId,
+                                        @RequestBody(required = false) Map<String, Object> payload) {
+        return AjaxResult.success(riskService.updateWarningRule(ruleId, payload));
+    }
+
+    @DeleteMapping("/rules/{ruleId}")
+    public AjaxResult deleteWarningRule(@PathVariable("ruleId") int ruleId) {
+        riskService.deleteWarningRule(ruleId);
+        return AjaxResult.success();
+    }
+
+    @GetMapping("/feedback")
+    public AjaxResult getOptimizationFeedback() {
+        return AjaxResult.success(riskService.getOptimizationFeedback());
+    }
+
+    @GetMapping("/settings")
+    public AjaxResult getRiskSettings() {
+        AjaxResult result = AjaxResult.success();
+        Map<String, Object> settings = riskService.getRiskSettings();
+        result.put("data", settings.get("data"));
+        result.put("defaults", settings.get("defaults"));
+        return result;
+    }
+
+    @PostMapping("/settings")
+    public AjaxResult updateRiskSettings(@RequestBody(required = false) Map<String, Object> payload) {
+        AjaxResult result = AjaxResult.success();
+        Map<String, Object> updated = riskService.updateRiskSettings(payload);
+        result.put("data", updated.get("data"));
+        return result;
+    }
+}
+

+ 79 - 0
dtm-storage/src/main/java/com/dtm/storage/controller/SemiProductController.java

@@ -0,0 +1,79 @@
+package com.dtm.storage.controller;
+
+import com.dtm.common.annotation.Anonymous;
+import com.dtm.common.core.domain.AjaxResult;
+import com.dtm.storage.service.SemiProductService;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+@Anonymous
+@RestController
+@RequestMapping("/api/semi-product")
+public class SemiProductController {
+    private final SemiProductService semiProductService;
+
+    public SemiProductController(SemiProductService semiProductService) {
+        this.semiProductService = semiProductService;
+    }
+
+    @GetMapping("/statistics")
+    public AjaxResult getStatistics() {
+        return AjaxResult.success(semiProductService.getStatistics());
+    }
+
+    @GetMapping("/list")
+    public AjaxResult getList(@RequestParam(value = "page", defaultValue = "1") int page,
+                              @RequestParam(value = "pageSize", defaultValue = "10") int pageSize,
+                              @RequestParam(value = "keyword", required = false) String keyword,
+                              @RequestParam(value = "status", required = false) String status,
+                              @RequestParam(value = "category", required = false) String category) {
+        return AjaxResult.success(semiProductService.getList(page, pageSize, keyword, status, category));
+    }
+
+    @GetMapping("/{id}")
+    public AjaxResult getDetail(@PathVariable("id") int id) {
+        return AjaxResult.success(semiProductService.getDetail(id));
+    }
+
+    @PostMapping
+    public AjaxResult create(@RequestBody(required = false) Map<String, Object> payload) {
+        return AjaxResult.success(semiProductService.create(payload));
+    }
+
+    @PutMapping("/{id}")
+    public AjaxResult update(@PathVariable("id") int id,
+                             @RequestBody(required = false) Map<String, Object> payload) {
+        return AjaxResult.success(semiProductService.update(id, payload));
+    }
+
+    @DeleteMapping("/{id}")
+    public AjaxResult delete(@PathVariable("id") int id) {
+        semiProductService.delete(id);
+        return AjaxResult.success();
+    }
+
+    @GetMapping("/turnover-analysis")
+    public AjaxResult getTurnoverAnalysis() {
+        return AjaxResult.success(semiProductService.getTurnoverAnalysis());
+    }
+
+    @GetMapping("/bom/{productCode}")
+    public AjaxResult getBomRelation(@PathVariable("productCode") String productCode) {
+        return AjaxResult.success(semiProductService.getBomRelation(productCode));
+    }
+
+    @GetMapping("/assembly-capacity")
+    public AjaxResult getAssemblyCapacity() {
+        return AjaxResult.success(semiProductService.getAssemblyCapacity());
+    }
+}
+

+ 29 - 0
dtm-storage/src/main/java/com/dtm/storage/model/AssemblyRecord.java

@@ -0,0 +1,29 @@
+package com.dtm.storage.model;
+
+import java.time.LocalDate;
+
+public class AssemblyRecord {
+    private final String productCode;
+    private final LocalDate date;
+    private final double quantity;
+
+    public AssemblyRecord(String productCode, LocalDate date, double quantity) {
+        this.productCode = productCode;
+        this.date = date;
+        this.quantity = quantity;
+    }
+
+    public String getProductCode() {
+        return productCode;
+    }
+
+    public LocalDate getDate() {
+        return date;
+    }
+
+    public double getQuantity() {
+        return quantity;
+    }
+}
+
+

+ 45 - 0
dtm-storage/src/main/java/com/dtm/storage/model/ProductInfo.java

@@ -0,0 +1,45 @@
+package com.dtm.storage.model;
+
+public class ProductInfo {
+    private final String productCode;
+    private final String productName;
+    private final String category;
+    private final String attribute;
+    private final String spuName;
+    private final Double price;
+
+    public ProductInfo(String productCode, String productName, String category, String attribute, String spuName, Double price) {
+        this.productCode = productCode;
+        this.productName = productName;
+        this.category = category;
+        this.attribute = attribute;
+        this.spuName = spuName;
+        this.price = price;
+    }
+
+    public String getProductCode() {
+        return productCode;
+    }
+
+    public String getProductName() {
+        return productName;
+    }
+
+    public String getCategory() {
+        return category;
+    }
+
+    public String getAttribute() {
+        return attribute;
+    }
+
+    public String getSpuName() {
+        return spuName;
+    }
+
+    public Double getPrice() {
+        return price;
+    }
+}
+
+

+ 35 - 0
dtm-storage/src/main/java/com/dtm/storage/model/PurchaseRecord.java

@@ -0,0 +1,35 @@
+package com.dtm.storage.model;
+
+import java.time.LocalDate;
+
+public class PurchaseRecord {
+    private final String productCode;
+    private final LocalDate date;
+    private final double quantity;
+    private final double amount;
+
+    public PurchaseRecord(String productCode, LocalDate date, double quantity, double amount) {
+        this.productCode = productCode;
+        this.date = date;
+        this.quantity = quantity;
+        this.amount = amount;
+    }
+
+    public String getProductCode() {
+        return productCode;
+    }
+
+    public LocalDate getDate() {
+        return date;
+    }
+
+    public double getQuantity() {
+        return quantity;
+    }
+
+    public double getAmount() {
+        return amount;
+    }
+}
+
+

+ 29 - 0
dtm-storage/src/main/java/com/dtm/storage/model/SalesRecord.java

@@ -0,0 +1,29 @@
+package com.dtm.storage.model;
+
+import java.time.LocalDate;
+
+public class SalesRecord {
+    private final String productCode;
+    private final LocalDate date;
+    private final double quantity;
+
+    public SalesRecord(String productCode, LocalDate date, double quantity) {
+        this.productCode = productCode;
+        this.date = date;
+        this.quantity = quantity;
+    }
+
+    public String getProductCode() {
+        return productCode;
+    }
+
+    public LocalDate getDate() {
+        return date;
+    }
+
+    public double getQuantity() {
+        return quantity;
+    }
+}
+
+

+ 486 - 0
dtm-storage/src/main/java/com/dtm/storage/service/InventoryService.java

@@ -0,0 +1,486 @@
+package com.dtm.storage.service;
+
+import com.dtm.storage.config.StorageSettings;
+import com.dtm.storage.model.AssemblyRecord;
+import com.dtm.storage.model.ProductInfo;
+import com.dtm.storage.model.PurchaseRecord;
+import com.dtm.storage.model.SalesRecord;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+import java.time.YearMonth;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.stream.Collectors;
+
+@Service
+public class InventoryService {
+    private final StorageDataLoader dataLoader;
+
+    public InventoryService(StorageDataLoader dataLoader) {
+        this.dataLoader = dataLoader;
+    }
+
+    public Map<String, Object> getOverviewData() {
+        List<PurchaseRecord> purchaseRecords = dataLoader.getPurchaseRecords();
+        List<SalesRecord> salesRecords = dataLoader.getSalesRecords();
+        Set<String> finishedSkus = getFinishedSkus();
+
+        if (!finishedSkus.isEmpty()) {
+            purchaseRecords = purchaseRecords.stream()
+                    .filter(r -> finishedSkus.contains(r.getProductCode()))
+                    .collect(Collectors.toList());
+            salesRecords = salesRecords.stream()
+                    .filter(r -> finishedSkus.contains(r.getProductCode()))
+                    .collect(Collectors.toList());
+        }
+
+        int totalPurchaseQty = (int) Math.round(purchaseRecords.stream().mapToDouble(PurchaseRecord::getQuantity).sum());
+        int totalSalesQty = (int) Math.round(salesRecords.stream().mapToDouble(SalesRecord::getQuantity).sum());
+        int assemblyQty = calculateAssemblyQuantity(finishedSkus);
+
+        int totalInventory = totalPurchaseQty + assemblyQty - totalSalesQty;
+        double totalValue = purchaseRecords.stream().mapToDouble(PurchaseRecord::getAmount).sum() / 10000.0;
+        totalValue = Math.round(totalValue * 100.0) / 100.0;
+
+        double avgInventory = totalInventory > 0 ? totalInventory / 2.0 : 1.0;
+        double turnoverRate = avgInventory > 0 ? (totalSalesQty / avgInventory) : 0.0;
+        turnoverRate = Math.round(turnoverRate * 100.0) / 100.0;
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("totalInventory", totalInventory);
+        result.put("totalValue", totalValue);
+        result.put("turnoverRate", turnoverRate);
+        result.put("inTransitRatio", 12.5);
+        result.put("purchaseQty", totalPurchaseQty);
+        result.put("salesQty", totalSalesQty);
+        result.put("assemblyQty", assemblyQty);
+        return result;
+    }
+
+    public Map<String, Object> getHealthIndex() {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("value", 78);
+        result.put("status", "一般");
+        result.put("trend", "up");
+        return result;
+    }
+
+    public List<Map<String, Object>> getFsmStates() {
+        List<Map<String, Object>> list = new ArrayList<>();
+        list.add(buildFsm("正常库存", 3250, "up", 5.2));
+        list.add(buildFsm("低库存", 820, "down", 2.1));
+        list.add(buildFsm("超储", 450, "up", 3.5));
+        list.add(buildFsm("滞销", 230, "down", 1.2));
+        list.add(buildFsm("在途", 580, "up", 4.8));
+        list.add(buildFsm("待检", 350, "down", 0.5));
+        return list;
+    }
+
+    public Map<String, Object> getLifecycleDistribution() {
+        Map<String, Object> result = new LinkedHashMap<>();
+        Map<String, Integer> finished = new LinkedHashMap<>();
+        finished.put("引入期", 3200);
+        finished.put("成长期", 8500);
+        finished.put("成熟期", 12800);
+        finished.put("衰退期", 4060);
+        Map<String, Integer> semi = new LinkedHashMap<>();
+        semi.put("引入期", 2100);
+        semi.put("成长期", 5800);
+        semi.put("成熟期", 7200);
+        semi.put("衰退期", 2020);
+        result.put("finished", finished);
+        result.put("semi_finished", semi);
+        return result;
+    }
+
+    public Map<String, Object> getStructureData() {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("finished", 28560);
+        result.put("semi_finished", 17120);
+        return result;
+    }
+
+    public Map<String, Object> getMonthlyComparisonData() {
+        List<PurchaseRecord> purchaseRecords = dataLoader.getPurchaseRecords();
+        List<SalesRecord> salesRecords = dataLoader.getSalesRecords();
+
+        Map<YearMonth, Double> purchaseMonthly = new TreeMap<>();
+        for (PurchaseRecord record : purchaseRecords) {
+            LocalDate date = record.getDate();
+            if (date == null) {
+                continue;
+            }
+            YearMonth month = YearMonth.from(date);
+            purchaseMonthly.merge(month, record.getQuantity(), Double::sum);
+        }
+
+        Map<YearMonth, Double> salesMonthly = new TreeMap<>();
+        for (SalesRecord record : salesRecords) {
+            LocalDate date = record.getDate();
+            if (date == null) {
+                continue;
+            }
+            YearMonth month = YearMonth.from(date);
+            salesMonthly.merge(month, record.getQuantity(), Double::sum);
+        }
+
+        Set<YearMonth> allMonths = new HashSet<>();
+        allMonths.addAll(purchaseMonthly.keySet());
+        allMonths.addAll(salesMonthly.keySet());
+        List<YearMonth> sorted = new ArrayList<>(allMonths);
+        Collections.sort(sorted);
+
+        List<String> months = new ArrayList<>();
+        List<Integer> purchase = new ArrayList<>();
+        List<Integer> sales = new ArrayList<>();
+        List<Integer> inventory = new ArrayList<>();
+        int cumulativeInventory = 0;
+
+        for (YearMonth month : sorted) {
+            double purchaseQty = purchaseMonthly.getOrDefault(month, 0.0);
+            double salesQty = salesMonthly.getOrDefault(month, 0.0);
+            cumulativeInventory += (int) Math.round(purchaseQty - salesQty);
+
+            months.add(month.toString());
+            purchase.add((int) Math.round(purchaseQty));
+            sales.add((int) Math.round(salesQty));
+            inventory.add(cumulativeInventory);
+        }
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("months", months);
+        result.put("purchase", purchase);
+        result.put("sales", sales);
+        result.put("inventory", inventory);
+        return result;
+    }
+
+    public List<Map<String, Object>> getSkuSummaryTable() {
+        List<PurchaseRecord> purchaseRecords = dataLoader.getPurchaseRecords();
+        List<SalesRecord> salesRecords = dataLoader.getSalesRecords();
+        Map<String, ProductInfo> productInfoMap = dataLoader.getProductInfo().stream()
+                .filter(info -> info.getProductCode() != null && !info.getProductCode().trim().isEmpty())
+                .collect(Collectors.toMap(ProductInfo::getProductCode, info -> info, (a, b) -> a));
+        Set<String> finishedSkus = getFinishedSkus();
+
+        if (!finishedSkus.isEmpty()) {
+            purchaseRecords = purchaseRecords.stream()
+                    .filter(r -> finishedSkus.contains(r.getProductCode()))
+                    .collect(Collectors.toList());
+            salesRecords = salesRecords.stream()
+                    .filter(r -> finishedSkus.contains(r.getProductCode()))
+                    .collect(Collectors.toList());
+        }
+
+        Map<String, double[]> purchaseSummary = new HashMap<>();
+        for (PurchaseRecord record : purchaseRecords) {
+            double[] agg = purchaseSummary.computeIfAbsent(record.getProductCode(), k -> new double[2]);
+            agg[0] += record.getQuantity();
+            agg[1] += record.getAmount();
+        }
+
+        Map<String, Double> salesSummary = new HashMap<>();
+        for (SalesRecord record : salesRecords) {
+            salesSummary.merge(record.getProductCode(), record.getQuantity(), Double::sum);
+        }
+
+        double totalAmount = purchaseSummary.values().stream().mapToDouble(v -> v[1]).sum();
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (Map.Entry<String, double[]> entry : purchaseSummary.entrySet()) {
+            String sku = entry.getKey();
+            double purchaseQty = entry.getValue()[0];
+            double purchaseAmount = entry.getValue()[1];
+            double salesQty = salesSummary.getOrDefault(sku, 0.0);
+
+            double inventory = purchaseQty - salesQty;
+            double amountRatio = totalAmount > 0 ? (purchaseAmount / totalAmount) * 100 : 0;
+            double avgInventory = (purchaseQty + inventory) / 2.0;
+            double turnoverRate = avgInventory > 0 ? salesQty / avgInventory : 0;
+
+            ProductInfo info = productInfoMap.get(sku);
+            Map<String, Object> row = new LinkedHashMap<>();
+            row.put("sku", sku);
+            row.put("attribute", info != null ? info.getAttribute() : "");
+            row.put("spuName", info != null ? info.getSpuName() : "");
+            row.put("purchaseQty", (int) Math.round(purchaseQty));
+            row.put("salesQty", (int) Math.round(salesQty));
+            row.put("inventory", (int) Math.round(inventory));
+            row.put("purchaseAmount", Math.round(purchaseAmount * 100.0) / 100.0);
+            row.put("amountRatio", Math.round(amountRatio * 100.0) / 100.0);
+            row.put("turnoverRate", Math.round(turnoverRate * 100.0) / 100.0);
+            result.add(row);
+        }
+
+        result.sort(Comparator.comparing((Map<String, Object> row) -> ((Number) row.getOrDefault("purchaseQty", 0)).doubleValue()).reversed());
+        if (result.size() > 20) {
+            return result.subList(0, 20);
+        }
+        return result;
+    }
+
+    public List<Map<String, Object>> getSpuSummaryTable() {
+        List<PurchaseRecord> purchaseRecords = dataLoader.getPurchaseRecords();
+        List<SalesRecord> salesRecords = dataLoader.getSalesRecords();
+        Map<String, ProductInfo> productInfoMap = dataLoader.getProductInfo().stream()
+                .filter(info -> info.getProductCode() != null && !info.getProductCode().trim().isEmpty())
+                .collect(Collectors.toMap(ProductInfo::getProductCode, info -> info, (a, b) -> a));
+        Set<String> finishedSkus = getFinishedSkus();
+
+        if (!finishedSkus.isEmpty()) {
+            purchaseRecords = purchaseRecords.stream()
+                    .filter(r -> finishedSkus.contains(r.getProductCode()))
+                    .collect(Collectors.toList());
+            salesRecords = salesRecords.stream()
+                    .filter(r -> finishedSkus.contains(r.getProductCode()))
+                    .collect(Collectors.toList());
+        }
+
+        Map<String, double[]> purchaseSummary = new HashMap<>();
+        for (PurchaseRecord record : purchaseRecords) {
+            double[] agg = purchaseSummary.computeIfAbsent(record.getProductCode(), k -> new double[2]);
+            agg[0] += record.getQuantity();
+            agg[1] += record.getAmount();
+        }
+
+        Map<String, Double> salesSummary = new HashMap<>();
+        for (SalesRecord record : salesRecords) {
+            salesSummary.merge(record.getProductCode(), record.getQuantity(), Double::sum);
+        }
+
+        Map<String, SpuAggregate> aggregates = new HashMap<>();
+        for (Map.Entry<String, double[]> entry : purchaseSummary.entrySet()) {
+            String sku = entry.getKey();
+            ProductInfo info = productInfoMap.get(sku);
+            String spu = info != null && info.getSpuName() != null && !info.getSpuName().trim().isEmpty()
+                    ? info.getSpuName()
+                    : "未命名SPU";
+            SpuAggregate agg = aggregates.computeIfAbsent(spu, k -> new SpuAggregate());
+            agg.purchaseQty += entry.getValue()[0];
+            agg.purchaseAmount += entry.getValue()[1];
+            agg.salesQty += salesSummary.getOrDefault(sku, 0.0);
+            agg.skuCount += 1;
+            if (info != null && info.getAttribute() != null && !info.getAttribute().trim().isEmpty()) {
+                String attr = info.getAttribute().trim();
+                agg.attributeCount.merge(attr, 1, Integer::sum);
+            }
+        }
+
+        double totalAmount = aggregates.values().stream().mapToDouble(a -> a.purchaseAmount).sum();
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (Map.Entry<String, SpuAggregate> entry : aggregates.entrySet()) {
+            String spu = entry.getKey();
+            SpuAggregate agg = entry.getValue();
+            double inventory = agg.purchaseQty - agg.salesQty;
+            double amountRatio = totalAmount > 0 ? (agg.purchaseAmount / totalAmount) * 100 : 0;
+            double avgInventory = (agg.purchaseQty + inventory) / 2.0;
+            double turnoverRate = avgInventory > 0 ? agg.salesQty / avgInventory : 0;
+
+            Map<String, Object> row = new LinkedHashMap<>();
+            row.put("spu", spu);
+            row.put("attribute", agg.getMostCommonAttribute());
+            row.put("skuCount", agg.skuCount);
+            row.put("purchaseQty", (int) Math.round(agg.purchaseQty));
+            row.put("salesQty", (int) Math.round(agg.salesQty));
+            row.put("inventory", (int) Math.round(inventory));
+            row.put("purchaseAmount", Math.round(agg.purchaseAmount * 100.0) / 100.0);
+            row.put("amountRatio", Math.round(amountRatio * 100.0) / 100.0);
+            row.put("turnoverRate", Math.round(turnoverRate * 100.0) / 100.0);
+            result.add(row);
+        }
+
+        result.sort(Comparator.comparing((Map<String, Object> row) -> ((Number) row.getOrDefault("purchaseAmount", 0)).doubleValue()).reversed());
+        return result;
+    }
+
+    public List<Map<String, Object>> getMonthlyTurnoverTable() {
+        return Collections.emptyList();
+    }
+
+    public Map<String, Object> getSettings() {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("data", StorageSettings.getAnalysisWeights());
+        result.put("defaults", StorageSettings.DEFAULT_ANALYSIS_WEIGHTS);
+        return result;
+    }
+
+    public Map<String, Object> updateSettings(Map<String, Object> payload) {
+        StorageSettings.updateAnalysisWeights(payload);
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("data", StorageSettings.getAnalysisWeights());
+        return result;
+    }
+
+    private int calculateAssemblyQuantity(Set<String> finishedSkus) {
+        List<AssemblyRecord> assemblyRecords = dataLoader.getAssemblyRecords();
+        if (assemblyRecords.isEmpty()) {
+            return 0;
+        }
+        Map<String, Set<String>> semiToFinished = buildSemiToFinishedMap();
+        if (semiToFinished.isEmpty() || finishedSkus == null || finishedSkus.isEmpty()) {
+            return (int) Math.round(assemblyRecords.stream().mapToDouble(AssemblyRecord::getQuantity).sum());
+        }
+        double total = 0.0;
+        for (AssemblyRecord record : assemblyRecords) {
+            String code = record.getProductCode();
+            if (code == null || code.trim().isEmpty()) {
+                continue;
+            }
+            boolean counted = false;
+            // 组装明细里可能直接记录成品编码
+            if (finishedSkus.contains(code)) {
+                total += record.getQuantity();
+                counted = true;
+            }
+            // 半成品编码需要映射到成品
+            if (!counted) {
+                Set<String> finished = semiToFinished.get(code);
+                if (finished != null && !finished.isEmpty()) {
+                    boolean match = finished.stream().anyMatch(finishedSkus::contains);
+                    if (match) {
+                        total += record.getQuantity();
+                    }
+                }
+            }
+        }
+        return (int) Math.round(total);
+    }
+
+    private Map<String, Set<String>> buildSemiToFinishedMap() {
+        List<List<Object>> rows = dataLoader.getSemiMappingRows();
+        List<String> headers = dataLoader.getSemiMappingHeaders();
+        if (rows.isEmpty()) {
+            return Collections.emptyMap();
+        }
+
+        int prodIdx = findHeaderIndex(headers, new String[]{"成品", "成品编码", "产品编码", "产品代码"}, 0);
+        int innerIdx = findHeaderIndex(headers, new String[]{"内芯", "内胆"}, 1);
+        int outerIdx = findHeaderIndex(headers, new String[]{"外壳", "外套", "外壳编码"}, 2);
+        int accessoryIdx = findHeaderIndex(headers, new String[]{"配件", "附件"}, 3);
+
+        Map<String, Set<String>> map = new HashMap<>();
+        for (List<Object> row : rows) {
+            String productCode = toText(getValue(row, prodIdx));
+            if (productCode.isEmpty()) {
+                continue;
+            }
+            List<String> semiCodes = new ArrayList<>();
+            collectCodes(semiCodes, toText(getValue(row, innerIdx)));
+            collectCodes(semiCodes, toText(getValue(row, outerIdx)));
+            collectCodes(semiCodes, toText(getValue(row, accessoryIdx)));
+            for (String code : semiCodes) {
+                map.computeIfAbsent(code, k -> new HashSet<>()).add(productCode);
+            }
+        }
+        return map;
+    }
+
+    private void collectCodes(List<String> target, String raw) {
+        if (raw == null || raw.trim().isEmpty()) {
+            return;
+        }
+        String normalized = raw.replace(',', ',');
+        for (String part : normalized.split(",")) {
+            String code = part.trim();
+            if (!code.isEmpty()) {
+                target.add(code);
+            }
+        }
+    }
+
+    private Set<String> getFinishedSkus() {
+        List<ProductInfo> infos = dataLoader.getProductInfo();
+        if (infos.isEmpty()) {
+            return Collections.emptySet();
+        }
+        Set<String> finished = new HashSet<>();
+        for (ProductInfo info : infos) {
+            String code = info.getProductCode();
+            if (code == null || code.trim().isEmpty()) {
+                continue;
+            }
+            String category = info.getCategory();
+            if (category == null) {
+                finished.add(code);
+                continue;
+            }
+            String text = category.toLowerCase(Locale.ROOT);
+            if (text.contains("半成") || text.contains("辅料") || text.contains("配件")) {
+                continue;
+            }
+            finished.add(code);
+        }
+        return finished;
+    }
+
+    private Map<String, Object> buildFsm(String name, int count, String trend, double percentage) {
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("name", name);
+        row.put("count", count);
+        row.put("trend", trend);
+        row.put("percentage", percentage);
+        return row;
+    }
+
+    private int findHeaderIndex(List<String> headers, String[] keywords, int fallback) {
+        if (headers == null || headers.isEmpty()) {
+            return fallback;
+        }
+        for (int i = 0; i < headers.size(); i++) {
+            String header = headers.get(i) == null ? "" : headers.get(i).trim();
+            if (header.isEmpty()) {
+                continue;
+            }
+            for (String keyword : keywords) {
+                if (keyword != null && !keyword.isEmpty() && header.contains(keyword)) {
+                    return i;
+                }
+            }
+        }
+        return fallback < headers.size() ? fallback : -1;
+    }
+
+    private Object getValue(List<Object> row, int index) {
+        if (row == null || index < 0 || index >= row.size()) {
+            return null;
+        }
+        return row.get(index);
+    }
+
+    private String toText(Object value) {
+        if (value == null) {
+            return "";
+        }
+        return String.valueOf(value).trim();
+    }
+
+    private static class SpuAggregate {
+        double purchaseQty;
+        double purchaseAmount;
+        double salesQty;
+        int skuCount;
+        Map<String, Integer> attributeCount = new HashMap<>();
+
+        String getMostCommonAttribute() {
+            if (attributeCount.isEmpty()) {
+                return "";
+            }
+            return attributeCount.entrySet().stream()
+                    .max(Map.Entry.comparingByValue())
+                    .map(Map.Entry::getKey)
+                    .orElse("");
+        }
+    }
+}
+
+

+ 694 - 0
dtm-storage/src/main/java/com/dtm/storage/service/ProductService.java

@@ -0,0 +1,694 @@
+package com.dtm.storage.service;
+
+import com.dtm.storage.config.StorageSettings;
+import com.dtm.storage.model.PurchaseRecord;
+import com.dtm.storage.model.SalesRecord;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.stream.Collectors;
+
+@Service
+public class ProductService {
+    private static final Map<String, String> STAGE_COLORS;
+
+    static {
+        Map<String, String> colors = new HashMap<>();
+        colors.put("引入期", "rgba(64,158,255,0.12)");
+        colors.put("成长期", "rgba(103,194,58,0.18)");
+        colors.put("成熟期", "rgba(250,200,88,0.2)");
+        colors.put("衰退期", "rgba(245,108,108,0.18)");
+        STAGE_COLORS = Collections.unmodifiableMap(colors);
+    }
+
+    private final StorageDataLoader dataLoader;
+
+    public ProductService(StorageDataLoader dataLoader) {
+        this.dataLoader = dataLoader;
+    }
+
+    public Map<String, Object> getProductTrend(String rawSku) {
+        String sku = rawSku == null ? "" : rawSku.trim().toUpperCase(Locale.ROOT);
+        if (sku.isEmpty()) {
+            return buildEmptyTrend(sku);
+        }
+
+        List<PurchaseRecord> purchaseRecords = dataLoader.getPurchaseRecords();
+        List<SalesRecord> salesRecords = dataLoader.getSalesRecords();
+
+        Map<LocalDate, Double> purchaseDaily = new TreeMap<>();
+        Map<LocalDate, Double> salesDaily = new TreeMap<>();
+
+        int totalPurchase = 0;
+        int totalSales = 0;
+
+        for (PurchaseRecord record : purchaseRecords) {
+            if (record.getProductCode() == null) {
+                continue;
+            }
+            if (!sku.equalsIgnoreCase(record.getProductCode().trim())) {
+                continue;
+            }
+            if (record.getDate() != null) {
+                purchaseDaily.merge(record.getDate(), record.getQuantity(), Double::sum);
+            }
+            totalPurchase += (int) Math.round(record.getQuantity());
+        }
+
+        for (SalesRecord record : salesRecords) {
+            if (record.getProductCode() == null) {
+                continue;
+            }
+            if (!sku.equalsIgnoreCase(record.getProductCode().trim())) {
+                continue;
+            }
+            if (record.getDate() != null) {
+                salesDaily.merge(record.getDate(), record.getQuantity(), Double::sum);
+            }
+            totalSales += (int) Math.round(record.getQuantity());
+        }
+
+        if (purchaseDaily.isEmpty() && salesDaily.isEmpty()) {
+            return buildEmptyTrend(sku);
+        }
+
+        LocalDate startDate = minDate(purchaseDaily, salesDaily);
+        LocalDate endDate = maxDate(purchaseDaily, salesDaily);
+        if (startDate == null || endDate == null) {
+            return buildEmptyTrend(sku);
+        }
+
+        List<LocalDate> dates = new ArrayList<>();
+        LocalDate cursor = startDate;
+        while (!cursor.isAfter(endDate)) {
+            dates.add(cursor);
+            cursor = cursor.plusDays(1);
+        }
+
+        int n = dates.size();
+        double[] purchaseArr = new double[n];
+        double[] salesArr = new double[n];
+        double[] inventoryArr = new double[n];
+        double running = 0.0;
+
+        for (int i = 0; i < n; i++) {
+            LocalDate date = dates.get(i);
+            double p = purchaseDaily.getOrDefault(date, 0.0);
+            double s = salesDaily.getOrDefault(date, 0.0);
+            purchaseArr[i] = p;
+            salesArr[i] = s;
+            running += (p - s);
+            inventoryArr[i] = running;
+        }
+
+        double currentInventory = n > 0 ? inventoryArr[n - 1] : 0.0;
+        double avgInventory = timeWeightedAverage(Arrays.stream(inventoryArr).map(v -> Math.max(v, 0.0)).toArray());
+        double turnoverRate = avgInventory > 1e-6 ? totalSales / avgInventory : 0.0;
+        turnoverRate = round(turnoverRate, 2);
+
+        Map<String, Double> weights = StorageSettings.getAnalysisWeights();
+        int stableWindow = Math.max(1, weights.getOrDefault("stable_window_days", 60.0).intValue());
+        int lifecycleWindow = Math.max(1, weights.getOrDefault("lifecycle_window_days", 14.0).intValue());
+
+        double[] stable = rollingMedian(inventoryArr, stableWindow);
+        double[] salesMa = rollingMean(salesArr, lifecycleWindow);
+        double[] salesGrowth = new double[n];
+        for (int i = 1; i < n; i++) {
+            salesGrowth[i] = salesMa[i] - salesMa[i - 1];
+        }
+        double[] invStd = rollingStd(inventoryArr, lifecycleWindow);
+        double[] turnoverSeries = rollingTurnover(salesArr, inventoryArr, lifecycleWindow);
+
+        double salesPercentile = percentile(salesArr, 30);
+        double[] lowFlag = new double[n];
+        double[] overFlag = new double[n];
+        double[] slowFlag = new double[n];
+        double[] spikeFlag = new double[n];
+        double[] normalFlag = new double[n];
+
+        double[] rollingSalesSum = rollingSum(salesArr, lifecycleWindow);
+        for (int i = 0; i < n; i++) {
+            double stableVal = stable[i];
+            double invVal = inventoryArr[i];
+            double salesVal = salesArr[i];
+            double salesMaVal = salesMa[i];
+            lowFlag[i] = (stableVal > 0 && invVal < stableVal * 0.7) ? 1.0 : 0.0;
+            overFlag[i] = (stableVal > 0 && invVal > stableVal * 1.3) ? 1.0 : 0.0;
+            slowFlag[i] = (rollingSalesSum[i] < salesPercentile && invVal > stableVal) ? 1.0 : 0.0;
+            spikeFlag[i] = (salesMaVal > 0 && salesVal > salesMaVal * 1.8) ? 1.0 : 0.0;
+            normalFlag[i] = (lowFlag[i] + overFlag[i] + slowFlag[i] + spikeFlag[i] == 0) ? 1.0 : 0.0;
+        }
+
+        double fLow = weights.getOrDefault("state_low_weight", 0.3);
+        double fOver = weights.getOrDefault("state_overstock_weight", 0.3);
+        double fSlow = weights.getOrDefault("state_slow_weight", 0.2);
+        double fSpike = weights.getOrDefault("state_spike_weight", 0.15);
+        double fNorm = weights.getOrDefault("state_normal_weight", 0.1);
+
+        double[] stateContrib = new double[n];
+        for (int i = 0; i < n; i++) {
+            stateContrib[i] = -fLow * lowFlag[i] - fOver * overFlag[i] - fSlow * slowFlag[i] + fSpike * spikeFlag[i] + fNorm * normalFlag[i];
+        }
+
+        double wSales = weights.getOrDefault("sales_growth_weight", 0.35);
+        double wVar = weights.getOrDefault("inventory_variance_weight", 0.25);
+        double wTurn = weights.getOrDefault("turnover_weight", 0.2);
+        double wState = weights.getOrDefault("state_signal_weight", 0.3);
+
+        double[] stageIndex = new double[n];
+        double[] salesGrowthScaled = unitScale(salesGrowth);
+        double[] invStdScaled = unitScale(invStd);
+        double[] turnoverScaled = unitScale(turnoverSeries);
+        double[] stateScaled = unitScale(stateContrib);
+        for (int i = 0; i < n; i++) {
+            stageIndex[i] = wSales * salesGrowthScaled[i] - wVar * invStdScaled[i] + wTurn * turnoverScaled[i] + wState * stateScaled[i];
+        }
+
+        List<Map<String, Object>> lifecycleSegments = buildLifecycleSegments(stageIndex, dates);
+        List<Map<String, Object>> turnoverBreakdown = buildTurnoverBreakdown(dates, salesArr, inventoryArr);
+
+        int forecastDays = 30;
+        List<Double> forecastValues = forecastInventory(inventoryArr, salesArr, purchaseArr, stable, lifecycleWindow, weights, salesPercentile, turnoverSeries);
+
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+        List<String> dateLabels = dates.stream().map(formatter::format).collect(Collectors.toList());
+        LocalDate last = dates.get(dates.size() - 1);
+        List<String> futureDates = new ArrayList<>();
+        for (int i = 1; i <= forecastDays; i++) {
+            futureDates.add(last.plusDays(i).format(formatter));
+        }
+
+        List<String> allDates = new ArrayList<>(dateLabels);
+        allDates.addAll(futureDates);
+
+        List<Integer> purchaseByDay = Arrays.stream(purchaseArr).mapToInt(v -> (int) Math.round(v)).boxed().collect(Collectors.toList());
+        List<Integer> salesByDay = Arrays.stream(salesArr).mapToInt(v -> (int) Math.round(v)).boxed().collect(Collectors.toList());
+        List<Integer> inventoryByDay = Arrays.stream(inventoryArr).mapToInt(v -> (int) Math.round(v)).boxed().collect(Collectors.toList());
+        List<Double> stableInventory = Arrays.stream(stable).boxed().collect(Collectors.toList());
+
+        for (int i = 0; i < forecastDays; i++) {
+            purchaseByDay.add(0);
+            salesByDay.add(0);
+            inventoryByDay.add(null);
+            stableInventory.add(stable.length > 0 ? stable[stable.length - 1] : null);
+        }
+
+        List<Double> forecastInventory = new ArrayList<>();
+        for (int i = 0; i < dates.size(); i++) {
+            forecastInventory.add(null);
+        }
+        forecastInventory.addAll(forecastValues);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("sku", sku);
+        result.put("dates", allDates);
+        result.put("purchaseByDay", purchaseByDay);
+        result.put("salesByDay", salesByDay);
+        result.put("inventoryByDay", inventoryByDay);
+        result.put("stableInventory", stableInventory);
+        result.put("forecastInventory", forecastInventory);
+        result.put("lifecycleSegments", lifecycleSegments);
+        result.put("purchaseQty", totalPurchase);
+        result.put("salesQty", totalSales);
+        result.put("currentInventory", (int) Math.round(currentInventory));
+        result.put("turnoverRate", turnoverRate);
+        result.put("turnoverBreakdown", turnoverBreakdown);
+        return result;
+    }
+
+    public List<Map<String, Object>> getProductList() {
+        List<PurchaseRecord> purchaseRecords = dataLoader.getPurchaseRecords();
+        Set<String> unique = purchaseRecords.stream()
+                .map(PurchaseRecord::getProductCode)
+                .filter(code -> code != null && !code.trim().isEmpty())
+                .collect(Collectors.toSet());
+        List<String> sorted = new ArrayList<>(unique);
+        Collections.sort(sorted);
+        List<Map<String, Object>> list = new ArrayList<>();
+        int idx = 1;
+        for (String code : sorted) {
+            Map<String, Object> row = new LinkedHashMap<>();
+            row.put("id", idx++);
+            row.put("name", "产品 " + code);
+            row.put("code", code);
+            row.put("category", idx % 2 == 0 ? "成品" : "半成品");
+            list.add(row);
+            if (list.size() >= 20) {
+                break;
+            }
+        }
+        return list;
+    }
+
+    public Map<String, Object> getProductDetail(int productId) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("id", productId);
+        result.put("name", "智能手表 Pro X1");
+        result.put("code", "PRD-001");
+        result.put("category", "成品");
+        result.put("lifecycle", "成熟期");
+        result.put("healthStatus", "健康");
+        return result;
+    }
+
+    public Map<String, Object> getProductMetrics(int productId) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("currentStock", 3560);
+        result.put("turnoverRate", 25);
+        result.put("capitalRatio", 8.5);
+        result.put("healthIndex", 82);
+        return result;
+    }
+
+    public Map<String, Object> getGrowthTrend(int productId, String timeRange) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("dates", Arrays.asList("1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"));
+        result.put("totalStock", Arrays.asList(1200, 1320, 1450, 1680, 2100, 2580, 3200, 3560, 3480, 3350, 3200, 3100));
+        result.put("purchased", Arrays.asList(800, 850, 900, 1100, 1400, 1600, 1800, 1500, 1200, 1000, 900, 850));
+        result.put("assembled", Arrays.asList(400, 470, 550, 580, 700, 980, 1400, 2060, 2280, 2350, 2300, 2250));
+        return result;
+    }
+
+    public List<Map<String, Object>> getLifecycleStages(int productId) {
+        List<Map<String, Object>> list = new ArrayList<>();
+        list.add(buildLifecycle("引入期", "2024年1月- 2024年4月", "产品刚推向市场,销量增长缓慢", 450, 8.5, false));
+        list.add(buildLifecycle("成长期", "2024年5月- 2024年7月", "产品逐渐被市场接受,销量快速增长", 2150, 45.2, false));
+        list.add(buildLifecycle("成熟期", "2024年8月- 2024年10月", "产品销量达到顶峰,市场趋于饱和", 3420, 5.8, true));
+        list.add(buildLifecycle("衰退期", "2024年11月- 预计持续", "产品销量开始下降,市场份额减少", 3050, -3.2, false));
+        return list;
+    }
+
+    public Map<String, Object> getFsmState(int productId) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("currentState", "健康");
+        List<Map<String, Object>> history = new ArrayList<>();
+        history.add(buildState("健康", "2024-11-16 10:00:00"));
+        history.add(buildState("低库存", "2024-11-15 08:30:00"));
+        history.add(buildState("健康", "2024-11-14 14:20:00"));
+        result.put("stateHistory", history);
+        return result;
+    }
+
+    public Map<String, Object> getForecastData(int productId) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("historical", Arrays.asList(3350, 3200, 3100));
+        result.put("forecast", Arrays.asList(3050, 2980, 2900, 2850, 2800, 2750));
+        result.put("upperBound", Arrays.asList(3280, 3250, 3200, 3180, 3150, 3120));
+        result.put("lowerBound", Arrays.asList(2820, 2710, 2600, 2520, 2450, 2380));
+        return result;
+    }
+
+    private Map<String, Object> buildEmptyTrend(String sku) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("sku", sku);
+        result.put("dates", Collections.emptyList());
+        result.put("purchaseByDay", Collections.emptyList());
+        result.put("salesByDay", Collections.emptyList());
+        result.put("inventoryByDay", Collections.emptyList());
+        result.put("stableInventory", Collections.emptyList());
+        result.put("forecastInventory", Collections.emptyList());
+        result.put("lifecycleSegments", Collections.emptyList());
+        result.put("purchaseQty", 0);
+        result.put("salesQty", 0);
+        result.put("currentInventory", 0);
+        result.put("turnoverRate", 0);
+        result.put("turnoverBreakdown", Collections.emptyList());
+        return result;
+    }
+
+    private List<Double> forecastInventory(double[] inventoryArr, double[] salesArr, double[] purchaseArr,
+                                           double[] stableArr, int roll, Map<String, Double> weights,
+                                           double salesPercentile, double[] turnoverSeries) {
+        int forecastDays = 30;
+        List<Double> values = new ArrayList<>();
+        if (inventoryArr.length == 0) {
+            return values;
+        }
+        double stableLast = stableArr.length > 0 ? stableArr[stableArr.length - 1] : inventoryArr[inventoryArr.length - 1];
+        double lastVal = inventoryArr[inventoryArr.length - 1];
+
+        int window = Math.min(roll, inventoryArr.length);
+        double netFlowMean = 0.0;
+        if (window > 0) {
+            double sum = 0.0;
+            for (int i = inventoryArr.length - window; i < inventoryArr.length; i++) {
+                sum += (purchaseArr[i] - salesArr[i]);
+            }
+            netFlowMean = sum / window;
+        }
+
+        double momentum = 0.0;
+        if (inventoryArr.length > 1) {
+            int start = Math.max(1, inventoryArr.length - window);
+            double diffSum = 0.0;
+            int diffCount = 0;
+            for (int i = start; i < inventoryArr.length; i++) {
+                diffSum += inventoryArr[i] - inventoryArr[i - 1];
+                diffCount++;
+            }
+            momentum = diffCount > 0 ? diffSum / diffCount : 0.0;
+        }
+
+        double driftWeight = weights.getOrDefault("forecast_drift_weight", 0.15);
+        double recentSalesMean = meanOfTail(salesArr, window);
+        double salesMaMean = meanOfTail(rollingMean(salesArr, roll), roll);
+        double turnoverRecent = turnoverSeries.length > 0 ? turnoverSeries[turnoverSeries.length - 1] : 0.0;
+
+        for (int horizon = 1; horizon <= forecastDays; horizon++) {
+            double baseline = stableLast + driftWeight * netFlowMean * horizon;
+            double proposal = 0.6 * baseline + 0.4 * (lastVal + momentum);
+
+            boolean low = stableLast > 0 && proposal < stableLast * 0.7;
+            boolean over = stableLast > 0 && proposal > stableLast * 1.3;
+            boolean slow = recentSalesMean < salesPercentile && proposal > stableLast;
+            boolean spike = salesMaMean > 0 && recentSalesMean > salesMaMean * 1.5;
+
+            double alpha = 0.0;
+            if (low) {
+                alpha += 0.18;
+            }
+            if (over) {
+                alpha += 0.22 + (turnoverRecent < 1.0 ? 0.10 : 0.0);
+            }
+            if (slow) {
+                alpha += 0.08;
+            }
+            if (spike) {
+                alpha += 0.05;
+            }
+            alpha = Math.min(alpha, 0.45);
+
+            double nextVal = proposal;
+            if (stableLast > 0 && alpha > 0) {
+                nextVal = (1 - alpha) * proposal + alpha * stableLast;
+            }
+            nextVal = Math.max(0.0, nextVal);
+            values.add(nextVal);
+            lastVal = nextVal;
+        }
+        return values;
+    }
+
+    private List<Map<String, Object>> buildLifecycleSegments(double[] stageIndex, List<LocalDate> dates) {
+        List<Map<String, Object>> segments = new ArrayList<>();
+        if (stageIndex.length == 0 || dates.isEmpty()) {
+            return segments;
+        }
+        String[] labels = new String[stageIndex.length];
+        for (int i = 0; i < stageIndex.length; i++) {
+            labels[i] = stageName(stageIndex[i]);
+        }
+
+        int segStart = 0;
+        for (int i = 1; i < labels.length; i++) {
+            if (!labels[i].equals(labels[segStart])) {
+                segments.add(buildSegment(labels[segStart], dates.get(segStart), dates.get(i - 1), stageIndex, segStart, i - 1));
+                segStart = i;
+            }
+        }
+        segments.add(buildSegment(labels[segStart], dates.get(segStart), dates.get(dates.size() - 1), stageIndex, segStart, stageIndex.length - 1));
+        return segments;
+    }
+
+    private Map<String, Object> buildSegment(String name, LocalDate start, LocalDate end, double[] stageIndex, int startIdx, int endIdx) {
+        double sum = 0.0;
+        for (int i = startIdx; i <= endIdx && i < stageIndex.length; i++) {
+            sum += stageIndex[i];
+        }
+        double avg = (endIdx >= startIdx) ? sum / (endIdx - startIdx + 1) : 0.0;
+
+        Map<String, Object> segment = new LinkedHashMap<>();
+        segment.put("name", name);
+        segment.put("start", start.toString());
+        segment.put("end", end.toString());
+        segment.put("score", round(avg, 3));
+        segment.put("color", STAGE_COLORS.get(name));
+        return segment;
+    }
+
+    private List<Map<String, Object>> buildTurnoverBreakdown(List<LocalDate> dates, double[] salesArr, double[] inventoryArr) {
+        List<Map<String, Object>> breakdown = new ArrayList<>();
+        if (dates.isEmpty()) {
+            return breakdown;
+        }
+        int n = dates.size();
+        for (Integer window : StorageSettings.TURNOVER_WINDOWS) {
+            if (window == null || window <= 1) {
+                continue;
+            }
+            int windowLen = Math.min(window, n);
+            double salesSum = 0.0;
+            double[] invSlice = new double[windowLen];
+            for (int i = 0; i < windowLen; i++) {
+                int idx = n - windowLen + i;
+                salesSum += Math.max(0.0, salesArr[idx]);
+                invSlice[i] = Math.max(0.0, inventoryArr[idx]);
+            }
+            double avgInventory = timeWeightedAverage(invSlice);
+            double turnover = avgInventory > 1e-6 ? salesSum / avgInventory : 0.0;
+
+            Map<String, Object> row = new LinkedHashMap<>();
+            row.put("windowDays", windowLen);
+            row.put("start", dates.get(n - windowLen).toString());
+            row.put("end", dates.get(n - 1).toString());
+            row.put("salesSum", round(salesSum, 2));
+            row.put("avgInventory", round(avgInventory, 2));
+            row.put("turnover", round(turnover, 2));
+            breakdown.add(row);
+        }
+        breakdown.sort(Comparator.comparingInt(o -> ((Number) o.get("windowDays")).intValue()));
+        return breakdown;
+    }
+
+    private String stageName(double value) {
+        if (value >= 0.55) {
+            return "成长期";
+        }
+        if (value >= 0.15) {
+            return "成熟期";
+        }
+        if (value <= -0.25) {
+            return "衰退期";
+        }
+        return "引入期";
+    }
+
+    private LocalDate minDate(Map<LocalDate, Double> purchase, Map<LocalDate, Double> sales) {
+        LocalDate min = null;
+        if (!purchase.isEmpty()) {
+            min = purchase.keySet().stream().min(LocalDate::compareTo).orElse(null);
+        }
+        if (!sales.isEmpty()) {
+            LocalDate minSales = sales.keySet().stream().min(LocalDate::compareTo).orElse(null);
+            if (min == null || (minSales != null && minSales.isBefore(min))) {
+                min = minSales;
+            }
+        }
+        return min;
+    }
+
+    private LocalDate maxDate(Map<LocalDate, Double> purchase, Map<LocalDate, Double> sales) {
+        LocalDate max = null;
+        if (!purchase.isEmpty()) {
+            max = purchase.keySet().stream().max(LocalDate::compareTo).orElse(null);
+        }
+        if (!sales.isEmpty()) {
+            LocalDate maxSales = sales.keySet().stream().max(LocalDate::compareTo).orElse(null);
+            if (max == null || (maxSales != null && maxSales.isAfter(max))) {
+                max = maxSales;
+            }
+        }
+        return max;
+    }
+
+    private double[] rollingMedian(double[] values, int window) {
+        int n = values.length;
+        double[] result = new double[n];
+        for (int i = 0; i < n; i++) {
+            int start = Math.max(0, i - window + 1);
+            double[] slice = Arrays.copyOfRange(values, start, i + 1);
+            Arrays.sort(slice);
+            double median;
+            if (slice.length == 0) {
+                median = 0.0;
+            } else if (slice.length % 2 == 1) {
+                median = slice[slice.length / 2];
+            } else {
+                median = (slice[slice.length / 2 - 1] + slice[slice.length / 2]) / 2.0;
+            }
+            result[i] = median;
+        }
+        return result;
+    }
+
+    private double[] rollingMean(double[] values, int window) {
+        int n = values.length;
+        double[] result = new double[n];
+        for (int i = 0; i < n; i++) {
+            int start = Math.max(0, i - window + 1);
+            double sum = 0.0;
+            int count = 0;
+            for (int j = start; j <= i; j++) {
+                sum += values[j];
+                count++;
+            }
+            result[i] = count > 0 ? sum / count : 0.0;
+        }
+        return result;
+    }
+
+    private double[] rollingSum(double[] values, int window) {
+        int n = values.length;
+        double[] result = new double[n];
+        for (int i = 0; i < n; i++) {
+            int start = Math.max(0, i - window + 1);
+            double sum = 0.0;
+            for (int j = start; j <= i; j++) {
+                sum += values[j];
+            }
+            result[i] = sum;
+        }
+        return result;
+    }
+
+    private double[] rollingStd(double[] values, int window) {
+        int n = values.length;
+        double[] result = new double[n];
+        for (int i = 0; i < n; i++) {
+            int start = Math.max(0, i - window + 1);
+            double sum = 0.0;
+            double sumSq = 0.0;
+            int count = 0;
+            for (int j = start; j <= i; j++) {
+                sum += values[j];
+                sumSq += values[j] * values[j];
+                count++;
+            }
+            if (count <= 1) {
+                result[i] = 0.0;
+            } else {
+                double mean = sum / count;
+                double variance = (sumSq / count) - mean * mean;
+                result[i] = Math.sqrt(Math.max(variance, 0.0));
+            }
+        }
+        return result;
+    }
+
+    private double[] rollingTurnover(double[] salesArr, double[] inventoryArr, int window) {
+        int n = salesArr.length;
+        double[] result = new double[n];
+        for (int i = 0; i < n; i++) {
+            int start = Math.max(0, i - window + 1);
+            double salesSum = 0.0;
+            double invSum = 0.0;
+            int count = 0;
+            for (int j = start; j <= i; j++) {
+                salesSum += salesArr[j];
+                invSum += Math.abs(inventoryArr[j]);
+                count++;
+            }
+            double invAvg = count > 0 ? invSum / count : 0.0;
+            result[i] = invAvg > 1e-6 ? salesSum / invAvg : 0.0;
+        }
+        return result;
+    }
+
+    private double[] unitScale(double[] values) {
+        if (values.length == 0) {
+            return new double[0];
+        }
+        double min = Arrays.stream(values).min().orElse(0.0);
+        double max = Arrays.stream(values).max().orElse(0.0);
+        double range = max - min;
+        double[] result = new double[values.length];
+        if (Math.abs(range) < 1e-9) {
+            return result;
+        }
+        for (int i = 0; i < values.length; i++) {
+            result[i] = ((values[i] - min) / range) * 2 - 1;
+        }
+        return result;
+    }
+
+    private double percentile(double[] values, double percentile) {
+        if (values.length == 0) {
+            return 0.0;
+        }
+        double[] copy = Arrays.copyOf(values, values.length);
+        Arrays.sort(copy);
+        double idx = (percentile / 100.0) * (copy.length - 1);
+        int lo = (int) Math.floor(idx);
+        int hi = (int) Math.ceil(idx);
+        if (lo == hi) {
+            return copy[lo];
+        }
+        double weight = idx - lo;
+        return copy[lo] * (1 - weight) + copy[hi] * weight;
+    }
+
+    private double timeWeightedAverage(double[] values) {
+        if (values.length == 0) {
+            return 0.0;
+        }
+        if (values.length == 1) {
+            return values[0];
+        }
+        double area = 0.0;
+        for (int i = 1; i < values.length; i++) {
+            area += (values[i - 1] + values[i]) / 2.0;
+        }
+        return area / (values.length - 1);
+    }
+
+    private double meanOfTail(double[] values, int window) {
+        if (values.length == 0) {
+            return 0.0;
+        }
+        int start = Math.max(0, values.length - window);
+        double sum = 0.0;
+        int count = 0;
+        for (int i = start; i < values.length; i++) {
+            sum += values[i];
+            count++;
+        }
+        return count > 0 ? sum / count : 0.0;
+    }
+
+    private double round(double value, int digits) {
+        double scale = Math.pow(10, digits);
+        return Math.round(value * scale) / scale;
+    }
+
+    private Map<String, Object> buildLifecycle(String name, String period, String description, int sales, double growth, boolean current) {
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("name", name);
+        row.put("period", period);
+        row.put("description", description);
+        row.put("sales", sales);
+        row.put("growth", growth);
+        row.put("current", current);
+        return row;
+    }
+
+    private Map<String, Object> buildState(String state, String timestamp) {
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("state", state);
+        row.put("timestamp", timestamp);
+        return row;
+    }
+}
+
+

+ 652 - 0
dtm-storage/src/main/java/com/dtm/storage/service/RiskService.java

@@ -0,0 +1,652 @@
+package com.dtm.storage.service;
+
+import com.dtm.storage.config.StorageSettings;
+import com.dtm.storage.model.AssemblyRecord;
+import com.dtm.storage.model.PurchaseRecord;
+import com.dtm.storage.model.SalesRecord;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
+
+@Service
+public class RiskService {
+    private static final long CACHE_TTL_MILLIS = 300_000;
+
+    private final StorageDataLoader dataLoader;
+
+    private volatile List<Map<String, Object>> scoreCache;
+    private volatile long scoreCacheTs;
+    private volatile CleanData cleanCache;
+    private volatile long cleanCacheTs;
+
+    public RiskService(StorageDataLoader dataLoader) {
+        this.dataLoader = dataLoader;
+    }
+
+    public void invalidateCache() {
+        scoreCache = null;
+        scoreCacheTs = 0;
+        cleanCache = null;
+        cleanCacheTs = 0;
+    }
+
+    public Map<String, Object> getRiskStatistics() {
+        List<Map<String, Object>> scores = ensureScores();
+        Map<String, Long> counter = scores.stream()
+                .collect(Collectors.groupingBy(row -> (String) row.get("riskLevel"), Collectors.counting()));
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("critical", counter.getOrDefault("critical", 0L));
+        result.put("warning", counter.getOrDefault("warning", 0L));
+        result.put("info", counter.getOrDefault("info", 0L));
+        result.put("safe", counter.getOrDefault("safe", 0L));
+        return result;
+    }
+
+    public Map<String, Object> getRiskList(int page, int pageSize, String riskLevel, String riskType) {
+        List<Map<String, Object>> scores = new ArrayList<>(ensureScores());
+        if (riskLevel != null && !riskLevel.trim().isEmpty()) {
+            scores = scores.stream().filter(row -> riskLevel.equals(row.get("riskLevel"))).collect(Collectors.toList());
+        }
+        if (riskType != null && !riskType.trim().isEmpty()) {
+            scores = scores.stream().filter(row -> riskType.equals(row.get("riskType"))).collect(Collectors.toList());
+        }
+
+        scores.sort(Comparator
+                .comparing((Map<String, Object> row) -> levelPriority((String) row.get("riskLevel")))
+                .thenComparing((Map<String, Object> row) -> -((Number) row.getOrDefault("riskScore", 0)).doubleValue()));
+
+        int total = scores.size();
+        int start = Math.max(0, (page - 1) * pageSize);
+        int end = Math.min(total, start + pageSize);
+        List<Map<String, Object>> pageList = start >= total ? Collections.emptyList() : scores.subList(start, end);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("list", pageList);
+        result.put("total", total);
+        result.put("page", page);
+        result.put("pageSize", pageSize);
+        return result;
+    }
+
+    public Map<String, Object> getRiskTrend() {
+        CleanData clean = loadCleanData();
+        List<LocalDate> allDates = new ArrayList<>();
+        allDates.addAll(clean.purchaseDates);
+        allDates.addAll(clean.salesDates);
+        allDates.addAll(clean.assemblyDates);
+
+        Map<String, Object> stats = getRiskStatistics();
+        if (allDates.isEmpty()) {
+            List<String> labels = new ArrayList<>();
+            for (int i = 7; i >= 0; i--) {
+                labels.add(LocalDate.now().minusDays(i).format(DateTimeFormatter.ofPattern("MM-dd")));
+            }
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("dates", labels);
+            result.put("critical", fillSeries(((Number) stats.get("critical")).intValue(), labels.size()));
+            result.put("warning", fillSeries(((Number) stats.get("warning")).intValue(), labels.size()));
+            result.put("info", fillSeries(((Number) stats.get("info")).intValue(), labels.size()));
+            return result;
+        }
+
+        LocalDate maxDate = allDates.stream().max(LocalDate::compareTo).orElse(LocalDate.now());
+        LocalDate minDate = maxDate.minusDays(21);
+        List<LocalDate> sampleDates = new ArrayList<>();
+        for (int i = 0; i < 8; i++) {
+            sampleDates.add(minDate.plusDays((long) i * 3));
+        }
+
+        List<Integer> critical = new ArrayList<>();
+        List<Integer> warning = new ArrayList<>();
+        List<Integer> info = new ArrayList<>();
+        List<String> labels = new ArrayList<>();
+
+        for (LocalDate target : sampleDates) {
+            List<Map<String, Object>> scores = calculateHealthScores(
+                    slicePurchaseUntil(clean.purchaseRecords, target),
+                    sliceSalesUntil(clean.salesRecords, target),
+                    sliceAssemblyUntil(clean.assemblyRecords, target)
+            );
+            Map<String, Long> counter = scores.stream()
+                    .collect(Collectors.groupingBy(row -> (String) row.get("riskLevel"), Collectors.counting()));
+            critical.add(counter.getOrDefault("critical", 0L).intValue());
+            warning.add(counter.getOrDefault("warning", 0L).intValue());
+            info.add(counter.getOrDefault("info", 0L).intValue());
+            labels.add(target.format(DateTimeFormatter.ofPattern("MM-dd")));
+        }
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("dates", labels);
+        result.put("critical", critical);
+        result.put("warning", warning);
+        result.put("info", info);
+        return result;
+    }
+
+    public List<Map<String, Object>> getWarningRules() {
+        List<Map<String, Object>> scores = ensureScores();
+        Map<String, Long> stats = scores.stream()
+                .filter(row -> !"safe".equals(row.get("riskLevel")))
+                .collect(Collectors.groupingBy(row -> (String) row.get("riskType"), Collectors.counting()));
+
+        List<Map<String, Object>> rules = new ArrayList<>();
+        rules.add(buildRule(1, "低库存预警", "库存覆盖天数 < 5天", "critical", stats.getOrDefault("stockout", 0L).intValue()));
+        rules.add(buildRule(2, "超储风险预警", "库存覆盖天数 > 30天且周转<1", "warning", stats.getOrDefault("overstock", 0L).intValue()));
+        rules.add(buildRule(3, "滞销预警", "日均销量<0.2仍占用库存", "warning", stats.getOrDefault("slow-moving", 0L).intValue()));
+        rules.add(buildRule(4, "周转风险预警", "库存周转率 < 1.5", "info", stats.getOrDefault("turnover", 0L).intValue()));
+        return rules;
+    }
+
+    public Map<String, Object> createWarningRule(Map<String, Object> payload) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("id", System.currentTimeMillis());
+        if (payload != null) {
+            result.putAll(payload);
+        }
+        return result;
+    }
+
+    public Map<String, Object> updateWarningRule(int ruleId, Map<String, Object> payload) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("id", ruleId);
+        if (payload != null) {
+            result.putAll(payload);
+        }
+        return result;
+    }
+
+    public boolean deleteWarningRule(int ruleId) {
+        return true;
+    }
+
+    public List<Map<String, Object>> getOptimizationFeedback() {
+        List<Map<String, Object>> scores = ensureScores();
+        List<Map<String, Object>> risky = scores.stream()
+                .filter(row -> {
+                    Object level = row.get("riskLevel");
+                    return "critical".equals(level) || "warning".equals(level);
+                })
+                .limit(3)
+                .collect(Collectors.toList());
+        List<Map<String, Object>> feedback = new ArrayList<>();
+        DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
+        String nowStr = LocalDateTime.now().format(fmt);
+
+        for (Map<String, Object> row : risky) {
+            String riskType = (String) row.get("riskType");
+            if ("stockout".equals(riskType)) {
+                feedback.add(buildFeedback(nowStr, "warning",
+                        row.get("productCode") + " 补货建议",
+                        "库存仅剩 " + row.get("inventory") + " 件,日均发货 " + row.get("avgDailySales") + " 件,建议立即安排补货。")
+                );
+            } else if ("overstock".equals(riskType)) {
+                feedback.add(buildFeedback(nowStr, "info",
+                        row.get("productCode") + " 超储优化",
+                        "覆盖天数 " + row.get("coverageDays") + " 天,库存占用 " + row.get("inventory") + " 件,建议发起促销或跨仓调拨。")
+                );
+            } else if ("slow-moving".equals(riskType)) {
+                feedback.add(buildFeedback(nowStr, "danger",
+                        row.get("productCode") + " 滞销处理",
+                        "连续30天日均销量低于 " + row.get("avgDailySales") + " 件,库存 " + row.get("inventory") + " 件,建议暂停采购并规划清仓。")
+                );
+            }
+        }
+
+        if (feedback.isEmpty()) {
+            feedback.add(buildFeedback(nowStr, "success", "库存结构稳定", "没有检测到高风险SKU,可继续保持当前补货节奏。"));
+        }
+        return feedback;
+    }
+
+    public Map<String, Object> getRiskSettings() {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("data", StorageSettings.getRiskWeights());
+        result.put("defaults", StorageSettings.DEFAULT_RISK_WEIGHTS);
+        return result;
+    }
+
+    public Map<String, Object> updateRiskSettings(Map<String, Object> payload) {
+        StorageSettings.updateRiskWeights(payload);
+        invalidateCache();
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("data", StorageSettings.getRiskWeights());
+        return result;
+    }
+
+    private List<Map<String, Object>> ensureScores() {
+        long now = System.currentTimeMillis();
+        if (scoreCache != null && (now - scoreCacheTs < CACHE_TTL_MILLIS)) {
+            return scoreCache;
+        }
+        CleanData clean = loadCleanData();
+        List<Map<String, Object>> scores = calculateHealthScores(clean.purchaseRecords, clean.salesRecords, clean.assemblyRecords);
+        scoreCache = scores;
+        scoreCacheTs = now;
+        return scores;
+    }
+
+    private CleanData loadCleanData() {
+        long now = System.currentTimeMillis();
+        if (cleanCache != null && now - cleanCacheTs < CACHE_TTL_MILLIS) {
+            return cleanCache;
+        }
+        List<PurchaseRecord> purchaseRecords = dataLoader.getPurchaseRecords();
+        List<SalesRecord> salesRecords = dataLoader.getSalesRecords();
+        List<AssemblyRecord> assemblyRecords = dataLoader.getAssemblyRecords();
+
+        CleanData clean = new CleanData(purchaseRecords, salesRecords, assemblyRecords);
+        cleanCache = clean;
+        cleanCacheTs = now;
+        return clean;
+    }
+
+    private List<Map<String, Object>> calculateHealthScores(List<PurchaseRecord> purchaseRecords,
+                                                            List<SalesRecord> salesRecords,
+                                                            List<AssemblyRecord> assemblyRecords) {
+        Map<String, Double> purchaseQty = groupSumPurchase(purchaseRecords, PurchaseRecord::getQuantity);
+        Map<String, Double> purchaseAmount = groupSumPurchase(purchaseRecords, PurchaseRecord::getAmount);
+        Map<String, Double> salesQty = groupSumSales(salesRecords);
+        Map<String, Double> assemblyQty = groupSumAssembly(assemblyRecords);
+
+        Map<String, Map<LocalDate, Double>> salesDaily = new HashMap<>();
+        for (SalesRecord record : salesRecords) {
+            if (record.getDate() == null) {
+                continue;
+            }
+            salesDaily
+                    .computeIfAbsent(record.getProductCode(), k -> new HashMap<>())
+                    .merge(record.getDate(), record.getQuantity(), Double::sum);
+        }
+
+        Map<String, Double> avgDailySales = new HashMap<>();
+        for (Map.Entry<String, Map<LocalDate, Double>> entry : salesDaily.entrySet()) {
+            double sum = entry.getValue().values().stream().mapToDouble(Double::doubleValue).sum();
+            int days = entry.getValue().size();
+            avgDailySales.put(entry.getKey(), days > 0 ? sum / days : 0.0);
+        }
+
+        Map<String, Double> recentAvg = new HashMap<>();
+        LocalDate latestSaleDate = salesRecords.stream()
+                .map(SalesRecord::getDate)
+                .filter(d -> d != null)
+                .max(LocalDate::compareTo)
+                .orElse(null);
+        if (latestSaleDate != null) {
+            LocalDate cutoff = latestSaleDate.minusDays(30);
+            Map<String, Map<LocalDate, Double>> recentDaily = new HashMap<>();
+            for (SalesRecord record : salesRecords) {
+                if (record.getDate() == null || record.getDate().isBefore(cutoff)) {
+                    continue;
+                }
+                recentDaily
+                        .computeIfAbsent(record.getProductCode(), k -> new HashMap<>())
+                        .merge(record.getDate(), record.getQuantity(), Double::sum);
+            }
+            for (Map.Entry<String, Map<LocalDate, Double>> entry : recentDaily.entrySet()) {
+                double sum = entry.getValue().values().stream().mapToDouble(Double::doubleValue).sum();
+                int days = entry.getValue().size();
+                recentAvg.put(entry.getKey(), days > 0 ? sum / days : 0.0);
+            }
+        }
+
+        Map<String, LocalDate> lastSaleDate = lastDateBySku(salesRecords.stream().filter(r -> r.getDate() != null).collect(Collectors.toList()), SalesRecord::getProductCode, SalesRecord::getDate);
+        Map<String, LocalDate> lastPurchaseDate = lastDateBySku(purchaseRecords.stream().filter(r -> r.getDate() != null).collect(Collectors.toList()), PurchaseRecord::getProductCode, PurchaseRecord::getDate);
+        Map<String, LocalDate> lastAssemblyDate = lastDateBySku(assemblyRecords.stream().filter(r -> r.getDate() != null).collect(Collectors.toList()), AssemblyRecord::getProductCode, AssemblyRecord::getDate);
+
+        double totalPurchaseAmount = purchaseAmount.values().stream().mapToDouble(Double::doubleValue).sum();
+
+        Set<String> allSkus = new java.util.HashSet<>();
+        allSkus.addAll(purchaseQty.keySet());
+        allSkus.addAll(salesQty.keySet());
+        allSkus.addAll(assemblyQty.keySet());
+
+        Map<String, Double> riskWeights = StorageSettings.getRiskWeights();
+
+        List<Map<String, Object>> results = new ArrayList<>();
+        DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+        String nowStr = LocalDateTime.now().format(fmt);
+
+        for (String sku : allSkus) {
+            double inbound = purchaseQty.getOrDefault(sku, 0.0) + assemblyQty.getOrDefault(sku, 0.0);
+            double sold = salesQty.getOrDefault(sku, 0.0);
+            double inventory = Math.max(inbound - sold, 0.0);
+
+            double avgDaily = avgDailySales.getOrDefault(sku, 0.0);
+            double recentDaily = recentAvg.getOrDefault(sku, 0.0);
+            double coverageDays = (avgDaily <= 0 && inventory > 0) ? Double.POSITIVE_INFINITY : (avgDaily > 0 ? inventory / avgDaily : 0.0);
+
+            double salesSurrogate = Math.max(inbound - inventory, 0.0);
+            double avgInventory = Math.max((inventory + salesSurrogate) / 2.0, 1.0);
+            double turnoverRate = avgInventory > 0 ? sold / avgInventory : 0.0;
+
+            double amount = purchaseAmount.getOrDefault(sku, 0.0);
+            double capitalRatio = totalPurchaseAmount > 0 ? amount / totalPurchaseAmount : 0.0;
+
+            LocalDate last = maxDate(lastSaleDate.get(sku), lastPurchaseDate.get(sku), lastAssemblyDate.get(sku));
+            String detected = last != null ? last.atStartOfDay().format(fmt) : nowStr;
+
+            double coverageScore = coverageScore(coverageDays, inventory);
+            double turnoverScore = turnoverScore(turnoverRate);
+            double trendScore = trendScore(avgDaily, recentDaily);
+            double capitalScore = Math.max(2, 10 - capitalRatio * 60);
+
+            double covNorm = coverageScore / 40.0;
+            double turNorm = turnoverScore / 30.0;
+            double trdNorm = trendScore / 18.0;
+            double capNorm = capitalScore / 10.0;
+
+            double healthScore = Math.min(100, covNorm * riskWeights.getOrDefault("coverageWeight", 40.0)
+                    + turNorm * riskWeights.getOrDefault("turnoverWeight", 30.0)
+                    + trdNorm * riskWeights.getOrDefault("trendWeight", 20.0)
+                    + capNorm * riskWeights.getOrDefault("capitalWeight", 10.0));
+            healthScore = Math.round(healthScore);
+            double riskScore = 100 - healthScore;
+            String riskLevel = riskLevel(healthScore);
+            String riskType = riskType(coverageDays, avgDaily, inventory, turnoverRate);
+
+            String[] recommendation = buildRecommendation(riskType, inventory, avgDaily, coverageDays);
+
+            Map<String, Object> row = new LinkedHashMap<>();
+            row.put("id", "R-" + sku);
+            row.put("productName", "SKU " + sku);
+            row.put("productCode", sku);
+            row.put("riskLevel", riskLevel);
+            row.put("riskType", riskType);
+            row.put("riskScore", round(riskScore, 2));
+            row.put("healthScore", (int) healthScore);
+            row.put("inventory", round(inventory, 2));
+            row.put("avgDailySales", round(avgDaily, 2));
+            row.put("coverageDays", round(Double.isInfinite(coverageDays) ? 9999 : coverageDays, 2));
+            row.put("turnoverRate", round(turnoverRate, 2));
+            row.put("capitalRatio", round(capitalRatio * 100, 2));
+            Map<String, Object> breakdown = new LinkedHashMap<>();
+            breakdown.put("coverage", round(covNorm * riskWeights.getOrDefault("coverageWeight", 40.0), 2));
+            breakdown.put("turnover", round(turNorm * riskWeights.getOrDefault("turnoverWeight", 30.0), 2));
+            breakdown.put("trend", round(trdNorm * riskWeights.getOrDefault("trendWeight", 20.0), 2));
+            breakdown.put("capital", round(capNorm * riskWeights.getOrDefault("capitalWeight", 10.0), 2));
+            row.put("scoreBreakdown", breakdown);
+            row.put("description", recommendation[0]);
+            row.put("suggestion", recommendation[1]);
+            row.put("detectedTime", detected);
+            results.add(row);
+        }
+
+        return results;
+    }
+
+    private Map<String, Double> groupSumPurchase(List<PurchaseRecord> records, java.util.function.ToDoubleFunction<PurchaseRecord> getter) {
+        Map<String, Double> map = new HashMap<>();
+        for (PurchaseRecord record : records) {
+            map.merge(record.getProductCode(), getter.applyAsDouble(record), Double::sum);
+        }
+        return map;
+    }
+
+    private Map<String, Double> groupSumSales(List<SalesRecord> records) {
+        Map<String, Double> map = new HashMap<>();
+        for (SalesRecord record : records) {
+            map.merge(record.getProductCode(), record.getQuantity(), Double::sum);
+        }
+        return map;
+    }
+
+    private Map<String, Double> groupSumAssembly(List<AssemblyRecord> records) {
+        Map<String, Double> map = new HashMap<>();
+        for (AssemblyRecord record : records) {
+            map.merge(record.getProductCode(), record.getQuantity(), Double::sum);
+        }
+        return map;
+    }
+
+    private <T> Map<String, LocalDate> lastDateBySku(List<T> records,
+                                                     java.util.function.Function<T, String> skuFn,
+                                                     java.util.function.Function<T, LocalDate> dateFn) {
+        Map<String, LocalDate> map = new HashMap<>();
+        for (T record : records) {
+            String sku = skuFn.apply(record);
+            LocalDate date = dateFn.apply(record);
+            if (sku == null || date == null) {
+                continue;
+            }
+            map.merge(sku, date, (a, b) -> a.isAfter(b) ? a : b);
+        }
+        return map;
+    }
+
+    private double coverageScore(double coverageDays, double inventory) {
+        if (Double.isInfinite(coverageDays)) {
+            return inventory > 0 ? 20 : 35;
+        }
+        if (coverageDays <= 0) {
+            return 5;
+        }
+        double idealLower = 7;
+        double idealUpper = 21;
+        if (coverageDays < idealLower) {
+            return Math.max(5, 40 * (coverageDays / idealLower));
+        }
+        if (coverageDays <= idealUpper) {
+            return 40;
+        }
+        double overflow = coverageDays - idealUpper;
+        double penalty = Math.min(overflow / idealUpper * 20, 20);
+        return Math.max(5, 40 - penalty);
+    }
+
+    private double turnoverScore(double turnoverRate) {
+        if (turnoverRate >= 8) {
+            return 30;
+        }
+        if (turnoverRate >= 4) {
+            return 22 + (turnoverRate - 4) / 4 * 8;
+        }
+        if (turnoverRate <= 0) {
+            return 5;
+        }
+        return Math.max(5, turnoverRate / 4 * 22);
+    }
+
+    private double trendScore(double avgDaily, double recentDaily) {
+        if (avgDaily <= 0 && recentDaily <= 0) {
+            return 12;
+        }
+        if (avgDaily <= 0 && recentDaily > 0) {
+            return 18;
+        }
+        if (recentDaily <= 0) {
+            return 6;
+        }
+        double ratio = avgDaily > 0 ? recentDaily / avgDaily : 1.0;
+        if (ratio >= 1.3) {
+            return 18;
+        }
+        if (ratio >= 1.0) {
+            return 16;
+        }
+        if (ratio >= 0.7) {
+            return 13;
+        }
+        if (ratio >= 0.4) {
+            return 10;
+        }
+        return 7;
+    }
+
+    private String riskLevel(double healthScore) {
+        if (healthScore >= 80) {
+            return "safe";
+        }
+        if (healthScore >= 60) {
+            return "info";
+        }
+        if (healthScore >= 40) {
+            return "warning";
+        }
+        return "critical";
+    }
+
+    private String riskType(double coverageDays, double avgDaily, double inventory, double turnoverRate) {
+        if (avgDaily > 0 && coverageDays < 3) {
+            return "stockout";
+        }
+        if (avgDaily > 0 && coverageDays > 30) {
+            return "overstock";
+        }
+        if (avgDaily <= 0.2 && inventory > 0) {
+            return "slow-moving";
+        }
+        if (inventory > 0 && turnoverRate < 1.5) {
+            return "turnover";
+        }
+        return "balanced";
+    }
+
+    private String[] buildRecommendation(String riskType, double inventory, double avgDaily, double coverageDays) {
+        if ("stockout".equals(riskType)) {
+            return new String[]{
+                    "库存覆盖 " + round(coverageDays, 1) + " 天,难以满足近期需求。",
+                    "建议立即补货并锁定未来一周的采购计划。"
+            };
+        }
+        if ("overstock".equals(riskType)) {
+            return new String[]{
+                    "库存覆盖 " + round(coverageDays, 1) + " 天,超出安全上限。",
+                    "建议开展促销或跨仓调拨以释放库存占用。"
+            };
+        }
+        if ("slow-moving".equals(riskType)) {
+            return new String[]{
+                    "日均销量" + round(avgDaily, 1) + "件但仍有 " + round(inventory, 0) + " 件库存。",
+                    "建议暂停采购、下架低效渠道或规划清仓。"
+            };
+        }
+        if ("turnover".equals(riskType)) {
+            return new String[]{
+                    "库存周转缓慢,库存 " + round(inventory, 0) + " 件。",
+                    "可优化补货批次或通过B2B渠道加速流转。"
+            };
+        }
+        return new String[]{"库存处于安全区间。", "保持当前补货频率,持续监控销售波动。"};
+    }
+
+    private int levelPriority(String level) {
+        Map<String, Integer> order = new HashMap<>();
+        order.put("critical", 0);
+        order.put("warning", 1);
+        order.put("info", 2);
+        order.put("safe", 3);
+        return order.getOrDefault(level, 4);
+    }
+
+    private List<PurchaseRecord> slicePurchaseUntil(List<PurchaseRecord> records, LocalDate cutoff) {
+        if (cutoff == null) {
+            return records;
+        }
+        return records.stream()
+                .filter(r -> r.getDate() == null || !r.getDate().isAfter(cutoff))
+                .collect(Collectors.toList());
+    }
+
+    private List<SalesRecord> sliceSalesUntil(List<SalesRecord> records, LocalDate cutoff) {
+        if (cutoff == null) {
+            return records;
+        }
+        return records.stream()
+                .filter(r -> r.getDate() == null || !r.getDate().isAfter(cutoff))
+                .collect(Collectors.toList());
+    }
+
+    private List<AssemblyRecord> sliceAssemblyUntil(List<AssemblyRecord> records, LocalDate cutoff) {
+        if (cutoff == null) {
+            return records;
+        }
+        return records.stream()
+                .filter(r -> r.getDate() == null || !r.getDate().isAfter(cutoff))
+                .collect(Collectors.toList());
+    }
+
+    private LocalDate maxDate(LocalDate a, LocalDate b, LocalDate c) {
+        LocalDate max = a;
+        if (b != null && (max == null || b.isAfter(max))) {
+            max = b;
+        }
+        if (c != null && (max == null || c.isAfter(max))) {
+            max = c;
+        }
+        return max;
+    }
+
+    private Map<String, Object> buildRule(int id, String name, String condition, String level, int triggerCount) {
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("id", id);
+        row.put("name", name);
+        row.put("condition", condition);
+        row.put("level", level);
+        row.put("enabled", true);
+        row.put("triggerCount", triggerCount);
+        return row;
+    }
+
+    private Map<String, Object> buildFeedback(String time, String type, String title, String content) {
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("time", time);
+        row.put("type", type);
+        row.put("title", title);
+        row.put("content", content);
+        if ("warning".equals(type)) {
+            row.put("tags", java.util.Arrays.asList("缺货", "补货"));
+        } else if ("info".equals(type)) {
+            row.put("tags", java.util.Arrays.asList("超储", "促销"));
+        } else if ("danger".equals(type)) {
+            row.put("tags", java.util.Arrays.asList("滞销", "清仓"));
+        } else {
+            row.put("tags", java.util.Arrays.asList("稳定", "监控"));
+        }
+        return row;
+    }
+
+    private List<Integer> fillSeries(int value, int size) {
+        List<Integer> list = new ArrayList<>();
+        for (int i = 0; i < size; i++) {
+            list.add(value);
+        }
+        return list;
+    }
+
+    private double round(double value, int digits) {
+        double scale = Math.pow(10, digits);
+        return Math.round(value * scale) / scale;
+    }
+
+    private static class CleanData {
+        private final List<PurchaseRecord> purchaseRecords;
+        private final List<SalesRecord> salesRecords;
+        private final List<AssemblyRecord> assemblyRecords;
+        private final List<LocalDate> purchaseDates;
+        private final List<LocalDate> salesDates;
+        private final List<LocalDate> assemblyDates;
+
+        private CleanData(List<PurchaseRecord> purchaseRecords, List<SalesRecord> salesRecords, List<AssemblyRecord> assemblyRecords) {
+            this.purchaseRecords = purchaseRecords;
+            this.salesRecords = salesRecords;
+            this.assemblyRecords = assemblyRecords;
+            this.purchaseDates = purchaseRecords.stream().map(PurchaseRecord::getDate).filter(d -> d != null).collect(Collectors.toList());
+            this.salesDates = salesRecords.stream().map(SalesRecord::getDate).filter(d -> d != null).collect(Collectors.toList());
+            this.assemblyDates = assemblyRecords.stream().map(AssemblyRecord::getDate).filter(d -> d != null).collect(Collectors.toList());
+        }
+    }
+}
+
+

+ 317 - 0
dtm-storage/src/main/java/com/dtm/storage/service/SemiProductService.java

@@ -0,0 +1,317 @@
+package com.dtm.storage.service;
+
+import com.dtm.storage.model.PurchaseRecord;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Service
+public class SemiProductService {
+    private final StorageDataLoader dataLoader;
+
+    public SemiProductService(StorageDataLoader dataLoader) {
+        this.dataLoader = dataLoader;
+    }
+
+    public Map<String, Object> getStatistics() {
+        List<List<Object>> mappingRows = dataLoader.getSemiMappingRows();
+        List<String> headers = dataLoader.getSemiMappingHeaders();
+        Set<String> semiCodes = collectSemiCodes(mappingRows, headers);
+
+        Map<String, Double> purchaseByCode = dataLoader.getPurchaseRecords().stream()
+                .collect(Collectors.groupingBy(PurchaseRecord::getProductCode, Collectors.summingDouble(PurchaseRecord::getQuantity)));
+
+        int totalQty = 0;
+        for (String code : semiCodes) {
+            totalQty += (int) Math.round(purchaseByCode.getOrDefault(code, 0.0));
+        }
+
+        int estimatedFinished = getAssemblyCapacity().stream()
+                .mapToInt(item -> ((Number) item.getOrDefault("capacity", 0)).intValue())
+                .sum();
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("totalSemiProducts", semiCodes.size());
+        result.put("totalQuantity", totalQty);
+        result.put("estimatedFinished", estimatedFinished);
+        return result;
+    }
+
+    public Map<String, Object> getList(int page, int pageSize, String keyword, String status, String category) {
+        List<Map<String, Object>> data = sampleSemiProducts();
+        List<Map<String, Object>> filtered = new ArrayList<>(data);
+        if (keyword != null && !keyword.trim().isEmpty()) {
+            String key = keyword.trim();
+            filtered = filtered.stream()
+                    .filter(d -> d.get("name").toString().contains(key) || d.get("code").toString().contains(key))
+                    .collect(Collectors.toList());
+        }
+        if (status != null && !status.trim().isEmpty()) {
+            filtered = filtered.stream()
+                    .filter(d -> status.equals(d.get("status")))
+                    .collect(Collectors.toList());
+        }
+        if (category != null && !category.trim().isEmpty()) {
+            filtered = filtered.stream()
+                    .filter(d -> category.equals(d.get("category")))
+                    .collect(Collectors.toList());
+        }
+
+        int total = filtered.size();
+        int start = Math.max(0, (page - 1) * pageSize);
+        int end = Math.min(total, start + pageSize);
+        List<Map<String, Object>> list = start >= total ? Collections.emptyList() : filtered.subList(start, end);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("list", list);
+        result.put("total", total);
+        result.put("page", page);
+        result.put("pageSize", pageSize);
+        return result;
+    }
+
+    public Map<String, Object> getDetail(int id) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("id", id);
+        result.put("code", "SEM-101");
+        result.put("name", "电路板 PCB-A");
+        result.put("category", "pcb");
+        result.put("quantity", 3200);
+        result.put("safeStock", 2000);
+        result.put("status", "in-stock");
+        return result;
+    }
+
+    public Map<String, Object> create(Map<String, Object> payload) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("id", 999);
+        if (payload != null) {
+            result.putAll(payload);
+        }
+        return result;
+    }
+
+    public Map<String, Object> update(int id, Map<String, Object> payload) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("id", id);
+        if (payload != null) {
+            result.putAll(payload);
+        }
+        return result;
+    }
+
+    public void delete(int id) {
+    }
+
+    public List<Map<String, Object>> getTurnoverAnalysis() {
+        List<Map<String, Object>> list = new ArrayList<>();
+        list.add(buildTurnover("电路板 PCB-A", 18, 85, 850, 1200, "正常", "库存充足,周转良好"));
+        list.add(buildTurnover("显示屏模块", 22, 78, 650, 1000, "正常", "建议保持当前库存水平"));
+        return list;
+    }
+
+    public Map<String, Object> getBomRelation(String productCode) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("product", "智能手表 Pro X1");
+        result.put("code", productCode);
+        List<Map<String, Object>> bom = new ArrayList<>();
+        Map<String, Object> module1 = new LinkedHashMap<>();
+        module1.put("module", "电子模块");
+        module1.put("items", java.util.Arrays.asList(
+                buildBomItem("电路板 PCB-A", 1, 3200),
+                buildBomItem("显示屏模块", 1, 2800),
+                buildBomItem("锂电池组", 1, 1200)
+        ));
+        Map<String, Object> module2 = new LinkedHashMap<>();
+        module2.put("module", "结构件");
+        module2.put("items", java.util.Arrays.asList(
+                buildBomItem("金属外壳", 1, 4500),
+                buildBomItem("表带", 2, 5200)
+        ));
+        bom.add(module1);
+        bom.add(module2);
+        result.put("bom", bom);
+        return result;
+    }
+
+    public List<Map<String, Object>> getAssemblyCapacity() {
+        List<List<Object>> mappingRows = dataLoader.getSemiMappingRows();
+        List<String> headers = dataLoader.getSemiMappingHeaders();
+        if (mappingRows.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        int productIdx = findHeaderIndex(headers, new String[]{"成品", "成品编码", "产品编码", "产品代码"}, 0);
+        int innerIdx = findHeaderIndex(headers, new String[]{"内芯", "内胆"}, 1);
+        int outerIdx = findHeaderIndex(headers, new String[]{"外壳", "外套"}, 2);
+        int accessoryIdx = findHeaderIndex(headers, new String[]{"配件", "附件"}, 3);
+
+        Map<String, Double> purchaseByCode = dataLoader.getPurchaseRecords().stream()
+                .collect(Collectors.groupingBy(PurchaseRecord::getProductCode, Collectors.summingDouble(PurchaseRecord::getQuantity)));
+
+        List<Map<String, Object>> results = new ArrayList<>();
+        for (List<Object> row : mappingRows) {
+            String productCode = toText(getValue(row, productIdx));
+            if (productCode.isEmpty()) {
+                continue;
+            }
+
+            List<String> components = new ArrayList<>();
+            collectCodes(components, toText(getValue(row, innerIdx)));
+            collectCodes(components, toText(getValue(row, outerIdx)));
+            collectCodes(components, toText(getValue(row, accessoryIdx)));
+
+            List<Map<String, Object>> componentList = new ArrayList<>();
+            List<Integer> capacities = new ArrayList<>();
+            for (String code : components) {
+                int available = (int) Math.round(purchaseByCode.getOrDefault(code, 0.0));
+                Map<String, Object> comp = new LinkedHashMap<>();
+                comp.put("code", code);
+                comp.put("available", available);
+                componentList.add(comp);
+                capacities.add(available);
+            }
+
+            int capacity = capacities.isEmpty() ? 0 : capacities.stream().min(Integer::compareTo).orElse(0);
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("product_code", productCode);
+            result.put("product_name", productCode);
+            result.put("components", componentList);
+            result.put("capacity", capacity);
+            results.add(result);
+        }
+        return results;
+    }
+
+    private Set<String> collectSemiCodes(List<List<Object>> rows, List<String> headers) {
+        if (rows == null || rows.isEmpty()) {
+            return Collections.emptySet();
+        }
+        int innerIdx = findHeaderIndex(headers, new String[]{"内芯", "内胆"}, 2);
+        int outerIdx = findHeaderIndex(headers, new String[]{"外壳", "外套"}, 3);
+        int accessoryIdx = findHeaderIndex(headers, new String[]{"配件", "附件"}, 4);
+
+        Set<String> codes = new java.util.HashSet<>();
+        for (List<Object> row : rows) {
+            collectCodes(codes, toText(getValue(row, innerIdx)));
+            collectCodes(codes, toText(getValue(row, outerIdx)));
+            collectCodes(codes, toText(getValue(row, accessoryIdx)));
+        }
+        return codes;
+    }
+
+    private void collectCodes(Set<String> target, String raw) {
+        if (raw == null || raw.trim().isEmpty()) {
+            return;
+        }
+        String normalized = raw.replace(',', ',');
+        for (String part : normalized.split(",")) {
+            String code = part.trim();
+            if (!code.isEmpty()) {
+                target.add(code);
+            }
+        }
+    }
+
+    private void collectCodes(List<String> target, String raw) {
+        if (raw == null || raw.trim().isEmpty()) {
+            return;
+        }
+        String normalized = raw.replace(',', ',');
+        for (String part : normalized.split(",")) {
+            String code = part.trim();
+            if (!code.isEmpty()) {
+                target.add(code);
+            }
+        }
+    }
+
+    private int findHeaderIndex(List<String> headers, String[] keywords, int fallback) {
+        if (headers == null || headers.isEmpty()) {
+            return fallback;
+        }
+        for (int i = 0; i < headers.size(); i++) {
+            String header = headers.get(i) == null ? "" : headers.get(i).trim();
+            if (header.isEmpty()) {
+                continue;
+            }
+            for (String keyword : keywords) {
+                if (keyword != null && !keyword.isEmpty() && header.contains(keyword)) {
+                    return i;
+                }
+            }
+        }
+        return fallback < headers.size() ? fallback : -1;
+    }
+
+    private Object getValue(List<Object> row, int index) {
+        if (row == null || index < 0 || index >= row.size()) {
+            return null;
+        }
+        return row.get(index);
+    }
+
+    private String toText(Object value) {
+        if (value == null) {
+            return "";
+        }
+        return String.valueOf(value).trim();
+    }
+
+    private Map<String, Object> buildTurnover(String name, int avgTurnover, int usageRate, int monthlyConsumption,
+                                              int reorderPoint, String stockStatus, String suggestion) {
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("name", name);
+        row.put("avgTurnover", avgTurnover);
+        row.put("usageRate", usageRate);
+        row.put("monthlyConsumption", monthlyConsumption);
+        row.put("reorderPoint", reorderPoint);
+        row.put("stockStatus", stockStatus);
+        row.put("suggestion", suggestion);
+        return row;
+    }
+
+    private Map<String, Object> buildBomItem(String name, int quantity, int stock) {
+        Map<String, Object> item = new LinkedHashMap<>();
+        item.put("name", name);
+        item.put("quantity", quantity);
+        item.put("stock", stock);
+        return item;
+    }
+
+    private List<Map<String, Object>> sampleSemiProducts() {
+        List<Map<String, Object>> data = new ArrayList<>();
+        data.add(buildSample("SEM-101", "电路板 PCB-A", "pcb", 3200, 2000, "in-stock",
+                java.util.Arrays.asList("智能手表 Pro X1", "智能手环"), "深圳电子有限公司", 15, 45.8, 146560));
+        data.add(buildSample("SEM-102", "显示屏模块", "electronics", 2800, 1500, "in-stock",
+                java.util.Arrays.asList("智能手表 Pro X1"), "京东方科技", 20, 128.5, 359800));
+        return data;
+    }
+
+    private Map<String, Object> buildSample(String code, String name, String category, int quantity, int safeStock,
+                                            String status, List<String> usedFor, String supplier, int leadTime,
+                                            double unitPrice, double totalValue) {
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("code", code);
+        row.put("name", name);
+        row.put("category", category);
+        row.put("quantity", quantity);
+        row.put("safeStock", safeStock);
+        row.put("status", status);
+        row.put("usedForProducts", usedFor);
+        row.put("supplier", supplier);
+        row.put("leadTime", leadTime);
+        row.put("unitPrice", unitPrice);
+        row.put("totalValue", totalValue);
+        return row;
+    }
+}
+
+

+ 482 - 0
dtm-storage/src/main/java/com/dtm/storage/service/StorageDataLoader.java

@@ -0,0 +1,482 @@
+package com.dtm.storage.service;
+
+import com.dtm.storage.model.AssemblyRecord;
+import com.dtm.storage.model.ProductInfo;
+import com.dtm.storage.model.PurchaseRecord;
+import com.dtm.storage.model.SalesRecord;
+import com.dtm.storage.util.ExcelSheet;
+import com.dtm.storage.util.ExcelUtils;
+import org.apache.poi.ss.usermodel.DateUtil;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.PostConstruct;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+@Service
+public class StorageDataLoader {
+    private static final long CACHE_EXPIRE_MILLIS = 300_000;
+
+    @Value("${storage.data.path:}")
+    private String storageDataPath;
+
+    private Path basePath;
+
+    private final ConcurrentMap<String, CacheEntry<?>> cache = new ConcurrentHashMap<>();
+
+    @PostConstruct
+    public void init() {
+        this.basePath = resolveBasePath();
+        System.out.println("StorageDataLoader basePath: " + (basePath == null ? "<null>" : basePath.toAbsolutePath()));
+        warmUp();
+    }
+
+    private void warmUp() {
+        try {
+            getPurchaseRecords();
+            getSalesRecords();
+            getAssemblyRecords();
+            getProductInfo();
+            getSemiMappingRows();
+        } catch (Exception e) {
+            System.out.println("StorageDataLoader warm-up failed: " + e.getMessage());
+        }
+    }
+
+    public List<PurchaseRecord> getPurchaseRecords() {
+        return getCached("purchase_records", this::loadPurchaseRecords);
+    }
+
+    public List<SalesRecord> getSalesRecords() {
+        return getCached("sales_records", this::loadSalesRecords);
+    }
+
+    public List<AssemblyRecord> getAssemblyRecords() {
+        return getCached("assembly_records", this::loadAssemblyRecords);
+    }
+
+    public List<ProductInfo> getProductInfo() {
+        return getCached("product_info", this::loadProductInfo);
+    }
+
+    public List<List<Object>> getSemiMappingRows() {
+        return getCached("semi_mapping", this::loadSemiMappingRows);
+    }
+
+    public List<String> getSemiMappingHeaders() {
+        ExcelSheet sheet = getCached("semi_mapping_sheet", this::loadSemiMappingSheet);
+        return sheet == null ? Collections.emptyList() : sheet.getHeaders();
+    }
+
+    public void clearCache() {
+        cache.clear();
+    }
+
+    private List<PurchaseRecord> loadPurchaseRecords() {
+        ExcelSheet sheet = loadPurchaseSheet();
+        if (sheet.getRows().isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        List<String> headers = sheet.getHeaders();
+        int dateIdx = findHeaderIndex(headers, new String[]{"业务日期", "日期"}, 0);
+        int codeIdx = findHeaderIndex(headers, new String[]{"产品代码", "产品编码", "编码", "代码"}, 1);
+        int qtyIdx = findHeaderIndex(headers, new String[]{"数量", "入库数量"}, 2);
+        int amountIdx = findHeaderIndex(headers, new String[]{"实际金额", "金额"}, 3);
+
+        List<PurchaseRecord> records = new ArrayList<>();
+        for (List<Object> row : sheet.getRows()) {
+            String code = toText(getValue(row, codeIdx)).trim();
+            if (code.isEmpty()) {
+                continue;
+            }
+            LocalDate date = toLocalDate(getValue(row, dateIdx));
+            double qty = toDouble(getValue(row, qtyIdx));
+            double amount = toDouble(getValue(row, amountIdx));
+            records.add(new PurchaseRecord(code, date, qty, amount));
+        }
+        return records;
+    }
+
+    private List<SalesRecord> loadSalesRecords() {
+        ExcelSheet sheet = loadSalesSheet();
+        if (sheet.getRows().isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<String> headers = sheet.getHeaders();
+        int codeIdx = findHeaderIndex(headers, new String[]{"商家编码", "产品编码", "产品代码", "编码"}, 0);
+        int qtyIdx = findHeaderIndex(headers, new String[]{"购买数量", "数量"}, 1);
+        int dateIdx = findHeaderIndex(headers, new String[]{"订单创建时间", "创建时间", "日期"}, 2);
+
+        List<SalesRecord> records = new ArrayList<>();
+        for (List<Object> row : sheet.getRows()) {
+            String code = toText(getValue(row, codeIdx)).trim();
+            if (code.isEmpty()) {
+                continue;
+            }
+            LocalDate date = toLocalDate(getValue(row, dateIdx));
+            double qty = toDouble(getValue(row, qtyIdx));
+            records.add(new SalesRecord(code, date, qty));
+        }
+        return records;
+    }
+
+    private List<AssemblyRecord> loadAssemblyRecords() {
+        ExcelSheet sheet = loadAssemblySheet();
+        if (sheet.getRows().isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<String> headers = sheet.getHeaders();
+        int codeIdx = findHeaderIndex(headers, new String[]{"产品编码", "产品代码", "半成品"}, 0);
+        int qtyIdx = findHeaderIndex(headers, new String[]{"数量", "入库数量"}, 1);
+        int dateIdx = findHeaderIndex(headers, new String[]{"日期", "时间"}, 2);
+
+        List<AssemblyRecord> records = new ArrayList<>();
+        for (List<Object> row : sheet.getRows()) {
+            String code = toText(getValue(row, codeIdx)).trim();
+            if (code.isEmpty()) {
+                continue;
+            }
+            LocalDate date = toLocalDate(getValue(row, dateIdx));
+            double qty = toDouble(getValue(row, qtyIdx));
+            if (qty <= 0) {
+                continue;
+            }
+            records.add(new AssemblyRecord(code, date, qty));
+        }
+        return records;
+    }
+
+    private List<ProductInfo> loadProductInfo() {
+        ExcelSheet sheet = loadProductInfoSheet();
+        if (sheet.getRows().isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<String> headers = sheet.getHeaders();
+        int codeIdx = findHeaderIndex(headers, new String[]{"产品代码", "产品编码", "编码", "代码"}, 0);
+        int nameIdx = findHeaderIndex(headers, new String[]{"产品名称", "名称"}, 1);
+        int categoryIdx = findHeaderIndex(headers, new String[]{"分类", "成品", "半成品", "辅料"}, 4);
+        int attributeIdx = findHeaderIndex(headers, new String[]{"属性", "等级", "ABC"}, 10);
+        int spuIdx = findHeaderIndex(headers, new String[]{"SPU"}, 5);
+        int priceIdx = findHeaderIndex(headers, new String[]{"价格", "单价"}, 6);
+
+        List<ProductInfo> infos = new ArrayList<>();
+        for (List<Object> row : sheet.getRows()) {
+            String code = toText(getValue(row, codeIdx)).trim();
+            if (code.isEmpty()) {
+                continue;
+            }
+            String name = toText(getValue(row, nameIdx)).trim();
+            String category = toText(getValue(row, categoryIdx)).trim();
+            String attribute = toText(getValue(row, attributeIdx)).trim();
+            String spu = toText(getValue(row, spuIdx)).trim();
+            Double price = null;
+            Object priceObj = getValue(row, priceIdx);
+            if (priceObj != null) {
+                double parsed = toDouble(priceObj);
+                if (parsed > 0) {
+                    price = parsed;
+                }
+            }
+            infos.add(new ProductInfo(code, name, category, attribute, spu, price));
+        }
+        return infos;
+    }
+
+    private List<List<Object>> loadSemiMappingRows() {
+        ExcelSheet sheet = loadSemiMappingSheet();
+        if (sheet.getRows().isEmpty()) {
+            return Collections.emptyList();
+        }
+        return sheet.getRows();
+    }
+
+    private ExcelSheet loadSemiMappingSheet() {
+        Path file = findSemiMappingFile();
+        if (file == null) {
+            return new ExcelSheet(Collections.emptyList(), Collections.emptyList());
+        }
+        return ExcelUtils.readSheet(file, 0);
+    }
+
+    private ExcelSheet loadPurchaseSheet() {
+        Path file = findPurchaseFile();
+        if (file == null) {
+            return new ExcelSheet(Collections.emptyList(), Collections.emptyList());
+        }
+        return ExcelUtils.readSheet(file, 0);
+    }
+
+    private ExcelSheet loadSalesSheet() {
+        Path file = findSalesFile();
+        if (file == null) {
+            return new ExcelSheet(Collections.emptyList(), Collections.emptyList());
+        }
+        return ExcelUtils.readSheet(file, 0);
+    }
+
+    private ExcelSheet loadAssemblySheet() {
+        Path file = findAssemblyFile();
+        if (file == null) {
+            return new ExcelSheet(Collections.emptyList(), Collections.emptyList());
+        }
+        return ExcelUtils.readSheet(file, 1);
+    }
+
+    private ExcelSheet loadProductInfoSheet() {
+        Path file = findProductInfoFile();
+        if (file == null) {
+            return new ExcelSheet(Collections.emptyList(), Collections.emptyList());
+        }
+        return ExcelUtils.readSheet(file, 0);
+    }
+
+    private Path resolveBasePath() {
+        String envPath = Optional.ofNullable(System.getenv("DTM_DATA_PATH"))
+                .filter(v -> !v.trim().isEmpty())
+                .orElseGet(() -> Optional.ofNullable(System.getenv("INVENTORY_DATA_PATH")).orElse(""));
+        if (!envPath.trim().isEmpty()) {
+            Path candidate = Paths.get(envPath.trim());
+            if (Files.exists(candidate)) {
+                return candidate;
+            }
+        }
+
+        if (storageDataPath != null && !storageDataPath.trim().isEmpty()) {
+            Path candidate = Paths.get(storageDataPath.trim());
+            if (Files.exists(candidate)) {
+                return candidate;
+            }
+        }
+
+        Path direct = Paths.get(System.getProperty("user.dir"), "dtm-storage", "data");
+        if (Files.exists(direct)) {
+            return direct;
+        }
+
+        Path parent = Paths.get(System.getProperty("user.dir"), "..", "dtm-storage", "data").normalize();
+        if (Files.exists(parent)) {
+            return parent;
+        }
+
+        Path fallback = Paths.get(System.getProperty("user.dir"), "data");
+        if (Files.exists(fallback)) {
+            return fallback;
+        }
+
+        return direct;
+    }
+
+    private Path findPurchaseFile() {
+        Path dir = resolveDir("入库数据");
+        return findFile(dir, "入库", "采购");
+    }
+
+    private Path findSalesFile() {
+        Path dir = resolveDir("销售数据");
+        return findFile(dir, "订单", "销售");
+    }
+
+    private Path findAssemblyFile() {
+        Path dir = resolveDir("半成品组装");
+        return findFile(dir, "组装");
+    }
+
+    private Path findSemiMappingFile() {
+        Path dir = resolveDir("半成品组装");
+        return findFile(dir, "匹配");
+    }
+
+    private Path findProductInfoFile() {
+        Path dir = resolveDir("入库数据");
+        return findFile(dir, "产品", "资料");
+    }
+
+    private Path resolveDir(String dirName) {
+        if (basePath == null) {
+            return null;
+        }
+        Path direct = basePath.resolve(dirName);
+        if (Files.exists(direct)) {
+            return direct;
+        }
+        return basePath;
+    }
+
+    private Path findFile(Path dir, String... keywords) {
+        if (dir == null || !Files.exists(dir)) {
+            return null;
+        }
+        try (Stream<Path> stream = Files.list(dir)) {
+            List<Path> candidates = stream
+                    .filter(Files::isRegularFile)
+                    .filter(p -> p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".xlsx"))
+                    .sorted(Comparator.comparing(Path::getFileName))
+                    .collect(Collectors.toList());
+
+            for (Path candidate : candidates) {
+                String name = candidate.getFileName().toString();
+                if (keywords == null || keywords.length == 0) {
+                    return candidate;
+                }
+                for (String keyword : keywords) {
+                    if (keyword == null || keyword.isEmpty()) {
+                        continue;
+                    }
+                    if (name.contains(keyword)) {
+                        return candidate;
+                    }
+                }
+            }
+
+            return candidates.isEmpty() ? null : candidates.get(0);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private int findHeaderIndex(List<String> headers, String[] keywords, int fallback) {
+        if (headers == null || headers.isEmpty()) {
+            return fallback;
+        }
+        for (int i = 0; i < headers.size(); i++) {
+            String header = headers.get(i) == null ? "" : headers.get(i).trim();
+            if (header.isEmpty()) {
+                continue;
+            }
+            for (String keyword : keywords) {
+                if (keyword != null && !keyword.isEmpty() && header.contains(keyword)) {
+                    return i;
+                }
+            }
+        }
+        return fallback < headers.size() ? fallback : -1;
+    }
+
+    private Object getValue(List<Object> row, int index) {
+        if (row == null || index < 0 || index >= row.size()) {
+            return null;
+        }
+        return row.get(index);
+    }
+
+    private String toText(Object value) {
+        if (value == null) {
+            return "";
+        }
+        if (value instanceof String) {
+            return ((String) value).trim();
+        }
+        if (value instanceof Date) {
+            Instant instant = ((Date) value).toInstant();
+            return DateTimeFormatter.ofPattern("yyyy-MM-dd").format(LocalDateTime.ofInstant(instant, ZoneId.systemDefault()));
+        }
+        return String.valueOf(value).trim();
+    }
+
+    private double toDouble(Object value) {
+        if (value == null) {
+            return 0.0;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).doubleValue();
+        }
+        if (value instanceof Date) {
+            return ((Date) value).getTime();
+        }
+        try {
+            String text = toText(value);
+            if (text.isEmpty()) {
+                return 0.0;
+            }
+            return Double.parseDouble(text.replace(",", ""));
+        } catch (Exception e) {
+            return 0.0;
+        }
+    }
+
+    private LocalDate toLocalDate(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof Date) {
+            return Instant.ofEpochMilli(((Date) value).getTime()).atZone(ZoneId.systemDefault()).toLocalDate();
+        }
+        if (value instanceof Number) {
+            double numeric = ((Number) value).doubleValue();
+            if (DateUtil.isValidExcelDate(numeric)) {
+                Date date = DateUtil.getJavaDate(numeric);
+                return Instant.ofEpochMilli(date.getTime()).atZone(ZoneId.systemDefault()).toLocalDate();
+            }
+        }
+        String text = toText(value);
+        if (text.isEmpty()) {
+            return null;
+        }
+        text = text.replace('/', '-').replace('.', '-');
+        List<String> patterns = new ArrayList<>();
+        patterns.add("yyyy-M-d");
+        patterns.add("yyyy-MM-dd");
+        patterns.add("yyyy-M-d HH:mm:ss");
+        patterns.add("yyyy-MM-dd HH:mm:ss");
+        patterns.add("yyyy-M-d HH:mm");
+        patterns.add("yyyy-MM-dd HH:mm");
+        for (String pattern : patterns) {
+            try {
+                DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern);
+                if (pattern.contains("H")) {
+                    return LocalDateTime.parse(text, formatter).toLocalDate();
+                }
+                return LocalDate.parse(text, formatter);
+            } catch (DateTimeParseException ignored) {
+            }
+        }
+        return null;
+    }
+
+    @SuppressWarnings("unchecked")
+    private <T> T getCached(String key, Supplier<T> supplier) {
+        CacheEntry<T> entry = (CacheEntry<T>) cache.get(key);
+        long now = System.currentTimeMillis();
+        if (entry != null && now - entry.timestamp < CACHE_EXPIRE_MILLIS) {
+            return entry.value;
+        }
+        T value = supplier.get();
+        cache.put(key, new CacheEntry<>(value, now));
+        return value;
+    }
+
+    private static class CacheEntry<T> {
+        private final T value;
+        private final long timestamp;
+
+        private CacheEntry(T value, long timestamp) {
+            this.value = value;
+            this.timestamp = timestamp;
+        }
+    }
+}
+
+
+

+ 23 - 0
dtm-storage/src/main/java/com/dtm/storage/util/ExcelSheet.java

@@ -0,0 +1,23 @@
+package com.dtm.storage.util;
+
+import java.util.List;
+
+public class ExcelSheet {
+    private final List<String> headers;
+    private final List<List<Object>> rows;
+
+    public ExcelSheet(List<String> headers, List<List<Object>> rows) {
+        this.headers = headers;
+        this.rows = rows;
+    }
+
+    public List<String> getHeaders() {
+        return headers;
+    }
+
+    public List<List<Object>> getRows() {
+        return rows;
+    }
+}
+
+

+ 113 - 0
dtm-storage/src/main/java/com/dtm/storage/util/ExcelUtils.java

@@ -0,0 +1,113 @@
+package com.dtm.storage.util;
+
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellType;
+import org.apache.poi.ss.usermodel.DataFormatter;
+import org.apache.poi.ss.usermodel.DateUtil;
+import org.apache.poi.ss.usermodel.FormulaEvaluator;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.apache.poi.ss.usermodel.WorkbookFactory;
+
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+public final class ExcelUtils {
+    private ExcelUtils() {
+    }
+
+    public static ExcelSheet readSheet(Path filePath, int headerRowIndex) {
+        if (filePath == null || !Files.exists(filePath)) {
+            return new ExcelSheet(new ArrayList<>(), new ArrayList<>());
+        }
+
+        List<String> headers = new ArrayList<>();
+        List<List<Object>> rows = new ArrayList<>();
+        DataFormatter formatter = new DataFormatter();
+
+        try (InputStream inputStream = Files.newInputStream(filePath);
+             Workbook workbook = WorkbookFactory.create(inputStream)) {
+            Sheet sheet = workbook.getNumberOfSheets() > 0 ? workbook.getSheetAt(0) : null;
+            if (sheet == null) {
+                return new ExcelSheet(headers, rows);
+            }
+
+            FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
+            Row headerRow = sheet.getRow(headerRowIndex);
+            if (headerRow == null) {
+                return new ExcelSheet(headers, rows);
+            }
+
+            int maxCol = headerRow.getLastCellNum();
+            if (maxCol < 0) {
+                return new ExcelSheet(headers, rows);
+            }
+
+            for (int c = 0; c < maxCol; c++) {
+                Cell cell = headerRow.getCell(c);
+                String raw = formatter.formatCellValue(cell, evaluator);
+                headers.add(raw == null ? "" : raw.trim());
+            }
+
+            for (int r = headerRowIndex + 1; r <= sheet.getLastRowNum(); r++) {
+                Row row = sheet.getRow(r);
+                if (row == null) {
+                    continue;
+                }
+                List<Object> values = new ArrayList<>(maxCol);
+                boolean hasValue = false;
+                for (int c = 0; c < maxCol; c++) {
+                    Cell cell = row.getCell(c);
+                    Object value = readCellValue(cell, formatter, evaluator);
+                    if (value != null && !(value instanceof String && ((String) value).trim().isEmpty())) {
+                        hasValue = true;
+                    }
+                    values.add(value);
+                }
+                if (hasValue) {
+                    rows.add(values);
+                }
+            }
+        } catch (Exception ignored) {
+            return new ExcelSheet(new ArrayList<>(), new ArrayList<>());
+        }
+
+        return new ExcelSheet(headers, rows);
+    }
+
+    private static Object readCellValue(Cell cell, DataFormatter formatter, FormulaEvaluator evaluator) {
+        if (cell == null) {
+            return null;
+        }
+        CellType type = cell.getCellType();
+        if (type == CellType.FORMULA) {
+            type = evaluator.evaluateFormulaCell(cell);
+        }
+        if (type == CellType.NUMERIC) {
+            if (DateUtil.isCellDateFormatted(cell)) {
+                Date date = cell.getDateCellValue();
+                return date == null ? null : new Date(date.getTime());
+            }
+            return cell.getNumericCellValue();
+        }
+        if (type == CellType.STRING) {
+            String value = cell.getStringCellValue();
+            return value == null ? null : value.trim();
+        }
+        if (type == CellType.BOOLEAN) {
+            return cell.getBooleanCellValue();
+        }
+        if (type == CellType.BLANK) {
+            return null;
+        }
+        String value = formatter.formatCellValue(cell, evaluator);
+        return value == null ? null : value.trim();
+    }
+}
+
+

+ 29 - 21
pom.xml

@@ -1,4 +1,4 @@
-<?xml version="1.0" encoding="UTF-8"?>
+<?xml version="1.0" encoding="UTF-8"?>
 <project xmlns="http://maven.apache.org/POM/4.0.0"
          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
@@ -41,7 +41,7 @@
     <dependencyManagement>
         <dependencies>
 
-            <!-- 覆盖SpringFrameworkçš„ä¾�èµ–é…�ç½-->
+            <!-- 覆盖SpringFramework的�赖��-->
             <dependency>
                 <groupId>org.springframework</groupId>
                 <artifactId>spring-framework-bom</artifactId>
@@ -50,7 +50,7 @@
                 <scope>import</scope>
             </dependency>
 
-            <!-- 覆盖SpringSecurityçš„ä¾�èµ–é…�ç½-->
+            <!-- 覆盖SpringSecurity的�赖��-->
             <dependency>
                 <groupId>org.springframework.security</groupId>
                 <artifactId>spring-security-bom</artifactId>
@@ -59,7 +59,7 @@
                 <scope>import</scope>
             </dependency>
 
-            <!-- SpringBootçš„ä¾�èµ–é…�ç½-->
+            <!-- SpringBoot的�赖��-->
             <dependency>
                 <groupId>org.springframework.boot</groupId>
                 <artifactId>spring-boot-dependencies</artifactId>
@@ -68,7 +68,7 @@
                 <scope>import</scope>
             </dependency>
 
-            <!-- 覆盖logbackçš„ä¾�èµ–é…�ç½-->
+            <!-- 覆盖logback的�赖��-->
             <dependency>
                 <groupId>ch.qos.logback</groupId>
                 <artifactId>logback-core</artifactId>
@@ -81,7 +81,7 @@
                 <version>${logback.version}</version>
             </dependency>
 
-            <!-- 覆盖tomcatçš„ä¾�èµ–é…�ç½-->
+            <!-- 覆盖tomcat的�赖��-->
             <dependency>
                 <groupId>org.apache.tomcat.embed</groupId>
                 <artifactId>tomcat-embed-core</artifactId>
@@ -107,7 +107,7 @@
                 <version>${druid.version}</version>
             </dependency>
 
-            <!-- 解�客户端�作系统��览器�-->
+            <!-- 解�客户端�作系统��览器�?-->
             <dependency>
                 <groupId>eu.bitwalker</groupId>
                 <artifactId>UserAgentUtils</artifactId>
@@ -141,7 +141,7 @@
                 </exclusions>
             </dependency>
 
-            <!-- io常用工具�-->
+            <!-- io常用工具�?-->
             <dependency>
                 <groupId>commons-io</groupId>
                 <artifactId>commons-io</artifactId>
@@ -162,21 +162,21 @@
                 <version>${velocity.version}</version>
             </dependency>
 
-            <!-- 阿里JSON解��-->
+            <!-- 阿里JSON解��?-->
             <dependency>
                 <groupId>com.alibaba.fastjson2</groupId>
                 <artifactId>fastjson2</artifactId>
                 <version>${fastjson.version}</version>
             </dependency>
 
-            <!-- Token生æˆ�与解æž-->
+            <!-- Token生�与解�-->
             <dependency>
                 <groupId>io.jsonwebtoken</groupId>
                 <artifactId>jjwt</artifactId>
                 <version>${jwt.version}</version>
             </dependency>
 
-            <!-- 验��-->
+            <!-- 验��?-->
             <dependency>
                 <groupId>pro.fessional</groupId>
                 <artifactId>kaptcha</artifactId>
@@ -218,6 +218,12 @@
                 <version>${ruoyi.version}</version>
             </dependency>
 
+            <dependency>
+                <groupId>com.dtm</groupId>
+                <artifactId>dtm-storage</artifactId>
+                <version>${ruoyi.version}</version>
+            </dependency>
+
             <dependency>
                 <groupId>com.dtm</groupId>
                 <artifactId>dtm-common</artifactId>
@@ -227,15 +233,16 @@
         </dependencies>
     </dependencyManagement>
 
-    <modules>
-        <module>dtm-admin</module>
-        <module>dtm-framework</module>
-        <module>dtm-system</module>
-        <module>dtm-order</module>
-        <module>dtm-quartz</module>
-        <module>dtm-generator</module>
-        <module>dtm-common</module>
-    </modules>
+    <modules>
+        <module>dtm-admin</module>
+        <module>dtm-framework</module>
+        <module>dtm-system</module>
+        <module>dtm-order</module>
+        <module>dtm-storage</module>
+        <module>dtm-quartz</module>
+        <module>dtm-generator</module>
+        <module>dtm-common</module>
+    </modules>
     <packaging>pom</packaging>
 
     <build>
@@ -283,7 +290,8 @@
         </pluginRepository>
     </pluginRepositories>
 
-</project>
+</project>
+
 
 
 

BIN
tmpcheck/BOOT-INF/lib/dtm-storage-3.9.0.jar


BIN
tmpcheck2/BOOT-INF/lib/dtm-storage-3.9.0.jar


BIN
tmpcheck3/BOOT-INF/lib/dtm-storage-3.9.0.jar


BIN
tmpcheck4/BOOT-INF/lib/dtm-storage-3.9.0.jar