3 Achegas 87f36d6bb6 ... bcefc96450

Autor SHA1 Mensaxe Data
  Yihao Kang bcefc96450 生命周期模块路径修改 hai 2 semanas
  Yihao Kang d700c301b2 Merge branch 'master' of http://106.14.194.251:3000/dtm/dtm_java hai 2 semanas
  Yihao Kang 568167b23e 生命周期模块路径修改 hai 2 semanas

+ 189 - 0
dtm-admin/src/main/java/com/dtm/web/controller/sale/SalesForecastController.java

@@ -0,0 +1,189 @@
+package com.dtm.web.controller.sale;
+
+import com.dtm.salesforecast.service.ISalesForecastService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.bind.annotation.RequestParam;
+
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Java implementation of the sales forecast APIs previously served by Python.
+ */
+@RestController
+@RequestMapping("/api")
+public class SalesForecastController
+{
+    @Autowired
+    private ISalesForecastService salesForecastService;
+
+    @PostMapping("/sale-overview/upload")
+    public Map<String, Object> uploadSaleOverview(@RequestParam(value = "file", required = false) MultipartFile file)
+    {
+        return ok(message("销量预测已切换为数据库模式,上传文件仅保留为兼容入口"));
+    }
+
+    @PostMapping("/sale-trend/upload")
+    public Map<String, Object> uploadSaleTrend(@RequestParam(value = "file", required = false) MultipartFile file)
+    {
+        return ok(message("销量预测已切换为数据库模式,上传文件仅保留为兼容入口"));
+    }
+
+    @PostMapping("/sale-effect/upload")
+    public Map<String, Object> uploadSaleEffect(@RequestParam(value = "file", required = false) MultipartFile file)
+    {
+        return ok(message("销量预测已切换为数据库模式,上传文件仅保留为兼容入口"));
+    }
+
+    @PostMapping("/sale-overview/analyze")
+    public Map<String, Object> analyzeSaleOverview(@RequestBody(required = false) Map<String, Object> params)
+    {
+        return handle(new Action()
+        {
+            @Override
+            public Map<String, Object> run()
+            {
+                return salesForecastService.analyzeOverview(safeParams(params));
+            }
+        });
+    }
+
+    @GetMapping("/sale-overview/results")
+    public Map<String, Object> getSaleOverviewResults()
+    {
+        return handle(new Action()
+        {
+            @Override
+            public Map<String, Object> run()
+            {
+                return salesForecastService.getLastOverviewResults();
+            }
+        });
+    }
+
+    @PostMapping("/sale-trend/analyze")
+    public Map<String, Object> analyzeSaleTrend(@RequestBody(required = false) Map<String, Object> params)
+    {
+        return handle(new Action()
+        {
+            @Override
+            public Map<String, Object> run()
+            {
+                return salesForecastService.analyzeTrend(safeParams(params));
+            }
+        });
+    }
+
+    @GetMapping("/sale-trend/results")
+    public Map<String, Object> getSaleTrendResults()
+    {
+        return handle(new Action()
+        {
+            @Override
+            public Map<String, Object> run()
+            {
+                return salesForecastService.getLastTrendResults();
+            }
+        });
+    }
+
+    @PostMapping("/sale-trend/predict")
+    public Map<String, Object> predictSaleTrend(@RequestBody(required = false) Map<String, Object> params)
+    {
+        return handle(new Action()
+        {
+            @Override
+            public Map<String, Object> run()
+            {
+                return salesForecastService.predictTrend(safeParams(params));
+            }
+        });
+    }
+
+    @PostMapping("/sale-trend/backtest")
+    public Map<String, Object> backtestSaleTrend(@RequestBody(required = false) Map<String, Object> params)
+    {
+        return handle(new Action()
+        {
+            @Override
+            public Map<String, Object> run()
+            {
+                return salesForecastService.backtestTrend(safeParams(params));
+            }
+        });
+    }
+
+    @PostMapping("/sale-effect/analyze")
+    public Map<String, Object> analyzeSaleEffect(@RequestBody(required = false) Map<String, Object> params)
+    {
+        return handle(new Action()
+        {
+            @Override
+            public Map<String, Object> run()
+            {
+                return salesForecastService.analyzeEffect(safeParams(params));
+            }
+        });
+    }
+
+    @GetMapping("/sale-effect/results")
+    public Map<String, Object> getSaleEffectResults()
+    {
+        return handle(new Action()
+        {
+            @Override
+            public Map<String, Object> run()
+            {
+                return salesForecastService.getLastEffectResults();
+            }
+        });
+    }
+
+    private Map<String, Object> handle(Action action)
+    {
+        try
+        {
+            return ok(action.run());
+        }
+        catch (Exception e)
+        {
+            Map<String, Object> result = new LinkedHashMap<String, Object>();
+            result.put("success", false);
+            result.put("message", e.getMessage());
+            return result;
+        }
+    }
+
+    private Map<String, Object> ok(Map<String, Object> data)
+    {
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        result.put("success", true);
+        result.put("message", "success");
+        result.put("data", data);
+        return result;
+    }
+
+    private Map<String, Object> message(String text)
+    {
+        Map<String, Object> data = new LinkedHashMap<String, Object>();
+        data.put("message", text);
+        return data;
+    }
+
+    private Map<String, Object> safeParams(Map<String, Object> params)
+    {
+        return params == null ? new HashMap<String, Object>() : params;
+    }
+
+    private interface Action
+    {
+        Map<String, Object> run();
+    }
+}

+ 118 - 0
dtm-system/src/main/java/com/dtm/salesforecast/domain/SalesForecastDailySales.java

@@ -0,0 +1,118 @@
+package com.dtm.salesforecast.domain;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * Daily sales aggregate used by the Java sales forecast module.
+ */
+public class SalesForecastDailySales
+{
+    private String sku;
+
+    private String category;
+
+    private Date saleDate;
+
+    private Long quantity;
+
+    private BigDecimal revenue;
+
+    private BigDecimal payableAmount;
+
+    private BigDecimal refundAmount;
+
+    private BigDecimal promotionStrength;
+
+    private BigDecimal avgPrice;
+
+    public String getSku()
+    {
+        return sku;
+    }
+
+    public void setSku(String sku)
+    {
+        this.sku = sku;
+    }
+
+    public String getCategory()
+    {
+        return category;
+    }
+
+    public void setCategory(String category)
+    {
+        this.category = category;
+    }
+
+    public Date getSaleDate()
+    {
+        return saleDate;
+    }
+
+    public void setSaleDate(Date saleDate)
+    {
+        this.saleDate = saleDate;
+    }
+
+    public Long getQuantity()
+    {
+        return quantity;
+    }
+
+    public void setQuantity(Long quantity)
+    {
+        this.quantity = quantity;
+    }
+
+    public BigDecimal getRevenue()
+    {
+        return revenue;
+    }
+
+    public void setRevenue(BigDecimal revenue)
+    {
+        this.revenue = revenue;
+    }
+
+    public BigDecimal getPayableAmount()
+    {
+        return payableAmount;
+    }
+
+    public void setPayableAmount(BigDecimal payableAmount)
+    {
+        this.payableAmount = payableAmount;
+    }
+
+    public BigDecimal getRefundAmount()
+    {
+        return refundAmount;
+    }
+
+    public void setRefundAmount(BigDecimal refundAmount)
+    {
+        this.refundAmount = refundAmount;
+    }
+
+    public BigDecimal getPromotionStrength()
+    {
+        return promotionStrength;
+    }
+
+    public void setPromotionStrength(BigDecimal promotionStrength)
+    {
+        this.promotionStrength = promotionStrength;
+    }
+
+    public BigDecimal getAvgPrice()
+    {
+        return avgPrice;
+    }
+
+    public void setAvgPrice(BigDecimal avgPrice)
+    {
+        this.avgPrice = avgPrice;
+    }
+}

