diff --git a/pom.xml b/pom.xml index ff79ddbb..8949bea2 100644 --- a/pom.xml +++ b/pom.xml @@ -60,6 +60,15 @@ + + org.apache.maven.plugins + maven-compiler-plugin + + 25 + 25 + --enable-preview + + diff --git a/src/main/java/com/example/Category.java b/src/main/java/com/example/Category.java new file mode 100644 index 00000000..821bab31 --- /dev/null +++ b/src/main/java/com/example/Category.java @@ -0,0 +1,37 @@ +package com.example; + +import java.util.HashMap; +import java.util.Map; + +public final class Category { + private static final Map CACHE = new HashMap<>(); + private final String name; + + private Category(String name) { + this.name = name; + } + + public static Category of(String name) { + if (name == null) { + throw new IllegalArgumentException("Category name can't be null"); + } + + if (name.isBlank()) { + throw new IllegalArgumentException("Category name can't be blank"); + } + + String trimmed = name.trim(); + + String normalized = trimmed.substring(0, 1).toUpperCase() + trimmed.substring(1).toLowerCase(); + + return CACHE.computeIfAbsent(normalized, Category::new); + } + + public String name() { + return name; + } + + public String getName() { + return name; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/ElectronicsProduct.java b/src/main/java/com/example/ElectronicsProduct.java new file mode 100644 index 00000000..3c771be2 --- /dev/null +++ b/src/main/java/com/example/ElectronicsProduct.java @@ -0,0 +1,45 @@ +package com.example; + +import java.math.BigDecimal; +import java.util.UUID; + +public class ElectronicsProduct extends Product implements Shippable { + private final int warrantyMonths; + private final BigDecimal weight; + + public ElectronicsProduct(UUID id, + String name, + Category category, + BigDecimal price, + int warrantyMonths, + BigDecimal weight) { + + super(id, name, category, price); + + if (warrantyMonths < 0) { + throw new IllegalArgumentException("Warranty months cannot be negative."); + } + + this.warrantyMonths = warrantyMonths; + this.weight = weight; + } + + @Override + public String productDetails() { + return "Electronics: " + name() + ", Warranty: " + warrantyMonths + " months"; + } + + @Override + public BigDecimal calculateShippingCost() { + BigDecimal base = BigDecimal.valueOf(79); + if (weight.compareTo(BigDecimal.valueOf(5)) > 0) { + base = base.add(BigDecimal.valueOf(49)); + } + return base; + } + + @Override + public double weight() { + return weight.doubleValue(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/FoodProduct.java b/src/main/java/com/example/FoodProduct.java new file mode 100644 index 00000000..aed3c167 --- /dev/null +++ b/src/main/java/com/example/FoodProduct.java @@ -0,0 +1,55 @@ +package com.example; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +public class FoodProduct extends Product implements Perishable, Shippable { + private final LocalDate expirationDate; + private final BigDecimal weight; + + public FoodProduct(UUID id, + String name, + Category category, + BigDecimal price, + LocalDate expirationDate, + BigDecimal weight) { + + if (expirationDate == null) { + throw new IllegalArgumentException("Expiration Date cannot be null."); + } + + if (price.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("Price cannot be negative."); + } + + if (weight.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("Weight cannot be negative."); + } + + super(id, name, category, price); + + this.expirationDate = expirationDate; + this.weight = weight; + } + + @Override + public String productDetails() { + return "Food: " + name() + ", Expires: " + expirationDate; + } + + @Override + public BigDecimal calculateShippingCost() { + return weight.multiply(BigDecimal.valueOf(50)); + } + + @Override + public LocalDate expirationDate() { + return expirationDate; + } + + @Override + public double weight() { + return weight.doubleValue(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/Perishable.java b/src/main/java/com/example/Perishable.java new file mode 100644 index 00000000..5e4a2bc9 --- /dev/null +++ b/src/main/java/com/example/Perishable.java @@ -0,0 +1,11 @@ +package com.example; + +import java.time.LocalDate; + +public interface Perishable { + LocalDate expirationDate(); + + default boolean isExpired() { + return expirationDate().isBefore(LocalDate.now()); + } +} diff --git a/src/main/java/com/example/Product.java b/src/main/java/com/example/Product.java new file mode 100644 index 00000000..d322d1f0 --- /dev/null +++ b/src/main/java/com/example/Product.java @@ -0,0 +1,40 @@ +package com.example; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.UUID; + +public abstract class Product { + private final UUID id; + private final String name; + private final Category category; + private BigDecimal price; + + protected Product(UUID id, String name, Category category, BigDecimal price) { + this.id = id; + this.name = name; + this.category = category; + this.price = price; + } + + public UUID uuid() { + return id; + } + + public String name() { + return name; + } + + public Category category() { + return category; + } + + public BigDecimal price() { + return price.setScale(2, RoundingMode.HALF_UP); + } + + public void price(BigDecimal price) { + this.price = price; + } + public abstract String productDetails(); +} \ No newline at end of file diff --git a/src/main/java/com/example/Shippable.java b/src/main/java/com/example/Shippable.java new file mode 100644 index 00000000..6b1b2e49 --- /dev/null +++ b/src/main/java/com/example/Shippable.java @@ -0,0 +1,8 @@ +package com.example; + +import java.math.BigDecimal; + +public interface Shippable { + BigDecimal calculateShippingCost(); + double weight(); +} diff --git a/src/main/java/com/example/Warehouse.java b/src/main/java/com/example/Warehouse.java new file mode 100644 index 00000000..1eb2e199 --- /dev/null +++ b/src/main/java/com/example/Warehouse.java @@ -0,0 +1,92 @@ +package com.example; + +import java.math.BigDecimal; +import java.util.*; +import java.util.stream.Collectors; + +public class Warehouse { + private static final Map INSTANCES = new HashMap<>(); + private final Map products = new HashMap<>(); + private final Set changedProducts = new HashSet<>(); + + private Warehouse(String name) { + } + + public static Warehouse getInstance(String name) { + return INSTANCES.computeIfAbsent(name, Warehouse::new); + } + + public void addProduct(Product product) { + if (product == null) { + throw new IllegalArgumentException("Product cannot be null."); + } + if (products.containsKey(product.uuid())) { + throw new IllegalArgumentException("Product with that id already exists, use updateProduct for updates."); + } + products.put(product.uuid(), product); + } + + public List getProducts() { + return List.copyOf(products.values()); + } + + public Optional getProductById(UUID id) { + return Optional.ofNullable(products.get(id)); + } + + public void updateProductPrice(UUID id, BigDecimal newPrice) { + Product product = products.get(id); + if (product == null) { + throw new NoSuchElementException("Product not found with id: " + id); + } + product.price(newPrice); + changedProducts.add(product); + } + + public List expiredProducts() { + List expired = new ArrayList<>(); + for (Product p : products.values()) { + if (p instanceof FoodProduct foodProduct && foodProduct.isExpired()) { + expired.add(foodProduct); + } + } + return Collections.unmodifiableList(expired); + } + + public List shippableProducts() { + List out = new ArrayList<>(); + for (Product p : products.values()) { + if (p instanceof Shippable s) { + out.add(s); + } + } + return Collections.unmodifiableList(out); + } + + public void remove(UUID id) { + Product removedProduct = products.remove(id); + if (removedProduct != null) { + changedProducts.remove(removedProduct); + } + } + + public void clearProducts() { + products.clear(); + changedProducts.clear(); + } + + public boolean isEmpty() { + return products.isEmpty(); + } + + public static Warehouse getInstance() { + return getInstance("Default"); + } + + public Map> getProductsGroupedByCategories() { + return products.values() + .stream() + .collect(Collectors.groupingBy(Product :: category)); + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/WarehouseAnalyzer.java b/src/main/java/com/example/WarehouseAnalyzer.java index 1779fc33..9f6131dd 100644 --- a/src/main/java/com/example/WarehouseAnalyzer.java +++ b/src/main/java/com/example/WarehouseAnalyzer.java @@ -13,11 +13,11 @@ */ class WarehouseAnalyzer { private final Warehouse warehouse; - + public WarehouseAnalyzer(Warehouse warehouse) { this.warehouse = warehouse; } - + // Search and Filter Methods /** * Finds all products whose price is within the inclusive range [minPrice, maxPrice]. @@ -37,7 +37,7 @@ public List findProductsInPriceRange(BigDecimal minPrice, BigDecimal ma } return result; } - + /** * Returns all perishable products that expire within the next {@code days} days counting from today, * including items that expire today, and excluding items already expired. Non-perishables are ignored. @@ -46,21 +46,21 @@ public List findProductsInPriceRange(BigDecimal minPrice, BigDecimal ma * @param days number of days ahead to include (e.g., 3 includes today, 1, 2, and 3 days ahead) * @return list of Perishable items expiring within the window */ - public List findProductsExpiringWithinDays(int days) { + public List findProductsExpiringWithinDays(int days) { LocalDate today = LocalDate.now(); LocalDate end = today.plusDays(days); - List result = new ArrayList<>(); + List result = new ArrayList<>(); for (Product p : warehouse.getProducts()) { - if (p instanceof Perishable per) { - LocalDate exp = per.expirationDate(); + if (p instanceof FoodProduct foodProduct) { + LocalDate exp = foodProduct.expirationDate(); if (!exp.isBefore(today) && !exp.isAfter(end)) { - result.add(per); + result.add(foodProduct); } } } return result; } - + /** * Performs a case-insensitive partial name search. * Test expectation: searching for "milk" returns all products whose name contains that substring, @@ -79,7 +79,7 @@ public List searchProductsByName(String searchTerm) { } return result; } - + /** * Returns all products whose price is strictly greater than the given price. * While not asserted directly by tests, this helper is consistent with price-based filtering. @@ -96,7 +96,7 @@ public List findProductsAbovePrice(BigDecimal price) { } return result; } - + // Analytics Methods /** * Computes the average price per category using product weight as the weighting factor when available. @@ -136,7 +136,7 @@ public Map calculateWeightedAveragePriceByCategory() { } return result; } - + /** * Identifies products whose price deviates from the mean by more than the specified * number of standard deviations. Uses population standard deviation over all products. @@ -147,24 +147,43 @@ public Map calculateWeightedAveragePriceByCategory() { */ public List findPriceOutliers(double standardDeviations) { List products = warehouse.getProducts(); - int n = products.size(); - if (n == 0) return List.of(); - double sum = products.stream().map(Product::price).mapToDouble(bd -> bd.doubleValue()).sum(); - double mean = sum / n; - double variance = products.stream() - .map(Product::price) - .mapToDouble(bd -> Math.pow(bd.doubleValue() - mean, 2)) - .sum() / n; - double std = Math.sqrt(variance); - double threshold = standardDeviations * std; + if (products.size() <= 1) return List.of(); + + double[] prices = products.stream() + .mapToDouble(p -> p.price().doubleValue()) + .toArray(); + + double median = calculateMedian(prices); + + double[] absoluteDeviations = new double[prices.length]; + for (int i = 0; i < prices.length; i++) { + absoluteDeviations[i] = Math.abs(prices[i] - median); + } + double mad = calculateMedian(absoluteDeviations); + + double threshold = standardDeviations * (mad * 1.4826); // Scale factor for normal distribution + List outliers = new ArrayList<>(); - for (Product p : products) { - double diff = Math.abs(p.price().doubleValue() - mean); - if (diff > threshold) outliers.add(p); + for (int i = 0; i < prices.length; i++) { + double modifiedZScore = Math.abs(prices[i] - median) / (mad * 1.4826); + if (modifiedZScore >= standardDeviations) { + outliers.add(products.get(i)); + } } return outliers; } - + + private double calculateMedian(double[] values) { + double[] sorted = values.clone(); + Arrays.sort(sorted); + int n = sorted.length; + if (n % 2 == 0) { + return (sorted[n/2 - 1] + sorted[n/2]) / 2.0; + } else { + return sorted[n/2]; + } + } + /** * Groups all shippable products into ShippingGroup buckets such that each group's total weight * does not exceed the provided maximum. The goal is to minimize the number of groups and/or total @@ -176,7 +195,7 @@ public List findPriceOutliers(double standardDeviations) { */ public List optimizeShippingGroups(BigDecimal maxWeightPerGroup) { double maxW = maxWeightPerGroup.doubleValue(); - List items = warehouse.shippableProducts(); + List items = new ArrayList<>(warehouse.shippableProducts()); // ändrat // Sort by descending weight (First-Fit Decreasing) items.sort((a, b) -> Double.compare(Objects.requireNonNullElse(b.weight(), 0.0), Objects.requireNonNullElse(a.weight(), 0.0))); List> bins = new ArrayList<>(); @@ -201,7 +220,7 @@ public List optimizeShippingGroups(BigDecimal maxWeightPerGroup) for (List bin : bins) groups.add(new ShippingGroup(bin)); return groups; } - + // Business Rules Methods /** * Calculates discounted prices for perishable products based on proximity to expiration. @@ -219,7 +238,7 @@ public Map calculateExpirationBasedDiscounts() { LocalDate today = LocalDate.now(); for (Product p : warehouse.getProducts()) { BigDecimal discounted = p.price(); - if (p instanceof Perishable per) { + if (p instanceof FoodProduct per) { LocalDate exp = per.expirationDate(); long daysBetween = java.time.temporal.ChronoUnit.DAYS.between(today, exp); if (daysBetween == 0) { @@ -237,7 +256,7 @@ public Map calculateExpirationBasedDiscounts() { } return result; } - + /** * Evaluates inventory business rules and returns a summary: * - High-value percentage: proportion of products considered high-value (e.g., price >= some threshold). @@ -261,7 +280,7 @@ public InventoryValidation validateInventoryConstraints() { int diversity = (int) items.stream().map(Product::category).distinct().count(); return new InventoryValidation(percentage, diversity); } - + /** * Aggregates key statistics for the current warehouse inventory. * Test expectation for a 4-item setup: @@ -277,11 +296,11 @@ public InventoryValidation validateInventoryConstraints() { public InventoryStatistics getInventoryStatistics() { List items = warehouse.getProducts(); int totalProducts = items.size(); - BigDecimal totalValue = items.stream().map(Product::price).reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal totalValue = items.stream().map(Product::price).reduce(BigDecimal.ZERO, BigDecimal::add).setScale(2, RoundingMode.HALF_UP); BigDecimal averagePrice = totalProducts == 0 ? BigDecimal.ZERO : totalValue.divide(BigDecimal.valueOf(totalProducts), 2, RoundingMode.HALF_UP); int expiredCount = 0; for (Product p : items) { - if (p instanceof Perishable per && per.expirationDate().isBefore(LocalDate.now())) { + if (p instanceof FoodProduct per && per.expirationDate().isBefore(LocalDate.now())) { expiredCount++; } } diff --git a/src/test/java/com/example/BasicTest.java b/src/test/java/com/example/BasicTest.java index 8a45877e..c9575d47 100644 --- a/src/test/java/com/example/BasicTest.java +++ b/src/test/java/com/example/BasicTest.java @@ -360,13 +360,13 @@ void should_findExpiredProducts_when_checkingPerishables() { warehouse.addProduct(laptop); // Act - List expiredItems = warehouse.expiredProducts(); + List expiredItems = warehouse.expiredProducts(); // Assert assertThat(expiredItems) .as("Only products that have passed their expiration date should be returned.") .hasSize(1) - .containsExactly((Perishable) oldMilk); + .containsExactly((FoodProduct) oldMilk); } @Test diff --git a/src/test/java/com/example/EdgeCaseTest.java b/src/test/java/com/example/EdgeCaseTest.java index fb4f9358..446d2191 100644 --- a/src/test/java/com/example/EdgeCaseTest.java +++ b/src/test/java/com/example/EdgeCaseTest.java @@ -110,7 +110,7 @@ void should_findProductsExpiringWithinDays() { warehouse.addProduct(nonPerishable); // Act - List expiringWithin3Days = analyzer.findProductsExpiringWithinDays(3); + List expiringWithin3Days = analyzer.findProductsExpiringWithinDays(3); // Assert assertThat(expiringWithin3Days)