Ver Fonte

库存后端提交

Gogs há 3 semanas atrás
pai
commit
01ad77dda7

+ 72 - 9
dtm-admin/src/main/java/com/dtm/web/controller/storage/InventoryController.java

@@ -2,8 +2,10 @@ package com.dtm.web.controller.storage;
 
 import com.dtm.common.annotation.Anonymous;
 import com.dtm.common.core.domain.AjaxResult;
+import com.dtm.common.utils.SecurityUtils;
 import com.dtm.storage.service.InventoryService;
 import com.dtm.storage.service.ProductService;
+import com.dtm.storage.service.WaterlineSettingsService;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
@@ -11,6 +13,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
+import java.util.HashMap;
 import java.util.Map;
 
 @Anonymous
@@ -19,15 +22,20 @@ import java.util.Map;
 public class InventoryController {
     private final InventoryService inventoryService;
     private final ProductService productService;
+    private final WaterlineSettingsService waterlineSettingsService;
 
-    public InventoryController(InventoryService inventoryService, ProductService productService) {
+    public InventoryController(InventoryService inventoryService,
+                               ProductService productService,
+                               WaterlineSettingsService waterlineSettingsService) {
         this.inventoryService = inventoryService;
         this.productService = productService;
+        this.waterlineSettingsService = waterlineSettingsService;
     }
 
     @GetMapping("/overview")
-    public AjaxResult getOverview() {
-        return AjaxResult.success(inventoryService.getOverviewData());
+    public AjaxResult getOverview(@RequestParam(value = "startDate", required = false) String startDate,
+                                  @RequestParam(value = "endDate", required = false) String endDate) {
+        return AjaxResult.success(inventoryService.getOverviewData(startDate, endDate));
     }
 
     @GetMapping("/health-index")
@@ -56,18 +64,44 @@ public class InventoryController {
     }
 
     @GetMapping("/monthly-comparison")
-    public AjaxResult getMonthlyComparison() {
-        return AjaxResult.success(inventoryService.getMonthlyComparisonData());
+    public AjaxResult getMonthlyComparison(@RequestParam(value = "startDate", required = false) String startDate,
+                                           @RequestParam(value = "endDate", required = false) String endDate) {
+        return AjaxResult.success(inventoryService.getMonthlyComparisonData(startDate, endDate));
     }
 
     @GetMapping("/sku-summary")
-    public AjaxResult getSkuSummary() {
-        return AjaxResult.success(inventoryService.getSkuSummaryTable());
+    public AjaxResult getSkuSummary(@RequestParam(value = "startDate", required = false) String startDate,
+                                    @RequestParam(value = "endDate", required = false) String endDate) {
+        return AjaxResult.success(inventoryService.getSkuSummaryTable(startDate, endDate));
     }
 
     @GetMapping("/spu-summary")
-    public AjaxResult getSpuSummary() {
-        return AjaxResult.success(inventoryService.getSpuSummaryTable());
+    public AjaxResult getSpuSummary(@RequestParam(value = "startDate", required = false) String startDate,
+                                    @RequestParam(value = "endDate", required = false) String endDate) {
+        return AjaxResult.success(inventoryService.getSpuSummaryTable(startDate, endDate));
+    }
+
+    @GetMapping("/low-salable-days")
+    public AjaxResult getLowSalableDays(@RequestParam(value = "startDate", required = false) String startDate,
+                                        @RequestParam(value = "endDate", required = false) String endDate,
+                                        @RequestParam(value = "page", required = false) Integer page,
+                                        @RequestParam(value = "pageSize", required = false) Integer pageSize) {
+        if (page != null || pageSize != null) {
+            return AjaxResult.success(inventoryService.getLowSalableDaysPage(startDate, endDate, page, pageSize));
+        }
+        return AjaxResult.success(inventoryService.getLowSalableDaysList(startDate, endDate));
+    }
+
+    @GetMapping("/category-value-distribution")
+    public AjaxResult getCategoryValueDistribution(@RequestParam(value = "startDate", required = false) String startDate,
+                                                   @RequestParam(value = "endDate", required = false) String endDate) {
+        return AjaxResult.success(inventoryService.getCategoryValueDistribution(startDate, endDate));
+    }
+
+    @GetMapping("/turnover-top10")
+    public AjaxResult getTurnoverTop10(@RequestParam(value = "startDate", required = false) String startDate,
+                                       @RequestParam(value = "endDate", required = false) String endDate) {
+        return AjaxResult.success(inventoryService.getTurnoverTop10(startDate, endDate));
     }
 
     @GetMapping("/settings")
@@ -87,9 +121,38 @@ public class InventoryController {
         return result;
     }
 
+    @GetMapping("/waterline-settings")
+    public AjaxResult getWaterlineSettings() {
+        return AjaxResult.success(waterlineSettingsService.getSettings());
+    }
+
+    @PostMapping("/waterline-settings/type")
+    public AjaxResult saveWaterlineTypeSetting(@RequestBody Map<String, Object> payload) {
+        return AjaxResult.success(waterlineSettingsService.saveTypeSetting(withCurrentUsername(payload)));
+    }
+
+    @PostMapping("/waterline-settings/sku")
+    public AjaxResult saveWaterlineSkuSetting(@RequestBody Map<String, Object> payload) {
+        return AjaxResult.success(waterlineSettingsService.saveSkuSetting(withCurrentUsername(payload)));
+    }
+
     @GetMapping("/product-trend")
     public AjaxResult getProductTrend(@RequestParam("sku") String sku) {
         return AjaxResult.success(productService.getProductTrend(sku));
     }
+
+    private Map<String, Object> withCurrentUsername(Map<String, Object> payload) {
+        Map<String, Object> data = payload == null ? new HashMap<>() : new HashMap<>(payload);
+        data.put("updatedBy", currentUsername());
+        return data;
+    }
+
+    private String currentUsername() {
+        try {
+            return SecurityUtils.getUsername();
+        } catch (Exception ignored) {
+            return "system";
+        }
+    }
 }
 

+ 5 - 1
dtm-admin/src/main/java/com/dtm/web/controller/storage/SemiProductController.java

@@ -72,7 +72,11 @@ public class SemiProductController {
     }
 
     @GetMapping("/assembly-capacity")
-    public AjaxResult getAssemblyCapacity() {
+    public AjaxResult getAssemblyCapacity(@RequestParam(value = "page", required = false) Integer page,
+                                          @RequestParam(value = "pageSize", required = false) Integer pageSize) {
+        if (page != null || pageSize != null) {
+            return AjaxResult.success(semiProductService.getAssemblyCapacityPage(page, pageSize));
+        }
         return AjaxResult.success(semiProductService.getAssemblyCapacity());
     }
 }

+ 5 - 0
dtm-system/pom.xml

@@ -50,6 +50,11 @@
             <artifactId>spring-context</artifactId>
         </dependency>
 
+        <dependency>
+            <groupId>org.springframework</groupId>
+            <artifactId>spring-jdbc</artifactId>
+        </dependency>
+
         <dependency>
             <groupId>org.apache.poi</groupId>
             <artifactId>poi-ooxml</artifactId>

+ 36 - 0
dtm-system/src/main/java/com/dtm/storage/model/InTransitInventory.java

@@ -0,0 +1,36 @@
+package com.dtm.storage.model;
+
+public class InTransitInventory {
+    private final double quantity;
+    private final double qualifiedQuantity;
+
+    public InTransitInventory(double quantity, double qualifiedQuantity) {
+        this.quantity = Math.max(0.0, quantity);
+        this.qualifiedQuantity = Math.max(0.0, Math.min(qualifiedQuantity, this.quantity));
+    }
+
+    public double getQuantity() {
+        return quantity;
+    }
+
+    public double getQualifiedQuantity() {
+        return qualifiedQuantity;
+    }
+
+    public double getSupplierDefectRate() {
+        if (quantity <= 1e-6) {
+            return 0.0;
+        }
+        return Math.max(0.0, Math.min(1.0, 1.0 - qualifiedQuantity / quantity));
+    }
+
+    public InTransitInventory plus(InTransitInventory other) {
+        if (other == null) {
+            return this;
+        }
+        return new InTransitInventory(
+                quantity + other.quantity,
+                qualifiedQuantity + other.qualifiedQuantity
+        );
+    }
+}

+ 472 - 0
dtm-system/src/main/java/com/dtm/storage/repository/StorageDbRepository.java

@@ -0,0 +1,472 @@
+package com.dtm.storage.repository;
+
+import com.dtm.storage.model.AssemblyRecord;
+import com.dtm.storage.model.InTransitInventory;
+import com.dtm.storage.model.ProductInfo;
+import com.dtm.storage.model.PurchaseRecord;
+import com.dtm.storage.model.SalesRecord;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.ResultSet;
+import java.sql.Timestamp;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.stream.Collectors;
+
+@Service
+public class StorageDbRepository {
+    private final JdbcTemplate jdbcTemplate;
+    private final ConcurrentMap<String, Optional<TableMeta>> tableMetaCache = new ConcurrentHashMap<>();
+
+    @Value("${storage.db.max-rows:200000}")
+    private int maxRows;
+
+    public StorageDbRepository(JdbcTemplate jdbcTemplate) {
+        this.jdbcTemplate = jdbcTemplate;
+    }
+
+    public List<ProductInfo> loadProductInfo() {
+        List<ProductInfo> result = new ArrayList<>();
+        result.addAll(loadProductTable("dtm_product", "成品"));
+        result.addAll(loadProductTable("dtm_semi_finished_product", "半成品"));
+        return result;
+    }
+
+    public List<PurchaseRecord> loadPurchaseRecords() {
+        TableMeta meta = getTableMeta("dtm_purchase_receipt").orElse(null);
+        if (meta == null) {
+            return Collections.emptyList();
+        }
+        String codeCol = pick(meta, "product_code", "sku", "code", "item_code", "material_code");
+        String qtyCol = pick(meta, "quantity", "qty", "num", "in_qty", "receipt_qty", "purchase_qty");
+        String amountCol = pick(meta, "actual_amount", "amount", "total_amount", "total_price", "money", "purchase_amount");
+        String unitPriceCol = pick(meta, "unitprice", "unit_price", "price");
+        String dateCol = pick(meta, "receipt_date", "biz_date", "date", "create_time", "receipt_time", "in_time");
+        if (codeCol == null || qtyCol == null) {
+            return Collections.emptyList();
+        }
+
+        String amountExpr;
+        if (amountCol != null && unitPriceCol != null) {
+            amountExpr = "coalesce(" + amountCol + ", (" + qtyCol + " * " + unitPriceCol + "), 0)";
+        } else if (amountCol != null) {
+            amountExpr = amountCol;
+        } else if (unitPriceCol != null) {
+            amountExpr = "(" + qtyCol + " * " + unitPriceCol + ")";
+        } else {
+            amountExpr = "0";
+        }
+
+        String sql = "select " + codeCol + " as code, " + qtyCol + " as qty"
+                + ", " + amountExpr + " as amount"
+                + (dateCol == null ? ", null as dt" : ", " + dateCol + " as dt")
+                + " from dtm_purchase_receipt"
+                + " limit " + maxRows;
+
+        return jdbcTemplate.query(sql, (rs, rowNum) -> {
+            String code = safeText(rs.getObject("code"));
+            if (code.isEmpty()) {
+                return null;
+            }
+            double qty = safeDouble(rs.getObject("qty"));
+            if (qty == 0) {
+                return null;
+            }
+            double amount = safeDouble(rs.getObject("amount"));
+            LocalDate date = toLocalDate(rs.getObject("dt"));
+            return new PurchaseRecord(code, date, qty, amount);
+        }).stream().filter(Objects::nonNull).collect(Collectors.toList());
+    }
+
+    public List<SalesRecord> loadSalesRecords() {
+        TableMeta meta = getTableMeta("dtm_order_main").orElse(null);
+        if (meta == null) {
+            return Collections.emptyList();
+        }
+        String codeCol = pick(meta, "product_code", "sku", "code", "item_code", "goods_code");
+        String qtyCol = pick(meta, "quantity", "qty", "num", "sale_qty", "order_qty");
+        String dateCol = pick(meta, "order_date", "biz_date", "date", "pay_time", "create_time");
+        if (codeCol == null || qtyCol == null) {
+            return Collections.emptyList();
+        }
+
+        String sql = "select " + codeCol + " as code, " + qtyCol + " as qty"
+                + (dateCol == null ? ", null as dt" : ", " + dateCol + " as dt")
+                + " from dtm_order_main"
+                + " limit " + maxRows;
+
+        return jdbcTemplate.query(sql, (rs, rowNum) -> {
+            String code = safeText(rs.getObject("code"));
+            if (code.isEmpty()) {
+                return null;
+            }
+            double qty = safeDouble(rs.getObject("qty"));
+            if (qty == 0) {
+                return null;
+            }
+            LocalDate date = toLocalDate(rs.getObject("dt"));
+            return new SalesRecord(code, date, qty);
+        }).stream().filter(Objects::nonNull).collect(Collectors.toList());
+    }
+
+    public List<AssemblyRecord> loadAssemblyRecords() {
+        TableMeta meta = getTableMeta("dtm_assembly_record").orElse(null);
+        if (meta == null) {
+            return Collections.emptyList();
+        }
+        String codeCol = pick(meta, "product_code", "finished_product_code", "sku", "code");
+        String qtyCol = pick(meta, "quantity", "qty", "num", "assembly_qty");
+        String dateCol = pick(meta, "assembly_date", "biz_date", "date", "create_time");
+        if (codeCol == null || qtyCol == null) {
+            return Collections.emptyList();
+        }
+
+        String sql = "select " + codeCol + " as code, " + qtyCol + " as qty"
+                + (dateCol == null ? ", null as dt" : ", " + dateCol + " as dt")
+                + " from dtm_assembly_record"
+                + " limit " + maxRows;
+
+        return jdbcTemplate.query(sql, (rs, rowNum) -> {
+            String code = safeText(rs.getObject("code"));
+            if (code.isEmpty()) {
+                return null;
+            }
+            double qty = safeDouble(rs.getObject("qty"));
+            if (qty <= 0) {
+                return null;
+            }
+            LocalDate date = toLocalDate(rs.getObject("dt"));
+            return new AssemblyRecord(code, date, qty);
+        }).stream().filter(Objects::nonNull).collect(Collectors.toList());
+    }
+
+    public Map<String, Map<String, Double>> loadBomItems() {
+        TableMeta meta = getTableMeta("dtm_bom_list").orElse(null);
+        if (meta == null) {
+            return Collections.emptyMap();
+        }
+        String finishedCol = pick(meta, "finished_sku", "product_code", "finished_product_code", "sku", "code");
+        String semiCol = pick(meta, "semi_sku", "semi_finished_product_code", "semi_code", "component_code", "material_code", "part_code");
+        String qtyCol = pick(meta, "quantity", "qty", "num", "need_qty", "component_qty", "require_qty");
+        if (finishedCol == null || semiCol == null || qtyCol == null) {
+            return Collections.emptyMap();
+        }
+        String sql = "select " + finishedCol + " as finished, " + semiCol + " as semi, " + qtyCol + " as qty"
+                + " from dtm_bom_list"
+                + " limit " + maxRows;
+
+        Map<String, Map<String, Double>> result = new HashMap<>();
+        jdbcTemplate.query(sql, rs -> {
+            String finished = safeText(rs.getObject("finished"));
+            String semi = safeText(rs.getObject("semi"));
+            if (finished.isEmpty() || semi.isEmpty()) {
+                return;
+            }
+            double qty = safeDouble(rs.getObject("qty"));
+            if (qty <= 0) {
+                return;
+            }
+            result.computeIfAbsent(finished, k -> new HashMap<>())
+                    .merge(semi, qty, Double::sum);
+        });
+        return result;
+    }
+
+    public Map<String, InTransitInventory> loadInTransitInventory() {
+        TableMeta meta = getTableMeta("dtm_cgdd_orderdetail").orElse(null);
+        if (meta == null) {
+            return Collections.emptyMap();
+        }
+        String codeCol = pick(meta,
+                "product_code", "sku", "sku_code", "goods_sku", "goods_code", "item_code",
+                "material_code", "productcode", "product_no", "product_no", "code");
+        String qtyCol = pick(meta,
+                "in_transit_qty", "unreceived_qty", "not_received_qty", "remaining_qty", "remain_qty",
+                "wait_qty", "quantity", "qty", "num", "purchase_qty", "order_qty", "cg_qty");
+        String receivedCol = pick(meta,
+                "received_qty", "receipt_qty", "in_qty", "arrival_qty", "arrived_qty", "stockin_qty",
+                "ruku_qty", "rk_qty");
+        String defectRateCol = pick(meta,
+                "supplier_defect_rate", "supplier_bad_rate", "supplier_unqualified_rate",
+                "history_defect_rate", "history_bad_rate", "historical_defect_rate",
+                "defect_rate", "bad_rate", "unqualified_rate", "reject_rate", "ng_rate", "fail_rate");
+        String qualifiedRateCol = pick(meta,
+                "supplier_qualified_rate", "qualified_rate", "pass_rate", "accept_rate");
+        String statusCol = pick(meta, "status", "order_status", "detail_status", "state", "bill_status");
+        if (codeCol == null || qtyCol == null) {
+            return Collections.emptyMap();
+        }
+
+        String sql = "select " + codeCol + " as code"
+                + ", " + qtyCol + " as qty"
+                + (receivedCol == null ? ", null as received_qty" : ", " + receivedCol + " as received_qty")
+                + (defectRateCol == null ? ", null as defect_rate" : ", " + defectRateCol + " as defect_rate")
+                + (qualifiedRateCol == null ? ", null as qualified_rate" : ", " + qualifiedRateCol + " as qualified_rate")
+                + (statusCol == null ? ", null as status_text" : ", " + statusCol + " as status_text")
+                + " from dtm_cgdd_orderdetail"
+                + " limit " + maxRows;
+
+        Map<String, InTransitInventory> result = new HashMap<>();
+        jdbcTemplate.query(sql, rs -> {
+            String code = safeText(rs.getObject("code"));
+            if (code.isEmpty() || isClosedInTransitStatus(safeText(rs.getObject("status_text")))) {
+                return;
+            }
+            double qty = safeDouble(rs.getObject("qty"));
+            double receivedQty = safeDouble(rs.getObject("received_qty"));
+            double inTransitQty = receivedCol == null ? qty : Math.max(0.0, qty - Math.max(0.0, receivedQty));
+            if (inTransitQty <= 0) {
+                return;
+            }
+            double defectRate = resolveDefectRate(rs.getObject("defect_rate"), rs.getObject("qualified_rate"));
+            InTransitInventory row = new InTransitInventory(inTransitQty, inTransitQty * (1.0 - defectRate));
+            result.merge(code, row, InTransitInventory::plus);
+        });
+        return result;
+    }
+
+    private List<ProductInfo> loadProductTable(String table, String defaultCategory) {
+        TableMeta meta = getTableMeta(table).orElse(null);
+        if (meta == null) {
+            return Collections.emptyList();
+        }
+
+        String codeCol = pick(meta, "product_code", "sku", "code");
+        if (codeCol == null) {
+            return Collections.emptyList();
+        }
+        String nameCol = pick(meta, "product_name", "semi_name", "name", "sku_name");
+        String categoryCol = pick(meta, "category", "product_category", "type");
+        String attributeCol = pick(meta, "attributes", "attribute", "abc", "abc_class", "level");
+        String spuCol = pick(meta, "spu_name", "spu", "spuName", "spu_title");
+        String priceCol = pick(meta, "product_price", "price", "unit_price", "unitprice", "cost", "purchase_price");
+
+        String sql = "select " + codeCol + " as code"
+                + (nameCol == null ? ", null as name" : ", " + nameCol + " as name")
+                + (categoryCol == null ? ", null as category" : ", " + categoryCol + " as category")
+                + (attributeCol == null ? ", null as attribute" : ", " + attributeCol + " as attribute")
+                + (spuCol == null ? ", null as spu" : ", " + spuCol + " as spu")
+                + (priceCol == null ? ", null as price" : ", " + priceCol + " as price")
+                + " from " + table
+                + " limit " + maxRows;
+
+        return jdbcTemplate.query(sql, (rs, rowNum) -> {
+            String code = safeText(rs.getObject("code"));
+            if (code.isEmpty()) {
+                return null;
+            }
+            String name = safeText(rs.getObject("name"));
+            String category = safeText(rs.getObject("category"));
+            if (category.isEmpty() && defaultCategory != null) {
+                category = defaultCategory;
+            }
+            String attribute = safeText(rs.getObject("attribute"));
+            String spu = safeText(rs.getObject("spu"));
+            if (spu.isEmpty()) {
+                spu = name;
+            }
+            Double price = safeDoubleNullable(rs.getObject("price"));
+            return new ProductInfo(code, name, category, attribute, spu, price);
+        }).stream().filter(Objects::nonNull).collect(Collectors.toList());
+    }
+
+    private Optional<TableMeta> getTableMeta(String table) {
+        return tableMetaCache.computeIfAbsent(table.toLowerCase(Locale.ROOT), key -> Optional.ofNullable(loadTableMeta(table)));
+    }
+
+    private TableMeta loadTableMeta(String table) {
+        try {
+            return jdbcTemplate.execute((Connection connection) -> {
+                String catalog = safeText(connection.getCatalog());
+                DatabaseMetaData metaData = connection.getMetaData();
+                Map<String, String> columns = new HashMap<>();
+                try (ResultSet rs = metaData.getColumns(catalog, null, table, null)) {
+                    while (rs.next()) {
+                        String name = rs.getString("COLUMN_NAME");
+                        if (name != null && !name.trim().isEmpty()) {
+                            columns.putIfAbsent(normalize(name), name);
+                        }
+                    }
+                }
+                if (columns.isEmpty()) {
+                    try (ResultSet rs = metaData.getColumns(catalog, null, table.toUpperCase(Locale.ROOT), null)) {
+                        while (rs.next()) {
+                            String name = rs.getString("COLUMN_NAME");
+                            if (name != null && !name.trim().isEmpty()) {
+                                columns.putIfAbsent(normalize(name), name);
+                            }
+                        }
+                    }
+                }
+                return columns.isEmpty() ? null : new TableMeta(table, columns);
+            });
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private String pick(TableMeta meta, String... candidates) {
+        if (meta == null || meta.columns == null || meta.columns.isEmpty() || candidates == null) {
+            return null;
+        }
+        for (String candidate : candidates) {
+            if (candidate == null) {
+                continue;
+            }
+            String sqlName = meta.columns.get(normalize(candidate));
+            if (sqlName != null && !sqlName.trim().isEmpty()) {
+                return sqlName;
+            }
+        }
+        return null;
+    }
+
+    private String normalize(String name) {
+        if (name == null) {
+            return "";
+        }
+        return name.toLowerCase(Locale.ROOT)
+                .replace("_", "")
+                .replace("-", "")
+                .replace(" ", "");
+    }
+
+    private String safeText(Object value) {
+        if (value == null) {
+            return "";
+        }
+        String text = String.valueOf(value).trim();
+        return "null".equalsIgnoreCase(text) ? "" : text;
+    }
+
+    private double safeDouble(Object value) {
+        if (value == null) {
+            return 0.0;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).doubleValue();
+        }
+        String text = safeText(value);
+        if (text.isEmpty()) {
+            return 0.0;
+        }
+        try {
+            return Double.parseDouble(text);
+        } catch (NumberFormatException e) {
+            return 0.0;
+        }
+    }
+
+    private Double safeDoubleNullable(Object value) {
+        if (value == null) {
+            return null;
+        }
+        double numeric = safeDouble(value);
+        return numeric == 0.0 ? null : numeric;
+    }
+
+    private double resolveDefectRate(Object defectRateValue, Object qualifiedRateValue) {
+        double defectRate = safeDouble(defectRateValue);
+        if (defectRate <= 0 && qualifiedRateValue != null) {
+            double qualifiedRate = normalizeRate(safeDouble(qualifiedRateValue));
+            defectRate = qualifiedRate > 0 ? 1.0 - qualifiedRate : 0.0;
+        } else {
+            defectRate = normalizeRate(defectRate);
+        }
+        return Math.max(0.0, Math.min(1.0, defectRate));
+    }
+
+    private double normalizeRate(double rate) {
+        if (rate > 1.0) {
+            return rate / 100.0;
+        }
+        return rate;
+    }
+
+    private boolean isClosedInTransitStatus(String status) {
+        if (status == null || status.trim().isEmpty()) {
+            return false;
+        }
+        String normalized = status.trim().toLowerCase(Locale.ROOT);
+        return normalized.contains("cancel")
+                || normalized.contains("close")
+                || normalized.contains("closed")
+                || normalized.contains("complete")
+                || normalized.contains("finished")
+                || normalized.contains("done")
+                || normalized.contains("\u4f5c\u5e9f")
+                || normalized.contains("\u53d6\u6d88")
+                || normalized.contains("\u5173\u95ed")
+                || normalized.contains("\u5b8c\u6210")
+                || normalized.contains("\u5df2\u5165\u5e93")
+                || normalized.contains("\u5168\u90e8\u5165\u5e93");
+    }
+
+    private LocalDate toLocalDate(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof java.sql.Date) {
+            return ((java.sql.Date) value).toLocalDate();
+        }
+        if (value instanceof Timestamp) {
+            return ((Timestamp) value).toLocalDateTime().toLocalDate();
+        }
+        if (value instanceof LocalDate) {
+            return (LocalDate) value;
+        }
+        if (value instanceof LocalDateTime) {
+            return ((LocalDateTime) value).toLocalDate();
+        }
+        String text = safeText(value);
+        if (text.isEmpty()) {
+            return null;
+        }
+        text = text.replace('/', '-').replace('.', '-');
+        String[] patterns = new String[]{
+                "yyyy-M-d",
+                "yyyy-MM-dd",
+                "yyyy-M-d HH:mm:ss",
+                "yyyy-MM-dd HH:mm:ss",
+                "yyyy-M-d HH:mm",
+                "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;
+    }
+
+    private static class TableMeta {
+        private final String table;
+        private final Map<String, String> columns;
+
+        private TableMeta(String table, Map<String, String> columns) {
+            this.table = table;
+            this.columns = columns;
+        }
+    }
+}

+ 762 - 20
dtm-system/src/main/java/com/dtm/storage/service/InventoryService.java

@@ -2,6 +2,7 @@ package com.dtm.storage.service;
 
 import com.dtm.storage.config.StorageSettings;
 import com.dtm.storage.model.AssemblyRecord;
+import com.dtm.storage.model.InTransitInventory;
 import com.dtm.storage.model.ProductInfo;
 import com.dtm.storage.model.PurchaseRecord;
 import com.dtm.storage.model.SalesRecord;
@@ -9,6 +10,7 @@ import org.springframework.stereotype.Service;
 
 import java.time.LocalDate;
 import java.time.YearMonth;
+import java.time.temporal.ChronoUnit;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
@@ -36,6 +38,10 @@ public class InventoryService {
         return ensureSnapshot().overview;
     }
 
+    public Map<String, Object> getOverviewData(String startDate, String endDate) {
+        return getSnapshotForRange(parseDate(startDate), parseDate(endDate)).overview;
+    }
+
     public Map<String, Object> getHealthIndex() {
         Map<String, Object> result = new LinkedHashMap<>();
         result.put("value", 78);
@@ -83,14 +89,57 @@ public class InventoryService {
         return ensureSnapshot().monthlyComparison;
     }
 
+    public Map<String, Object> getMonthlyComparisonData(String startDate, String endDate) {
+        return getSnapshotForRange(parseDate(startDate), parseDate(endDate)).monthlyComparison;
+    }
+
     public List<Map<String, Object>> getSkuSummaryTable() {
         return ensureSnapshot().skuSummary;
     }
 
+    public List<Map<String, Object>> getSkuSummaryTable(String startDate, String endDate) {
+        return getSnapshotForRange(parseDate(startDate), parseDate(endDate)).skuSummary;
+    }
+
     public List<Map<String, Object>> getSpuSummaryTable() {
         return ensureSnapshot().spuSummary;
     }
 
+    public List<Map<String, Object>> getSpuSummaryTable(String startDate, String endDate) {
+        return getSnapshotForRange(parseDate(startDate), parseDate(endDate)).spuSummary;
+    }
+
+    public List<Map<String, Object>> getLowSalableDaysList(String startDate, String endDate) {
+        return getSnapshotForRange(parseDate(startDate), parseDate(endDate)).lowSalableDays;
+    }
+
+    public Map<String, Object> getLowSalableDaysPage(String startDate, String endDate, Integer page, Integer pageSize) {
+        return paginate(getLowSalableDaysList(startDate, endDate), page, pageSize);
+    }
+
+    public List<Map<String, Object>> getCategoryValueDistribution(String startDate, String endDate) {
+        return getSnapshotForRange(parseDate(startDate), parseDate(endDate)).categoryValueDistribution;
+    }
+
+    public List<Map<String, Object>> getTurnoverTop10(String startDate, String endDate) {
+        return getSnapshotForRange(parseDate(startDate), parseDate(endDate)).skuSummary.stream()
+                .filter(row -> ((Number) row.getOrDefault("turnoverRate", 0)).doubleValue() > 0)
+                .sorted(Comparator.comparing((Map<String, Object> row) ->
+                        ((Number) row.getOrDefault("turnoverRate", 0)).doubleValue()).reversed())
+                .limit(10)
+                .map(row -> {
+                    Map<String, Object> item = new LinkedHashMap<>();
+                    item.put("sku", row.getOrDefault("sku", ""));
+                    item.put("productName", row.getOrDefault("productName", ""));
+                    item.put("spuName", row.getOrDefault("spuName", ""));
+                    item.put("turnoverRate", row.getOrDefault("turnoverRate", 0));
+                    item.put("salesQty", row.getOrDefault("salesQty", 0));
+                    item.put("inventory", row.getOrDefault("inventory", 0));
+                    return item;
+                })
+                .collect(Collectors.toList());
+    }
+
     public List<Map<String, Object>> getMonthlyTurnoverTable() {
         return Collections.emptyList();
     }
@@ -133,13 +182,40 @@ public class InventoryService {
         return current;
     }
 
+    private InventorySnapshot getSnapshotForRange(LocalDate startDate, LocalDate endDate) {
+        if (startDate == null && endDate == null) {
+            return ensureSnapshot();
+        }
+        return buildSnapshot(startDate, endDate);
+    }
+
     private InventorySnapshot buildSnapshot() {
+        return buildSnapshot(null, null);
+    }
+
+    private InventorySnapshot buildSnapshot(LocalDate startDate, LocalDate endDate) {
         List<PurchaseRecord> purchaseRecords = dataLoader.getPurchaseRecords();
         List<SalesRecord> salesRecords = dataLoader.getSalesRecords();
+        List<AssemblyRecord> assemblyRecords = dataLoader.getAssemblyRecords();
+        Map<String, InTransitInventory> inTransitBySku = dataLoader.getInTransitInventoryBySku();
+        boolean filtered = startDate != null || endDate != null;
+        if (filtered) {
+            purchaseRecords = purchaseRecords.stream()
+                    .filter(record -> record != null && isDateInRange(record.getDate(), startDate, endDate))
+                    .collect(Collectors.toList());
+            salesRecords = salesRecords.stream()
+                    .filter(record -> record != null && isDateInRange(record.getDate(), startDate, endDate))
+                    .collect(Collectors.toList());
+            assemblyRecords = assemblyRecords.stream()
+                    .filter(record -> record != null && isDateInRange(record.getDate(), startDate, endDate))
+                    .collect(Collectors.toList());
+        }
         List<ProductInfo> productInfos = dataLoader.getProductInfo();
         Map<String, ProductInfo> productInfoMap = productInfos.stream()
                 .filter(info -> info.getProductCode() != null && !info.getProductCode().trim().isEmpty())
                 .collect(Collectors.toMap(ProductInfo::getProductCode, info -> info, (a, b) -> a));
+        Map<String, Map<String, Double>> bomItems = dataLoader.getBomItems();
+        Set<String> semiSkus = getSemiSkus(productInfos, bomItems);
         Set<String> finishedSkus = getFinishedSkus(productInfos);
 
         Map<String, double[]> purchaseSummary = new HashMap<>();
@@ -158,6 +234,9 @@ public class InventoryService {
             if (code == null || code.trim().isEmpty()) {
                 continue;
             }
+            if (!semiSkus.contains(code)) {
+                finishedSkus.add(code);
+            }
             double[] agg = purchaseSummary.computeIfAbsent(code, key -> new double[2]);
             agg[0] += record.getQuantity();
             agg[1] += record.getAmount();
@@ -180,6 +259,9 @@ public class InventoryService {
             if (code == null || code.trim().isEmpty()) {
                 continue;
             }
+            if (!semiSkus.contains(code)) {
+                finishedSkus.add(code);
+            }
             salesSummary.merge(code, record.getQuantity(), Double::sum);
             if (shouldIncludeSku(code, finishedSkus)) {
                 totalSalesQtyRaw += record.getQuantity();
@@ -193,40 +275,72 @@ public class InventoryService {
 
         int totalPurchaseQty = (int) Math.round(totalPurchaseQtyRaw);
         int totalSalesQty = (int) Math.round(totalSalesQtyRaw);
-        AssemblyAggregation assemblyAggregation = buildAssemblyAggregation(finishedSkus);
+        AssemblyAggregation assemblyAggregation = buildAssemblyAggregation(finishedSkus, assemblyRecords);
         int assemblyQty = assemblyAggregation.totalQty;
-        int totalInventory = totalPurchaseQty + assemblyQty - totalSalesQty;
-        double totalValue = Math.round((totalPurchaseAmountRaw / 10000.0) * 100.0) / 100.0;
+        Totals totals = computeTotals(purchaseSummary, salesSummary, productInfoMap, assemblyAggregation, finishedSkus);
+        int totalInventory = (int) Math.round(totals.totalInventory);
+        double totalValue = Math.round((totals.totalValue / 10000.0) * 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;
+        double stockSalesRatio = totalSalesQty > 0 ? totalInventory / (double) totalSalesQty : 0.0;
+        stockSalesRatio = Math.round(stockSalesRatio * 100.0) / 100.0;
 
+        Map<String, Object> monthlyComparison = buildMonthlyComparison(purchaseMonthly, salesMonthly, assemblyAggregation.monthly);
+        Map<String, Integer> assemblyCapacityBySku = buildAssemblyCapacityBySku(purchaseSummary, assemblyAggregation);
+        int analysisDays = resolveAnalysisDays(salesRecords, startDate, endDate);
+        List<Map<String, Object>> skuSummary = buildSkuSummary(
+                purchaseSummary,
+                salesSummary,
+                productInfoMap,
+                assemblyAggregation.outputBySku,
+                assemblyAggregation.consumeBySku,
+                assemblyCapacityBySku,
+                inTransitBySku,
+                finishedSkus,
+                analysisDays
+        );
+        Map<String, Object> waterlineStats = buildWaterlineStats(skuSummary);
         Map<String, Object> overview = new LinkedHashMap<>();
         overview.put("totalInventory", totalInventory);
         overview.put("totalValue", totalValue);
         overview.put("turnoverRate", turnoverRate);
-        overview.put("inTransitRatio", 12.5);
+        overview.put("stockSalesRatio", stockSalesRatio);
+        overview.put("inTransitQty", (int) Math.round(totalInTransit(inTransitBySku, finishedSkus)));
+        overview.put("qualifiedInTransitQty", (int) Math.round(totalQualifiedInTransit(inTransitBySku, finishedSkus)));
+        overview.put("inTransitRatio", roundInTransitRatio(totalInventory, inTransitBySku, finishedSkus));
         overview.put("purchaseQty", totalPurchaseQty);
         overview.put("salesQty", totalSalesQty);
         overview.put("assemblyQty", assemblyQty);
+        overview.putAll(waterlineStats);
 
-        Map<String, Object> monthlyComparison = buildMonthlyComparison(purchaseMonthly, salesMonthly, assemblyAggregation.monthly);
-        List<Map<String, Object>> skuSummary = buildSkuSummary(
-                purchaseSummary,
-                salesSummary,
-                productInfoMap,
-                assemblyAggregation.outputBySku,
-                assemblyAggregation.consumeBySku
-        );
         List<Map<String, Object>> spuSummary = buildSpuSummary(
                 finishedPurchaseSummary,
                 finishedSalesSummary,
                 productInfoMap,
                 assemblyAggregation.outputBySku
         );
+        List<Map<String, Object>> lowSalableDays = buildLowSalableDaysList(
+                purchaseSummary,
+                salesSummary,
+                productInfoMap,
+                assemblyAggregation,
+                finishedSkus,
+                assemblyCapacityBySku,
+                inTransitBySku,
+                analysisDays
+        );
+        List<Map<String, Object>> categoryValueDistribution = buildCategoryValueDistribution(
+                purchaseSummary,
+                salesSummary,
+                productInfoMap,
+                assemblyAggregation,
+                finishedSkus,
+                semiSkus
+        );
 
         dataLoader.clearCache();
-        return new InventorySnapshot(overview, monthlyComparison, skuSummary, spuSummary);
+        return new InventorySnapshot(overview, monthlyComparison, skuSummary, spuSummary, lowSalableDays, categoryValueDistribution);
     }
 
     private Map<String, Object> buildMonthlyComparison(Map<YearMonth, Double> purchaseMonthly,
@@ -271,31 +385,55 @@ public class InventoryService {
                                                       Map<String, Double> salesSummary,
                                                       Map<String, ProductInfo> productInfoMap,
                                                       Map<String, Double> assemblyOutputBySku,
-                                                      Map<String, Double> assemblyConsumeBySku) {
+                                                      Map<String, Double> assemblyConsumeBySku,
+                                                      Map<String, Integer> assemblyCapacityBySku,
+                                                      Map<String, InTransitInventory> inTransitBySku,
+                                                      Set<String> finishedSkus,
+                                                      int analysisDays) {
         double totalAmount = purchaseSummary.values().stream().mapToDouble(v -> v[1]).sum();
         Set<String> allSkus = new HashSet<>();
         allSkus.addAll(purchaseSummary.keySet());
         allSkus.addAll(salesSummary.keySet());
         allSkus.addAll(assemblyOutputBySku.keySet());
         allSkus.addAll(assemblyConsumeBySku.keySet());
+        if (inTransitBySku != null) {
+            allSkus.addAll(inTransitBySku.keySet());
+        }
 
         List<Map<String, Object>> result = new ArrayList<>();
         for (String sku : allSkus) {
             double[] purchase = purchaseSummary.getOrDefault(sku, new double[2]);
             double purchaseQty = purchase[0];
             double purchaseAmount = purchase[1];
-            double salesQty = salesSummary.getOrDefault(sku, 0.0);
+            boolean finishedSku = shouldIncludeSku(sku, finishedSkus);
+            double salesQty = finishedSku
+                    ? salesSummary.getOrDefault(sku, 0.0)
+                    : assemblyConsumeBySku.getOrDefault(sku, 0.0);
             double assemblyOutput = assemblyOutputBySku.getOrDefault(sku, 0.0);
             double assemblyConsume = assemblyConsumeBySku.getOrDefault(sku, 0.0);
 
-            double inventory = purchaseQty + assemblyOutput - salesQty - assemblyConsume;
+            double inventory = finishedSku
+                    ? purchaseQty + assemblyOutput - salesQty
+                    : purchaseQty - assemblyConsume;
             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);
+            WaterlineMetrics waterline = calculateWaterlineMetrics(
+                    inventory,
+                    salesQty,
+                    assemblyCapacityBySku.getOrDefault(sku, 0),
+                    turnoverRate,
+                    info,
+                    finishedSku,
+                    inTransitBySku == null ? null : inTransitBySku.get(sku),
+                    analysisDays
+            );
             Map<String, Object> row = new LinkedHashMap<>();
             row.put("sku", sku);
+            row.put("finishedSku", finishedSku);
+            row.put("productName", info != null ? info.getProductName() : "");
             row.put("category", info != null ? info.getCategory() : "");
             row.put("attribute", info != null ? info.getAttribute() : "");
             row.put("spuName", info != null ? info.getSpuName() : "");
@@ -305,6 +443,23 @@ public class InventoryService {
             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);
+            row.put("dailySales", round(waterline.dailySales, 2));
+            row.put("safetyStock", (int) Math.round(waterline.safetyStock));
+            row.put("leadTime", waterline.leadTime);
+            row.put("reorderPoint", (int) Math.round(waterline.reorderPoint));
+            row.put("waterlineMax", (int) Math.round(waterline.waterlineMax));
+            row.put("waterlineMin", (int) Math.round(waterline.waterlineMin));
+            row.put("effectiveInventory", (int) Math.round(waterline.effectiveInventory));
+            row.put("inTransit", (int) Math.round(waterline.inTransit));
+            row.put("qualifiedInTransit", (int) Math.round(waterline.qualifiedInTransit));
+            row.put("supplierDefectRate", round(waterline.supplierDefectRate * 100.0, 2));
+            row.put("assemblyCapacity", waterline.assemblyCapacity);
+            row.put("salableDays", round(waterline.salableDays, 2));
+            row.put("waterlineStatus", waterline.status);
+            row.put("riskType", waterline.riskType);
+            row.put("riskReason", waterline.reason);
+            row.put("suggestedQty", (int) Math.round(waterline.suggestedQty));
+            row.put("suggestedAction", waterline.suggestedAction);
             result.add(row);
         }
 
@@ -312,6 +467,104 @@ public class InventoryService {
         return result;
     }
 
+    private Map<String, Object> buildWaterlineStats(List<Map<String, Object>> skuSummary) {
+        int belowReorder = 0;
+        int overMax = 0;
+        int slowMoving = 0;
+        int healthy = 0;
+        int riskTotal = 0;
+        if (skuSummary != null) {
+            for (Map<String, Object> row : skuSummary) {
+                if (!Boolean.TRUE.equals(row.get("finishedSku"))) {
+                    continue;
+                }
+                String status = String.valueOf(row.getOrDefault("waterlineStatus", ""));
+                if ("below_reorder".equals(status)) {
+                    belowReorder++;
+                    riskTotal++;
+                } else if ("over_max".equals(status)) {
+                    overMax++;
+                    riskTotal++;
+                } else if ("slow_moving".equals(status)) {
+                    slowMoving++;
+                    riskTotal++;
+                } else if ("healthy".equals(status)) {
+                    healthy++;
+                }
+            }
+        }
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("waterlineBelowReorder", belowReorder);
+        result.put("waterlineOverMax", overMax);
+        result.put("waterlineSlowMoving", slowMoving);
+        result.put("waterlineHealthy", healthy);
+        result.put("waterlineRiskTotal", riskTotal);
+        return result;
+    }
+
+    private List<Map<String, Object>> buildCategoryValueDistribution(Map<String, double[]> purchaseSummary,
+                                                                     Map<String, Double> salesSummary,
+                                                                     Map<String, ProductInfo> productInfoMap,
+                                                                     AssemblyAggregation assemblyAggregation,
+                                                                     Set<String> finishedSkus,
+                                                                     Set<String> semiSkus) {
+        Map<String, CategoryValueAggregate> aggregates = new HashMap<>();
+        Set<String> allSkus = new HashSet<>();
+        allSkus.addAll(purchaseSummary.keySet());
+        allSkus.addAll(salesSummary.keySet());
+        allSkus.addAll(assemblyAggregation.outputBySku.keySet());
+        allSkus.addAll(assemblyAggregation.consumeBySku.keySet());
+
+        for (String sku : allSkus) {
+            double[] purchase = purchaseSummary.getOrDefault(sku, new double[2]);
+            double purchaseQty = purchase[0];
+            double purchaseAmount = purchase[1];
+            double salesQty = salesSummary.getOrDefault(sku, 0.0);
+            double assemblyOutput = assemblyAggregation.outputBySku.getOrDefault(sku, 0.0);
+            double assemblyConsume = assemblyAggregation.consumeBySku.getOrDefault(sku, 0.0);
+            boolean semiSku = semiSkus != null && semiSkus.contains(sku);
+            boolean finishedSku = shouldIncludeSku(sku, finishedSkus);
+            double inventory = semiSku
+                    ? purchaseQty - assemblyConsume
+                    : purchaseQty + assemblyOutput - salesQty;
+            double unitCost = resolveUnitCost(purchaseQty, purchaseAmount, productInfoMap.get(sku));
+            double positiveInventory = Math.max(0.0, inventory);
+
+            String category;
+            if (semiSku) {
+                category = "\u534a\u6210\u54c1";
+            } else if (finishedSku) {
+                category = "\u6210\u54c1";
+            } else {
+                category = "\u672a\u8bc6\u522bSKU";
+            }
+            CategoryValueAggregate agg = aggregates.computeIfAbsent(category, key -> new CategoryValueAggregate());
+            agg.value += positiveInventory;
+            agg.inventory += positiveInventory;
+            agg.amount += Math.max(0.0, inventory * unitCost);
+            agg.skuCount += 1;
+        }
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (Map.Entry<String, CategoryValueAggregate> entry : aggregates.entrySet()) {
+            CategoryValueAggregate agg = entry.getValue();
+            if (agg.inventory <= 0) {
+                continue;
+            }
+            Map<String, Object> row = new LinkedHashMap<>();
+            row.put("name", entry.getKey());
+            row.put("value", (int) Math.round(agg.inventory));
+            row.put("inventory", (int) Math.round(agg.inventory));
+            row.put("amount", Math.round(agg.amount * 100.0) / 100.0);
+            row.put("skuCount", agg.skuCount);
+            result.add(row);
+        }
+        result.sort(Comparator.comparing((Map<String, Object> row) ->
+                ((Number) row.getOrDefault("value", 0)).doubleValue()).reversed());
+        return result;
+    }
+
     private List<Map<String, Object>> buildSpuSummary(Map<String, double[]> purchaseSummary,
                                                       Map<String, Double> salesSummary,
                                                       Map<String, ProductInfo> productInfoMap,
@@ -363,14 +616,19 @@ public class InventoryService {
     }
 
     private AssemblyAggregation buildAssemblyAggregation(Set<String> finishedSkus) {
-        List<AssemblyRecord> assemblyRecords = dataLoader.getAssemblyRecords();
+        return buildAssemblyAggregation(finishedSkus, dataLoader.getAssemblyRecords());
+    }
+
+    private AssemblyAggregation buildAssemblyAggregation(Set<String> finishedSkus, List<AssemblyRecord> assemblyRecords) {
         Map<YearMonth, Double> monthly = new TreeMap<>();
         Map<String, Double> outputBySku = new HashMap<>();
         Map<String, Double> consumeBySku = new HashMap<>();
         if (assemblyRecords.isEmpty()) {
             return new AssemblyAggregation(0, monthly, outputBySku, consumeBySku);
         }
-        Map<String, Set<String>> semiToFinished = buildSemiToFinishedMap();
+        Map<String, Map<String, Double>> bomItems = dataLoader.getBomItems();
+        boolean hasBom = bomItems != null && !bomItems.isEmpty();
+        Map<String, Set<String>> semiToFinished = hasBom ? Collections.emptyMap() : buildSemiToFinishedMap();
         Set<String> safeFinishedSkus = finishedSkus == null ? Collections.emptySet() : finishedSkus;
         double total = 0.0;
         for (AssemblyRecord record : assemblyRecords) {
@@ -386,6 +644,29 @@ public class InventoryService {
             if (code == null || code.trim().isEmpty()) {
                 continue;
             }
+
+            if (hasBom) {
+                if (!safeFinishedSkus.isEmpty() && !safeFinishedSkus.contains(code)) {
+                    continue;
+                }
+                total += qty;
+                outputBySku.merge(code, qty, Double::sum);
+                if (date != null) {
+                    monthly.merge(YearMonth.from(date), qty, Double::sum);
+                }
+                Map<String, Double> bom = bomItems.get(code);
+                if (bom != null && !bom.isEmpty()) {
+                    for (Map.Entry<String, Double> entry : bom.entrySet()) {
+                        String semiCode = entry.getKey();
+                        Double needQty = entry.getValue();
+                        if (semiCode == null || semiCode.trim().isEmpty() || needQty == null || needQty <= 0) {
+                            continue;
+                        }
+                        consumeBySku.merge(semiCode, qty * needQty, Double::sum);
+                    }
+                }
+                continue;
+            }
             if (safeFinishedSkus.contains(code)) {
                 total += qty;
                 outputBySku.merge(code, qty, Double::sum);
@@ -485,6 +766,41 @@ public class InventoryService {
         return finished;
     }
 
+    private Set<String> getSemiSkus(List<ProductInfo> infos, Map<String, Map<String, Double>> bomItems) {
+        Set<String> semiSkus = new HashSet<>();
+        if (infos != null) {
+            for (ProductInfo info : infos) {
+                if (info == null || info.getProductCode() == null || info.getProductCode().trim().isEmpty()) {
+                    continue;
+                }
+                if (isSemiCategory(info.getCategory())) {
+                    semiSkus.add(info.getProductCode());
+                }
+            }
+        }
+        if (bomItems != null) {
+            for (Map<String, Double> components : bomItems.values()) {
+                if (components == null) {
+                    continue;
+                }
+                for (String semiSku : components.keySet()) {
+                    if (semiSku != null && !semiSku.trim().isEmpty()) {
+                        semiSkus.add(semiSku);
+                    }
+                }
+            }
+        }
+        return semiSkus;
+    }
+
+    private boolean isSemiCategory(String category) {
+        if (category == null || category.trim().isEmpty()) {
+            return false;
+        }
+        String text = category.replace(" ", "").toLowerCase(Locale.ROOT);
+        return text.contains("\u534a\u6210\u54c1") || text.contains("semi");
+    }
+
     private boolean isExcludedCategory(String categoryText) {
         if (categoryText == null || categoryText.isEmpty()) {
             return false;
@@ -511,6 +827,293 @@ public class InventoryService {
         return finishedSkus.isEmpty() || finishedSkus.contains(code);
     }
 
+    private List<Map<String, Object>> buildLowSalableDaysList(Map<String, double[]> purchaseSummary,
+                                                              Map<String, Double> salesSummary,
+                                                              Map<String, ProductInfo> productInfoMap,
+                                                              AssemblyAggregation assemblyAggregation,
+                                                              Set<String> finishedSkus,
+                                                              Map<String, Integer> assemblyCapacityBySku,
+                                                              Map<String, InTransitInventory> inTransitBySku,
+                                                              int analysisDays) {
+        Set<String> allSkus = new HashSet<>();
+        allSkus.addAll(purchaseSummary.keySet());
+        allSkus.addAll(salesSummary.keySet());
+        allSkus.addAll(assemblyAggregation.outputBySku.keySet());
+        if (inTransitBySku != null) {
+            allSkus.addAll(inTransitBySku.keySet());
+        }
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (String sku : allSkus) {
+            if (!shouldIncludeSku(sku, finishedSkus)) {
+                continue;
+            }
+            double[] purchase = purchaseSummary.getOrDefault(sku, new double[2]);
+            double purchaseQty = purchase[0];
+            double salesQty = salesSummary.getOrDefault(sku, 0.0);
+            double assemblyOutput = assemblyAggregation.outputBySku.getOrDefault(sku, 0.0);
+            double inventory = purchaseQty + assemblyOutput - salesQty;
+            int assemblyCapacity = assemblyCapacityBySku.getOrDefault(sku, 0);
+            double avgInventory = (purchaseQty + inventory) / 2.0;
+            double turnoverRate = avgInventory > 0 ? salesQty / avgInventory : 0;
+            WaterlineMetrics waterline = calculateWaterlineMetrics(
+                    inventory,
+                    salesQty,
+                    assemblyCapacity,
+                    turnoverRate,
+                    productInfoMap.get(sku),
+                    true,
+                    inTransitBySku == null ? null : inTransitBySku.get(sku),
+                    analysisDays
+            );
+            if ("healthy".equals(waterline.status)) {
+                continue;
+            }
+
+            ProductInfo info = productInfoMap.get(sku);
+            Map<String, Object> row = new LinkedHashMap<>();
+            row.put("sku", sku);
+            row.put("productName", info != null ? info.getProductName() : "");
+            row.put("spuName", info != null ? info.getSpuName() : "");
+            row.put("remainingInventory", (int) Math.round(inventory));
+            row.put("inTransit", (int) Math.round(waterline.inTransit));
+            row.put("assemblyCapacity", assemblyCapacity);
+            row.put("dailySales", round(waterline.dailySales, 2));
+            row.put("salableDays", round(waterline.salableDays, 2));
+            row.put("safetyStock", (int) Math.round(waterline.safetyStock));
+            row.put("leadTime", waterline.leadTime);
+            row.put("reorderPoint", (int) Math.round(waterline.reorderPoint));
+            row.put("waterlineMax", (int) Math.round(waterline.waterlineMax));
+            row.put("waterlineMin", (int) Math.round(waterline.waterlineMin));
+            row.put("effectiveInventory", (int) Math.round(waterline.effectiveInventory));
+            row.put("qualifiedInTransit", (int) Math.round(waterline.qualifiedInTransit));
+            row.put("supplierDefectRate", round(waterline.supplierDefectRate * 100.0, 2));
+            row.put("waterlineStatus", waterline.status);
+            row.put("riskType", waterline.riskType);
+            row.put("riskReason", waterline.reason);
+            row.put("suggestedQty", (int) Math.round(waterline.suggestedQty));
+            row.put("suggestedAction", waterline.suggestedAction);
+            result.add(row);
+        }
+
+        result.sort(Comparator
+                .comparing((Map<String, Object> row) -> waterlineStatusPriority(String.valueOf(row.get("waterlineStatus"))))
+                .thenComparingDouble(row -> ((Number) row.getOrDefault("salableDays", 0)).doubleValue()));
+        return result;
+    }
+
+    private WaterlineMetrics calculateWaterlineMetrics(double inventory,
+                                                       double salesQty,
+                                                       int assemblyCapacity,
+                                                       double turnoverRate,
+                                                       ProductInfo info,
+                                                       boolean finishedSku,
+                                                       InTransitInventory inTransitInventory,
+                                                       int analysisDays) {
+        double safeInventory = Math.max(0.0, inventory);
+        double dailySales = analysisDays > 0 ? salesQty / analysisDays : 0.0;
+        int leadTime = resolveLeadTime(info, assemblyCapacity);
+        int longestLeadTime = Math.max(leadTime, 30);
+        double safetyStockDays = resolveSafetyStockDays(dailySales, turnoverRate, info);
+        double safetyStock = dailySales * safetyStockDays;
+        double reorderPoint = safetyStock + dailySales * leadTime;
+        double monthlyTarget = dailySales * 30.0;
+        double waterlineMax = Math.max(dailySales * longestLeadTime * 1.5, monthlyTarget * 0.3);
+        if (dailySales <= 0 && safeInventory > 0) {
+            waterlineMax = 0.0;
+        }
+        double inTransit = inTransitInventory == null ? 0.0 : inTransitInventory.getQuantity();
+        double qualifiedInTransit = inTransitInventory == null ? 0.0 : inTransitInventory.getQualifiedQuantity();
+        double supplierDefectRate = inTransitInventory == null ? 0.0 : inTransitInventory.getSupplierDefectRate();
+        double effectiveInventory = inventory + qualifiedInTransit + Math.max(0, assemblyCapacity);
+        double waterlineMin = effectiveInventory;
+        double salableDays = dailySales > 0 ? Math.max(0.0, effectiveInventory) / dailySales : (effectiveInventory > 0 ? 9999 : 0);
+
+        String status = "healthy";
+        String riskType = "balanced";
+        String reason = "\u5e93\u5b58\u5904\u4e8e\u63a8\u5bfc\u6c34\u4f4d\u533a\u95f4";
+        String suggestedAction = "\u4fdd\u6301\u73b0\u6709\u8865\u8d27\u8282\u594f";
+        double suggestedQty = 0.0;
+
+        if (!finishedSku) {
+            status = "healthy";
+        } else if (dailySales <= 0 && effectiveInventory > 0) {
+            status = "slow_moving";
+            riskType = "slow-moving";
+            reason = "\u5206\u6790\u5468\u671f\u5185\u65e0\u9500\u91cf\uff0c\u4f46\u4ecd\u6709\u5e93\u5b58";
+            suggestedAction = "\u6682\u505c\u8865\u8d27\uff0c\u6838\u5bf9\u5e93\u9f84\u5e76\u89c4\u5212\u6e05\u4ed3";
+        } else if (dailySales > 0 && effectiveInventory < reorderPoint) {
+            status = "below_reorder";
+            riskType = "stockout";
+            reason = "\u6709\u6548\u5e93\u5b58\u4f4e\u4e8e\u8865\u8d27\u70b9";
+            suggestedQty = Math.max(reorderPoint - effectiveInventory, waterlineMax - effectiveInventory);
+            suggestedAction = "\u5efa\u8bae\u8865\u8d27 " + (int) Math.ceil(Math.max(0.0, suggestedQty)) + " \u4ef6";
+        } else if (waterlineMax > 0 && effectiveInventory > waterlineMax) {
+            status = "over_max";
+            riskType = "overstock";
+            reason = "\u6709\u6548\u5e93\u5b58\u9ad8\u4e8e\u6700\u9ad8\u6c34\u4f4d";
+            suggestedAction = "\u6682\u505c\u8865\u8d27\uff0c\u4f18\u5148\u4fc3\u9500\u6216\u8c03\u62e8\u6d88\u5316";
+        } else if (dailySales > 0 && dailySales <= 0.2 && effectiveInventory > Math.max(reorderPoint, 0.0)) {
+            status = "slow_moving";
+            riskType = "slow-moving";
+            reason = "\u65e5\u5747\u9500\u91cf\u504f\u4f4e\uff0c\u5e93\u5b58\u5360\u7528\u672a\u6d88\u5316";
+            suggestedAction = "\u6682\u505c\u8865\u8d27\uff0c\u68c0\u67e5\u6e20\u9053\u52a8\u9500\u548c\u6e05\u4ed3\u65b9\u6848";
+        }
+
+        WaterlineMetrics metrics = new WaterlineMetrics();
+        metrics.dailySales = dailySales;
+        metrics.safetyStock = safetyStock;
+        metrics.leadTime = leadTime;
+        metrics.reorderPoint = reorderPoint;
+        metrics.waterlineMax = waterlineMax;
+        metrics.waterlineMin = waterlineMin;
+        metrics.inTransit = inTransit;
+        metrics.qualifiedInTransit = qualifiedInTransit;
+        metrics.supplierDefectRate = supplierDefectRate;
+        metrics.assemblyCapacity = assemblyCapacity;
+        metrics.effectiveInventory = effectiveInventory;
+        metrics.salableDays = salableDays;
+        metrics.status = status;
+        metrics.riskType = riskType;
+        metrics.reason = reason;
+        metrics.suggestedQty = Math.max(0.0, suggestedQty);
+        metrics.suggestedAction = suggestedAction;
+        return metrics;
+    }
+
+    private int resolveLeadTime(ProductInfo info, int assemblyCapacity) {
+        String text = "";
+        if (info != null) {
+            text = ((info.getCategory() == null ? "" : info.getCategory()) + " "
+                    + (info.getAttribute() == null ? "" : info.getAttribute())).toLowerCase(Locale.ROOT);
+        }
+        if (assemblyCapacity > 0 || text.contains("\u7ec4\u88c5")) {
+            return 15;
+        }
+        return 30;
+    }
+
+    private double resolveSafetyStockDays(double dailySales, double turnoverRate, ProductInfo info) {
+        String text = "";
+        if (info != null) {
+            text = ((info.getCategory() == null ? "" : info.getCategory()) + " "
+                    + (info.getAttribute() == null ? "" : info.getAttribute()) + " "
+                    + (info.getProductName() == null ? "" : info.getProductName())).toLowerCase(Locale.ROOT);
+        }
+        if (text.contains("\u65b0\u54c1") || text.contains("\u7206\u54c1") || dailySales >= 5 || turnoverRate >= 4) {
+            return 4;
+        }
+        if (dailySales >= 1 || turnoverRate >= 1.5) {
+            return 2;
+        }
+        return 0;
+    }
+
+    private int waterlineStatusPriority(String status) {
+        if ("below_reorder".equals(status)) {
+            return 0;
+        }
+        if ("over_max".equals(status)) {
+            return 1;
+        }
+        if ("slow_moving".equals(status)) {
+            return 2;
+        }
+        return 3;
+    }
+
+    private Map<String, Integer> buildAssemblyCapacityBySku(Map<String, double[]> purchaseSummary,
+                                                            AssemblyAggregation assemblyAggregation) {
+        Map<String, Map<String, Double>> bomItems = dataLoader.getBomItems();
+        if (bomItems == null || bomItems.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        Map<String, Double> semiInventory = new HashMap<>();
+        for (Map.Entry<String, double[]> entry : purchaseSummary.entrySet()) {
+            String sku = entry.getKey();
+            double purchaseQty = entry.getValue() == null ? 0.0 : entry.getValue()[0];
+            double consumeQty = assemblyAggregation.consumeBySku.getOrDefault(sku, 0.0);
+            semiInventory.put(sku, Math.max(0.0, purchaseQty - consumeQty));
+        }
+
+        Map<String, Integer> result = new HashMap<>();
+        for (Map.Entry<String, Map<String, Double>> bomEntry : bomItems.entrySet()) {
+            String finishedSku = bomEntry.getKey();
+            Map<String, Double> components = bomEntry.getValue();
+            if (finishedSku == null || finishedSku.trim().isEmpty() || components == null || components.isEmpty()) {
+                continue;
+            }
+            int capacity = Integer.MAX_VALUE;
+            for (Map.Entry<String, Double> component : components.entrySet()) {
+                String semiSku = component.getKey();
+                double needQty = component.getValue() == null ? 0.0 : component.getValue();
+                if (semiSku == null || semiSku.trim().isEmpty() || needQty <= 0) {
+                    continue;
+                }
+                int possible = (int) Math.floor(semiInventory.getOrDefault(semiSku, 0.0) / needQty);
+                capacity = Math.min(capacity, Math.max(0, possible));
+            }
+            if (capacity != Integer.MAX_VALUE) {
+                result.put(finishedSku, capacity);
+            }
+        }
+        return result;
+    }
+
+    private int resolveAnalysisDays(List<SalesRecord> salesRecords, LocalDate startDate, LocalDate endDate) {
+        if (startDate != null && endDate != null) {
+            return (int) Math.max(1, ChronoUnit.DAYS.between(startDate, endDate) + 1);
+        }
+        LocalDate min = null;
+        LocalDate max = null;
+        for (SalesRecord record : salesRecords) {
+            if (record == null || record.getDate() == null) {
+                continue;
+            }
+            LocalDate date = record.getDate();
+            if (startDate != null && date.isBefore(startDate)) {
+                continue;
+            }
+            if (endDate != null && date.isAfter(endDate)) {
+                continue;
+            }
+            if (min == null || date.isBefore(min)) {
+                min = date;
+            }
+            if (max == null || date.isAfter(max)) {
+                max = date;
+            }
+        }
+        if (min == null || max == null) {
+            return 1;
+        }
+        return (int) Math.max(1, ChronoUnit.DAYS.between(min, max) + 1);
+    }
+
+    private boolean isDateInRange(LocalDate date, LocalDate startDate, LocalDate endDate) {
+        if (startDate == null && endDate == null) {
+            return true;
+        }
+        if (date == null) {
+            return false;
+        }
+        if (startDate != null && date.isBefore(startDate)) {
+            return false;
+        }
+        return endDate == null || !date.isAfter(endDate);
+    }
+
+    private LocalDate parseDate(String value) {
+        if (value == null || value.trim().isEmpty()) {
+            return null;
+        }
+        try {
+            return LocalDate.parse(value.trim());
+        } catch (Exception ignored) {
+            return null;
+        }
+    }
+
     private Map<String, Object> buildFsm(String name, int count, String trend, double percentage) {
         Map<String, Object> row = new LinkedHashMap<>();
         row.put("name", name);
@@ -520,6 +1123,102 @@ public class InventoryService {
         return row;
     }
 
+    private Totals computeTotals(Map<String, double[]> purchaseSummary,
+                                 Map<String, Double> salesSummary,
+                                 Map<String, ProductInfo> productInfoMap,
+                                 AssemblyAggregation assemblyAggregation,
+                                 Set<String> finishedSkus) {
+        Set<String> allSkus = new HashSet<>();
+        allSkus.addAll(purchaseSummary.keySet());
+        allSkus.addAll(salesSummary.keySet());
+        allSkus.addAll(assemblyAggregation.outputBySku.keySet());
+
+        double totalInventory = 0.0;
+        double totalValue = 0.0;
+
+        for (String sku : allSkus) {
+            if (!shouldIncludeSku(sku, finishedSkus)) {
+                continue;
+            }
+            double[] purchase = purchaseSummary.getOrDefault(sku, new double[2]);
+            double purchaseQty = purchase[0];
+            double purchaseAmount = purchase[1];
+            double salesQty = salesSummary.getOrDefault(sku, 0.0);
+            double assemblyOutput = assemblyAggregation.outputBySku.getOrDefault(sku, 0.0);
+
+            double inventory = purchaseQty + assemblyOutput - salesQty;
+            totalInventory += inventory;
+
+            double unitCost = resolveUnitCost(purchaseQty, purchaseAmount, productInfoMap.get(sku));
+            totalValue += inventory * unitCost;
+        }
+
+        return new Totals(totalInventory, totalValue);
+    }
+
+    private double resolveUnitCost(double purchaseQty, double purchaseAmount, ProductInfo info) {
+        if (purchaseQty > 0) {
+            return purchaseAmount / purchaseQty;
+        }
+        if (info != null && info.getPrice() != null && info.getPrice() > 0) {
+            return info.getPrice();
+        }
+        return 0.0;
+    }
+
+    private double totalInTransit(Map<String, InTransitInventory> inTransitBySku, Set<String> finishedSkus) {
+        if (inTransitBySku == null || inTransitBySku.isEmpty()) {
+            return 0.0;
+        }
+        return inTransitBySku.entrySet().stream()
+                .filter(entry -> shouldIncludeSku(entry.getKey(), finishedSkus))
+                .map(Map.Entry::getValue)
+                .filter(value -> value != null)
+                .mapToDouble(InTransitInventory::getQuantity)
+                .sum();
+    }
+
+    private double totalQualifiedInTransit(Map<String, InTransitInventory> inTransitBySku, Set<String> finishedSkus) {
+        if (inTransitBySku == null || inTransitBySku.isEmpty()) {
+            return 0.0;
+        }
+        return inTransitBySku.entrySet().stream()
+                .filter(entry -> shouldIncludeSku(entry.getKey(), finishedSkus))
+                .map(Map.Entry::getValue)
+                .filter(value -> value != null)
+                .mapToDouble(InTransitInventory::getQualifiedQuantity)
+                .sum();
+    }
+
+    private double roundInTransitRatio(double totalInventory, Map<String, InTransitInventory> inTransitBySku, Set<String> finishedSkus) {
+        double inTransit = totalInTransit(inTransitBySku, finishedSkus);
+        double denominator = Math.max(0.0, totalInventory) + inTransit;
+        return denominator > 0 ? round((inTransit / denominator) * 100.0, 2) : 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> paginate(List<Map<String, Object>> source, Integer page, Integer pageSize) {
+        int safePageSize = pageSize == null || pageSize <= 0 ? 10 : pageSize;
+        int safePage = page == null || page <= 0 ? 1 : page;
+        int total = source == null ? 0 : source.size();
+        int start = Math.max(0, (safePage - 1) * safePageSize);
+        int end = Math.min(total, start + safePageSize);
+        List<Map<String, Object>> list = source == null || start >= total
+                ? Collections.emptyList()
+                : source.subList(start, end);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("list", list);
+        result.put("total", total);
+        result.put("page", safePage);
+        result.put("pageSize", safePageSize);
+        return result;
+    }
+
     private int findHeaderIndex(List<String> headers, String[] keywords, int fallback) {
         if (headers == null || headers.isEmpty()) {
             return fallback;
@@ -567,24 +1266,57 @@ public class InventoryService {
             return attributeCount.entrySet().stream()
                     .max(Map.Entry.comparingByValue())
                     .map(Map.Entry::getKey)
-                    .orElse("");
+                .orElse("");
         }
     }
 
+    private static class CategoryValueAggregate {
+        double value;
+        double inventory;
+        double amount;
+        int skuCount;
+    }
+
+    private static class WaterlineMetrics {
+        double dailySales;
+        double safetyStock;
+        int leadTime;
+        double reorderPoint;
+        double waterlineMax;
+        double waterlineMin;
+        double inTransit;
+        double qualifiedInTransit;
+        double supplierDefectRate;
+        int assemblyCapacity;
+        double effectiveInventory;
+        double salableDays;
+        String status;
+        String riskType;
+        String reason;
+        double suggestedQty;
+        String suggestedAction;
+    }
+
     private static class InventorySnapshot {
         private final Map<String, Object> overview;
         private final Map<String, Object> monthlyComparison;
         private final List<Map<String, Object>> skuSummary;
         private final List<Map<String, Object>> spuSummary;
+        private final List<Map<String, Object>> lowSalableDays;
+        private final List<Map<String, Object>> categoryValueDistribution;
 
         private InventorySnapshot(Map<String, Object> overview,
                                   Map<String, Object> monthlyComparison,
                                   List<Map<String, Object>> skuSummary,
-                                  List<Map<String, Object>> spuSummary) {
+                                  List<Map<String, Object>> spuSummary,
+                                  List<Map<String, Object>> lowSalableDays,
+                                  List<Map<String, Object>> categoryValueDistribution) {
             this.overview = overview;
             this.monthlyComparison = monthlyComparison;
             this.skuSummary = skuSummary;
             this.spuSummary = spuSummary;
+            this.lowSalableDays = lowSalableDays;
+            this.categoryValueDistribution = categoryValueDistribution;
         }
     }
 
@@ -604,4 +1336,14 @@ public class InventoryService {
             this.consumeBySku = consumeBySku;
         }
     }
+
+    private static class Totals {
+        private final double totalInventory;
+        private final double totalValue;
+
+        private Totals(double totalInventory, double totalValue) {
+            this.totalInventory = totalInventory;
+            this.totalValue = totalValue;
+        }
+    }
 }

+ 128 - 1
dtm-system/src/main/java/com/dtm/storage/service/ProductService.java

@@ -1,6 +1,7 @@
 package com.dtm.storage.service;
 
 import com.dtm.storage.config.StorageSettings;
+import com.dtm.storage.model.InTransitInventory;
 import com.dtm.storage.model.PurchaseRecord;
 import com.dtm.storage.model.SalesRecord;
 import org.springframework.stereotype.Service;
@@ -34,9 +35,11 @@ public class ProductService {
     }
 
     private final StorageDataLoader dataLoader;
+    private final WaterlineSettingsService waterlineSettingsService;
 
-    public ProductService(StorageDataLoader dataLoader) {
+    public ProductService(StorageDataLoader dataLoader, WaterlineSettingsService waterlineSettingsService) {
         this.dataLoader = dataLoader;
+        this.waterlineSettingsService = waterlineSettingsService;
     }
 
     public Map<String, Object> getProductTrend(String rawSku) {
@@ -47,6 +50,7 @@ public class ProductService {
 
         List<PurchaseRecord> purchaseRecords = dataLoader.getPurchaseRecords();
         List<SalesRecord> salesRecords = dataLoader.getSalesRecords();
+        InTransitInventory inTransitInventory = findInTransitInventory(sku, dataLoader.getInTransitInventoryBySku());
 
         Map<LocalDate, Double> purchaseDaily = new TreeMap<>();
         Map<LocalDate, Double> salesDaily = new TreeMap<>();
@@ -211,6 +215,32 @@ public class ProductService {
         }
         forecastInventory.addAll(forecastValues);
 
+        double dailySales = n > 0 ? totalSales / (double) n : 0.0;
+        WaterlineSettingsService.WaterlineConfig waterlineConfig = waterlineSettingsService.resolveConfig(sku, dailySales, turnoverRate);
+        int leadTime = waterlineConfig.leadTime;
+        double safetyDays = waterlineConfig.safetyDays;
+        double safetyStock = waterlineConfig.safetyStockOverride == null
+                ? dailySales * safetyDays
+                : waterlineConfig.safetyStockOverride;
+        double reorderPoint = safetyStock + dailySales * leadTime;
+        double monthlyTarget = waterlineConfig.monthlyTargetOverride == null
+                ? dailySales * Math.max(30, waterlineConfig.targetCoverageDays)
+                : waterlineConfig.monthlyTargetOverride;
+        double waterlineMax = Math.max(
+                dailySales * Math.max(leadTime, waterlineConfig.maxReplenishCycle) * waterlineConfig.wmaxCycleFactor,
+                monthlyTarget * waterlineConfig.monthlyTargetRatio);
+        if (dailySales <= 1e-6 && currentInventory > 0) {
+            waterlineMax = 0.0;
+        }
+        double inTransit = inTransitInventory == null ? 0.0 : inTransitInventory.getQuantity();
+        double qualifiedInTransit = inTransitInventory == null ? 0.0 : inTransitInventory.getQualifiedQuantity();
+        double supplierDefectRate = waterlineConfig.supplierDefectRateOverride == null
+                ? (inTransitInventory == null ? 0.0 : inTransitInventory.getSupplierDefectRate())
+                : normalizeRate(waterlineConfig.supplierDefectRateOverride);
+        double effectiveInventory = currentInventory + inTransit * (1.0 - supplierDefectRate);
+        double waterlineMin = effectiveInventory;
+        String waterlineStatus = resolveWaterlineStatus(effectiveInventory, dailySales, reorderPoint, waterlineMax);
+
         Map<String, Object> result = new LinkedHashMap<>();
         result.put("sku", sku);
         result.put("dates", allDates);
@@ -225,6 +255,26 @@ public class ProductService {
         result.put("currentInventory", (int) Math.round(currentInventory));
         result.put("turnoverRate", turnoverRate);
         result.put("turnoverBreakdown", turnoverBreakdown);
+        result.put("dailySales", round(dailySales, 2));
+        result.put("productType", waterlineConfig.productType);
+        result.put("waterlineSetting", waterlineConfig.toMap());
+        result.put("leadTime", leadTime);
+        result.put("maxReplenishCycle", waterlineConfig.maxReplenishCycle);
+        result.put("monthlyTarget", round(monthlyTarget, 2));
+        result.put("safetyDays", round(safetyDays, 2));
+        result.put("safetyStock", round(safetyStock, 2));
+        result.put("reorderPoint", round(reorderPoint, 2));
+        result.put("waterlineMax", round(waterlineMax, 2));
+        result.put("waterlineMin", round(waterlineMin, 2));
+        result.put("inTransit", round(inTransit, 2));
+        result.put("qualifiedInTransit", round(qualifiedInTransit, 2));
+        result.put("supplierDefectRate", round(supplierDefectRate * 100.0, 2));
+        result.put("effectiveInventory", round(effectiveInventory, 2));
+        result.put("waterlineStatus", waterlineStatus);
+        result.put("safetyStockLine", constantLine(safetyStock, allDates.size()));
+        result.put("reorderPointLine", constantLine(reorderPoint, allDates.size()));
+        result.put("waterlineMaxLine", constantLine(waterlineMax, allDates.size()));
+        result.put("waterlineMinLine", constantLine(waterlineMin, allDates.size()));
         return result;
     }
 
@@ -325,9 +375,86 @@ public class ProductService {
         result.put("currentInventory", 0);
         result.put("turnoverRate", 0);
         result.put("turnoverBreakdown", Collections.emptyList());
+        result.put("dailySales", 0);
+        result.put("productType", "normal");
+        result.put("waterlineSetting", Collections.emptyMap());
+        result.put("leadTime", 30);
+        result.put("maxReplenishCycle", 30);
+        result.put("monthlyTarget", 0);
+        result.put("safetyDays", 0);
+        result.put("safetyStock", 0);
+        result.put("reorderPoint", 0);
+        result.put("waterlineMax", 0);
+        result.put("waterlineMin", 0);
+        result.put("inTransit", 0);
+        result.put("qualifiedInTransit", 0);
+        result.put("supplierDefectRate", 0);
+        result.put("effectiveInventory", 0);
+        result.put("waterlineStatus", "healthy");
+        result.put("safetyStockLine", Collections.emptyList());
+        result.put("reorderPointLine", Collections.emptyList());
+        result.put("waterlineMaxLine", Collections.emptyList());
+        result.put("waterlineMinLine", Collections.emptyList());
         return result;
     }
 
+    private InTransitInventory findInTransitInventory(String sku, Map<String, InTransitInventory> inTransitBySku) {
+        if (sku == null || inTransitBySku == null || inTransitBySku.isEmpty()) {
+            return null;
+        }
+        InTransitInventory direct = inTransitBySku.get(sku);
+        if (direct != null) {
+            return direct;
+        }
+        for (Map.Entry<String, InTransitInventory> entry : inTransitBySku.entrySet()) {
+            if (entry.getKey() != null && sku.equalsIgnoreCase(entry.getKey().trim())) {
+                return entry.getValue();
+            }
+        }
+        return null;
+    }
+
+    private int resolveSafetyDays(double dailySales, double turnoverRate) {
+        if (dailySales >= 5.0 || turnoverRate >= 4.0) {
+            return 4;
+        }
+        if (dailySales >= 1.0 || turnoverRate >= 1.5) {
+            return 2;
+        }
+        return 0;
+    }
+
+    private String resolveWaterlineStatus(double currentInventory, double dailySales, double reorderPoint, double waterlineMax) {
+        if (dailySales <= 1e-6 && currentInventory > 0) {
+            return "slow_moving";
+        }
+        if (currentInventory < reorderPoint) {
+            return "below_reorder";
+        }
+        if (waterlineMax > 0 && currentInventory > waterlineMax) {
+            return "over_max";
+        }
+        return "healthy";
+    }
+
+    private double normalizeRate(double rate) {
+        if (rate <= 0) {
+            return 0.0;
+        }
+        if (rate > 1.0) {
+            return Math.min(rate / 100.0, 1.0);
+        }
+        return Math.min(rate, 1.0);
+    }
+
+    private List<Double> constantLine(double value, int size) {
+        List<Double> line = new ArrayList<>();
+        for (int i = 0; i < size; i++) {
+            line.add(round(value, 2));
+        }
+        return line;
+    }
+
     private List<Double> forecastInventory(double[] inventoryArr, double[] salesArr, double[] purchaseArr,
                                            double[] stableArr, int roll, Map<String, Double> weights,
                                            double salesPercentile, double[] turnoverSeries) {

+ 274 - 180
dtm-system/src/main/java/com/dtm/storage/service/SemiProductService.java

@@ -1,11 +1,15 @@
 package com.dtm.storage.service;
 
+import com.dtm.storage.model.AssemblyRecord;
+import com.dtm.storage.model.ProductInfo;
 import com.dtm.storage.model.PurchaseRecord;
 import org.springframework.stereotype.Service;
 
 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.Map;
@@ -21,77 +25,73 @@ public class SemiProductService {
     }
 
     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));
-        }
-
+        SemiSnapshot snapshot = buildSnapshot();
+        int totalQty = snapshot.rows.stream()
+                .mapToInt(row -> ((Number) row.getOrDefault("quantity", 0)).intValue())
+                .sum();
         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("totalSemiProducts", snapshot.rows.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);
+        SemiSnapshot snapshot = buildSnapshot();
+        List<Map<String, Object>> filtered = new ArrayList<>(snapshot.rows);
+
         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());
+            filtered = filtered.stream().filter(row -> {
+                String code = String.valueOf(row.getOrDefault("code", ""));
+                String name = String.valueOf(row.getOrDefault("name", ""));
+                return code.contains(key) || name.contains(key);
+            }).collect(Collectors.toList());
         }
         if (status != null && !status.trim().isEmpty()) {
             filtered = filtered.stream()
-                    .filter(d -> status.equals(d.get("status")))
+                    .filter(row -> status.equals(row.get("status")))
                     .collect(Collectors.toList());
         }
         if (category != null && !category.trim().isEmpty()) {
             filtered = filtered.stream()
-                    .filter(d -> category.equals(d.get("category")))
+                    .filter(row -> category.equals(row.get("category")))
                     .collect(Collectors.toList());
         }
 
+        int safePageSize = pageSize <= 0 ? 10 : pageSize;
+        int safePage = page <= 0 ? 1 : page;
         int total = filtered.size();
-        int start = Math.max(0, (page - 1) * pageSize);
-        int end = Math.min(total, start + pageSize);
+        int start = Math.max(0, (safePage - 1) * safePageSize);
+        int end = Math.min(total, start + safePageSize);
         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);
+        result.put("page", safePage);
+        result.put("pageSize", safePageSize);
         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;
+        SemiSnapshot snapshot = buildSnapshot();
+        for (Map<String, Object> row : snapshot.rows) {
+            int rowId = ((Number) row.getOrDefault("id", 0)).intValue();
+            if (rowId == id) {
+                return new LinkedHashMap<>(row);
+            }
+        }
+        return Collections.emptyMap();
     }
 
     public Map<String, Object> create(Map<String, Object> payload) {
         Map<String, Object> result = new LinkedHashMap<>();
-        result.put("id", 999);
+        result.put("id", 0);
         if (payload != null) {
             result.putAll(payload);
         }
@@ -111,78 +111,111 @@ public class SemiProductService {
     }
 
     public List<Map<String, Object>> getTurnoverAnalysis() {
+        SemiSnapshot snapshot = buildSnapshot();
         List<Map<String, Object>> list = new ArrayList<>();
-        list.add(buildTurnover("电路板 PCB-A", 18, 85, 850, 1200, "正常", "库存充足,周转良好"));
-        list.add(buildTurnover("显示屏模块", 22, 78, 650, 1000, "正常", "建议保持当前库存水平"));
+        for (Map<String, Object> row : snapshot.rows) {
+            int quantity = ((Number) row.getOrDefault("quantity", 0)).intValue();
+            int inboundQty = ((Number) row.getOrDefault("inboundQty", 0)).intValue();
+            int consumedQty = ((Number) row.getOrDefault("consumedQty", 0)).intValue();
+            int avgTurnover = consumedQty <= 0 ? 999 : (int) Math.round(quantity * 30.0 / consumedQty);
+            int usageRate = inboundQty <= 0 ? 0 : (int) Math.min(100, Math.round(consumedQty * 100.0 / inboundQty));
+            int monthlyConsumption = consumedQty;
+            int reorderPoint = Math.max(100, (int) Math.round(monthlyConsumption * 0.6));
+            String status = String.valueOf(row.getOrDefault("status", ""));
+
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("name", row.getOrDefault("name", ""));
+            item.put("avgTurnover", avgTurnover);
+            item.put("usageRate", usageRate);
+            item.put("monthlyConsumption", monthlyConsumption);
+            item.put("reorderPoint", reorderPoint);
+            item.put("stockStatus", convertStockStatus(status));
+            item.put("suggestion", buildSuggestion(status));
+            list.add(item);
+        }
+        list.sort(Comparator.comparingInt((Map<String, Object> item) ->
+                ((Number) item.getOrDefault("monthlyConsumption", 0)).intValue()).reversed());
         return list;
     }
 
     public Map<String, Object> getBomRelation(String productCode) {
+        SemiSnapshot snapshot = buildSnapshot();
         Map<String, Object> result = new LinkedHashMap<>();
-        result.put("product", "智能手表 Pro X1");
+        ProductInfo finishedInfo = snapshot.productInfoByCode.get(productCode);
+        result.put("product", finishedInfo != null && !isBlank(finishedInfo.getProductName())
+                ? finishedInfo.getProductName()
+                : productCode);
         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);
+
+        Map<String, Double> bom = snapshot.bomItems.getOrDefault(productCode, Collections.emptyMap());
+        List<Map<String, Object>> items = new ArrayList<>();
+        for (Map.Entry<String, Double> entry : bom.entrySet()) {
+            String semiCode = entry.getKey();
+            double needQty = entry.getValue() == null ? 0.0 : entry.getValue();
+            if (needQty <= 0) {
+                continue;
+            }
+            ProductInfo info = snapshot.productInfoByCode.get(semiCode);
+            int stock = toInt(snapshot.semiInventoryQty.getOrDefault(semiCode, 0.0));
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("name", info != null && !isBlank(info.getProductName()) ? info.getProductName() : semiCode);
+            item.put("code", semiCode);
+            item.put("quantity", toInt(needQty));
+            item.put("stock", stock);
+            items.add(item);
+        }
+
+        Map<String, Object> module = new LinkedHashMap<>();
+        module.put("module", "BOM");
+        module.put("items", items);
+        result.put("bom", Collections.singletonList(module));
         return result;
     }
 
     public List<Map<String, Object>> getAssemblyCapacity() {
-        List<List<Object>> mappingRows = dataLoader.getSemiMappingRows();
-        List<String> headers = dataLoader.getSemiMappingHeaders();
-        if (mappingRows.isEmpty()) {
+        SemiSnapshot snapshot = buildSnapshot();
+        if (snapshot.bomItems.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()) {
+        List<String> finishedCodes = new ArrayList<>(snapshot.bomItems.keySet());
+        Collections.sort(finishedCodes);
+        for (String finishedCode : finishedCodes) {
+            Map<String, Double> components = snapshot.bomItems.getOrDefault(finishedCode, Collections.emptyMap());
+            if (components.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));
+            int capacity = Integer.MAX_VALUE;
+            for (Map.Entry<String, Double> entry : components.entrySet()) {
+                String semiCode = entry.getKey();
+                double needQty = entry.getValue() == null ? 0.0 : entry.getValue();
+                if (isBlank(semiCode) || needQty <= 0) {
+                    continue;
+                }
+                int available = toInt(snapshot.semiInventoryQty.getOrDefault(semiCode, 0.0));
+                int possible = (int) Math.floor(available / needQty);
+                capacity = Math.min(capacity, Math.max(0, possible));
+
+                ProductInfo semiInfo = snapshot.productInfoByCode.get(semiCode);
                 Map<String, Object> comp = new LinkedHashMap<>();
-                comp.put("code", code);
+                comp.put("code", semiCode);
+                comp.put("name", semiInfo != null && !isBlank(semiInfo.getProductName()) ? semiInfo.getProductName() : semiCode);
+                comp.put("required", toInt(needQty));
                 comp.put("available", available);
                 componentList.add(comp);
-                capacities.add(available);
+            }
+            if (capacity == Integer.MAX_VALUE) {
+                capacity = 0;
             }
 
-            int capacity = capacities.isEmpty() ? 0 : capacities.stream().min(Integer::compareTo).orElse(0);
+            ProductInfo finishedInfo = snapshot.productInfoByCode.get(finishedCode);
             Map<String, Object> result = new LinkedHashMap<>();
-            result.put("product_code", productCode);
-            result.put("product_name", productCode);
+            result.put("product_code", finishedCode);
+            result.put("product_name", finishedInfo != null && !isBlank(finishedInfo.getProductName())
+                    ? finishedInfo.getProductName()
+                    : finishedCode);
             result.put("components", componentList);
             result.put("capacity", capacity);
             results.add(result);
@@ -190,128 +223,189 @@ public class SemiProductService {
         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;
-    }
+    public Map<String, Object> getAssemblyCapacityPage(Integer page, Integer pageSize) {
+        List<Map<String, Object>> source = getAssemblyCapacity();
+        int safePageSize = pageSize == null || pageSize <= 0 ? 10 : pageSize;
+        int safePage = page == null || page <= 0 ? 1 : page;
+        int total = source.size();
+        int start = Math.max(0, (safePage - 1) * safePageSize);
+        int end = Math.min(total, start + safePageSize);
+        List<Map<String, Object>> list = start >= total ? Collections.emptyList() : source.subList(start, end);
 
-    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);
-            }
-        }
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("list", list);
+        result.put("total", total);
+        result.put("page", safePage);
+        result.put("pageSize", safePageSize);
+        return result;
     }
 
-    private void collectCodes(List<String> target, String raw) {
-        if (raw == null || raw.trim().isEmpty()) {
-            return;
+    private SemiSnapshot buildSnapshot() {
+        List<ProductInfo> productInfos = dataLoader.getProductInfo();
+        Map<String, ProductInfo> productInfoByCode = productInfos.stream()
+                .filter(item -> item != null && !isBlank(item.getProductCode()))
+                .collect(Collectors.toMap(ProductInfo::getProductCode, item -> item, (a, b) -> a));
+
+        Map<String, Double> purchaseBySku = dataLoader.getPurchaseRecords().stream()
+                .filter(item -> item != null && !isBlank(item.getProductCode()))
+                .collect(Collectors.groupingBy(PurchaseRecord::getProductCode, Collectors.summingDouble(PurchaseRecord::getQuantity)));
+        Map<String, Double> assemblyBySku = dataLoader.getAssemblyRecords().stream()
+                .filter(item -> item != null && !isBlank(item.getProductCode()))
+                .collect(Collectors.groupingBy(AssemblyRecord::getProductCode, Collectors.summingDouble(AssemblyRecord::getQuantity)));
+        Map<String, Map<String, Double>> bomItems = dataLoader.getBomItems();
+        if (bomItems == null) {
+            bomItems = Collections.emptyMap();
         }
-        String normalized = raw.replace(',', ',');
-        for (String part : normalized.split(",")) {
-            String code = part.trim();
-            if (!code.isEmpty()) {
-                target.add(code);
+
+        Map<String, Double> semiConsumedQty = new HashMap<>();
+        Map<String, Set<String>> semiToFinished = new HashMap<>();
+        for (Map.Entry<String, Map<String, Double>> bomEntry : bomItems.entrySet()) {
+            String finishedCode = bomEntry.getKey();
+            Map<String, Double> components = bomEntry.getValue();
+            if (isBlank(finishedCode) || components == null || components.isEmpty()) {
+                continue;
+            }
+            double assemblyQty = assemblyBySku.getOrDefault(finishedCode, 0.0);
+            for (Map.Entry<String, Double> component : components.entrySet()) {
+                String semiCode = component.getKey();
+                double needQty = component.getValue() == null ? 0.0 : component.getValue();
+                if (isBlank(semiCode) || needQty <= 0) {
+                    continue;
+                }
+                semiConsumedQty.merge(semiCode, assemblyQty * needQty, Double::sum);
+                semiToFinished.computeIfAbsent(semiCode, key -> new HashSet<>()).add(finishedCode);
             }
         }
-    }
 
-    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()) {
+        Set<String> semiCodes = new HashSet<>(semiToFinished.keySet());
+        for (ProductInfo info : productInfos) {
+            if (info == null || isBlank(info.getProductCode())) {
                 continue;
             }
-            for (String keyword : keywords) {
-                if (keyword != null && !keyword.isEmpty() && header.contains(keyword)) {
-                    return i;
-                }
+            if (isSemiCategory(info.getCategory())) {
+                semiCodes.add(info.getProductCode());
             }
         }
-        return fallback < headers.size() ? fallback : -1;
-    }
+        Map<String, Double> semiInventoryQty = new HashMap<>();
+        List<Map<String, Object>> rows = new ArrayList<>();
+        List<String> sortedCodes = new ArrayList<>(semiCodes);
+        Collections.sort(sortedCodes);
+        int index = 1;
+        for (String code : sortedCodes) {
+            ProductInfo info = productInfoByCode.get(code);
+            String name = info != null && !isBlank(info.getProductName()) ? info.getProductName() : code;
+            String category = info != null && !isBlank(info.getCategory()) ? info.getCategory() : "\u534a\u6210\u54c1";
+            int inboundQty = toInt(purchaseBySku.getOrDefault(code, 0.0));
+            int consumedQty = toInt(semiConsumedQty.getOrDefault(code, 0.0));
+            int quantity = Math.max(0, inboundQty - consumedQty);
+            semiInventoryQty.put(code, (double) quantity);
+            int safeStock = Math.max(50, (int) Math.round(inboundQty * 0.2));
+            String status = computeStatus(quantity, safeStock);
+            double unitPrice = info != null && info.getPrice() != null ? info.getPrice() : 0.0;
+            double totalValue = Math.round(quantity * unitPrice * 100.0) / 100.0;
+
+            List<String> usedForProducts = semiToFinished.getOrDefault(code, Collections.emptySet())
+                    .stream()
+                    .map(finishedCode -> {
+                        ProductInfo product = productInfoByCode.get(finishedCode);
+                        if (product == null) {
+                            return finishedCode;
+                        }
+                        if (!isBlank(product.getSpuName())) {
+                            return product.getSpuName();
+                        }
+                        if (!isBlank(product.getProductName())) {
+                            return product.getProductName();
+                        }
+                        return finishedCode;
+                    })
+                    .distinct()
+                    .sorted()
+                    .collect(Collectors.toList());
 
-    private Object getValue(List<Object> row, int index) {
-        if (row == null || index < 0 || index >= row.size()) {
-            return null;
+            Map<String, Object> row = new LinkedHashMap<>();
+            row.put("id", index++);
+            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", usedForProducts);
+            row.put("supplier", "");
+            row.put("leadTime", 0);
+            row.put("unitPrice", Math.round(unitPrice * 100.0) / 100.0);
+            row.put("totalValue", totalValue);
+            row.put("inboundQty", inboundQty);
+            row.put("consumedQty", consumedQty);
+            rows.add(row);
         }
-        return row.get(index);
+        rows.sort(Comparator.comparingInt((Map<String, Object> row) ->
+                ((Number) row.getOrDefault("quantity", 0)).intValue()).reversed());
+
+        return new SemiSnapshot(productInfoByCode, bomItems, semiInventoryQty, rows);
     }
 
-    private String toText(Object value) {
-        if (value == null) {
-            return "";
+    private boolean isSemiCategory(String category) {
+        if (category == null || category.trim().isEmpty()) {
+            return false;
         }
-        return String.valueOf(value).trim();
+        String text = category.replace(" ", "").toLowerCase();
+        return text.contains("\u534a\u6210\u54c1") || text.contains("semi");
     }
 
-    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 String computeStatus(int quantity, int safeStock) {
+        if (quantity <= 0) {
+            return "used";
+        }
+        if (quantity < safeStock) {
+            return "low-stock";
+        }
+        return "in-stock";
     }
 
-    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 String convertStockStatus(String status) {
+        if ("in-stock".equals(status)) {
+            return "\u6b63\u5e38";
+        }
+        if ("low-stock".equals(status)) {
+            return "\u504f\u4f4e";
+        }
+        return "\u7d27\u5f20";
     }
 
-    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 String buildSuggestion(String status) {
+        if ("in-stock".equals(status)) {
+            return "\u5e93\u5b58\u5145\u8db3\uff0c\u5efa\u8bae\u4fdd\u6301\u5f53\u524d\u91c7\u8d2d\u8282\u594f";
+        }
+        if ("low-stock".equals(status)) {
+            return "\u5e93\u5b58\u504f\u4f4e\uff0c\u5efa\u8bae\u53ca\u65f6\u8865\u8d27";
+        }
+        return "\u5e93\u5b58\u7d27\u5f20\uff0c\u5efa\u8bae\u4f18\u5148\u6392\u4ea7\u5e76\u8865\u5145\u5e93\u5b58";
     }
 
-    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;
+    private int toInt(double value) {
+        return (int) Math.round(value);
     }
-}
 
+    private boolean isBlank(String text) {
+        return text == null || text.trim().isEmpty();
+    }
 
+    private static class SemiSnapshot {
+        private final Map<String, ProductInfo> productInfoByCode;
+        private final Map<String, Map<String, Double>> bomItems;
+        private final Map<String, Double> semiInventoryQty;
+        private final List<Map<String, Object>> rows;
+
+        private SemiSnapshot(Map<String, ProductInfo> productInfoByCode,
+                             Map<String, Map<String, Double>> bomItems,
+                             Map<String, Double> semiInventoryQty,
+                             List<Map<String, Object>> rows) {
+            this.productInfoByCode = productInfoByCode;
+            this.bomItems = bomItems;
+            this.semiInventoryQty = semiInventoryQty;
+            this.rows = rows;
+        }
+    }
+}

+ 130 - 5
dtm-system/src/main/java/com/dtm/storage/service/StorageDataLoader.java

@@ -1,12 +1,15 @@
 package com.dtm.storage.service;
 
 import com.dtm.storage.model.AssemblyRecord;
+import com.dtm.storage.model.InTransitInventory;
 import com.dtm.storage.model.ProductInfo;
 import com.dtm.storage.model.PurchaseRecord;
 import com.dtm.storage.model.SalesRecord;
+import com.dtm.storage.repository.StorageDbRepository;
 import com.dtm.storage.util.ExcelSheet;
 import com.dtm.storage.util.ExcelUtils;
 import org.apache.poi.ss.usermodel.DateUtil;
+import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 
@@ -47,22 +50,34 @@ public class StorageDataLoader {
     private boolean requireUpload;
     @Value("${storage.data.warmup:true}")
     private boolean warmupEnabled;
+    @Value("${storage.data.db-fallback:true}")
+    private boolean dbFallbackEnabled;
+    @Value("${storage.data.force-db:false}")
+    private boolean forceDbDefault;
 
     private Path basePath;
     private Path defaultBasePath;
     private Path tempBasePath;
     private volatile long tempExpireAt;
     private volatile boolean tempMode;
+    private volatile boolean forceDatabase;
+
+    private final StorageDbRepository dbRepository;
 
     private final ConcurrentMap<String, CacheEntry<?>> cache = new ConcurrentHashMap<>();
     private final ConcurrentMap<String, Object> cacheLocks = new ConcurrentHashMap<>();
 
+    public StorageDataLoader(ObjectProvider<StorageDbRepository> dbRepositoryProvider) {
+        this.dbRepository = dbRepositoryProvider.getIfAvailable();
+    }
+
     @PostConstruct
     public void init() {
         this.defaultBasePath = requireUpload ? null : resolveBasePath();
         this.basePath = defaultBasePath;
+        this.forceDatabase = forceDbDefault;
         System.out.println("StorageDataLoader basePath: " + (basePath == null ? "<null>" : basePath.toAbsolutePath()));
-        if (warmupEnabled && basePath != null) {
+        if (warmupEnabled) {
             warmUp();
         }
     }
@@ -74,6 +89,8 @@ public class StorageDataLoader {
             getAssemblyRecords();
             getProductInfo();
             getSemiMappingRows();
+            getBomItems();
+            getInTransitInventoryBySku();
         } catch (Exception e) {
             System.out.println("StorageDataLoader warm-up failed: " + e.getMessage());
         }
@@ -104,16 +121,30 @@ public class StorageDataLoader {
         return sheet == null ? Collections.emptyList() : sheet.getHeaders();
     }
 
+    public Map<String, Map<String, Double>> getBomItems() {
+        return getCached("bom_items", this::loadBomItems);
+    }
+
+    public Map<String, InTransitInventory> getInTransitInventoryBySku() {
+        return getCached("in_transit_inventory", this::loadInTransitInventoryBySku);
+    }
+
     public void clearCache() {
         cache.clear();
     }
 
+    public void forceDatabase(boolean enabled) {
+        this.forceDatabase = enabled;
+        clearCache();
+    }
+
     public synchronized void useTemporaryBasePath(Path path) {
         if (path == null) {
             return;
         }
         this.tempBasePath = path;
         this.tempMode = true;
+        this.forceDatabase = false;
         this.tempExpireAt = System.currentTimeMillis() + TEMP_DATA_TTL_MILLIS;
         this.basePath = path;
         clearCache();
@@ -149,9 +180,12 @@ public class StorageDataLoader {
     }
 
     private List<PurchaseRecord> loadPurchaseRecords() {
+        if (shouldForceDatabase()) {
+            return loadPurchaseRecordsFromDb();
+        }
         Path file = findPurchaseFile();
         if (file == null) {
-            return Collections.emptyList();
+            return loadPurchaseRecordsFromDb();
         }
 
         List<PurchaseRecord> records = new ArrayList<>();
@@ -181,9 +215,12 @@ public class StorageDataLoader {
     }
 
     private List<SalesRecord> loadSalesRecords() {
+        if (shouldForceDatabase()) {
+            return loadSalesRecordsFromDb();
+        }
         Path file = findSalesFile();
         if (file == null) {
-            return Collections.emptyList();
+            return loadSalesRecordsFromDb();
         }
 
         List<SalesRecord> records = new ArrayList<>();
@@ -211,9 +248,12 @@ public class StorageDataLoader {
     }
 
     private List<AssemblyRecord> loadAssemblyRecords() {
+        if (shouldForceDatabase()) {
+            return loadAssemblyRecordsFromDb();
+        }
         Path file = findAssemblyFile();
         if (file == null) {
-            return Collections.emptyList();
+            return loadAssemblyRecordsFromDb();
         }
 
         List<AssemblyRecord> records = new ArrayList<>();
@@ -244,9 +284,12 @@ public class StorageDataLoader {
     }
 
     private List<ProductInfo> loadProductInfo() {
+        if (shouldForceDatabase()) {
+            return loadProductInfoFromDb();
+        }
         Path file = findProductInfoFile();
         if (file == null) {
-            return Collections.emptyList();
+            return loadProductInfoFromDb();
         }
 
         List<ProductInfo> infos = new ArrayList<>();
@@ -302,6 +345,88 @@ public class StorageDataLoader {
         return readSheet(file, 0);
     }
 
+    private Map<String, Map<String, Double>> loadBomItems() {
+        if (!shouldLoadFromDb()) {
+            return Collections.emptyMap();
+        }
+        try {
+            return dbRepository.loadBomItems();
+        } catch (Exception e) {
+            System.out.println("StorageDataLoader loadBomItems from db failed: " + e.getMessage());
+            return Collections.emptyMap();
+        }
+    }
+
+    private Map<String, InTransitInventory> loadInTransitInventoryBySku() {
+        if (!shouldLoadFromDb()) {
+            return Collections.emptyMap();
+        }
+        try {
+            return dbRepository.loadInTransitInventory();
+        } catch (Exception e) {
+            System.out.println("StorageDataLoader loadInTransitInventory from db failed: " + e.getMessage());
+            return Collections.emptyMap();
+        }
+    }
+
+    private List<PurchaseRecord> loadPurchaseRecordsFromDb() {
+        if (!shouldLoadFromDb()) {
+            return Collections.emptyList();
+        }
+        try {
+            return dbRepository.loadPurchaseRecords();
+        } catch (Exception e) {
+            System.out.println("StorageDataLoader loadPurchaseRecords from db failed: " + e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    private List<SalesRecord> loadSalesRecordsFromDb() {
+        if (!shouldLoadFromDb()) {
+            return Collections.emptyList();
+        }
+        try {
+            return dbRepository.loadSalesRecords();
+        } catch (Exception e) {
+            System.out.println("StorageDataLoader loadSalesRecords from db failed: " + e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    private List<AssemblyRecord> loadAssemblyRecordsFromDb() {
+        if (!shouldLoadFromDb()) {
+            return Collections.emptyList();
+        }
+        try {
+            return dbRepository.loadAssemblyRecords();
+        } catch (Exception e) {
+            System.out.println("StorageDataLoader loadAssemblyRecords from db failed: " + e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    private List<ProductInfo> loadProductInfoFromDb() {
+        if (!shouldLoadFromDb()) {
+            return Collections.emptyList();
+        }
+        try {
+            return dbRepository.loadProductInfo();
+        } catch (Exception e) {
+            System.out.println("StorageDataLoader loadProductInfo from db failed: " + e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    private boolean shouldForceDatabase() {
+        ensureTempValid();
+        return !tempMode && forceDatabase;
+    }
+
+    private boolean shouldLoadFromDb() {
+        ensureTempValid();
+        return !tempMode && dbRepository != null && (dbFallbackEnabled || forceDatabase);
+    }
+
     private ExcelSheet readSheet(Path file, int headerRowIndex) {
         if (tempMode) {
             return ExcelUtils.readSheet(file, headerRowIndex, TEMP_MAX_ROWS);

+ 19 - 0
dtm-system/src/main/java/com/dtm/storage/service/StorageUploadService.java

@@ -31,6 +31,25 @@ public class StorageUploadService {
                                            MultipartFile assemblyFile,
                                            MultipartFile productFile,
                                            MultipartFile semiMappingFile) {
+        boolean hasAnyFile = (purchaseFile != null && !purchaseFile.isEmpty())
+                || (salesFile != null && !salesFile.isEmpty())
+                || (assemblyFile != null && !assemblyFile.isEmpty())
+                || (productFile != null && !productFile.isEmpty())
+                || (semiMappingFile != null && !semiMappingFile.isEmpty());
+        if (!hasAnyFile) {
+            dataLoader.resetBasePath();
+            dataLoader.forceDatabase(true);
+            inventoryService.invalidateCache();
+            riskService.invalidateCache();
+            inventoryService.warmCache();
+
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("mode", "db");
+            result.put("files", new ArrayList<>());
+            result.put("count", 0);
+            return result;
+        }
+
         List<Map<String, Object>> saved = new ArrayList<>();
         Path basePath = prepareTempDir();
 

+ 430 - 0
dtm-system/src/main/java/com/dtm/storage/service/WaterlineSettingsService.java

@@ -0,0 +1,430 @@
+package com.dtm.storage.service;
+
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+import java.sql.ResultSet;
+import java.sql.Timestamp;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+@Service
+public class WaterlineSettingsService {
+    private static final String TYPE_TABLE = "dtm_inventory_waterline_type_setting";
+    private static final String SKU_TABLE = "dtm_inventory_waterline_sku_setting";
+
+    private final JdbcTemplate jdbcTemplate;
+    private final AtomicBoolean initialized = new AtomicBoolean(false);
+
+    public WaterlineSettingsService(JdbcTemplate jdbcTemplate) {
+        this.jdbcTemplate = jdbcTemplate;
+    }
+
+    public Map<String, Object> getSettings() {
+        ensureInitialized();
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("typeSettings", listTypeSettings());
+        result.put("skuSettings", listSkuSettings(null));
+        return result;
+    }
+
+    public List<Map<String, Object>> listTypeSettings() {
+        ensureInitialized();
+        String sql = "select id, product_type, safety_days_default, lead_time_default,"
+                + " max_replenish_cycle, monthly_target_ratio, wmax_cycle_factor,"
+                + " target_coverage_days, target_turnover_rate, updated_by, updated_time"
+                + " from " + TYPE_TABLE
+                + " order by field(product_type, 'hot', 'normal', 'new', 'slow'), id";
+        return jdbcTemplate.query(sql, (rs, rowNum) -> mapTypeSetting(rs));
+    }
+
+    public List<Map<String, Object>> listSkuSettings(String sku) {
+        ensureInitialized();
+        List<Object> args = new ArrayList<>();
+        String sql = "select id, sku, product_type, safety_stock_override, safety_days_override,"
+                + " lead_time_override, max_replenish_cycle_override, monthly_target_override,"
+                + " supplier_defect_rate_override, updated_by, updated_time"
+                + " from " + SKU_TABLE;
+        if (sku != null && !sku.trim().isEmpty()) {
+            sql += " where upper(sku) = ?";
+            args.add(sku.trim().toUpperCase(Locale.ROOT));
+        }
+        sql += " order by updated_time desc, id desc";
+        return jdbcTemplate.query(sql, args.toArray(), (rs, rowNum) -> mapSkuSetting(rs));
+    }
+
+    public Map<String, Object> saveTypeSetting(Map<String, Object> payload) {
+        ensureInitialized();
+        String productType = normalizedType(asText(payload.get("productType"), asText(payload.get("product_type"), "normal")));
+        double safetyDays = nonNegative(asDouble(payload.get("safetyDaysDefault"), asDouble(payload.get("safety_days_default"), 0.0)));
+        int leadTime = nonNegativeInt(asDouble(payload.get("leadTimeDefault"), asDouble(payload.get("lead_time_default"), 30.0)));
+        int maxCycle = nonNegativeInt(asDouble(payload.get("maxReplenishCycle"), asDouble(payload.get("max_replenish_cycle"), Math.max(leadTime, 30))));
+        double monthlyRatio = nonNegative(asDouble(payload.get("monthlyTargetRatio"), asDouble(payload.get("monthly_target_ratio"), 0.3)));
+        double cycleFactor = nonNegative(asDouble(payload.get("wmaxCycleFactor"), asDouble(payload.get("wmax_cycle_factor"), 1.5)));
+        int targetCoverageDays = nonNegativeInt(asDouble(payload.get("targetCoverageDays"), asDouble(payload.get("target_coverage_days"), 0.0)));
+        double targetTurnoverRate = nonNegative(asDouble(payload.get("targetTurnoverRate"), asDouble(payload.get("target_turnover_rate"), 0.0)));
+        String updatedBy = asText(payload.get("updatedBy"), asText(payload.get("updated_by"), "system"));
+
+        jdbcTemplate.update("insert into " + TYPE_TABLE
+                        + " (product_type, safety_days_default, lead_time_default, max_replenish_cycle,"
+                        + " monthly_target_ratio, wmax_cycle_factor, target_coverage_days, target_turnover_rate,"
+                        + " updated_by, updated_time)"
+                        + " values (?, ?, ?, ?, ?, ?, ?, ?, ?, now())"
+                        + " on duplicate key update safety_days_default = values(safety_days_default),"
+                        + " lead_time_default = values(lead_time_default),"
+                        + " max_replenish_cycle = values(max_replenish_cycle),"
+                        + " monthly_target_ratio = values(monthly_target_ratio),"
+                        + " wmax_cycle_factor = values(wmax_cycle_factor),"
+                        + " target_coverage_days = values(target_coverage_days),"
+                        + " target_turnover_rate = values(target_turnover_rate),"
+                        + " updated_by = values(updated_by), updated_time = now()",
+                productType, safetyDays, leadTime, maxCycle, monthlyRatio, cycleFactor,
+                targetCoverageDays, targetTurnoverRate, updatedBy);
+        return findTypeSetting(productType);
+    }
+
+    public Map<String, Object> saveSkuSetting(Map<String, Object> payload) {
+        ensureInitialized();
+        String sku = asText(payload.get("sku"), "").trim().toUpperCase(Locale.ROOT);
+        if (sku.isEmpty()) {
+            throw new IllegalArgumentException("sku不能为空");
+        }
+        String productType = normalizedType(asText(payload.get("productType"), asText(payload.get("product_type"), "normal")));
+        Double safetyStock = nullableDouble(payload.get("safetyStockOverride"), payload.get("safety_stock_override"));
+        Double safetyDays = nullableDouble(payload.get("safetyDaysOverride"), payload.get("safety_days_override"));
+        Integer leadTime = nullableInt(payload.get("leadTimeOverride"), payload.get("lead_time_override"));
+        Integer maxCycle = nullableInt(payload.get("maxReplenishCycleOverride"), payload.get("max_replenish_cycle_override"));
+        Double monthlyTarget = nullableDouble(payload.get("monthlyTargetOverride"), payload.get("monthly_target_override"));
+        Double defectRate = nullableDouble(payload.get("supplierDefectRateOverride"), payload.get("supplier_defect_rate_override"));
+        String updatedBy = asText(payload.get("updatedBy"), asText(payload.get("updated_by"), "system"));
+
+        jdbcTemplate.update("insert into " + SKU_TABLE
+                        + " (sku, product_type, safety_stock_override, safety_days_override, lead_time_override,"
+                        + " max_replenish_cycle_override, monthly_target_override, supplier_defect_rate_override,"
+                        + " updated_by, updated_time)"
+                        + " values (?, ?, ?, ?, ?, ?, ?, ?, ?, now())"
+                        + " on duplicate key update product_type = values(product_type),"
+                        + " safety_stock_override = values(safety_stock_override),"
+                        + " safety_days_override = values(safety_days_override),"
+                        + " lead_time_override = values(lead_time_override),"
+                        + " max_replenish_cycle_override = values(max_replenish_cycle_override),"
+                        + " monthly_target_override = values(monthly_target_override),"
+                        + " supplier_defect_rate_override = values(supplier_defect_rate_override),"
+                        + " updated_by = values(updated_by), updated_time = now()",
+                sku, productType, safetyStock, safetyDays, leadTime, maxCycle, monthlyTarget, defectRate, updatedBy);
+        List<Map<String, Object>> rows = listSkuSettings(sku);
+        return rows.isEmpty() ? new LinkedHashMap<>() : rows.get(0);
+    }
+
+    public WaterlineConfig resolveConfig(String sku, double dailySales, double turnoverRate) {
+        ensureInitialized();
+        Map<String, Object> skuSetting = findSkuSetting(sku);
+        String productType = skuSetting == null
+                ? classifyProductType(dailySales, turnoverRate)
+                : normalizedType(String.valueOf(skuSetting.getOrDefault("productType", "normal")));
+        Map<String, Object> typeSetting = findTypeSetting(productType);
+
+        double safetyDays = asDouble(typeSetting.get("safetyDaysDefault"), 0.0);
+        int leadTime = nonNegativeInt(asDouble(typeSetting.get("leadTimeDefault"), 30.0));
+        int maxCycle = nonNegativeInt(asDouble(typeSetting.get("maxReplenishCycle"), Math.max(leadTime, 30)));
+        double monthlyRatio = nonNegative(asDouble(typeSetting.get("monthlyTargetRatio"), 0.3));
+        double cycleFactor = nonNegative(asDouble(typeSetting.get("wmaxCycleFactor"), 1.5));
+        int targetCoverageDays = nonNegativeInt(asDouble(typeSetting.get("targetCoverageDays"), 0.0));
+        double targetTurnoverRate = nonNegative(asDouble(typeSetting.get("targetTurnoverRate"), 0.0));
+
+        Double safetyStockOverride = null;
+        Double monthlyTargetOverride = null;
+        Double supplierDefectRateOverride = null;
+        if (skuSetting != null) {
+            safetyStockOverride = asNullableDouble(skuSetting.get("safetyStockOverride"));
+            Double daysOverride = asNullableDouble(skuSetting.get("safetyDaysOverride"));
+            if (daysOverride != null) {
+                safetyDays = nonNegative(daysOverride);
+            }
+            Integer leadOverride = asNullableInt(skuSetting.get("leadTimeOverride"));
+            if (leadOverride != null) {
+                leadTime = Math.max(0, leadOverride);
+            }
+            Integer maxOverride = asNullableInt(skuSetting.get("maxReplenishCycleOverride"));
+            if (maxOverride != null) {
+                maxCycle = Math.max(0, maxOverride);
+            }
+            monthlyTargetOverride = asNullableDouble(skuSetting.get("monthlyTargetOverride"));
+            supplierDefectRateOverride = asNullableDouble(skuSetting.get("supplierDefectRateOverride"));
+        }
+
+        WaterlineConfig config = new WaterlineConfig();
+        config.productType = productType;
+        config.safetyDays = safetyDays;
+        config.leadTime = leadTime;
+        config.maxReplenishCycle = maxCycle;
+        config.monthlyTargetRatio = monthlyRatio;
+        config.wmaxCycleFactor = cycleFactor;
+        config.targetCoverageDays = targetCoverageDays;
+        config.targetTurnoverRate = targetTurnoverRate;
+        config.safetyStockOverride = safetyStockOverride;
+        config.monthlyTargetOverride = monthlyTargetOverride;
+        config.supplierDefectRateOverride = supplierDefectRateOverride;
+        return config;
+    }
+
+    private void ensureInitialized() {
+        if (initialized.get()) {
+            return;
+        }
+        synchronized (initialized) {
+            if (initialized.get()) {
+                return;
+            }
+            createTables();
+            insertDefaultTypes();
+            initialized.set(true);
+        }
+    }
+
+    private void createTables() {
+        jdbcTemplate.execute("create table if not exists " + TYPE_TABLE + " ("
+                + " id bigint not null auto_increment,"
+                + " product_type varchar(32) not null,"
+                + " safety_days_default decimal(10,2) default 0,"
+                + " lead_time_default int default 30,"
+                + " max_replenish_cycle int default 30,"
+                + " monthly_target_ratio decimal(10,4) default 0.3000,"
+                + " wmax_cycle_factor decimal(10,4) default 1.5000,"
+                + " target_coverage_days int default 0,"
+                + " target_turnover_rate decimal(10,4) default 0,"
+                + " updated_by varchar(64) default 'system',"
+                + " updated_time datetime default current_timestamp on update current_timestamp,"
+                + " primary key (id),"
+                + " unique key uk_waterline_type (product_type)"
+                + ") engine=InnoDB default charset=utf8mb4 comment='库存水位线产品类型默认设置'");
+
+        jdbcTemplate.execute("create table if not exists " + SKU_TABLE + " ("
+                + " id bigint not null auto_increment,"
+                + " sku varchar(64) not null,"
+                + " product_type varchar(32) not null default 'normal',"
+                + " safety_stock_override decimal(18,2) null,"
+                + " safety_days_override decimal(10,2) null,"
+                + " lead_time_override int null,"
+                + " max_replenish_cycle_override int null,"
+                + " monthly_target_override decimal(18,2) null,"
+                + " supplier_defect_rate_override decimal(10,4) null,"
+                + " updated_by varchar(64) default 'system',"
+                + " updated_time datetime default current_timestamp on update current_timestamp,"
+                + " primary key (id),"
+                + " unique key uk_waterline_sku (sku)"
+                + ") engine=InnoDB default charset=utf8mb4 comment='库存水位线SKU覆盖设置'");
+    }
+
+    private void insertDefaultTypes() {
+        List<Object[]> defaults = Arrays.asList(
+                new Object[]{"hot", 4.0, 15, 30, 0.3, 1.5, 20, 4.0},
+                new Object[]{"normal", 2.0, 30, 30, 0.3, 1.5, 30, 1.5},
+                new Object[]{"new", 4.0, 30, 45, 0.3, 1.5, 45, 1.0},
+                new Object[]{"slow", 0.0, 30, 45, 0.2, 1.0, 15, 0.5}
+        );
+        for (Object[] row : defaults) {
+            jdbcTemplate.update("insert ignore into " + TYPE_TABLE
+                            + " (product_type, safety_days_default, lead_time_default, max_replenish_cycle,"
+                            + " monthly_target_ratio, wmax_cycle_factor, target_coverage_days, target_turnover_rate,"
+                            + " updated_by, updated_time)"
+                            + " values (?, ?, ?, ?, ?, ?, ?, ?, 'system', now())",
+                    row);
+        }
+    }
+
+    private Map<String, Object> findTypeSetting(String productType) {
+        List<Map<String, Object>> rows = jdbcTemplate.query("select id, product_type, safety_days_default, lead_time_default,"
+                        + " max_replenish_cycle, monthly_target_ratio, wmax_cycle_factor,"
+                        + " target_coverage_days, target_turnover_rate, updated_by, updated_time"
+                        + " from " + TYPE_TABLE + " where product_type = ?",
+                new Object[]{normalizedType(productType)}, (rs, rowNum) -> mapTypeSetting(rs));
+        return rows.isEmpty() ? defaultTypeMap(normalizedType(productType)) : rows.get(0);
+    }
+
+    private Map<String, Object> findSkuSetting(String sku) {
+        if (sku == null || sku.trim().isEmpty()) {
+            return null;
+        }
+        List<Map<String, Object>> rows = listSkuSettings(sku);
+        return rows.isEmpty() ? null : rows.get(0);
+    }
+
+    private Map<String, Object> mapTypeSetting(ResultSet rs) throws java.sql.SQLException {
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("id", rs.getLong("id"));
+        row.put("productType", rs.getString("product_type"));
+        row.put("safetyDaysDefault", rs.getDouble("safety_days_default"));
+        row.put("leadTimeDefault", rs.getInt("lead_time_default"));
+        row.put("maxReplenishCycle", rs.getInt("max_replenish_cycle"));
+        row.put("monthlyTargetRatio", rs.getDouble("monthly_target_ratio"));
+        row.put("wmaxCycleFactor", rs.getDouble("wmax_cycle_factor"));
+        row.put("targetCoverageDays", rs.getInt("target_coverage_days"));
+        row.put("targetTurnoverRate", rs.getDouble("target_turnover_rate"));
+        row.put("updatedBy", rs.getString("updated_by"));
+        row.put("updatedTime", formatTime(rs.getTimestamp("updated_time")));
+        return row;
+    }
+
+    private Map<String, Object> mapSkuSetting(ResultSet rs) throws java.sql.SQLException {
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("id", rs.getLong("id"));
+        row.put("sku", rs.getString("sku"));
+        row.put("productType", rs.getString("product_type"));
+        row.put("safetyStockOverride", nullableNumber(rs, "safety_stock_override"));
+        row.put("safetyDaysOverride", nullableNumber(rs, "safety_days_override"));
+        row.put("leadTimeOverride", nullableInt(rs, "lead_time_override"));
+        row.put("maxReplenishCycleOverride", nullableInt(rs, "max_replenish_cycle_override"));
+        row.put("monthlyTargetOverride", nullableNumber(rs, "monthly_target_override"));
+        row.put("supplierDefectRateOverride", nullableNumber(rs, "supplier_defect_rate_override"));
+        row.put("updatedBy", rs.getString("updated_by"));
+        row.put("updatedTime", formatTime(rs.getTimestamp("updated_time")));
+        return row;
+    }
+
+    private Map<String, Object> defaultTypeMap(String productType) {
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("productType", normalizedType(productType));
+        row.put("safetyDaysDefault", 2.0);
+        row.put("leadTimeDefault", 30);
+        row.put("maxReplenishCycle", 30);
+        row.put("monthlyTargetRatio", 0.3);
+        row.put("wmaxCycleFactor", 1.5);
+        row.put("targetCoverageDays", 30);
+        row.put("targetTurnoverRate", 1.5);
+        return row;
+    }
+
+    private String classifyProductType(double dailySales, double turnoverRate) {
+        if (dailySales >= 5.0 || turnoverRate >= 4.0) {
+            return "hot";
+        }
+        if (dailySales <= 0.2 || turnoverRate < 0.5) {
+            return "slow";
+        }
+        return "normal";
+    }
+
+    private String normalizedType(String productType) {
+        if (productType == null) {
+            return "normal";
+        }
+        String text = productType.trim().toLowerCase(Locale.ROOT);
+        if ("hot".equals(text) || "new".equals(text) || "slow".equals(text)) {
+            return text;
+        }
+        return "normal";
+    }
+
+    private static String asText(Object value, String fallback) {
+        if (value == null) {
+            return fallback;
+        }
+        String text = String.valueOf(value).trim();
+        return text.isEmpty() || "null".equalsIgnoreCase(text) ? fallback : text;
+    }
+
+    private static double asDouble(Object value, double fallback) {
+        if (value instanceof Number) {
+            return ((Number) value).doubleValue();
+        }
+        if (value instanceof String && !((String) value).trim().isEmpty()) {
+            try {
+                return Double.parseDouble(((String) value).trim());
+            } catch (NumberFormatException ignored) {
+            }
+        }
+        return fallback;
+    }
+
+    private static Double nullableDouble(Object first, Object second) {
+        Double value = asNullableDouble(first);
+        return value == null ? asNullableDouble(second) : value;
+    }
+
+    private static Integer nullableInt(Object first, Object second) {
+        Integer value = asNullableInt(first);
+        return value == null ? asNullableInt(second) : value;
+    }
+
+    private static Double asNullableDouble(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).doubleValue();
+        }
+        String text = String.valueOf(value).trim();
+        if (text.isEmpty() || "null".equalsIgnoreCase(text)) {
+            return null;
+        }
+        try {
+            return Double.parseDouble(text);
+        } catch (NumberFormatException ignored) {
+            return null;
+        }
+    }
+
+    private static Integer asNullableInt(Object value) {
+        Double number = asNullableDouble(value);
+        return number == null ? null : Math.max(0, number.intValue());
+    }
+
+    private static double nonNegative(double value) {
+        return Math.max(0.0, value);
+    }
+
+    private static int nonNegativeInt(double value) {
+        return Math.max(0, (int) Math.round(value));
+    }
+
+    private static Object nullableNumber(ResultSet rs, String column) throws java.sql.SQLException {
+        double value = rs.getDouble(column);
+        return rs.wasNull() ? null : value;
+    }
+
+    private static Object nullableInt(ResultSet rs, String column) throws java.sql.SQLException {
+        int value = rs.getInt(column);
+        return rs.wasNull() ? null : value;
+    }
+
+    private static String formatTime(Timestamp timestamp) {
+        return timestamp == null ? null : timestamp.toLocalDateTime().toString().replace('T', ' ');
+    }
+
+    public static class WaterlineConfig {
+        public String productType;
+        public double safetyDays;
+        public int leadTime;
+        public int maxReplenishCycle;
+        public double monthlyTargetRatio;
+        public double wmaxCycleFactor;
+        public int targetCoverageDays;
+        public double targetTurnoverRate;
+        public Double safetyStockOverride;
+        public Double monthlyTargetOverride;
+        public Double supplierDefectRateOverride;
+
+        public Map<String, Object> toMap() {
+            Map<String, Object> row = new LinkedHashMap<>();
+            row.put("productType", productType);
+            row.put("safetyDays", safetyDays);
+            row.put("leadTime", leadTime);
+            row.put("maxReplenishCycle", maxReplenishCycle);
+            row.put("monthlyTargetRatio", monthlyTargetRatio);
+            row.put("wmaxCycleFactor", wmaxCycleFactor);
+            row.put("targetCoverageDays", targetCoverageDays);
+            row.put("targetTurnoverRate", targetTurnoverRate);
+            row.put("safetyStockOverride", safetyStockOverride);
+            row.put("monthlyTargetOverride", monthlyTargetOverride);
+            row.put("supplierDefectRateOverride", supplierDefectRateOverride);
+            return row;
+        }
+    }
+}