Skip to content
Open
38 changes: 38 additions & 0 deletions src/main/java/com/example/Category.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.example;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public final class Category {
private static final Map<String, Category> CATEGORIES = new ConcurrentHashMap<>();
private final String name;

private Category(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");
this.name = normalizedName(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");
}
name = normalizedName(name);
return CATEGORIES.computeIfAbsent(name, Category::new);
}

public String getName() {
return name;
}

public static String normalizedName(String name) {
return name.substring(0, 1).toUpperCase() + name.substring(1).toLowerCase();
}

}


34 changes: 34 additions & 0 deletions src/main/java/com/example/ElectronicsProduct.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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;

protected 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;
}

public String productDetails() {
return "Electronics: " + name() + ", Warranty: " + warrantyMonths + " months";
}

public BigDecimal calculateShippingCost() {
BigDecimal shippingCost = BigDecimal.valueOf(79);
if (this.weight.compareTo(BigDecimal.valueOf(5.0)) > 0) {
return shippingCost.add(BigDecimal.valueOf(49));
} else {
return shippingCost;
}
}

public double weight() {
return this.weight.doubleValue();
}

}
37 changes: 37 additions & 0 deletions src/main/java/com/example/FoodProduct.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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;

protected FoodProduct(UUID id, String name, Category category, BigDecimal price, LocalDate expirationDate, BigDecimal weight) {
if (price.doubleValue() < 0) throw new IllegalArgumentException("Price cannot be negative.");
if (weight.doubleValue() < 0) throw new IllegalArgumentException("Weight cannot be negative.");
super(id, name, category, price);
this.expirationDate = expirationDate;
this.weight = weight;
}


public String productDetails() {
return "Food: " + name() + ", Expires: " + this.expirationDate;
}

public LocalDate expirationDate() {
return this.expirationDate;
}

public BigDecimal calculateShippingCost() {
return this.weight.multiply(BigDecimal.valueOf(50));
}

@Override
public double weight() {
return this.weight.doubleValue();
}

}
12 changes: 12 additions & 0 deletions src/main/java/com/example/Perishable.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.example;

import java.time.LocalDate;

public interface Perishable {

LocalDate expirationDate();

default boolean isExpired(LocalDate referenceDate) {
return expirationDate().isBefore(referenceDate);
}
}
43 changes: 43 additions & 0 deletions src/main/java/com/example/Product.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.example;

import java.math.BigDecimal;
import java.util.Objects;
import java.util.UUID;

public abstract class Product {

private final UUID id;
private final String name;
private final Category category;
private BigDecimal price;

Product(UUID id, String name, Category category, BigDecimal price) {
this.id = Objects.requireNonNull(id, "ID can't be null");
this.name = Objects.requireNonNull(name, "Name can't be null");
this.category = Objects.requireNonNull(category, "Category can't be null");
this.price = Objects.requireNonNull(price, "Price can't be null");
}

public UUID uuid() {
return id;
}

public String name() {
return name;
}

public Category category() {
return category;
}

public BigDecimal price() {
return price;
}

public void setPrice(BigDecimal price) {
if (price == null || price.doubleValue() < 0) throw new IllegalArgumentException("Price can't be null or negative.");
this.price = price;
}

public abstract String productDetails();
}
9 changes: 9 additions & 0 deletions src/main/java/com/example/Shippable.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.example;

import java.math.BigDecimal;

public interface Shippable {
BigDecimal calculateShippingCost();

double weight();
}
116 changes: 116 additions & 0 deletions src/main/java/com/example/Warehouse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package com.example;

import java.time.LocalDate;
import java.util.stream.Collectors;
import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;


public class Warehouse {
private static final Map<String, Warehouse> WAREHOUSES = new ConcurrentHashMap<>();

private final String name;

private final Map<UUID, Product> products;

private final Set<UUID> changedProductIds;

private Warehouse(String name) {
this.name = name;
this.products = new ConcurrentHashMap<>();
this.changedProductIds = Collections.newSetFromMap(new ConcurrentHashMap<>());
}

public static Warehouse getInstance(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Warehouse name must be provided");
}
return WAREHOUSES.computeIfAbsent(name, Warehouse::new);
}

public static Warehouse getInstance() {
return getInstance("DefaultWarehouse");
}

public void addProduct(Product product) {
if (product == null) {
throw new IllegalArgumentException("Product cannot be null.");
}
Product existingProduct = products.putIfAbsent(product.uuid(), product);
if (existingProduct != null) {
throw new IllegalArgumentException("Product with that id already exists, use updateProduct for updates.");
}

changedProductIds.add(product.uuid());

}

public List<Product> getProducts() {
return products.values().stream().toList();
}

public Optional<Product> getProductById(UUID id) {
if (id == null) {
return Optional.empty();
}
return Optional.ofNullable(products.get(id));
}

public Map<Category, List<Product>> getProductsGroupedByCategories() {
return products.values()
.stream()
.collect(Collectors.groupingBy(Product::category));

}

public void clearProducts() {
Warehouse warehouse = WAREHOUSES.get(name);
if (warehouse != null) {
warehouse.products.clear();
warehouse.changedProductIds.clear();
}
}

public boolean isEmpty() {
return products.isEmpty();
}

public void updateProductPrice(UUID id, BigDecimal newPrice) {
Product product = getProductById(id).
orElseThrow(() -> new NoSuchElementException("Product not found with id: " + id));
product.setPrice(newPrice);
changedProductIds.add(id);
}

public List<Product> getChangedProducts() {
return changedProductIds.stream()
.filter(products::containsKey)
.map(products::get)
.toList();
}

public List<Perishable> expiredProducts() {
LocalDate today = LocalDate.now();
return products.values().stream()
.filter(p -> p instanceof Perishable)
.filter(p -> ((Perishable) p).isExpired(today))
.map(p -> (Perishable) p)
.collect(Collectors.toList());

}

public List<Shippable> shippableProducts() {
return products.values().stream()
.filter(p -> p instanceof Shippable)
.map(p -> (Shippable) p)
.collect(Collectors.toList());
}

public void remove(UUID id) {
if (id != null) {
products.remove(id);
changedProductIds.remove(id);
}
}
}
61 changes: 37 additions & 24 deletions src/main/java/com/example/WarehouseAnalyzer.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.example;

import java.io.Serializable;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.*;
Expand Down Expand Up @@ -138,31 +138,45 @@ public Map<Category, BigDecimal> calculateWeightedAveragePriceByCategory() {
}

/**
* Identifies products whose price deviates from the mean by more than the specified
* number of standard deviations. Uses population standard deviation over all products.
* Test expectation: with a mostly tight cluster and two extremes, calling with 2.0 returns the two extremes.
* Identifies products whose prices are statistical outliers,
* using the interquartile range (IQR) method.
* Products with prices outside the IQR threshold are considered outliers.
* Test expectation: with a mostly tight cluster and two extremes,
* calling with 1.5 (the typical IQR threshold) returns the two extremes.
*
* @param standardDeviations threshold in standard deviations (e.g., 2.0)
* @param factor multiplier in IQR calculation
* @return list of products considered outliers
*/
public List<Product> findPriceOutliers(double standardDeviations) {
public List<Product> findPriceOutliers(double factor) {
List<Product> 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<Product> 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.size() < 4) return List.of();

List<Double> prices = products.stream()
.map(p -> p.price().doubleValue())
.sorted()
.toList();

int n = prices.size();
double q1 = median(prices.subList(0, n / 2));
double q3 = median(prices.subList((n + 1) / 2, n));
double iqr = q3 - q1;

double lowerLimit = q1 - factor * iqr;
double upperLimit = q3 + factor * iqr;

return products.stream()
.filter(p -> {
double price = p.price().doubleValue();
return price < lowerLimit || price > upperLimit;
})
.toList();
}

private double median(List<Double> sortedList){
int n = sortedList.size();
return n % 2 == 0
? (sortedList.get(n/2 - 1) + sortedList.get(n/2)) / 2.0
: sortedList.get(n/2);
}

/**
Expand Down Expand Up @@ -245,7 +259,6 @@ public Map<Product, BigDecimal> calculateExpirationBasedDiscounts() {
* when percentage exceeds 70%.
* - Category diversity: count of distinct categories in the inventory. The tests expect at least 2.
* - Convenience booleans: highValueWarning (percentage > 70%) and minimumDiversity (category count >= 2).
*
* Note: The exact high-value threshold is implementation-defined, but the provided tests create a clear
* separation using very expensive electronics (e.g., 2000) vs. low-priced food items (e.g., 10),
* allowing percentage computation regardless of the chosen cutoff as long as it matches the scenario.
Expand Down Expand Up @@ -295,7 +308,7 @@ public InventoryStatistics getInventoryStatistics() {
/**
* Represents a group of products for shipping
*/
class ShippingGroup {
class ShippingGroup implements Serializable {
private final List<Shippable> products;
private final Double totalWeight;
private final BigDecimal totalShippingCost;
Expand Down
Loading
Loading