+ 17 - 0
dtm-system/src/main/java/com/dtm/salesforecast/mapper/SalesForecastMapper.java

@@ -0,0 +1,17 @@
+package com.dtm.salesforecast.mapper;
+
+import com.dtm.salesforecast.domain.SalesForecastDailySales;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+public interface SalesForecastMapper
+{
+    List<SalesForecastDailySales> selectDailySales(
+            @Param("startDate") String startDate,
+            @Param("endDate") String endDate,
+            @Param("sku") String sku,
+            @Param("category") String category,
+            @Param("lookbackDays") Integer lookbackDays
+    );
+}

+ 22 - 0
dtm-system/src/main/java/com/dtm/salesforecast/service/ISalesForecastService.java

@@ -0,0 +1,22 @@
+package com.dtm.salesforecast.service;
+
+import java.util.Map;
+
+public interface ISalesForecastService
+{
+    Map<String, Object> analyzeTrend(Map<String, Object> params);
+
+    Map<String, Object> predictTrend(Map<String, Object> params);
+
+    Map<String, Object> backtestTrend(Map<String, Object> params);
+
+    Map<String, Object> analyzeOverview(Map<String, Object> params);
+
+    Map<String, Object> analyzeEffect(Map<String, Object> params);
+
+    Map<String, Object> getLastTrendResults();
+
+    Map<String, Object> getLastOverviewResults();
+
+    Map<String, Object> getLastEffectResults();
+}

+ 1319 - 0
dtm-system/src/main/java/com/dtm/salesforecast/service/impl/SalesForecastServiceImpl.java

