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