Workspace UI

This commit is contained in:
Domonkos
2026-01-21 19:13:52 +01:00
parent 6b0b7ae903
commit b717952234
13 changed files with 423 additions and 206 deletions

View File

@@ -20,8 +20,9 @@ public class InventoryMovement {
@Column(nullable = false)
private int delta;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private String reason; // PRODUCTION, SALE, RETURN, ADJUSTMENT
private MovementReason reason;
private String reference;
@@ -33,12 +34,12 @@ public class InventoryMovement {
public Long getId() { return id; }
public Sku getSku() { return sku; }
public int getDelta() { return delta; }
public String getReason() { return reason; }
public MovementReason getReason() { return reason; }
public String getReference() { return reference; }
public Instant getCreatedAt() { return createdAt; }
public void setSku(Sku sku) { this.sku = sku; }
public void setDelta(int delta) { this.delta = delta; }
public void setReason(String reason) { this.reason = reason; }
public void setReason(MovementReason reason) { this.reason = reason; }
public void setReference(String reference) { this.reference = reference; }
}

View File

@@ -3,6 +3,6 @@ package com.voyage.workspace.inventory;
public record InventoryMovementCreateRequest(
Long skuId,
int delta,
String reason,
MovementReason reason,
String reference
) {}

View File

@@ -0,0 +1,8 @@
package com.voyage.workspace.inventory;
public enum MovementReason {
PRODUCTION,
SALE,
RETURN,
ADJUSTMENT
}

View File

@@ -0,0 +1,33 @@
package com.voyage.workspace.orders;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String customerRef;
private Instant createdAt = Instant.now();
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this);
}
// getters/setters
public Long getId() { return id; }
public String getCustomerRef() { return customerRef; }
public void setCustomerRef(String customerRef) { this.customerRef = customerRef; }
public Instant getCreatedAt() { return createdAt; }
public List<OrderItem> getItems() { return items; }
}

View File

@@ -0,0 +1,40 @@
package com.voyage.workspace.orders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderRepository orderRepo;
private final OrderService orderService;
public OrderController(OrderRepository orderRepo, OrderService orderService) {
this.orderRepo = orderRepo;
this.orderService = orderService;
}
@PostMapping
public ResponseEntity<?> create(@RequestBody OrderCreateRequest req) {
try {
return ResponseEntity.ok(orderService.create(req));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
@GetMapping
public List<Order> list() {
return orderRepo.findAll();
}
@GetMapping("/{id}")
public ResponseEntity<?> get(@PathVariable Long id) {
return orderRepo.findById(id)
.<ResponseEntity<?>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
}

View File

@@ -0,0 +1,8 @@
package com.voyage.workspace.orders;
import java.util.List;
public record OrderCreateRequest(
String customerRef,
List<OrderItemRequest> items
) {}

View File

@@ -0,0 +1,40 @@
package com.voyage.workspace.orders;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.voyage.workspace.products.Sku;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false, fetch = FetchType.EAGER)
private Sku sku;
private int qty;
@Column(precision = 10, scale = 2)
private BigDecimal unitPrice;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
@JsonIgnore
private Order order;
// getters/setters
public Long getId() { return id; }
public Order getOrder() { return order; }
public void setOrder(Order order) { this.order = order; }
public Sku getSku() { return sku; }
public void setSku(Sku sku) { this.sku = sku; }
public int getQty() { return qty; }
public void setQty(int qty) { this.qty = qty; }
public BigDecimal getUnitPrice() { return unitPrice; }
public void setUnitPrice(BigDecimal unitPrice) { this.unitPrice = unitPrice; }
}

View File

@@ -0,0 +1,9 @@
package com.voyage.workspace.orders;
import java.math.BigDecimal;
public record OrderItemRequest(
Long skuId,
int qty,
BigDecimal unitPrice
) {}

View File

@@ -0,0 +1,6 @@
package com.voyage.workspace.orders;
import org.springframework.data.jpa.repository.JpaRepository;
public interface OrderRepository extends JpaRepository<Order, Long> {}

View File

@@ -0,0 +1,76 @@
package com.voyage.workspace.orders;
import com.voyage.workspace.inventory.InventoryMovement;
import com.voyage.workspace.inventory.InventoryMovementRepository;
import com.voyage.workspace.inventory.MovementReason;
import com.voyage.workspace.products.SkuRepository;
import jakarta.transaction.Transactional;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final OrderRepository orderRepo;
private final SkuRepository skuRepo;
private final InventoryMovementRepository movementRepo;
public OrderService(OrderRepository orderRepo, SkuRepository skuRepo, InventoryMovementRepository movementRepo) {
this.orderRepo = orderRepo;
this.skuRepo = skuRepo;
this.movementRepo = movementRepo;
}
@Transactional
public Order create(OrderCreateRequest req) {
if (req.items() == null || req.items().isEmpty()) {
throw new IllegalArgumentException("Order must have at least 1 item");
}
// 1) Stock check first (fail fast)
for (var item : req.items()) {
var sku = skuRepo.findById(item.skuId())
.orElseThrow(() -> new IllegalArgumentException("Unknown skuId: " + item.skuId()));
if (!sku.isActive()) {
throw new IllegalArgumentException("SKU is inactive: " + sku.getId());
}
int current = movementRepo.currentStock(sku.getId());
if (item.qty() <= 0) throw new IllegalArgumentException("qty must be > 0");
if (current < item.qty()) {
throw new IllegalArgumentException("Insufficient stock for skuId=" + sku.getId()
+ " (current=" + current + ", requested=" + item.qty() + ")");
}
}
// 2) Create order + items
Order order = new Order();
order.setCustomerRef(req.customerRef());
for (var item : req.items()) {
var sku = skuRepo.findById(item.skuId())
.orElseThrow(() -> new IllegalArgumentException("Unknown skuId: " + item.skuId()));
OrderItem oi = new OrderItem();
oi.setSku(sku);
oi.setQty(item.qty());
oi.setUnitPrice(item.unitPrice());
order.addItem(oi);
}
Order saved = orderRepo.save(order);
// 3) Create SALE movements (negative)
for (var oi : saved.getItems()) {
InventoryMovement m = new InventoryMovement();
m.setSku(oi.getSku());
m.setDelta(-oi.getQty());
m.setReason(MovementReason.SALE);
m.setReference("ORDER:" + saved.getId());
movementRepo.save(m);
}
return saved;
}
}