@@ -0,0 +1,1319 @@
+package com.dtm.salesforecast.service.impl;
+
+import com.dtm.salesforecast.domain.SalesForecastDailySales;
+import com.dtm.salesforecast.mapper.SalesForecastMapper;
+import com.dtm.salesforecast.service.ISalesForecastService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.text.SimpleDateFormat;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+
+@Service
+public class SalesForecastServiceImpl implements ISalesForecastService
+{
+    private static final int DEFAULT_LOOKBACK_DAYS = 730;
+
+    private static final int MIN_NEURAL_DAYS = 8;
+
+    private static final String MODEL_TYPE = "neural_network_mlp";
+
+    private static final List<String> FEATURE_NAMES = Arrays.asList(
+            "前1日销量",
+            "前7日销量",
+            "前14日销量",
+            "近7日平均销量",
+            "近14日平均销量",
+            "近7日销量波动",
+            "平均成交价",
+            "促销力度",
+            "退款率",
+            "星期周期-正弦",
+            "星期周期-余弦",
+            "长期时间趋势"
+    );
+
+    @Autowired
+    private SalesForecastMapper salesForecastMapper;
+
+    private volatile Map<String, Object> lastTrendResults;
+
+    private volatile Map<String, Object> lastOverviewResults;
+
+    private volatile Map<String, Object> lastEffectResults;
+
+    @Override
+    public Map<String, Object> analyzeTrend(Map<String, Object> params)
+    {
+        List<SalesForecastDailySales> rows = loadRows(params);
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("summary", buildSummary(rows));
+        result.put("categories", buildGroupedSeries(rows, "category"));
+        result.put("category_list", new ArrayList<String>(collectValues(rows, "category")));
+        result.put("category_skus", buildCategorySkus(rows));
+        result.put("data", buildGroupedSeries(rows, "sku"));
+        result.put("sku_list", new ArrayList<String>(collectValues(rows, "sku")));
+        result.put("trends", buildTrendData(buildDailySeries(rows)));
+        result.put("seasonality", buildSeasonality(buildDailySeries(rows)));
+        result.put("feature_importance", calculateFeatureImportance(buildDailySeries(rows), true));
+        lastTrendResults = result;
+        return result;
+    }
+
+    @Override
+    public Map<String, Object> predictTrend(Map<String, Object> params)
+    {
+        int predictDays = clampInt(intParam(params, "predict_days", 30), 1, 180);
+        Controls controls = Controls.from(params);
+        List<SalesForecastDailySales> rows = loadRows(params);
+        Map<String, Object> analysis = analyzeTrend(params);
+
+        Map<String, Object> overall = forecast(buildDailySeries(rows), predictDays, controls, true);
+        Map<String, Object> featureImportance = asMap(overall.remove("feature_importance"));
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("summary", analysis.get("summary"));
+        result.put("overall_prediction", overall);
+        result.put("category_predictions", buildPredictionByGroup(rows, "category", predictDays, controls));
+        result.put("sku_predictions", buildPredictionByGroup(rows, "sku", predictDays, controls));
+        result.put("feature_importance", featureImportance);
+        result.put("predict_days", predictDays);
+        result.put("control_params", controls.toMap());
+        return result;
+    }
+
+    @Override
+    public Map<String, Object> backtestTrend(Map<String, Object> params)
+    {
+        int window = clampInt(intParam(params, "window", 30), 1, 180);
+        Controls controls = Controls.from(params);
+        DailySeries daily = buildDailySeries(loadRows(params));
+        if (daily.quantities.size() < MIN_NEURAL_DAYS + 3)
+        {
+            throw new IllegalArgumentException("神经网络回测至少需要 " + (MIN_NEURAL_DAYS + 3) + " 个自然日的销售数据");
+        }
+
+        window = Math.min(window, daily.quantities.size() - MIN_NEURAL_DAYS);
+        DailySeries train = daily.slice(0, daily.quantities.size() - window);
+        DailySeries actual = daily.slice(daily.quantities.size() - window, daily.quantities.size());
+        FitResult fit = fitModel(train, true);
+        if (fit.model == null)
+        {
+            throw new IllegalArgumentException("训练数据不足,无法执行神经网络回测");
+        }
+
+        List<Double> history = new ArrayList<Double>(train.quantities);
+        List<Double> prices = new ArrayList<Double>(train.avgPrices);
+        List<Double> promos = new ArrayList<Double>(train.promotions);
+        List<Double> refunds = new ArrayList<Double>(train.refundRates);
+        List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
+        for (int i = 0; i < actual.dates.size(); i++)
+        {
+            LocalDate date = actual.dates.get(i);
+            double predicted = Math.max(0, fit.model.predict(featureRow(history, date, prices, promos, refunds, history.size(), daily.quantities.size())) * controls.adjustment());
+            double real = actual.quantities.get(i);
+            double abs = Math.abs(real - predicted);
+            double rel = real > 0 ? abs / real * 100 : (predicted > 0 ? 100 : 0);
+            Map<String, Object> row = new LinkedHashMap<String, Object>();
+            row.put("date", date.toString());
+            row.put("actual", round(real, 2));
+            row.put("predicted", round(predicted, 2));
+            row.put("absError", round(abs, 2));
+            row.put("relativeError", round(rel, 2));
+            row.put("status", rel > 20 ? "异常" : "正常");
+            rows.add(row);
+
+            history.add(real);
+            prices.add(actual.avgPrices.get(i));
+            promos.add(actual.promotions.get(i));
+            refunds.add(actual.refundRates.get(i));
+        }
+
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        result.put("window", window);
+        result.put("train_days", train.quantities.size());
+        result.put("rows", rows);
+        result.put("metrics", buildMetrics(rows));
+        result.put("control_params", controls.toMap());
+        result.put("model_type", MODEL_TYPE);
+        result.put("feature_importance", fit.importance);
+        return result;
+    }
+
+    @Override
+    public Map<String, Object> analyzeOverview(Map<String, Object> params)
+    {
+        List<SalesForecastDailySales> rows = loadRows(params);
+        DailySeries daily = buildDailySeries(rows);
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        result.put("summary", buildSummary(rows));
+        result.put("daily_trend", buildTrendData(daily));
+        result.put("category_analysis", buildCategoryOverview(rows));
+        result.put("top_skus", buildTopSku(rows));
+        result.put("generated_at", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
+        lastOverviewResults = result;
+        return result;
+    }
+
+    @Override
+    public Map<String, Object> analyzeEffect(Map<String, Object> params)
+    {
+        List<SalesForecastDailySales> rows = loadRows(params);
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        result.put("summary", buildEffectSummary(rows));
+        result.put("category_analysis", buildCategoryOverview(rows));
+        result.put("promotion_trend", buildPromotionTrend(buildDailySeries(rows)));
+        result.put("sku_analysis", buildTopSku(rows));
+        lastEffectResults = result;
+        return result;
+    }
+
+    @Override
+    public Map<String, Object> getLastTrendResults()
+    {
+        return lastTrendResults == null ? analyzeTrend(new HashMap<String, Object>()) : lastTrendResults;
+    }
+
+    @Override
+    public Map<String, Object> getLastOverviewResults()
+    {
+        return lastOverviewResults == null ? analyzeOverview(new HashMap<String, Object>()) : lastOverviewResults;
+    }
+
+    @Override
+    public Map<String, Object> getLastEffectResults()
+    {
+        return lastEffectResults == null ? analyzeEffect(new HashMap<String, Object>()) : lastEffectResults;
+    }
+
+    private List<SalesForecastDailySales> loadRows(Map<String, Object> params)
+    {
+        String startDate = strParam(params, "start_date", strParam(params, "date_start", strParam(params, "startDate", null)));
+        String endDate = strParam(params, "end_date", strParam(params, "date_end", strParam(params, "endDate", null)));
+        String sku = strParam(params, "sku", null);
+        String category = strParam(params, "category", null);
+        Integer lookbackDays = (isBlank(startDate) && isBlank(endDate)) ? DEFAULT_LOOKBACK_DAYS : null;
+        List<SalesForecastDailySales> rows = salesForecastMapper.selectDailySales(startDate, endDate, sku, category, lookbackDays);
+        if (rows == null || rows.isEmpty())
+        {
+            throw new IllegalArgumentException("数据库中没有找到符合条件的销售订单数据");
+        }
+        return rows;
+    }
+
+    private Map<String, Object> buildSummary(List<SalesForecastDailySales> rows)
+    {
+        Set<String> skus = new LinkedHashSet<String>();
+        Set<String> categories = new LinkedHashSet<String>();
+        long quantity = 0L;
+        double revenue = 0;
+        LocalDate min = null;
+        LocalDate max = null;
+        for (SalesForecastDailySales row : rows)
+        {
+            skus.add(blankToOther(row.getSku()));
+            categories.add(blankToOther(row.getCategory()));
+            quantity += longValue(row.getQuantity());
+            revenue += doubleValue(row.getRevenue());
+            LocalDate date = toLocalDate(row.getSaleDate());
+            if (date != null)
+            {
+                min = min == null || date.isBefore(min) ? date : min;
+                max = max == null || date.isAfter(max) ? date : max;
+            }
+        }
+        long days = min == null || max == null ? 0 : java.time.temporal.ChronoUnit.DAYS.between(min, max) + 1;
+        Map<String, Object> summary = new LinkedHashMap<String, Object>();
+        summary.put("total_quantity", quantity);
+        summary.put("total_revenue", round(revenue, 2));
+        summary.put("avg_daily_quantity", days <= 0 ? 0 : round(quantity * 1.0 / days, 2));
+        summary.put("sku_count", skus.size());
+        summary.put("category_count", categories.size());
+        summary.put("date_range", Arrays.asList(min == null ? "" : min.toString(), max == null ? "" : max.toString()));
+        summary.put("data_source", "database");
+        return summary;
+    }
+
+    private Map<String, Object> buildGroupedSeries(List<SalesForecastDailySales> rows, String groupType)
+    {
+        Map<String, List<SalesForecastDailySales>> groups = groupRows(rows, groupType);
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        for (Map.Entry<String, List<SalesForecastDailySales>> entry : groups.entrySet())
+        {
+            result.put(entry.getKey(), buildTrendData(buildDailySeries(entry.getValue())));
+        }
+        return result;
+    }
+
+    private Map<String, Object> buildPredictionByGroup(List<SalesForecastDailySales> rows, String groupType, int days, Controls controls)
+    {
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        for (Map.Entry<String, List<SalesForecastDailySales>> entry : groupRows(rows, groupType).entrySet())
+        {
+            Map<String, Object> prediction = forecast(buildDailySeries(entry.getValue()), days, controls, false);
+            prediction.remove("feature_importance");
+            result.put(entry.getKey(), prediction);
+        }
+        return result;
+    }
+
+    private Map<String, Object> buildTrendData(DailySeries daily)
+    {
+        Map<String, Object> data = new LinkedHashMap<String, Object>();
+        List<String> dates = new ArrayList<String>();
+        List<Double> quantity = new ArrayList<Double>();
+        List<Double> revenue = new ArrayList<Double>();
+        for (int i = 0; i < daily.dates.size(); i++)
+        {
+            dates.add(daily.dates.get(i).toString());
+            quantity.add(round(daily.quantities.get(i), 2));
+            revenue.add(round(daily.revenues.get(i), 2));
+        }
+        data.put("date_series", dates);
+        data.put("quantity_series", quantity);
+        data.put("revenue_series", revenue);
+        return data;
+    }
+
+    private Map<String, Object> buildSeasonality(DailySeries daily)
+    {
+        double[] sum = new double[7];
+        int[] count = new int[7];
+        for (int i = 0; i < daily.dates.size(); i++)
+        {
+            int idx = daily.dates.get(i).getDayOfWeek().getValue() - 1;
+            sum[idx] += daily.quantities.get(i);
+            count[idx]++;
+        }
+        List<String> labels = Arrays.asList("周一", "周二", "周三", "周四", "周五", "周六", "周日");
+        List<Double> values = new ArrayList<Double>();
+        for (int i = 0; i < 7; i++)
+        {
+            values.add(count[i] == 0 ? 0 : round(sum[i] / count[i], 2));
+        }
+        Map<String, Object> data = new LinkedHashMap<String, Object>();
+        data.put("labels", labels);
+        data.put("values", values);
+        return data;
+    }
+
+    private Map<String, Object> forecast(DailySeries daily, int predictDays, Controls controls, boolean importance)
+    {
+        FitResult fit = fitModel(daily, importance);
+        List<Double> quantities = new ArrayList<Double>(daily.quantities);
+        List<Double> prices = new ArrayList<Double>(daily.avgPrices);
+        List<Double> promos = new ArrayList<Double>(daily.promotions);
+        List<Double> refunds = new ArrayList<Double>(daily.refundRates);
+        double recentPrice = recent(prices, 14, 0);
+        double recentPromo = recent(promos, 14, 0);
+        double recentRefund = recent(refunds, 14, 0);
+        LocalDate lastDate = daily.dates.get(daily.dates.size() - 1);
+
+        List<String> dates = new ArrayList<String>();
+        List<Double> predictions = new ArrayList<Double>();
+        List<Double> revenues = new ArrayList<Double>();
+        List<Double> lower = new ArrayList<Double>();
+        List<Double> upper = new ArrayList<Double>();
+        double z = inverseNormal(0.5 + controls.confidence / 200.0);
+
+        for (int i = 1; i <= predictDays; i++)
+        {
+            LocalDate date = lastDate.plusDays(i);
+            double base = fit.model == null ? recent(quantities, Math.min(7, quantities.size()), 0)
+                    : fit.model.predict(featureRow(quantities, date, prices, promos, refunds, quantities.size(), daily.quantities.size() + predictDays));
+            double predicted = Math.max(0, base * controls.adjustment());
+            double band = fit.residualStd > 0 ? z * fit.residualStd * Math.sqrt(i) : 0;
+            dates.add(date.toString());
+            predictions.add(round(predicted, 2));
+            revenues.add(round(predicted * recentPrice, 2));
+            lower.add(round(Math.max(0, predicted - band), 2));
+            upper.add(round(Math.max(0, predicted + band), 2));
+            quantities.add(predicted);
+            prices.add(recentPrice);
+            promos.add(recentPromo);
+            refunds.add(recentRefund);
+        }
+
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        result.put("date_series", dates);
+        result.put("quantity_series", predictions);
+        result.put("revenue_series", revenues);
+        result.put("confidence_lower_series", lower);
+        result.put("confidence_upper_series", upper);
+        Map<String, Object> modelParams = controls.toMap();
+        modelParams.put("model_type", fit.model == null ? "insufficient_history_fallback" : MODEL_TYPE);
+        modelParams.put("training_days", daily.quantities.size());
+        modelParams.put("training_samples", intValue(fit.importance.get("training_samples")));
+        modelParams.put("validation_mae", fit.importance.get("validation_mae"));
+        modelParams.put("residual_std", round(fit.residualStd, 4));
+        result.put("model_params", modelParams);
+        result.put("feature_importance", fit.importance);
+        return result;
+    }
+
+    private FitResult fitModel(DailySeries daily, boolean calculateImportance)
+    {
+        Dataset dataset = buildDataset(daily);
+        double[][] corr = correlationMatrix(dataset.x, FEATURE_NAMES.size());
+        if (dataset.y.length < MIN_NEURAL_DAYS - 1)
+        {
+            return new FitResult(null, buildImportance(new double[FEATURE_NAMES.size()], corr, "insufficient_history", null, dataset.y.length), 0);
+        }
+
+        int split = Math.max(4, (int) Math.floor(dataset.y.length * 0.8));
+        split = Math.min(split, dataset.y.length - 1);
+        NeuralNet validationModel = new NeuralNet(FEATURE_NAMES.size(), dataset.y.length >= 60 ? 16 : 10, 42);
+        validationModel.fit(slice(dataset.x, 0, split), slice(dataset.y, 0, split), 600);
+        double[] validationPredictions = validationModel.predict(slice(dataset.x, split, dataset.y.length));
+        double[] validationActual = slice(dataset.y, split, dataset.y.length);
+        double mae = meanAbsoluteError(validationActual, validationPredictions);
+        double residualStd = std(diff(validationActual, validationPredictions));
+        double[] rawImportance = calculateImportance ? permutationImportance(validationModel, slice(dataset.x, split, dataset.y.length), validationActual, mae) : new double[FEATURE_NAMES.size()];
+
+        NeuralNet finalModel = new NeuralNet(FEATURE_NAMES.size(), dataset.y.length >= 60 ? 16 : 10, 42);
+        finalModel.fit(dataset.x, dataset.y, 700);
+        return new FitResult(finalModel, buildImportance(rawImportance, corr, "permutation_importance", mae, dataset.y.length), residualStd);
+    }
+
+    private Map<String, Object> calculateFeatureImportance(DailySeries daily, boolean calculateImportance)
+    {
+        return fitModel(daily, calculateImportance).importance;
+    }
+
+    private Dataset buildDataset(DailySeries daily)
+    {
+        List<double[]> rows = new ArrayList<double[]>();
+        List<Double> targets = new ArrayList<Double>();
+        for (int i = 1; i < daily.quantities.size(); i++)
+        {
+            rows.add(featureRow(daily.quantities.subList(0, i), daily.dates.get(i), daily.avgPrices.subList(0, i),
+                    daily.promotions.subList(0, i), daily.refundRates.subList(0, i), i, daily.quantities.size()));
+            targets.add(daily.quantities.get(i));
+        }
+        double[][] x = rows.toArray(new double[rows.size()][]);
+        double[] y = new double[targets.size()];
+        for (int i = 0; i < targets.size(); i++)
+        {
+            y[i] = targets.get(i);
+        }
+        return new Dataset(x, y);
+    }
+
+    private double[] featureRow(List<Double> history, LocalDate date, List<Double> prices, List<Double> promos, List<Double> refunds, int trendIndex, int trendScale)
+    {
+        double angle = 2 * Math.PI * (date.getDayOfWeek().getValue() - 1) / 7.0;
+        return new double[] {
+                lag(history, 1),
+                lag(history, 7),
+                lag(history, 14),
+                recent(history, 7, 0),
+                recent(history, 14, 0),
+                recentStd(history, 7),
+                recent(prices, 7, 0),
+                recent(promos, 7, 0),
+                recent(refunds, 7, 0),
+                Math.sin(angle),
+                Math.cos(angle),
+                trendIndex * 1.0 / Math.max(1, trendScale)
+        };
+    }
+
+    private DailySeries buildDailySeries(List<SalesForecastDailySales> rows)
+    {
+        Map<LocalDate, DailyPoint> map = new LinkedHashMap<LocalDate, DailyPoint>();
+        LocalDate min = null;
+        LocalDate max = null;
+        for (SalesForecastDailySales row : rows)
+        {
+            LocalDate date = toLocalDate(row.getSaleDate());
+            if (date == null)
+            {
+                continue;
+            }
+            DailyPoint point = map.get(date);
+            if (point == null)
+            {
+                point = new DailyPoint();
+                map.put(date, point);
+            }
+            point.quantity += longValue(row.getQuantity());
+            point.revenue += doubleValue(row.getRevenue());
+            point.payable += doubleValue(row.getPayableAmount());
+            point.refund += doubleValue(row.getRefundAmount());
+            point.promotionTotal += doubleValue(row.getPromotionStrength());
+            point.promotionCount++;
+            min = min == null || date.isBefore(min) ? date : min;
+            max = max == null || date.isAfter(max) ? date : max;
+        }
+        if (min == null || max == null)
+        {
+            throw new IllegalArgumentException("销售数据中没有有效日期");
+        }
+
+        DailySeries daily = new DailySeries();
+        double lastPrice = 0;
+        for (LocalDate date = min; !date.isAfter(max); date = date.plusDays(1))
+        {
+            DailyPoint point = map.get(date);
+            if (point == null)
+            {
+                point = new DailyPoint();
+            }
+            double avgPrice = point.quantity > 0 ? point.revenue / point.quantity : lastPrice;
+            if (avgPrice > 0)
+            {
+                lastPrice = avgPrice;
+            }
+            daily.dates.add(date);
+            daily.quantities.add(point.quantity);
+            daily.revenues.add(point.revenue);
+            daily.avgPrices.add(avgPrice);
+            daily.promotions.add(point.promotionCount == 0 ? 0 : point.promotionTotal / point.promotionCount);
+            daily.refundRates.add(point.payable > 0 ? Math.max(0, Math.min(1, point.refund / point.payable)) : 0);
+        }
+        backfillPrices(daily.avgPrices);
+        return daily;
+    }
+
+    private Map<String, List<SalesForecastDailySales>> groupRows(List<SalesForecastDailySales> rows, String groupType)
+    {
+        Map<String, List<SalesForecastDailySales>> groups = new LinkedHashMap<String, List<SalesForecastDailySales>>();
+        for (SalesForecastDailySales row : rows)
+        {
+            String key = "sku".equals(groupType) ? row.getSku() : row.getCategory();
+            key = blankToOther(key);
+            List<SalesForecastDailySales> list = groups.get(key);
+            if (list == null)
+            {
+                list = new ArrayList<SalesForecastDailySales>();
+                groups.put(key, list);
+            }
+            list.add(row);
+        }
+        return groups;
+    }
+
+    private Set<String> collectValues(List<SalesForecastDailySales> rows, String type)
+    {
+        Set<String> values = new LinkedHashSet<String>();
+        for (SalesForecastDailySales row : rows)
+        {
+            values.add(blankToOther("sku".equals(type) ? row.getSku() : row.getCategory()));
+        }
+        return values;
+    }
+
+    private Map<String, Object> buildCategorySkus(List<SalesForecastDailySales> rows)
+    {
+        Map<String, Set<String>> temp = new LinkedHashMap<String, Set<String>>();
+        for (SalesForecastDailySales row : rows)
+        {
+            String category = blankToOther(row.getCategory());
+            if (!temp.containsKey(category))
+            {
+                temp.put(category, new LinkedHashSet<String>());
+            }
+            temp.get(category).add(blankToOther(row.getSku()));
+        }
+        Map<String, Object> result = new LinkedHashMap<String, Object>();
+        for (Map.Entry<String, Set<String>> entry : temp.entrySet())
+        {
+            result.put(entry.getKey(), new ArrayList<String>(entry.getValue()));
+        }
+        return result;
+    }
+
+    private List<Map<String, Object>> buildCategoryOverview(List<SalesForecastDailySales> rows)
+    {
+        List<Map<String, Object>> result = new ArrayList<Map<String, Object>>();
+        for (Map.Entry<String, List<SalesForecastDailySales>> entry : groupRows(rows, "category").entrySet())
+        {
+            result.add(buildAggregateRow(entry.getKey(), entry.getValue()));
+        }
+        Collections.sort(result, aggregateComparator());
+        return result;
+    }
+
+    private List<Map<String, Object>> buildTopSku(List<SalesForecastDailySales> rows)
+    {
+        List<Map<String, Object>> result = new ArrayList<Map<String, Object>>();
+        for (Map.Entry<String, List<SalesForecastDailySales>> entry : groupRows(rows, "sku").entrySet())
+        {
+            result.add(buildAggregateRow(entry.getKey(), entry.getValue()));
+        }
+        Collections.sort(result, aggregateComparator());
+        return result.size() > 20 ? result.subList(0, 20) : result;
+    }
+
+    private Map<String, Object> buildAggregateRow(String name, List<SalesForecastDailySales> rows)
+    {
+        long quantity = 0;
+        double revenue = 0;
+        for (SalesForecastDailySales row : rows)
+        {
+            quantity += longValue(row.getQuantity());
+            revenue += doubleValue(row.getRevenue());
+        }
+        Map<String, Object> data = new LinkedHashMap<String, Object>();
+        data.put("name", name);
+        data.put("quantity", quantity);
+        data.put("revenue", round(revenue, 2));
+        return data;
+    }
+
+    private Map<String, Object> buildEffectSummary(List<SalesForecastDailySales> rows)
+    {
+        double avgPromo = 0;
+        int count = 0;
+        double revenue = 0;
+        long quantity = 0;
+        for (SalesForecastDailySales row : rows)
+        {
+            avgPromo += doubleValue(row.getPromotionStrength());
+            revenue += doubleValue(row.getRevenue());
+            quantity += longValue(row.getQuantity());
+            count++;
+        }
+        Map<String, Object> summary = new LinkedHashMap<String, Object>();
+        summary.put("avg_promotion_strength", count == 0 ? 0 : round(avgPromo / count * 100, 2));
+        summary.put("total_quantity", quantity);
+        summary.put("total_revenue", round(revenue, 2));
+        summary.put("effect_score", round(Math.min(100, Math.max(0, (count == 0 ? 0 : avgPromo / count) * 60 + Math.log10(Math.max(1, quantity)) * 10)), 2));
+        return summary;
+    }
+
+    private Map<String, Object> buildPromotionTrend(DailySeries daily)
+    {
+        Map<String, Object> data = new LinkedHashMap<String, Object>();
+        List<String> dates = new ArrayList<String>();
+        List<Double> values = new ArrayList<Double>();
+        for (int i = 0; i < daily.dates.size(); i++)
+        {
+            dates.add(daily.dates.get(i).toString());
+            values.add(round(daily.promotions.get(i) * 100, 2));
+        }
+        data.put("date_series", dates);
+        data.put("promotion_series", values);
+        return data;
+    }
+
+    private Map<String, Object> buildMetrics(List<Map<String, Object>> rows)
+    {
+        List<Double> actual = new ArrayList<Double>();
+        List<Double> predicted = new ArrayList<Double>();
+        for (Map<String, Object> row : rows)
+        {
+            actual.add(doubleParam(row, "actual", 0));
+            predicted.add(doubleParam(row, "predicted", 0));
+        }
+        double mae = 0;
+        double rmse = 0;
+        double mape = 0;
+        int nonZero = 0;
+        double mean = mean(actual);
+        double ssRes = 0;
+        double ssTot = 0;
+        for (int i = 0; i < actual.size(); i++)
+        {
+            double err = Math.abs(actual.get(i) - predicted.get(i));
+            mae += err;
+            rmse += err * err;
+            ssRes += Math.pow(actual.get(i) - predicted.get(i), 2);
+            ssTot += Math.pow(actual.get(i) - mean, 2);
+            if (actual.get(i) > 0)
+            {
+                mape += err / actual.get(i) * 100;
+                nonZero++;
+            }
+        }
+        int n = Math.max(1, actual.size());
+        Map<String, Object> metrics = new LinkedHashMap<String, Object>();
+        metrics.put("mape", round(nonZero == 0 ? 0 : mape / nonZero, 2));
+        metrics.put("mae", round(mae / n, 2));
+        metrics.put("rmse", round(Math.sqrt(rmse / n), 2));
+        metrics.put("r2", round(ssTot > 0 ? 1 - ssRes / ssTot : 0, 4));
+        return metrics;
+    }
+
+    private Map<String, Object> buildImportance(double[] raw, double[][] corr, String method, Double mae, int samples)
+    {
+        double sum = 0;
+        for (double value : raw)
+        {
+            sum += Math.max(0, value);
+        }
+        List<Double> percentages = new ArrayList<Double>();
+        List<Double> rawValues = new ArrayList<Double>();
+        for (double value : raw)
+        {
+            rawValues.add(round(Math.max(0, value), 6));
+            percentages.add(sum > 0 ? round(Math.max(0, value) / sum * 100, 2) : 0);
+        }
+        List<List<Double>> matrix = new ArrayList<List<Double>>();
+        for (double[] line : corr)
+        {
+            List<Double> row = new ArrayList<Double>();
+            for (double value : line)
+            {
+                row.add(round(value, 4));
+            }
+            matrix.add(row);
+        }
+        Map<String, Object> importance = new LinkedHashMap<String, Object>();
+        importance.put("features", FEATURE_NAMES);
+        importance.put("importance", percentages);
+        importance.put("raw_importance", rawValues);
+        importance.put("correlation_matrix", matrix);
+        importance.put("method", method);
+        importance.put("validation_mae", mae == null ? null : round(mae, 4));
+        importance.put("training_samples", samples);
+        return importance;
+    }
+
+    private double[] permutationImportance(NeuralNet model, double[][] x, double[] y, double baselineMae)
+    {
+        double[] result = new double[FEATURE_NAMES.size()];
+        if (x.length < 2)
+        {
+            return result;
+        }
+        Random random = new Random(42);
+        for (int feature = 0; feature < FEATURE_NAMES.size(); feature++)
+        {
+            double total = 0;
+            for (int repeat = 0; repeat < 6; repeat++)
+            {
+                double[][] shuffled = copyMatrix(x);
+                for (int i = shuffled.length - 1; i > 0; i--)
+                {
+                    int j = random.nextInt(i + 1);
+                    double tmp = shuffled[i][feature];
+                    shuffled[i][feature] = shuffled[j][feature];
+                    shuffled[j][feature] = tmp;
+                }
+                total += Math.max(0, meanAbsoluteError(y, model.predict(shuffled)) - baselineMae);
+            }
+            result[feature] = total / 6.0;
+        }
+        return result;
+    }
+
+    private static class NeuralNet
+    {
+        private final int input;
+
+        private final int hidden;
+
+        private final double[][] w1;
+
+        private final double[] b1;
+
+        private final double[] w2;
+
+        private double b2;
+
+        private double[] meanX;
+
+        private double[] stdX;
+
+        private double meanY;
+
+        private double stdY;
+
+        NeuralNet(int input, int hidden, long seed)
+        {
+            this.input = input;
+            this.hidden = hidden;
+            this.w1 = new double[input][hidden];
+            this.b1 = new double[hidden];
+            this.w2 = new double[hidden];
+            Random random = new Random(seed);
+            for (int i = 0; i < input; i++)
+            {
+                for (int j = 0; j < hidden; j++)
+                {
+                    w1[i][j] = (random.nextDouble() - 0.5) * 0.2;
+                }
+            }
+            for (int j = 0; j < hidden; j++)
+            {
+                w2[j] = (random.nextDouble() - 0.5) * 0.2;
+            }
+        }
+
+        void fit(double[][] x, double[] y, int epochs)
+        {
+            if (x.length == 0)
+            {
+                return;
+            }
+            fitScalers(x, y);
+            double learningRate = 0.01;
+            for (int epoch = 0; epoch < epochs; epoch++)
+            {
+                for (int row = 0; row < x.length; row++)
+                {
+                    double[] sx = scaleX(x[row]);
+                    double target = (y[row] - meanY) / stdY;
+                    double[] hiddenValues = new double[hidden];
+                    for (int j = 0; j < hidden; j++)
+                    {
+                        double z = b1[j];
+                        for (int i = 0; i < input; i++)
+                        {
+                            z += sx[i] * w1[i][j];
+                        }
+                        hiddenValues[j] = Math.max(0, z);
+                    }
+                    double output = b2;
+                    for (int j = 0; j < hidden; j++)
+                    {
+                        output += hiddenValues[j] * w2[j];
+                    }
+                    double error = output - target;
+                    for (int j = 0; j < hidden; j++)
+                    {
+                        double gradW2 = error * hiddenValues[j] + 0.001 * w2[j];
+                        double hiddenGrad = hiddenValues[j] > 0 ? error * w2[j] : 0;
+                        w2[j] -= learningRate * gradW2;
+                        b1[j] -= learningRate * hiddenGrad;
+                        for (int i = 0; i < input; i++)
+                        {
+                            w1[i][j] -= learningRate * (hiddenGrad * sx[i] + 0.001 * w1[i][j]);
+                        }
+                    }
+                    b2 -= learningRate * error;
+                }
+            }
+        }
+
+        double predict(double[] x)
+        {
+            double[] sx = scaleX(x);
+            double output = b2;
+            for (int j = 0; j < hidden; j++)
+            {
+                double z = b1[j];
+                for (int i = 0; i < input; i++)
+                {
+                    z += sx[i] * w1[i][j];
+                }
+                output += Math.max(0, z) * w2[j];
+            }
+            return Math.max(0, output * stdY + meanY);
+        }
+
+        double[] predict(double[][] x)
+        {
+            double[] result = new double[x.length];
+            for (int i = 0; i < x.length; i++)
+            {
+                result[i] = predict(x[i]);
+            }
+            return result;
+        }
+
+        private void fitScalers(double[][] x, double[] y)
+        {
+            meanX = new double[input];
+            stdX = new double[input];
+            for (int i = 0; i < input; i++)
+            {
+                for (double[] row : x)
+                {
+                    meanX[i] += row[i];
+                }
+                meanX[i] /= x.length;
+                for (double[] row : x)
+                {
+                    stdX[i] += Math.pow(row[i] - meanX[i], 2);
+                }
+                stdX[i] = Math.sqrt(stdX[i] / x.length);
+                if (stdX[i] == 0)
+                {
+                    stdX[i] = 1;
+                }
+            }
+            for (double value : y)
+            {
+                meanY += value;
+            }
+            meanY /= y.length;
+            for (double value : y)
+            {
+                stdY += Math.pow(value - meanY, 2);
+            }
+            stdY = Math.sqrt(stdY / y.length);
+            if (stdY == 0)
+            {
+                stdY = 1;
+            }
+        }
+
+        private double[] scaleX(double[] x)
+        {
+            double[] scaled = new double[input];
+            for (int i = 0; i < input; i++)
+            {
+                scaled[i] = (x[i] - meanX[i]) / stdX[i];
+            }
+            return scaled;
+        }
+    }
+
+    private static class DailySeries
+    {
+        private final List<LocalDate> dates = new ArrayList<LocalDate>();
+        private final List<Double> quantities = new ArrayList<Double>();
+        private final List<Double> revenues = new ArrayList<Double>();
+        private final List<Double> avgPrices = new ArrayList<Double>();
+        private final List<Double> promotions = new ArrayList<Double>();
+        private final List<Double> refundRates = new ArrayList<Double>();
+
+        DailySeries slice(int start, int end)
+        {
+            DailySeries result = new DailySeries();
+            result.dates.addAll(dates.subList(start, end));
+            result.quantities.addAll(quantities.subList(start, end));
+            result.revenues.addAll(revenues.subList(start, end));
+            result.avgPrices.addAll(avgPrices.subList(start, end));
+            result.promotions.addAll(promotions.subList(start, end));
+            result.refundRates.addAll(refundRates.subList(start, end));
+            return result;
+        }
+    }
+
+    private static class DailyPoint
+    {
+        private double quantity;
+        private double revenue;
+        private double payable;
+        private double refund;
+        private double promotionTotal;
+        private int promotionCount;
+    }
+
+    private static class Controls
+    {
+        private double growth;
+        private double season;
+        private double promo;
+        private double confidence;
+
+        static Controls from(Map<String, Object> params)
+        {
+            Controls controls = new Controls();
+            controls.growth = clamp(doubleParam(params, "growth", 1.0), 0.2, 3.0);
+            controls.season = clamp(doubleParam(params, "season", 1.0), 0.2, 3.0);
+            controls.promo = clamp(doubleParam(params, "promo", 1.0), 0.2, 3.0);
+            controls.confidence = clamp(doubleParam(params, "confidence", 90), 50, 99);
+            return controls;
+        }
+
+        double adjustment()
+        {
+            return growth * season * promo;
+        }
+
+        Map<String, Object> toMap()
+        {
+            Map<String, Object> map = new LinkedHashMap<String, Object>();
+            map.put("growth", growth);
+            map.put("season", season);
+            map.put("promo", promo);
+            map.put("confidence", confidence);
+            return map;
+        }
+    }
+
+    private static class Dataset
+    {
+        private final double[][] x;
+        private final double[] y;
+
+        Dataset(double[][] x, double[] y)
+        {
+            this.x = x;
+            this.y = y;
+        }
+    }
+
+    private static class FitResult
+    {
+        private final NeuralNet model;
+        private final Map<String, Object> importance;
+        private final double residualStd;
+
+        FitResult(NeuralNet model, Map<String, Object> importance, double residualStd)
+        {
+            this.model = model;
+            this.importance = importance;
+            this.residualStd = residualStd;
+        }
+    }
+
+    private Comparator<Map<String, Object>> aggregateComparator()
+    {
+        return new Comparator<Map<String, Object>>()
+        {
+            @Override
+            public int compare(Map<String, Object> a, Map<String, Object> b)
+            {
+                return Double.compare(doubleParam(b, "revenue", 0), doubleParam(a, "revenue", 0));
+            }
+        };
+    }
+
+    private static double lag(List<Double> values, int days)
+    {
+        if (values == null || values.isEmpty())
+        {
+            return 0;
+        }
+        if (values.size() >= days)
+        {
+            return values.get(values.size() - days);
+        }
+        return mean(values);
+    }
+
+    private static double recent(List<Double> values, int size, double fallback)
+    {
+        if (values == null || values.isEmpty())
+        {
+            return fallback;
+        }
+        int start = Math.max(0, values.size() - size);
+        return mean(values.subList(start, values.size()));
+    }
+
+    private static double recentStd(List<Double> values, int size)
+    {
+        if (values == null || values.isEmpty())
+        {
+            return 0;
+        }
+        int start = Math.max(0, values.size() - size);
+        List<Double> sub = values.subList(start, values.size());
+        double avg = mean(sub);
+        double total = 0;
+        for (Double value : sub)
+        {
+            total += Math.pow(value - avg, 2);
+        }
+        return Math.sqrt(total / Math.max(1, sub.size()));
+    }
+
+    private static double mean(List<Double> values)
+    {
+        if (values == null || values.isEmpty())
+        {
+            return 0;
+        }
+        double total = 0;
+        for (Double value : values)
+        {
+            total += value == null ? 0 : value;
+        }
+        return total / values.size();
+    }
+
+    private static double std(double[] values)
+    {
+        if (values.length == 0)
+        {
+            return 0;
+        }
+        double avg = 0;
+        for (double value : values)
+        {
+            avg += value;
+        }
+        avg /= values.length;
+        double total = 0;
+        for (double value : values)
+        {
+            total += Math.pow(value - avg, 2);
+        }
+        return Math.sqrt(total / values.length);
+    }
+
+    private static double meanAbsoluteError(double[] actual, double[] predicted)
+    {
+        if (actual.length == 0)
+        {
+            return 0;
+        }
+        double total = 0;
+        for (int i = 0; i < actual.length; i++)
+        {
+            total += Math.abs(actual[i] - predicted[i]);
+        }
+        return total / actual.length;
+    }
+
+    private static double[] diff(double[] actual, double[] predicted)
+    {
+        double[] result = new double[actual.length];
+        for (int i = 0; i < actual.length; i++)
+        {
+            result[i] = actual[i] - predicted[i];
+        }
+        return result;
+    }
+
+    private static double[][] correlationMatrix(double[][] x, int size)
+    {
+        double[][] matrix = new double[size][size];
+        for (int i = 0; i < size; i++)
+        {
+            for (int j = 0; j < size; j++)
+            {
+                matrix[i][j] = i == j ? 1 : Math.abs(correlation(column(x, i), column(x, j)));
+            }
+        }
+        return matrix;
+    }
+
+    private static double correlation(double[] a, double[] b)
+    {
+        if (a.length < 2)
+        {
+            return 0;
+        }
+        double avgA = 0;
+        double avgB = 0;
+        for (int i = 0; i < a.length; i++)
+        {
+            avgA += a[i];
+            avgB += b[i];
+        }
+        avgA /= a.length;
+        avgB /= b.length;
+        double numerator = 0;
+        double denA = 0;
+        double denB = 0;
+        for (int i = 0; i < a.length; i++)
+        {
+            numerator += (a[i] - avgA) * (b[i] - avgB);
+            denA += Math.pow(a[i] - avgA, 2);
+            denB += Math.pow(b[i] - avgB, 2);
+        }
+        double den = Math.sqrt(denA * denB);
+        return den == 0 ? 0 : numerator / den;
+    }
+
+    private static double[] column(double[][] x, int col)
+    {
+        double[] result = new double[x.length];
+        for (int i = 0; i < x.length; i++)
+        {
+            result[i] = x[i][col];
+        }
+        return result;
+    }
+
+    private static double[][] slice(double[][] x, int start, int end)
+    {
+        double[][] result = new double[Math.max(0, end - start)][];
+        for (int i = start; i < end; i++)
+        {
+            result[i - start] = Arrays.copyOf(x[i], x[i].length);
+        }
+        return result;
+    }
+
+    private static double[] slice(double[] x, int start, int end)
+    {
+        return Arrays.copyOfRange(x, start, end);
+    }
+
+    private static double[][] copyMatrix(double[][] x)
+    {
+        double[][] result = new double[x.length][];
+        for (int i = 0; i < x.length; i++)
+        {
+            result[i] = Arrays.copyOf(x[i], x[i].length);
+        }
+        return result;
+    }
+
+    private static void backfillPrices(List<Double> prices)
+    {
+        double first = 0;
+        for (Double price : prices)
+        {
+            if (price != null && price > 0)
+            {
+                first = price;
+                break;
+            }
+        }
+        double last = first;
+        for (int i = 0; i < prices.size(); i++)
+        {
+            if (prices.get(i) == null || prices.get(i) <= 0)
+            {
+                prices.set(i, last);
+            }
+            else
+            {
+                last = prices.get(i);
+            }
+        }
+    }
+
+    private static double inverseNormal(double p)
+    {
+        if (p <= 0 || p >= 1)
+        {
+            return 0;
+        }
+        double a1 = -39.6968302866538, a2 = 220.946098424521, a3 = -275.928510446969;
+        double a4 = 138.357751867269, a5 = -30.6647980661472, a6 = 2.50662827745924;
+        double b1 = -54.4760987982241, b2 = 161.585836858041, b3 = -155.698979859887;
+        double b4 = 66.8013118877197, b5 = -13.2806815528857;
+        double c1 = -0.00778489400243029, c2 = -0.322396458041136, c3 = -2.40075827716184;
+        double c4 = -2.54973253934373, c5 = 4.37466414146497, c6 = 2.93816398269878;
+        double d1 = 0.00778469570904146, d2 = 0.32246712907004, d3 = 2.445134137143, d4 = 3.75440866190742;
+        double plow = 0.02425;
+        double phigh = 1 - plow;
+        double q;
+        if (p < plow)
+        {
+            q = Math.sqrt(-2 * Math.log(p));
+            return (((((c1 * q + c2) * q + c3) * q + c4) * q + c5) * q + c6)
+                    / ((((d1 * q + d2) * q + d3) * q + d4) * q + 1);
+        }
+        if (phigh < p)
+        {
+            q = Math.sqrt(-2 * Math.log(1 - p));
+            return -(((((c1 * q + c2) * q + c3) * q + c4) * q + c5) * q + c6)
+                    / ((((d1 * q + d2) * q + d3) * q + d4) * q + 1);
+        }
+        q = p - 0.5;
+        double r = q * q;
+        return (((((a1 * r + a2) * r + a3) * r + a4) * r + a5) * r + a6) * q
+                / (((((b1 * r + b2) * r + b3) * r + b4) * r + b5) * r + 1);
+    }
+
+    private static LocalDate toLocalDate(Date date)
+    {
+        if (date == null)
+        {
+            return null;
+        }
+        if (date instanceof java.sql.Date)
+        {
+            return ((java.sql.Date) date).toLocalDate();
+        }
+        return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
+    }
+
+    private static double doubleValue(BigDecimal value)
+    {
+        return value == null ? 0 : value.doubleValue();
+    }
+
+    private static long longValue(Long value)
+    {
+        return value == null ? 0L : value;
+    }
+
+    private static String strParam(Map<String, Object> params, String key, String defaultValue)
+    {
+        if (params == null || params.get(key) == null)
+        {
+            return defaultValue;
+        }
+        String value = String.valueOf(params.get(key)).trim();
+        return value.isEmpty() ? defaultValue : value;
+    }
+
+    private static int intParam(Map<String, Object> params, String key, int defaultValue)
+    {
+        if (params == null || params.get(key) == null)
+        {
+            return defaultValue;
+        }
+        try
+        {
+            return Integer.parseInt(String.valueOf(params.get(key)));
+        }
+        catch (Exception ignored)
+        {
+            return defaultValue;
+        }
+    }
+
+    private static int intValue(Object value)
+    {
+        if (value instanceof Number)
+        {
+            return ((Number) value).intValue();
+        }
+        return 0;
+    }
+
+    private static double doubleParam(Map<String, Object> params, String key, double defaultValue)
+    {
+        if (params == null || params.get(key) == null)
+        {
+            return defaultValue;
+        }
+        try
+        {
+            return Double.parseDouble(String.valueOf(params.get(key)));
+        }
+        catch (Exception ignored)
+        {
+            return defaultValue;
+        }
+    }
+
+    private static int clampInt(int value, int min, int max)
+    {
+        return Math.max(min, Math.min(max, value));
+    }
+
+    private static double clamp(double value, double min, double max)
+    {
+        return Math.max(min, Math.min(max, value));
+    }
+
+    private static boolean isBlank(String value)
+    {
+        return value == null || value.trim().isEmpty();
+    }
+
+    private static String blankToOther(String value)
+    {
+        return isBlank(value) ? "其他" : value;
+    }
+
+    private static double round(double value, int scale)
+    {
+        return BigDecimal.valueOf(value).setScale(scale, RoundingMode.HALF_UP).doubleValue();
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Map<String, Object> asMap(Object value)
+    {
+        return value instanceof Map ? (Map<String, Object>) value : new LinkedHashMap<String, Object>();
+    }
+}

+ 85 - 0
dtm-system/src/main/resources/mapper/salesforecast/SalesForecastMapper.xml

@@ -0,0 +1,85 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.dtm.salesforecast.mapper.SalesForecastMapper">
+
+    <resultMap id="SalesForecastDailySalesResult" type="com.dtm.salesforecast.domain.SalesForecastDailySales">
+        <result property="sku" column="sku"/>
+        <result property="category" column="category"/>
+        <result property="saleDate" column="sale_date"/>
+        <result property="quantity" column="quantity"/>
+        <result property="revenue" column="revenue"/>
+        <result property="payableAmount" column="payable_amount"/>
+        <result property="refundAmount" column="refund_amount"/>
+        <result property="promotionStrength" column="promotion_strength"/>
+        <result property="avgPrice" column="avg_price"/>
+    </resultMap>
+
+    <select id="selectDailySales" resultMap="SalesForecastDailySalesResult">
+        SELECT
+            o.sku AS sku,
+            COALESCE(NULLIF(ps.level_name, ''), NULLIF(p.spu, ''), '其他') AS category,
+            DATE(o.create_time) AS sale_date,
+            IFNULL(SUM(IFNULL(o.quantity, 0)), 0) AS quantity,
+            IFNULL(SUM(
+                CASE
+                    WHEN IFNULL(o.paid_amount, 0) &gt; 0 THEN IFNULL(o.paid_amount, 0)
+                    WHEN IFNULL(o.pay_amount, 0) &gt; 0 THEN IFNULL(o.pay_amount, 0)
+                    ELSE IFNULL(o.price, 0) * IFNULL(o.quantity, 0)
+                END
+            ), 0) AS revenue,
+            IFNULL(SUM(
+                CASE
+                    WHEN IFNULL(o.pay_amount, 0) &gt; 0 THEN IFNULL(o.pay_amount, 0)
+                    ELSE IFNULL(o.price, 0) * IFNULL(o.quantity, 0)
+                END
+            ), 0) AS payable_amount,
+            IFNULL(SUM(
+                CASE
+                    WHEN o.refund_amount REGEXP '^-?[0-9]+(\\.[0-9]+)?$'
+                    THEN CAST(o.refund_amount AS DECIMAL(18, 2))
+                    ELSE 0
+                END
+            ), 0) AS refund_amount,
+            IFNULL(AVG(
+                CASE
+                    WHEN IFNULL(o.pay_amount, 0) &gt; 0
+                    THEN GREATEST(0, LEAST(1, 1 - IFNULL(o.paid_amount, 0) / o.pay_amount))
+                    ELSE 0
+                END
+            ), 0) AS promotion_strength,
+            IFNULL(SUM(
+                CASE
+                    WHEN IFNULL(o.paid_amount, 0) &gt; 0 THEN IFNULL(o.paid_amount, 0)
+                    WHEN IFNULL(o.pay_amount, 0) &gt; 0 THEN IFNULL(o.pay_amount, 0)
+                    ELSE IFNULL(o.price, 0) * IFNULL(o.quantity, 0)
+                END
+            ) / NULLIF(SUM(IFNULL(o.quantity, 0)), 0), 0) AS avg_price
+        FROM dtm_order_main o
+        LEFT JOIN dtm_product p ON p.sku = o.sku
+        LEFT JOIN dtm_product_structure ps ON ps.level_no = p.bom_level_id
+        <where>
+            o.sku IS NOT NULL
+            AND o.sku != ''
+            AND o.create_time IS NOT NULL
+            <if test="startDate != null and startDate != ''">
+                AND o.create_time &gt;= STR_TO_DATE(CONCAT(#{startDate}, ' 00:00:00'), '%Y-%m-%d %H:%i:%s')
+            </if>
+            <if test="endDate != null and endDate != ''">
+                AND o.create_time &lt; DATE_ADD(STR_TO_DATE(CONCAT(#{endDate}, ' 00:00:00'), '%Y-%m-%d %H:%i:%s'), INTERVAL 1 DAY)
+            </if>
+            <if test="(startDate == null or startDate == '') and (endDate == null or endDate == '') and lookbackDays != null and lookbackDays &gt; 0">
+                AND o.create_time &gt;= DATE_SUB((SELECT MAX(create_time) FROM dtm_order_main), INTERVAL #{lookbackDays} DAY)
+            </if>
+            <if test="sku != null and sku != ''">
+                AND o.sku = #{sku}
+            </if>
+            <if test="category != null and category != ''">
+                AND (ps.level_name = #{category} OR ps.category_id = #{category} OR p.spu = #{category})
+            </if>
+        </where>
+        GROUP BY o.sku, COALESCE(NULLIF(ps.level_name, ''), NULLIF(p.spu, ''), '其他'), DATE(o.create_time)
+        ORDER BY sale_date ASC, sku ASC
+    </select>
+</mapper>