|
|
@@ -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;
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|