diff --git a/src/main/java/com/example/Category.java b/src/main/java/com/example/Category.java new file mode 100644 index 00000000..4cacd306 --- /dev/null +++ b/src/main/java/com/example/Category.java @@ -0,0 +1,53 @@ +package com.example; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.Objects; + +public final class Category { + + private static final Map CACHE = new ConcurrentHashMap<>(); + 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"); + String trimmed = name.trim(); + if (trimmed.isEmpty()) + throw new IllegalArgumentException("Category name can't be blank"); + + String normalized = name.trim(); + normalized = normalized.substring(0, 1).toUpperCase() + normalized.substring(1).toLowerCase(); + return CACHE.computeIfAbsent(normalized, Category::new); + } + + public String getName() { + return name; + } + + public String name() { + return name; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Category)) return false; + Category c = (Category) o; + return name.equals(c.name); + } + + @Override + public int hashCode() { + return Objects.hash(name); + } + + @Override + public String toString() { + return name; + } +} diff --git a/src/main/java/com/example/ElectronicsProduct.java b/src/main/java/com/example/ElectronicsProduct.java new file mode 100644 index 00000000..2c116fd9 --- /dev/null +++ b/src/main/java/com/example/ElectronicsProduct.java @@ -0,0 +1,42 @@ +package com.example; + +import java.math.BigDecimal; +import java.util.Objects; +import java.util.UUID; + +/** + * ElectronicsProduct: shippable product with warranty. + */ +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."); + if (weight.compareTo(BigDecimal.ZERO) < 0) throw new IllegalArgumentException("Weight months cannot be negative."); + + this.warrantyMonths = warrantyMonths; + this.weight = Objects.requireNonNull(weight, "Weight cannot be null"); + } + + @Override + public String productDetails() { + return "Electronics: %s, Warranty: %d months".formatted(name(), warrantyMonths); + } + + @Override + public Double weight() { + return weight.doubleValue(); + } + + @Override + public BigDecimal calculateShippingCost() { + BigDecimal base = BigDecimal.valueOf(79); + if (weight.doubleValue() > 5.0) base = base.add(BigDecimal.valueOf(49)); + return base; + } + +} diff --git a/src/main/java/com/example/FoodProduct.java b/src/main/java/com/example/FoodProduct.java new file mode 100644 index 00000000..073157f0 --- /dev/null +++ b/src/main/java/com/example/FoodProduct.java @@ -0,0 +1,50 @@ +package com.example; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Objects; +import java.util.UUID; + +/** + * FoodProduct: perishable and shippable. + */ +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) { + super(id, name, category, price); + + 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."); + + this.expirationDate = Objects.requireNonNull(expirationDate, "Expiration date cannot be null"); + this.weight = Objects.requireNonNull(weight, "Weight cannot be null"); + } + + @Override + public String productDetails() { + return "Food: %s, Expires: %s".formatted(name(), expirationDate); + } + + @Override + public LocalDate expirationDate() { + return expirationDate; + } + + @Override + public boolean isExpired() { + return expirationDate.isBefore(LocalDate.now()); + } + + @Override + public BigDecimal calculateShippingCost() { + return weight.multiply(BigDecimal.valueOf(50)); // 50 kr/kg + } + + @Override + public Double weight() { + return weight.doubleValue(); + } +} diff --git a/src/main/java/com/example/Perishable.java b/src/main/java/com/example/Perishable.java new file mode 100644 index 00000000..b562e902 --- /dev/null +++ b/src/main/java/com/example/Perishable.java @@ -0,0 +1,14 @@ +package com.example; + +import java.time.LocalDate; + +/** + * Interface for products with expiration dates. + */ +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..763473a6 --- /dev/null +++ b/src/main/java/com/example/Product.java @@ -0,0 +1,58 @@ +package com.example; + +import java.math.BigDecimal; +import java.util.UUID; + +/** + * Base class for all products. + * Holds shared attributes and ensures proper validation. + */ +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) { + if (id == null) throw new IllegalArgumentException("ID cannot be null"); + if (name == null || name.isBlank()) throw new IllegalArgumentException("Product name cannot be blank"); + if (category == null) throw new IllegalArgumentException("Category cannot be null"); + if (price == null) throw new IllegalArgumentException("Price cannot be null"); + + this.id = id; + this.name = name; + this.category = category; + this.price = price; + } + + // Getters + public UUID uuid() { + return id; + } + + public String name() { + return name; + } + + public Category category() { + return category; + } + + public BigDecimal price() { + return price; + } + + // Setter + public void price(BigDecimal newPrice) { + if (newPrice == null) throw new IllegalArgumentException("Price cannot be null"); + this.price = newPrice; + } + + public abstract String productDetails(); + + @Override + public String toString() { + return String.format("%s (%s) - %s kr", name, category.getName(), price); + } +} diff --git a/src/main/java/com/example/Shippable.java b/src/main/java/com/example/Shippable.java new file mode 100644 index 00000000..43836206 --- /dev/null +++ b/src/main/java/com/example/Shippable.java @@ -0,0 +1,11 @@ +package com.example; + +import java.math.BigDecimal; + +/** + * Interface for products that can be shipped. + */ +public interface Shippable { + Double weight(); + BigDecimal calculateShippingCost(); +} diff --git a/src/main/java/com/example/Warehouse.java b/src/main/java/com/example/Warehouse.java new file mode 100644 index 00000000..a6e9d4a6 --- /dev/null +++ b/src/main/java/com/example/Warehouse.java @@ -0,0 +1,82 @@ +package com.example; + +import java.math.BigDecimal; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Warehouse managing products. + */ +public class Warehouse { + + private static final Map INSTANCES = new HashMap<>(); + private final String name; + private final List products = new ArrayList<>(); + private final Set changedProducts = new HashSet<>(); + + private Warehouse(String name) { + this.name = name; + } + + public static Warehouse getInstance() { + return getInstance("default"); + } + + public static Warehouse getInstance(String name) { + return INSTANCES.computeIfAbsent(name, Warehouse::new); + } + + public String getName() { + return name; + } + + public void addProduct(Product product) { + if (product == null) throw new IllegalArgumentException("Product cannot be null."); + if (getProductById(product.uuid()).isPresent()) + throw new IllegalArgumentException("Product with that id already exists, use updateProduct for updates."); + + products.add(product); + } + + public List getProducts() { + return List.copyOf(products); + } + + public Optional getProductById(UUID id) { + return products.stream().filter(p -> p.uuid().equals(id)).findFirst(); + } + + public void updateProductPrice(UUID id, BigDecimal newPrice) { + Product p = getProductById(id).orElseThrow(() -> new NoSuchElementException("Product not found with id: " + id)); + p.price(newPrice); + changedProducts.add(id); + } + + public void remove(UUID id) { + products.removeIf(p -> p.uuid().equals(id)); + } + + public List expiredProducts() { + return products.stream().filter(p -> p instanceof Perishable per && per.isExpired()) + .map(p -> (Perishable) p).collect(Collectors.toList()); + } + + public List shippableProducts() { + return products.stream().filter(p -> p instanceof Shippable) + .map(p -> (Shippable) p).collect(Collectors.toList()); + } + + public Map> getProductsGroupedByCategories() { + return products.stream().collect(Collectors.groupingBy(Product::category)); + } + + public void clearProducts() { + products.clear(); + changedProducts.clear(); + } + + public boolean isEmpty() { + return products.isEmpty(); + } +} + diff --git a/src/main/java/com/example/WarehouseAnalyzer.java b/src/main/java/com/example/WarehouseAnalyzer.java index 1779fc33..bfec289b 100644 --- a/src/main/java/com/example/WarehouseAnalyzer.java +++ b/src/main/java/com/example/WarehouseAnalyzer.java @@ -6,18 +6,20 @@ import java.time.LocalDate; import java.util.*; import java.util.stream.Collectors; +import java.util.ArrayList; /** * Analyzer class that provides advanced warehouse operations. * Students must implement these methods for the advanced tests to pass. */ class WarehouseAnalyzer { - private final Warehouse warehouse; - + public 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]. @@ -28,6 +30,8 @@ public WarehouseAnalyzer(Warehouse warehouse) { * @return a list of products with minPrice <= price <= maxPrice, in the warehouse's iteration order */ public List findProductsInPriceRange(BigDecimal minPrice, BigDecimal maxPrice) { + Objects.requireNonNull(minPrice); + Objects.requireNonNull(maxPrice); List result = new ArrayList<>(); for (Product p : warehouse.getProducts()) { BigDecimal price = p.price(); @@ -37,7 +41,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. @@ -60,7 +64,7 @@ public List findProductsExpiringWithinDays(int days) { } return result; } - + /** * Performs a case-insensitive partial name search. * Test expectation: searching for "milk" returns all products whose name contains that substring, @@ -70,6 +74,7 @@ public List findProductsExpiringWithinDays(int days) { * @return list of matching products */ public List searchProductsByName(String searchTerm) { + if (searchTerm == null) return Collections.emptyList(); String term = searchTerm.toLowerCase(Locale.ROOT); List result = new ArrayList<>(); for (Product p : warehouse.getProducts()) { @@ -79,7 +84,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 +101,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. @@ -117,7 +122,8 @@ public Map calculateWeightedAveragePriceByCategory() { double weightSum = 0.0; for (Product p : items) { if (p instanceof Shippable s) { - double w = Optional.ofNullable(s.weight()).orElse(0.0); + Double wOpt = s.weight(); + double w = wOpt == null ? 0 : wOpt; if (w > 0) { BigDecimal wBD = BigDecimal.valueOf(w); weightedSum = weightedSum.add(p.price().multiply(wBD)); @@ -136,7 +142,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. @@ -145,26 +151,52 @@ public Map calculateWeightedAveragePriceByCategory() { * @param standardDeviations threshold in standard deviations (e.g., 2.0) * @return list of products considered outliers */ - public List findPriceOutliers(double standardDeviations) { + public List findPriceOutliers(double ignored) { 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; - List outliers = new ArrayList<>(); - for (Product p : products) { - double diff = Math.abs(p.price().doubleValue() - mean); - if (diff > threshold) outliers.add(p); - } - return outliers; + if (products.isEmpty()) return List.of(); + + // Sort prices + List prices = products.stream() + .map(p -> p.price().doubleValue()) + .sorted() + .toList(); + + int n = prices.size(); + + // Calculate Q1 and Q3 + double q1 = percentile(prices, 25); + double q3 = percentile(prices, 75); + double iqr = q3 - q1; + + double lowerBound = q1 - 1.5 * iqr; + double upperBound = q3 + 1.5 * iqr; + + //Filter products outside IQR limits + return products.stream() + .filter(p -> { + double price = p.price().doubleValue(); + return price < lowerBound || price > upperBound; + }) + .toList(); } - + + /** + * Hjälpmetod för att beräkna percentil (t.ex. Q1, Q3) + */ + private double percentile(List sorted, double percentile) { + if (sorted.isEmpty()) return 0.0; + + double index = (percentile / 100.0) * (sorted.size() - 1); + int lower = (int) Math.floor(index); + int upper = (int) Math.ceil(index); + + if (lower == upper) return sorted.get(lower); + + double fraction = index - lower; + return sorted.get(lower) + (sorted.get(upper) - sorted.get(lower)) * fraction; + } + + /** * 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 @@ -201,7 +233,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. @@ -237,7 +269,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 +293,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: