diff --git a/.gitignore b/.gitignore index 0fc8e37..bad524d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,45 @@ out/ .DS_Store logs/ -*.log \ No newline at end of file +*.log + +### Java ### +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +### react ### +.DS_* +logs +**/*.backup.* +**/*.back.* + +node_modules +bower_components + +*.sublime* + +psd +thumb +sketch diff --git a/orders-backend/src/main/java/com/andwati/orders/OrdersBackendApplication.java b/orders-backend/src/main/java/com/andwati/orders/OrdersBackendApplication.java index d1108f5..47a67e0 100644 --- a/orders-backend/src/main/java/com/andwati/orders/OrdersBackendApplication.java +++ b/orders-backend/src/main/java/com/andwati/orders/OrdersBackendApplication.java @@ -1,10 +1,17 @@ package com.andwati.orders; +import com.andwati.orders.config.FareProperties; +import com.andwati.orders.config.InventoryProperties; import io.github.cdimascio.dotenv.Dotenv; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; @SpringBootApplication +@EnableConfigurationProperties({ + FareProperties.class, + InventoryProperties.class +}) public class OrdersBackendApplication { public static void main(String[] args) { diff --git a/orders-backend/src/main/java/com/andwati/orders/config/DataSeeder.java b/orders-backend/src/main/java/com/andwati/orders/config/DataSeeder.java new file mode 100644 index 0000000..40fe7c9 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/config/DataSeeder.java @@ -0,0 +1,129 @@ +package com.andwati.orders.config; + + +import com.andwati.orders.model.InventoryItem; +import com.andwati.orders.model.OrderItem; +import com.andwati.orders.model.OrderStatus; +import com.andwati.orders.model.Product; +import jakarta.transaction.Transactional; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +@Component +public class DataSeeder implements CommandLineRunner { + private final ProductRepository productRepository; + private final InventoryItemRepository inventoryItemRepository; + private final OrderRepository orderRepository; + private final boolean seedData; + + + public DataSeeder( + ProductRepository productRepository, + InventoryItemRepository inventoryItemRepository, + OrderRepository orderRepository, + @Value("${app.seed-data:true}") boolean seedData + ) { + this.productRepository = productRepository; + this.inventoryItemRepository = inventoryItemRepository; + this.orderRepository = orderRepository; + this.seedData = seedData; + } + + @Override + @Transactional + public void run(String... args) { + if (!seedData || productRepository.count() > 0) { + return; + } + + + Product mouse = product("Wireless Mouse", "MOUSE-001", "1500.00"); + Product keyboard = product("Keyboard", "KEYBOARD-001", "2500.00"); + Product cable = product("USB-C Cable", "USB-C-001", "800.00"); + Product stand = product("Laptop Stand", "STAND-001", "3500.00"); + Product webcam = product("Webcam", "WEBCAM-001", "4500.00"); + + productRepository.saveAll(List.of(mouse, keyboard, cable, stand, webcam)); + + inventoryItemRepository.saveAll(List.of( + inventory(mouse, 12, 5), + inventory(keyboard, 3, 5), + inventory(cable, 0, 5), + inventory(stand, 7, 5), + inventory(webcam, 2, 5) + )); + Order placedOrder = new Order(); + placedOrder.setCustomerName("Demo Customer"); + placedOrder.setStatus(OrderStatus.PLACED); + placedOrder.setCurrency("KES"); + placedOrder.setCreatedAt(Instant.now().minusSeconds(3600)); + + OrderItem itemOne = new OrderItem(); + itemOne.setOrder(placedOrder); + itemOne.setProduct(mouse); + itemOne.setProductName(mouse.getName()); + itemOne.setQuantity(1); + itemOne.setUnitPrice(mouse.getPrice()); + itemOne.setLineTotal(mouse.getPrice()); + + OrderItem itemTwo = new OrderItem(); + itemTwo.setOrder(placedOrder); + itemTwo.setProduct(keyboard); + itemTwo.setProductName(keyboard.getName()); + itemTwo.setQuantity(1); + itemTwo.setUnitPrice(keyboard.getPrice()); + itemTwo.setLineTotal(keyboard.getPrice()); + + placedOrder.addItem(itemOne); + placedOrder.addItem(itemTwo); + placedOrder.recalculateTotal(); + + Order cancelledOrder = new Order(); + cancelledOrder.setCustomerName("Cancelled Customer"); + cancelledOrder.setStatus(OrderStatus.CANCELLED); + cancelledOrder.setCurrency("KES"); + cancelledOrder.setCreatedAt(Instant.now().minusSeconds(86_400)); + cancelledOrder.setCancelledAt(Instant.now().minusSeconds(80_000)); + + OrderItem cancelledItem = new OrderItem(); + cancelledItem.setOrder(cancelledOrder); + cancelledItem.setProduct(webcam); + cancelledItem.setProductName(webcam.getName()); + cancelledItem.setQuantity(1); + cancelledItem.setUnitPrice(webcam.getPrice()); + cancelledItem.setLineTotal(webcam.getPrice()); + + cancelledOrder.addItem(cancelledItem); + cancelledOrder.recalculateTotal(); + + orderRepository.saveAll(List.of(placedOrder, cancelledOrder)); + } + + + private Product product(String name, String sku, String price) { + Product product = new Product(); + product.setName(name); + product.setSku(sku); + product.setPrice(new BigDecimal(price)); + product.setCurrency("KES"); + product.setActive(true); + product.setCreatedAt(Instant.now()); + product.setUpdatedAt(Instant.now()); + return product; + } + + private InventoryItem inventory(Product product, int availableQuantity, int reorderLevel) { + InventoryItem inventoryItem = new InventoryItem(); + inventoryItem.setProduct(product); + inventoryItem.setAvailableQuantity(availableQuantity); + inventoryItem.setReservedQuantity(0); + inventoryItem.setReorderLevel(reorderLevel); + inventoryItem.setUpdatedAt(Instant.now()); + return inventoryItem; + } +} diff --git a/orders-backend/src/main/java/com/andwati/orders/config/FareProperties.java b/orders-backend/src/main/java/com/andwati/orders/config/FareProperties.java new file mode 100644 index 0000000..d0a9915 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/config/FareProperties.java @@ -0,0 +1,34 @@ +package com.andwati.orders.config; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import java.math.BigDecimal; + +@Validated +@ConfigurationProperties(prefix = "fare") +public record FareProperties( + + @NotNull + @DecimalMin(value = "0.00") + BigDecimal baseFare, + + @NotNull + @DecimalMin(value = "0.00", inclusive = false) + BigDecimal perKmRate, + + @NotNull + @DecimalMin(value = "0.00") + BigDecimal minimumFare, + + @NotNull + @DecimalMin(value = "1.00") + BigDecimal defaultSurgeMultiplier, + + @NotBlank + String currency +) { +} diff --git a/orders-backend/src/main/java/com/andwati/orders/config/InventoryProperties.java b/orders-backend/src/main/java/com/andwati/orders/config/InventoryProperties.java new file mode 100644 index 0000000..9690de7 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/config/InventoryProperties.java @@ -0,0 +1,15 @@ +package com.andwati.orders.config; + +import jakarta.validation.constraints.Min; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Validated +@ConfigurationProperties(prefix = "inventory") +public record InventoryProperties( + + @Min(0) + int lowStockThreshold +) { + +} diff --git a/orders-backend/src/main/java/com/andwati/orders/model/InventoryItem.java b/orders-backend/src/main/java/com/andwati/orders/model/InventoryItem.java new file mode 100644 index 0000000..40472c4 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/model/InventoryItem.java @@ -0,0 +1,172 @@ +package com.andwati.orders.model; + + +import jakarta.persistence.*; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "inventory_items", uniqueConstraints = { + @UniqueConstraint(name = "uk_inventory_product", columnNames = "product_id") +}) +public class InventoryItem { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "product_id", nullable = false) + private Product product; + + + @Column(nullable = false) + private int availableQuantity; + + @Column(nullable = false) + private int reservedQuantity; + + @Column(nullable = false) + private int reorderLevel; + + @Column(nullable = false) + private Instant updatedAt; + + @PrePersist + public void prePersist() { + if (updatedAt == null) { + updatedAt = Instant.now(); + } + } + + @PreUpdate + public void preUpdate() { + updatedAt = Instant.now(); + } + + public boolean hasEnoughStock(int requestedQuantity) { + return availableQuantity >= requestedQuantity; + } + + + public void deduct(int quantity) { + if (quantity <= 0) { + throw new IllegalArgumentException("Quantity to deduct must be greater than zero"); + } + + if (availableQuantity < quantity) { + throw new IllegalStateException("Insufficient stock"); + } + + availableQuantity -= quantity; + updatedAt = Instant.now(); + } + + public void restore(int quantity) { + if (quantity <= 0) { + throw new IllegalArgumentException("Quantity to deduct must be greater than zero"); + } + + availableQuantity += quantity; + updatedAt = Instant.now(); + } + + + public InventoryStatus getStatus() { + if (availableQuantity == 0) { + return InventoryStatus.OUT_OF_STOCK; + } + + if (availableQuantity <= reorderLevel) { + return InventoryStatus.LOW_STOCK; + } + + return InventoryStatus.IN_STOCK; + } + + public UUID getId() { + return id; + } + + public InventoryItem setId(UUID id) { + this.id = id; + return this; + } + + public Product getProduct() { + return product; + } + + public InventoryItem setProduct(Product product) { + this.product = product; + return this; + } + + public int getAvailableQuantity() { + return availableQuantity; + } + + public InventoryItem setAvailableQuantity(int availableQuantity) { + this.availableQuantity = availableQuantity; + return this; + } + + + public int getReservedQuantity() { + return reservedQuantity; + } + + public InventoryItem setReservedQuantity(int reservedQuantity) { + this.reservedQuantity = reservedQuantity; + return this; + } + + public int getReorderLevel() { + return reorderLevel; + } + + public InventoryItem setReorderLevel(int reorderLevel) { + this.reorderLevel = reorderLevel; + return this; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public InventoryItem setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + return this; + } +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/orders-backend/src/main/java/com/andwati/orders/model/InventoryStatus.java b/orders-backend/src/main/java/com/andwati/orders/model/InventoryStatus.java new file mode 100644 index 0000000..c3e63b4 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/model/InventoryStatus.java @@ -0,0 +1,7 @@ +package com.andwati.orders.model; + +public enum InventoryStatus { + IN_STOCK, + LOW_STOCK, + OUT_OF_STOCK, +} diff --git a/orders-backend/src/main/java/com/andwati/orders/model/Order.java b/orders-backend/src/main/java/com/andwati/orders/model/Order.java new file mode 100644 index 0000000..a39b1fe --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/model/Order.java @@ -0,0 +1,152 @@ +package com.andwati.orders.model; + + +import jakarta.persistence.*; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "orders") +public class Order { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false, length = 120) + private String customerName; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private OrderStatus status = OrderStatus.PLACED; + + @Column(nullable = false, precision = 12, scale = 2) + private BigDecimal totalAmount = BigDecimal.ZERO; + + @Column(nullable = false, length = 3) + private String currency = "KES"; + + @Column(nullable = false) + private Instant createdAt; + + private Instant cancelledAt; + + @OneToMany( + mappedBy = "order", + cascade = CascadeType.ALL, + orphanRemoval = true + ) + private List items = new ArrayList<>(); + + @PrePersist + public void prePersist() { + if (createdAt == null) { + createdAt = Instant.now(); + } + + if (totalAmount == null) { + totalAmount = BigDecimal.ZERO; + } + + if (status == null) { + status = OrderStatus.PLACED; + } + } + + + public void addItem(OrderItem item) { + items.add(item); + item.setOrder(this); + } + + public void recalculateTotal() { + this.totalAmount = items.stream() + .map(OrderItem::getLineTotal) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + public boolean isCancellable() { + return status == OrderStatus.PLACED; + } + + public void cancel() { + this.status = OrderStatus.CANCELLED; + this.cancelledAt = Instant.now(); + } + + public UUID getId() { + return id; + } + + public Order setId(UUID id) { + this.id = id; + return this; + } + + public String getCustomerName() { + return customerName; + } + + public Order setCustomerName(String customerName) { + this.customerName = customerName; + return this; + } + + public OrderStatus getStatus() { + return status; + } + + public Order setStatus(OrderStatus status) { + this.status = status; + return this; + } + + public BigDecimal getTotalAmount() { + return totalAmount; + } + + public Order setTotalAmount(BigDecimal totalAmount) { + this.totalAmount = totalAmount; + return this; + } + + public String getCurrency() { + return currency; + } + + public Order setCurrency(String currency) { + this.currency = currency; + return this; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Order setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + return this; + } + + public Instant getCancelledAt() { + return cancelledAt; + } + + public Order setCancelledAt(Instant cancelledAt) { + this.cancelledAt = cancelledAt; + return this; + } + + public List getItems() { + return items; + } + + public Order setItems(List items) { + this.items = items; + return this; + } +} diff --git a/orders-backend/src/main/java/com/andwati/orders/model/OrderItem.java b/orders-backend/src/main/java/com/andwati/orders/model/OrderItem.java new file mode 100644 index 0000000..0ba4820 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/model/OrderItem.java @@ -0,0 +1,109 @@ +package com.andwati.orders.model; + + +import jakarta.persistence.*; + +import java.math.BigDecimal; +import java.util.UUID; + +@Entity +@Table(name = "order_items") +public class OrderItem { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "order_id", nullable = false) + private Order order; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "product_id", nullable = false) + private Product product; + + @Column(nullable = false, length = 120) + private String productName; + + @Column(nullable = false) + private int quantity; + + @Column(nullable = false, precision = 12, scale = 2) + private BigDecimal unitPrice; + + @Column(nullable = false, precision = 12, scale = 2) + private BigDecimal lineTotal; + + public static OrderItem from(Product product, int quantity) { + OrderItem item = new OrderItem(); + item.setProduct(product); + item.setProductName(product.getName()); + item.setQuantity(quantity); + item.setUnitPrice(product.getPrice()); + item.setLineTotal(product.getPrice().multiply(BigDecimal.valueOf(quantity))); + return item; + } + + public UUID getId() { + return id; + } + + public OrderItem setId(UUID id) { + this.id = id; + return this; + } + + public Order getOrder() { + return order; + } + + public OrderItem setOrder(Order order) { + this.order = order; + return this; + } + + public Product getProduct() { + return product; + } + + public OrderItem setProduct(Product product) { + this.product = product; + return this; + } + + public String getProductName() { + return productName; + } + + public OrderItem setProductName(String productName) { + this.productName = productName; + return this; + } + + public int getQuantity() { + return quantity; + } + + public OrderItem setQuantity(int quantity) { + this.quantity = quantity; + return this; + } + + public BigDecimal getUnitPrice() { + return unitPrice; + } + + public OrderItem setUnitPrice(BigDecimal unitPrice) { + this.unitPrice = unitPrice; + return this; + } + + public BigDecimal getLineTotal() { + return lineTotal; + } + + public OrderItem setLineTotal(BigDecimal lineTotal) { + this.lineTotal = lineTotal; + return this; + } +} \ No newline at end of file diff --git a/orders-backend/src/main/java/com/andwati/orders/model/OrderStatus.java b/orders-backend/src/main/java/com/andwati/orders/model/OrderStatus.java new file mode 100644 index 0000000..6f1662c --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/model/OrderStatus.java @@ -0,0 +1,7 @@ +package com.andwati.orders.model; + + +public enum OrderStatus { + PLACED, + CANCELLED +} \ No newline at end of file diff --git a/orders-backend/src/main/java/com/andwati/orders/model/Product.java b/orders-backend/src/main/java/com/andwati/orders/model/Product.java new file mode 100644 index 0000000..68cf8e7 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/model/Product.java @@ -0,0 +1,134 @@ +package com.andwati.orders.model; + + +import jakarta.persistence.*; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table( + name = "products", + uniqueConstraints = { + @UniqueConstraint(name = "uk_products_sku", columnNames = "sku") + } +) +public class Product { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + + @Column(nullable = false, length = 120) + private String name; + + @Column(nullable = false, length = 80) + private String sku; + + @Column(nullable = false, precision = 12, scale = 2) + private BigDecimal price; + + @Column(nullable = false, length = 3) + private String currency = "KES"; + + @Column(nullable = false) + private boolean active = true; + + @Column(nullable = false) + private Instant createdAt; + + @Column(nullable = false) + private Instant updatedAt; + + @PrePersist + public void prePersist() { + Instant now = Instant.now(); + + if (createdAt == null) { + createdAt = now; + } + + if (updatedAt == null) { + updatedAt = now; + } + } + + @PreUpdate + public void preUpdate() { + updatedAt = Instant.now(); + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getName() { + return name; + } + + public Product setName(String name) { + this.name = name; + return this; + } + + public String getSku() { + return sku; + } + + public Product setSku(String sku) { + + this.sku = sku; + return this; + } + + public BigDecimal getPrice() { + return price; + } + + public Product setPrice(BigDecimal price) { + this.price = price; + return this; + } + + public String getCurrency() { + return currency; + } + + public Product setCurrency(String currency) { + this.currency = currency; + return this; + } + + public boolean isActive() { + return active; + } + + public Product setActive(boolean active) { + this.active = active; + return this; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Product setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + return this; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public Product setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + return this; + } +}