diff --git a/backend/src/main/java/de/effigenix/application/inventory/AddStockBatch.java b/backend/src/main/java/de/effigenix/application/inventory/AddStockBatch.java new file mode 100644 index 0000000..b1d1cbf --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/AddStockBatch.java @@ -0,0 +1,53 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.AddStockBatchCommand; +import de.effigenix.domain.inventory.*; +import de.effigenix.shared.common.Result; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +public class AddStockBatch { + + private final StockRepository stockRepository; + + public AddStockBatch(StockRepository stockRepository) { + this.stockRepository = stockRepository; + } + + public Result execute(AddStockBatchCommand cmd) { + // 1. Stock laden + Stock stock; + switch (stockRepository.findById(StockId.of(cmd.stockId()))) { + case Result.Failure(var err) -> + { return Result.failure(new StockError.RepositoryFailure(err.message())); } + case Result.Success(var opt) -> { + if (opt.isEmpty()) { + return Result.failure(new StockError.StockNotFound(cmd.stockId())); + } + stock = opt.get(); + } + } + + // 2. Batch hinzufügen (Domain validiert) + var draft = new StockBatchDraft( + cmd.batchId(), cmd.batchType(), + cmd.quantityAmount(), cmd.quantityUnit(), + cmd.expiryDate() + ); + + StockBatch batch; + switch (stock.addBatch(draft)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var val) -> batch = val; + } + + // 3. Stock speichern + switch (stockRepository.save(stock)) { + case Result.Failure(var err) -> + { return Result.failure(new StockError.RepositoryFailure(err.message())); } + case Result.Success(var ignored) -> { } + } + + return Result.success(batch); + } +} diff --git a/backend/src/main/java/de/effigenix/application/inventory/command/AddStockBatchCommand.java b/backend/src/main/java/de/effigenix/application/inventory/command/AddStockBatchCommand.java new file mode 100644 index 0000000..997e6b2 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/command/AddStockBatchCommand.java @@ -0,0 +1,10 @@ +package de.effigenix.application.inventory.command; + +public record AddStockBatchCommand( + String stockId, + String batchId, + String batchType, + String quantityAmount, + String quantityUnit, + String expiryDate +) {} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/BatchReference.java b/backend/src/main/java/de/effigenix/domain/inventory/BatchReference.java new file mode 100644 index 0000000..df6318d --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/BatchReference.java @@ -0,0 +1,29 @@ +package de.effigenix.domain.inventory; + +import de.effigenix.shared.common.Result; + +import java.util.Objects; + +public record BatchReference(String batchId, BatchType batchType) { + + public BatchReference { + Objects.requireNonNull(batchId, "batchId must not be null"); + Objects.requireNonNull(batchType, "batchType must not be null"); + } + + public static Result of(String batchId, String batchType) { + if (batchId == null || batchId.isBlank()) { + return Result.failure(new StockError.InvalidBatchReference("batchId must not be blank")); + } + + BatchType type; + try { + type = BatchType.valueOf(batchType); + } catch (IllegalArgumentException | NullPointerException e) { + return Result.failure(new StockError.InvalidBatchReference( + "Invalid batchType: " + batchType + ". Allowed values: PRODUCED, PURCHASED")); + } + + return Result.success(new BatchReference(batchId, type)); + } +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/BatchType.java b/backend/src/main/java/de/effigenix/domain/inventory/BatchType.java new file mode 100644 index 0000000..47cac63 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/BatchType.java @@ -0,0 +1,5 @@ +package de.effigenix.domain.inventory; + +public enum BatchType { + PRODUCED, PURCHASED +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/Stock.java b/backend/src/main/java/de/effigenix/domain/inventory/Stock.java index d20fcf0..cb088fc 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/Stock.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/Stock.java @@ -3,6 +3,9 @@ package de.effigenix.domain.inventory; import de.effigenix.domain.masterdata.ArticleId; import de.effigenix.shared.common.Result; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Objects; /** @@ -12,6 +15,7 @@ import java.util.Objects; * - ArticleId + StorageLocationId = unique (Application Layer, Repository-Concern) * - MinimumLevel optional (quantity amount >= 0) * - MinimumShelfLife optional (days > 0) + * - BatchReference (batchId + batchType) unique within batches */ public class Stock { @@ -20,19 +24,22 @@ public class Stock { private final StorageLocationId storageLocationId; private MinimumLevel minimumLevel; private MinimumShelfLife minimumShelfLife; + private final List batches; private Stock( StockId id, ArticleId articleId, StorageLocationId storageLocationId, MinimumLevel minimumLevel, - MinimumShelfLife minimumShelfLife + MinimumShelfLife minimumShelfLife, + List batches ) { this.id = id; this.articleId = articleId; this.storageLocationId = storageLocationId; this.minimumLevel = minimumLevel; this.minimumShelfLife = minimumShelfLife; + this.batches = new ArrayList<>(batches); } /** @@ -81,7 +88,7 @@ public class Stock { } return Result.success(new Stock( - StockId.generate(), articleId, storageLocationId, minimumLevel, minimumShelfLife + StockId.generate(), articleId, storageLocationId, minimumLevel, minimumShelfLife, List.of() )); } @@ -93,9 +100,28 @@ public class Stock { ArticleId articleId, StorageLocationId storageLocationId, MinimumLevel minimumLevel, - MinimumShelfLife minimumShelfLife + MinimumShelfLife minimumShelfLife, + List batches ) { - return new Stock(id, articleId, storageLocationId, minimumLevel, minimumShelfLife); + return new Stock(id, articleId, storageLocationId, minimumLevel, minimumShelfLife, batches); + } + + // ==================== Batch Management ==================== + + public Result addBatch(StockBatchDraft draft) { + StockBatch batch; + switch (StockBatch.create(draft)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var val) -> batch = val; + } + + if (hasBatchReference(batch.batchReference())) { + return Result.failure(new StockError.DuplicateBatchReference( + batch.batchReference().batchId(), batch.batchReference().batchType().name())); + } + + this.batches.add(batch); + return Result.success(batch); } // ==================== Getters ==================== @@ -105,6 +131,13 @@ public class Stock { public StorageLocationId storageLocationId() { return storageLocationId; } public MinimumLevel minimumLevel() { return minimumLevel; } public MinimumShelfLife minimumShelfLife() { return minimumShelfLife; } + public List batches() { return Collections.unmodifiableList(batches); } + + // ==================== Helpers ==================== + + private boolean hasBatchReference(BatchReference ref) { + return batches.stream().anyMatch(b -> b.batchReference().equals(ref)); + } @Override public boolean equals(Object obj) { diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StockBatch.java b/backend/src/main/java/de/effigenix/domain/inventory/StockBatch.java new file mode 100644 index 0000000..9abb522 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/StockBatch.java @@ -0,0 +1,115 @@ +package de.effigenix.domain.inventory; + +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.common.UnitOfMeasure; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.Objects; + +/** + * Child entity of Stock representing a single batch (Charge). + * + * Invariants: + * - BatchReference (batchId + batchType) must be valid + * - Quantity must be positive + * - ExpiryDate must be a valid ISO date + * - New batches always have status AVAILABLE + * - receivedAt is set automatically on creation + */ +public class StockBatch { + + private final StockBatchId id; + private final BatchReference batchReference; + private final Quantity quantity; + private final LocalDate expiryDate; + private final StockBatchStatus status; + private final Instant receivedAt; + + private StockBatch(StockBatchId id, BatchReference batchReference, Quantity quantity, + LocalDate expiryDate, StockBatchStatus status, Instant receivedAt) { + this.id = id; + this.batchReference = batchReference; + this.quantity = quantity; + this.expiryDate = expiryDate; + this.status = status; + this.receivedAt = receivedAt; + } + + public static Result create(StockBatchDraft draft) { + // 1. BatchReference validieren + BatchReference batchReference; + switch (BatchReference.of(draft.batchId(), draft.batchType())) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var val) -> batchReference = val; + } + + // 2. Quantity validieren (Pflicht, positiv) + Quantity quantity; + try { + BigDecimal amount = new BigDecimal(draft.quantityAmount()); + UnitOfMeasure uom; + try { + uom = UnitOfMeasure.valueOf(draft.quantityUnit()); + } catch (IllegalArgumentException | NullPointerException e) { + return Result.failure(new StockError.InvalidQuantity("Invalid unit: " + draft.quantityUnit())); + } + switch (Quantity.of(amount, uom)) { + case Result.Failure(var err) -> { + return Result.failure(new StockError.InvalidQuantity(err.message())); + } + case Result.Success(var val) -> quantity = val; + } + } catch (NumberFormatException | NullPointerException e) { + return Result.failure(new StockError.InvalidQuantity( + "Invalid quantity amount: " + draft.quantityAmount())); + } + + // 3. ExpiryDate validieren (Pflicht) + LocalDate expiryDate; + try { + if (draft.expiryDate() == null || draft.expiryDate().isBlank()) { + return Result.failure(new StockError.InvalidExpiryDate("expiryDate must not be blank")); + } + expiryDate = LocalDate.parse(draft.expiryDate()); + } catch (DateTimeParseException e) { + return Result.failure(new StockError.InvalidExpiryDate( + "Invalid ISO date: " + draft.expiryDate())); + } + + return Result.success(new StockBatch( + StockBatchId.generate(), batchReference, quantity, + expiryDate, StockBatchStatus.AVAILABLE, Instant.now() + )); + } + + public static StockBatch reconstitute(StockBatchId id, BatchReference batchReference, + Quantity quantity, LocalDate expiryDate, + StockBatchStatus status, Instant receivedAt) { + return new StockBatch(id, batchReference, quantity, expiryDate, status, receivedAt); + } + + // ==================== Getters ==================== + + public StockBatchId id() { return id; } + public BatchReference batchReference() { return batchReference; } + public Quantity quantity() { return quantity; } + public LocalDate expiryDate() { return expiryDate; } + public StockBatchStatus status() { return status; } + public Instant receivedAt() { return receivedAt; } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof StockBatch other)) return false; + return Objects.equals(id, other.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StockBatchDraft.java b/backend/src/main/java/de/effigenix/domain/inventory/StockBatchDraft.java new file mode 100644 index 0000000..4374c7a --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/StockBatchDraft.java @@ -0,0 +1,9 @@ +package de.effigenix.domain.inventory; + +public record StockBatchDraft( + String batchId, + String batchType, + String quantityAmount, + String quantityUnit, + String expiryDate +) {} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StockBatchId.java b/backend/src/main/java/de/effigenix/domain/inventory/StockBatchId.java new file mode 100644 index 0000000..54a7c35 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/StockBatchId.java @@ -0,0 +1,20 @@ +package de.effigenix.domain.inventory; + +import java.util.UUID; + +public record StockBatchId(String value) { + + public StockBatchId { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("StockBatchId must not be blank"); + } + } + + public static StockBatchId generate() { + return new StockBatchId(UUID.randomUUID().toString()); + } + + public static StockBatchId of(String value) { + return new StockBatchId(value); + } +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StockBatchStatus.java b/backend/src/main/java/de/effigenix/domain/inventory/StockBatchStatus.java new file mode 100644 index 0000000..9f22d9e --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/StockBatchStatus.java @@ -0,0 +1,5 @@ +package de.effigenix.domain.inventory; + +public enum StockBatchStatus { + AVAILABLE, EXPIRING_SOON, BLOCKED, EXPIRED +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StockError.java b/backend/src/main/java/de/effigenix/domain/inventory/StockError.java index 8f924c8..d1e69d5 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/StockError.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/StockError.java @@ -35,6 +35,31 @@ public sealed interface StockError { @Override public String message() { return "Invalid storage location ID: " + reason; } } + record InvalidBatchReference(String reason) implements StockError { + @Override public String code() { return "INVALID_BATCH_REFERENCE"; } + @Override public String message() { return "Invalid batch reference: " + reason; } + } + + record DuplicateBatchReference(String batchId, String batchType) implements StockError { + @Override public String code() { return "DUPLICATE_BATCH_REFERENCE"; } + @Override public String message() { return "Batch reference already exists: " + batchId + "/" + batchType; } + } + + record InvalidQuantity(String reason) implements StockError { + @Override public String code() { return "INVALID_QUANTITY"; } + @Override public String message() { return "Invalid quantity: " + reason; } + } + + record InvalidExpiryDate(String reason) implements StockError { + @Override public String code() { return "INVALID_EXPIRY_DATE"; } + @Override public String message() { return "Invalid expiry date: " + reason; } + } + + record BatchNotFound(String id) implements StockError { + @Override public String code() { return "BATCH_NOT_FOUND"; } + @Override public String message() { return "Batch not found: " + id; } + } + record Unauthorized(String message) implements StockError { @Override public String code() { return "UNAUTHORIZED"; } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java index 734ea5d..17f2cfd 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java @@ -1,6 +1,7 @@ package de.effigenix.infrastructure.config; import de.effigenix.application.inventory.ActivateStorageLocation; +import de.effigenix.application.inventory.AddStockBatch; import de.effigenix.application.inventory.CreateStock; import de.effigenix.application.inventory.CreateStorageLocation; import de.effigenix.application.inventory.DeactivateStorageLocation; @@ -47,4 +48,9 @@ public class InventoryUseCaseConfiguration { public CreateStock createStock(StockRepository stockRepository) { return new CreateStock(stockRepository); } + + @Bean + public AddStockBatch addStockBatch(StockRepository stockRepository) { + return new AddStockBatch(stockRepository); + } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/StockBatchEntity.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/StockBatchEntity.java new file mode 100644 index 0000000..db23081 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/StockBatchEntity.java @@ -0,0 +1,77 @@ +package de.effigenix.infrastructure.inventory.persistence.entity; + +import jakarta.persistence.*; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; + +@Entity +@Table(name = "stock_batches") +public class StockBatchEntity { + + @Id + @Column(name = "id", nullable = false, length = 36) + private String id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "stock_id", nullable = false) + private StockEntity stock; + + @Column(name = "batch_id", nullable = false, length = 100) + private String batchId; + + @Column(name = "batch_type", nullable = false, length = 20) + private String batchType; + + @Column(name = "quantity_amount", nullable = false, precision = 19, scale = 6) + private BigDecimal quantityAmount; + + @Column(name = "quantity_unit", nullable = false, length = 20) + private String quantityUnit; + + @Column(name = "expiry_date", nullable = false) + private LocalDate expiryDate; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @Column(name = "received_at", nullable = false) + private Instant receivedAt; + + protected StockBatchEntity() {} + + public StockBatchEntity(String id, StockEntity stock, String batchId, String batchType, + BigDecimal quantityAmount, String quantityUnit, LocalDate expiryDate, + String status, Instant receivedAt) { + this.id = id; + this.stock = stock; + this.batchId = batchId; + this.batchType = batchType; + this.quantityAmount = quantityAmount; + this.quantityUnit = quantityUnit; + this.expiryDate = expiryDate; + this.status = status; + this.receivedAt = receivedAt; + } + + public String getId() { return id; } + public StockEntity getStock() { return stock; } + public String getBatchId() { return batchId; } + public String getBatchType() { return batchType; } + public BigDecimal getQuantityAmount() { return quantityAmount; } + public String getQuantityUnit() { return quantityUnit; } + public LocalDate getExpiryDate() { return expiryDate; } + public String getStatus() { return status; } + public Instant getReceivedAt() { return receivedAt; } + + public void setId(String id) { this.id = id; } + public void setStock(StockEntity stock) { this.stock = stock; } + public void setBatchId(String batchId) { this.batchId = batchId; } + public void setBatchType(String batchType) { this.batchType = batchType; } + public void setQuantityAmount(BigDecimal quantityAmount) { this.quantityAmount = quantityAmount; } + public void setQuantityUnit(String quantityUnit) { this.quantityUnit = quantityUnit; } + public void setExpiryDate(LocalDate expiryDate) { this.expiryDate = expiryDate; } + public void setStatus(String status) { this.status = status; } + public void setReceivedAt(Instant receivedAt) { this.receivedAt = receivedAt; } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/StockEntity.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/StockEntity.java index 460aa73..1ee5942 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/StockEntity.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/StockEntity.java @@ -3,6 +3,8 @@ package de.effigenix.infrastructure.inventory.persistence.entity; import jakarta.persistence.*; import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; @Entity @Table(name = "stocks") @@ -27,6 +29,9 @@ public class StockEntity { @Column(name = "minimum_shelf_life_days") private Integer minimumShelfLifeDays; + @OneToMany(mappedBy = "stock", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) + private List batches = new ArrayList<>(); + public StockEntity() {} // ==================== Getters & Setters ==================== @@ -48,4 +53,7 @@ public class StockEntity { public Integer getMinimumShelfLifeDays() { return minimumShelfLifeDays; } public void setMinimumShelfLifeDays(Integer minimumShelfLifeDays) { this.minimumShelfLifeDays = minimumShelfLifeDays; } + + public List getBatches() { return batches; } + public void setBatches(List batches) { this.batches = batches; } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/mapper/StockMapper.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/mapper/StockMapper.java index 6fd94ea..5352707 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/mapper/StockMapper.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/mapper/StockMapper.java @@ -4,9 +4,13 @@ import de.effigenix.domain.inventory.*; import de.effigenix.domain.masterdata.ArticleId; import de.effigenix.shared.common.Quantity; import de.effigenix.shared.common.UnitOfMeasure; +import de.effigenix.infrastructure.inventory.persistence.entity.StockBatchEntity; import de.effigenix.infrastructure.inventory.persistence.entity.StockEntity; import org.springframework.stereotype.Component; +import java.util.List; +import java.util.stream.Collectors; + @Component public class StockMapper { @@ -25,6 +29,11 @@ public class StockMapper { entity.setMinimumShelfLifeDays(stock.minimumShelfLife().days()); } + List batchEntities = stock.batches().stream() + .map(b -> toBatchEntity(b, entity)) + .collect(Collectors.toList()); + entity.setBatches(batchEntities); + return entity; } @@ -44,12 +53,46 @@ public class StockMapper { minimumShelfLife = new MinimumShelfLife(entity.getMinimumShelfLifeDays()); } + List batches = entity.getBatches().stream() + .map(this::toDomainBatch) + .collect(Collectors.toList()); + return Stock.reconstitute( StockId.of(entity.getId()), ArticleId.of(entity.getArticleId()), StorageLocationId.of(entity.getStorageLocationId()), minimumLevel, - minimumShelfLife + minimumShelfLife, + batches + ); + } + + private StockBatchEntity toBatchEntity(StockBatch batch, StockEntity stockEntity) { + return new StockBatchEntity( + batch.id().value(), + stockEntity, + batch.batchReference().batchId(), + batch.batchReference().batchType().name(), + batch.quantity().amount(), + batch.quantity().uom().name(), + batch.expiryDate(), + batch.status().name(), + batch.receivedAt() + ); + } + + private StockBatch toDomainBatch(StockBatchEntity entity) { + return StockBatch.reconstitute( + StockBatchId.of(entity.getId()), + new BatchReference(entity.getBatchId(), BatchType.valueOf(entity.getBatchType())), + Quantity.reconstitute( + entity.getQuantityAmount(), + UnitOfMeasure.valueOf(entity.getQuantityUnit()), + null, null + ), + entity.getExpiryDate(), + StockBatchStatus.valueOf(entity.getStatus()), + entity.getReceivedAt() ); } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java index 6000c7b..a9e0955 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java @@ -1,9 +1,13 @@ package de.effigenix.infrastructure.inventory.web.controller; +import de.effigenix.application.inventory.AddStockBatch; import de.effigenix.application.inventory.CreateStock; +import de.effigenix.application.inventory.command.AddStockBatchCommand; import de.effigenix.application.inventory.command.CreateStockCommand; import de.effigenix.domain.inventory.StockError; +import de.effigenix.infrastructure.inventory.web.dto.AddStockBatchRequest; import de.effigenix.infrastructure.inventory.web.dto.CreateStockRequest; +import de.effigenix.infrastructure.inventory.web.dto.StockBatchResponse; import de.effigenix.infrastructure.inventory.web.dto.StockResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; @@ -25,9 +29,11 @@ public class StockController { private static final Logger logger = LoggerFactory.getLogger(StockController.class); private final CreateStock createStock; + private final AddStockBatch addStockBatch; - public StockController(CreateStock createStock) { + public StockController(CreateStock createStock, AddStockBatch addStockBatch) { this.createStock = createStock; + this.addStockBatch = addStockBatch; } @PostMapping @@ -55,6 +61,31 @@ public class StockController { .body(StockResponse.from(result.unsafeGetValue())); } + @PostMapping("/{stockId}/batches") + @PreAuthorize("hasAuthority('STOCK_WRITE')") + public ResponseEntity addBatch( + @PathVariable String stockId, + @Valid @RequestBody AddStockBatchRequest request, + Authentication authentication + ) { + logger.info("Adding batch to stock: {} by actor: {}", stockId, authentication.getName()); + + var cmd = new AddStockBatchCommand( + stockId, request.batchId(), request.batchType(), + request.quantityAmount(), request.quantityUnit(), + request.expiryDate() + ); + var result = addStockBatch.execute(cmd); + + if (result.isFailure()) { + throw new StockDomainErrorException(result.unsafeGetError()); + } + + logger.info("Batch added: {}", result.unsafeGetValue().id().value()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(StockBatchResponse.from(result.unsafeGetValue())); + } + public static class StockDomainErrorException extends RuntimeException { private final StockError error; diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/AddStockBatchRequest.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/AddStockBatchRequest.java new file mode 100644 index 0000000..c98c2d9 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/AddStockBatchRequest.java @@ -0,0 +1,11 @@ +package de.effigenix.infrastructure.inventory.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record AddStockBatchRequest( + @NotBlank String batchId, + @NotBlank String batchType, + @NotBlank String quantityAmount, + @NotBlank String quantityUnit, + @NotBlank String expiryDate +) {} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockBatchResponse.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockBatchResponse.java new file mode 100644 index 0000000..df05d9d --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockBatchResponse.java @@ -0,0 +1,32 @@ +package de.effigenix.infrastructure.inventory.web.dto; + +import de.effigenix.domain.inventory.StockBatch; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; + +public record StockBatchResponse( + String id, + String batchId, + String batchType, + BigDecimal quantityAmount, + String quantityUnit, + LocalDate expiryDate, + String status, + Instant receivedAt +) { + + public static StockBatchResponse from(StockBatch batch) { + return new StockBatchResponse( + batch.id().value(), + batch.batchReference().batchId(), + batch.batchReference().batchType().name(), + batch.quantity().amount(), + batch.quantity().uom().name(), + batch.expiryDate(), + batch.status().name(), + batch.receivedAt() + ); + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/exception/InventoryErrorHttpStatusMapper.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/exception/InventoryErrorHttpStatusMapper.java index c1bcf6a..ad612e7 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/exception/InventoryErrorHttpStatusMapper.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/exception/InventoryErrorHttpStatusMapper.java @@ -25,11 +25,16 @@ public final class InventoryErrorHttpStatusMapper { public static int toHttpStatus(StockError error) { return switch (error) { case StockError.StockNotFound e -> 404; + case StockError.BatchNotFound e -> 404; case StockError.DuplicateStock e -> 409; + case StockError.DuplicateBatchReference e -> 409; case StockError.InvalidMinimumLevel e -> 400; case StockError.InvalidMinimumShelfLife e -> 400; case StockError.InvalidArticleId e -> 400; case StockError.InvalidStorageLocationId e -> 400; + case StockError.InvalidBatchReference e -> 400; + case StockError.InvalidQuantity e -> 400; + case StockError.InvalidExpiryDate e -> 400; case StockError.Unauthorized e -> 403; case StockError.RepositoryFailure e -> 500; }; diff --git a/backend/src/main/java/de/effigenix/infrastructure/stub/StubStockRepository.java b/backend/src/main/java/de/effigenix/infrastructure/stub/StubStockRepository.java new file mode 100644 index 0000000..bacccf8 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/stub/StubStockRepository.java @@ -0,0 +1,41 @@ +package de.effigenix.infrastructure.stub; + +import de.effigenix.domain.inventory.Stock; +import de.effigenix.domain.inventory.StockId; +import de.effigenix.domain.inventory.StockRepository; +import de.effigenix.domain.inventory.StorageLocationId; +import de.effigenix.domain.masterdata.ArticleId; +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@Profile("no-db") +public class StubStockRepository implements StockRepository { + + private static final RepositoryError.DatabaseError STUB_ERROR = + new RepositoryError.DatabaseError("Stub-Modus: keine Datenbankverbindung"); + + @Override + public Result> findById(StockId id) { + return Result.failure(STUB_ERROR); + } + + @Override + public Result> findByArticleIdAndStorageLocationId(ArticleId articleId, StorageLocationId storageLocationId) { + return Result.failure(STUB_ERROR); + } + + @Override + public Result existsByArticleIdAndStorageLocationId(ArticleId articleId, StorageLocationId storageLocationId) { + return Result.failure(STUB_ERROR); + } + + @Override + public Result save(Stock stock) { + return Result.failure(STUB_ERROR); + } +} diff --git a/backend/src/main/resources/db/changelog/changes/014-create-stock-batches-table.xml b/backend/src/main/resources/db/changelog/changes/014-create-stock-batches-table.xml new file mode 100644 index 0000000..c25356d --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/014-create-stock-batches-table.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ALTER TABLE stock_batches ADD CONSTRAINT chk_stock_batch_type + CHECK (batch_type IN ('PRODUCED', 'PURCHASED')); + + + + ALTER TABLE stock_batches ADD CONSTRAINT chk_stock_batch_status + CHECK (status IN ('AVAILABLE', 'EXPIRING_SOON', 'BLOCKED', 'EXPIRED')); + + + + diff --git a/backend/src/main/resources/db/changelog/db.changelog-master.xml b/backend/src/main/resources/db/changelog/db.changelog-master.xml index ba7d26b..a3a1b2d 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -18,5 +18,6 @@ + diff --git a/backend/src/test/java/de/effigenix/application/inventory/AddStockBatchTest.java b/backend/src/test/java/de/effigenix/application/inventory/AddStockBatchTest.java new file mode 100644 index 0000000..581090f --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/AddStockBatchTest.java @@ -0,0 +1,180 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.AddStockBatchCommand; +import de.effigenix.domain.inventory.*; +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.common.UnitOfMeasure; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AddStockBatch Use Case") +class AddStockBatchTest { + + @Mock private StockRepository stockRepository; + + private AddStockBatch addStockBatch; + private AddStockBatchCommand validCommand; + private Stock existingStock; + + @BeforeEach + void setUp() { + addStockBatch = new AddStockBatch(stockRepository); + + existingStock = Stock.reconstitute( + StockId.of("stock-1"), + de.effigenix.domain.masterdata.ArticleId.of("article-1"), + StorageLocationId.of("location-1"), + null, null, List.of() + ); + + validCommand = new AddStockBatchCommand( + "stock-1", "BATCH-001", "PRODUCED", "10.5", "KILOGRAM", "2026-12-31" + ); + } + + @Test + @DisplayName("should add batch to existing stock") + void shouldAddBatchToExistingStock() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + when(stockRepository.save(any())).thenReturn(Result.success(null)); + + var result = addStockBatch.execute(validCommand); + + assertThat(result.isSuccess()).isTrue(); + var batch = result.unsafeGetValue(); + assertThat(batch.batchReference().batchId()).isEqualTo("BATCH-001"); + assertThat(batch.batchReference().batchType()).isEqualTo(BatchType.PRODUCED); + assertThat(batch.quantity().amount()).isEqualByComparingTo(new BigDecimal("10.5")); + assertThat(batch.status()).isEqualTo(StockBatchStatus.AVAILABLE); + assertThat(batch.receivedAt()).isNotNull(); + verify(stockRepository).save(existingStock); + } + + @Test + @DisplayName("should fail with StockNotFound when stock does not exist") + void shouldFailWhenStockNotFound() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.empty())); + + var result = addStockBatch.execute(validCommand); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.StockNotFound.class); + verify(stockRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail with RepositoryFailure when findById fails") + void shouldFailWhenFindByIdFails() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = addStockBatch.execute(validCommand); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class); + verify(stockRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail with RepositoryFailure when save fails") + void shouldFailWhenSaveFails() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + when(stockRepository.save(any())) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = addStockBatch.execute(validCommand); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail with DuplicateBatchReference when batch already exists") + void shouldFailWhenDuplicateBatchReference() { + var stockWithBatch = Stock.reconstitute( + StockId.of("stock-1"), + de.effigenix.domain.masterdata.ArticleId.of("article-1"), + StorageLocationId.of("location-1"), + null, null, + List.of(StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM, null, null), + LocalDate.of(2026, 12, 31), + StockBatchStatus.AVAILABLE, + Instant.now() + )) + ); + + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(stockWithBatch))); + + var result = addStockBatch.execute(validCommand); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.DuplicateBatchReference.class); + verify(stockRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail with InvalidBatchReference when batchType is invalid") + void shouldFailWhenInvalidBatchType() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + + var cmd = new AddStockBatchCommand("stock-1", "BATCH-001", "INVALID", "10", "KILOGRAM", "2026-12-31"); + var result = addStockBatch.execute(cmd); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class); + verify(stockRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail with InvalidQuantity when quantity is negative") + void shouldFailWhenInvalidQuantity() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + + var cmd = new AddStockBatchCommand("stock-1", "BATCH-001", "PRODUCED", "-1", "KILOGRAM", "2026-12-31"); + var result = addStockBatch.execute(cmd); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class); + verify(stockRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail with InvalidExpiryDate when date format is wrong") + void shouldFailWhenInvalidExpiryDate() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + + var cmd = new AddStockBatchCommand("stock-1", "BATCH-001", "PRODUCED", "10", "KILOGRAM", "not-a-date"); + var result = addStockBatch.execute(cmd); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidExpiryDate.class); + verify(stockRepository, never()).save(any()); + } +} diff --git a/backend/src/test/java/de/effigenix/domain/inventory/BatchReferenceTest.java b/backend/src/test/java/de/effigenix/domain/inventory/BatchReferenceTest.java new file mode 100644 index 0000000..00de536 --- /dev/null +++ b/backend/src/test/java/de/effigenix/domain/inventory/BatchReferenceTest.java @@ -0,0 +1,115 @@ +package de.effigenix.domain.inventory; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("BatchReference") +class BatchReferenceTest { + + @Nested + @DisplayName("of()") + class Of { + + @Test + @DisplayName("should create with PRODUCED type") + void shouldCreateWithProducedType() { + var result = BatchReference.of("BATCH-001", "PRODUCED"); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().batchId()).isEqualTo("BATCH-001"); + assertThat(result.unsafeGetValue().batchType()).isEqualTo(BatchType.PRODUCED); + } + + @Test + @DisplayName("should create with PURCHASED type") + void shouldCreateWithPurchasedType() { + var result = BatchReference.of("PO-123", "PURCHASED"); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().batchId()).isEqualTo("PO-123"); + assertThat(result.unsafeGetValue().batchType()).isEqualTo(BatchType.PURCHASED); + } + + @Test + @DisplayName("should fail when batchId is null") + void shouldFailWhenBatchIdNull() { + var result = BatchReference.of(null, "PRODUCED"); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class); + } + + @Test + @DisplayName("should fail when batchId is blank") + void shouldFailWhenBatchIdBlank() { + var result = BatchReference.of(" ", "PRODUCED"); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class); + } + + @Test + @DisplayName("should fail when batchType is null") + void shouldFailWhenBatchTypeNull() { + var result = BatchReference.of("BATCH-001", null); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class); + } + + @Test + @DisplayName("should fail when batchType is invalid") + void shouldFailWhenBatchTypeInvalid() { + var result = BatchReference.of("BATCH-001", "INVALID"); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class); + assertThat(result.unsafeGetError().message()).contains("INVALID"); + } + + @Test + @DisplayName("should fail when batchType is empty string") + void shouldFailWhenBatchTypeEmpty() { + var result = BatchReference.of("BATCH-001", ""); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class); + } + } + + @Nested + @DisplayName("equals / hashCode") + class Equality { + + @Test + @DisplayName("should be equal with same batchId and batchType") + void shouldBeEqualWithSameValues() { + var ref1 = new BatchReference("BATCH-001", BatchType.PRODUCED); + var ref2 = new BatchReference("BATCH-001", BatchType.PRODUCED); + + assertThat(ref1).isEqualTo(ref2); + assertThat(ref1.hashCode()).isEqualTo(ref2.hashCode()); + } + + @Test + @DisplayName("should not be equal with different batchType") + void shouldNotBeEqualWithDifferentType() { + var ref1 = new BatchReference("BATCH-001", BatchType.PRODUCED); + var ref2 = new BatchReference("BATCH-001", BatchType.PURCHASED); + + assertThat(ref1).isNotEqualTo(ref2); + } + + @Test + @DisplayName("should not be equal with different batchId") + void shouldNotBeEqualWithDifferentId() { + var ref1 = new BatchReference("BATCH-001", BatchType.PRODUCED); + var ref2 = new BatchReference("BATCH-002", BatchType.PRODUCED); + + assertThat(ref1).isNotEqualTo(ref2); + } + } +} diff --git a/backend/src/test/java/de/effigenix/domain/inventory/StockBatchTest.java b/backend/src/test/java/de/effigenix/domain/inventory/StockBatchTest.java new file mode 100644 index 0000000..859def9 --- /dev/null +++ b/backend/src/test/java/de/effigenix/domain/inventory/StockBatchTest.java @@ -0,0 +1,176 @@ +package de.effigenix.domain.inventory; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +class StockBatchTest { + + // ==================== Create ==================== + + @Nested + @DisplayName("create()") + class Create { + + @Test + @DisplayName("should create batch with valid data") + void shouldCreateWithValidData() { + var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "10.5", "KILOGRAM", "2026-12-31"); + + var result = StockBatch.create(draft); + + assertThat(result.isSuccess()).isTrue(); + var batch = result.unsafeGetValue(); + assertThat(batch.id()).isNotNull(); + assertThat(batch.batchReference().batchId()).isEqualTo("BATCH-001"); + assertThat(batch.batchReference().batchType()).isEqualTo(BatchType.PRODUCED); + assertThat(batch.quantity().amount()).isEqualByComparingTo(new BigDecimal("10.5")); + assertThat(batch.expiryDate().toString()).isEqualTo("2026-12-31"); + assertThat(batch.status()).isEqualTo(StockBatchStatus.AVAILABLE); + assertThat(batch.receivedAt()).isNotNull(); + } + + @Test + @DisplayName("should create batch with PURCHASED type") + void shouldCreateWithPurchasedType() { + var draft = new StockBatchDraft("PO-123", "PURCHASED", "5", "PIECE", "2027-01-15"); + + var result = StockBatch.create(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().batchReference().batchType()).isEqualTo(BatchType.PURCHASED); + } + + @Test + @DisplayName("should fail when batchId is blank") + void shouldFailWhenBatchIdBlank() { + var draft = new StockBatchDraft("", "PRODUCED", "10", "KILOGRAM", "2026-12-31"); + + var result = StockBatch.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class); + } + + @Test + @DisplayName("should fail when batchType is invalid") + void shouldFailWhenBatchTypeInvalid() { + var draft = new StockBatchDraft("BATCH-001", "INVALID", "10", "KILOGRAM", "2026-12-31"); + + var result = StockBatch.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class); + } + + @Test + @DisplayName("should fail when quantity is negative") + void shouldFailWhenQuantityNegative() { + var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "-1", "KILOGRAM", "2026-12-31"); + + var result = StockBatch.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class); + } + + @Test + @DisplayName("should fail when quantity is zero") + void shouldFailWhenQuantityZero() { + var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "0", "KILOGRAM", "2026-12-31"); + + var result = StockBatch.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class); + } + + @Test + @DisplayName("should fail when quantity is not a number") + void shouldFailWhenQuantityNotNumber() { + var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "abc", "KILOGRAM", "2026-12-31"); + + var result = StockBatch.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class); + } + + @Test + @DisplayName("should fail when unit is invalid") + void shouldFailWhenUnitInvalid() { + var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "10", "INVALID_UNIT", "2026-12-31"); + + var result = StockBatch.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class); + } + + @Test + @DisplayName("should fail when expiryDate is blank") + void shouldFailWhenExpiryDateBlank() { + var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", ""); + + var result = StockBatch.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidExpiryDate.class); + } + + @Test + @DisplayName("should fail when expiryDate is null") + void shouldFailWhenExpiryDateNull() { + var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", null); + + var result = StockBatch.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidExpiryDate.class); + } + + @Test + @DisplayName("should fail when expiryDate is not ISO format") + void shouldFailWhenExpiryDateInvalidFormat() { + var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", "31.12.2026"); + + var result = StockBatch.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidExpiryDate.class); + } + } + + // ==================== Equality ==================== + + @Nested + @DisplayName("equals / hashCode") + class Equality { + + @Test + @DisplayName("should be equal if same ID") + void shouldBeEqualBySameId() { + var id = StockBatchId.generate(); + var ref = new BatchReference("B1", BatchType.PRODUCED); + var batch1 = StockBatch.reconstitute(id, ref, null, null, StockBatchStatus.AVAILABLE, null); + var batch2 = StockBatch.reconstitute(id, ref, null, null, StockBatchStatus.BLOCKED, null); + + assertThat(batch1).isEqualTo(batch2); + assertThat(batch1.hashCode()).isEqualTo(batch2.hashCode()); + } + + @Test + @DisplayName("should not be equal if different ID") + void shouldNotBeEqualByDifferentId() { + var ref = new BatchReference("B1", BatchType.PRODUCED); + var batch1 = StockBatch.reconstitute(StockBatchId.generate(), ref, null, null, StockBatchStatus.AVAILABLE, null); + var batch2 = StockBatch.reconstitute(StockBatchId.generate(), ref, null, null, StockBatchStatus.AVAILABLE, null); + + assertThat(batch1).isNotEqualTo(batch2); + } + } +} diff --git a/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java b/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java index d0c5da3..0750e1d 100644 --- a/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java +++ b/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import java.math.BigDecimal; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -37,6 +38,7 @@ class StockTest { assertThat(stock.minimumLevel().quantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM); assertThat(stock.minimumShelfLife()).isNotNull(); assertThat(stock.minimumShelfLife().days()).isEqualTo(30); + assertThat(stock.batches()).isEmpty(); } @Test @@ -187,6 +189,68 @@ class StockTest { } } + // ==================== addBatch ==================== + + @Nested + @DisplayName("addBatch()") + class AddBatch { + + @Test + @DisplayName("should add batch successfully") + void shouldAddBatch() { + var stock = createValidStock(); + var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31"); + + var result = stock.addBatch(draft); + + assertThat(result.isSuccess()).isTrue(); + var batch = result.unsafeGetValue(); + assertThat(batch.batchReference().batchId()).isEqualTo("BATCH-001"); + assertThat(batch.batchReference().batchType()).isEqualTo(BatchType.PRODUCED); + assertThat(batch.status()).isEqualTo(StockBatchStatus.AVAILABLE); + assertThat(stock.batches()).hasSize(1); + } + + @Test + @DisplayName("should fail when duplicate batch reference") + void shouldFailOnDuplicateBatchReference() { + var stock = createValidStock(); + var draft1 = new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31"); + stock.addBatch(draft1); + + var draft2 = new StockBatchDraft("BATCH-001", "PRODUCED", "5", "KILOGRAM", "2027-01-15"); + var result = stock.addBatch(draft2); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.DuplicateBatchReference.class); + } + + @Test + @DisplayName("should allow same batchId with different batchType") + void shouldAllowSameBatchIdDifferentType() { + var stock = createValidStock(); + stock.addBatch(new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31")); + + var result = stock.addBatch(new StockBatchDraft("BATCH-001", "PURCHASED", "5", "KILOGRAM", "2027-01-15")); + + assertThat(result.isSuccess()).isTrue(); + assertThat(stock.batches()).hasSize(2); + } + + @Test + @DisplayName("should return unmodifiable batches list") + void shouldReturnUnmodifiableBatchesList() { + var stock = createValidStock(); + stock.addBatch(new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31")); + + var batches = stock.batches(); + + assertThat(batches).hasSize(1); + org.junit.jupiter.api.Assertions.assertThrows(UnsupportedOperationException.class, + () -> batches.add(null)); + } + } + // ==================== Reconstitute ==================== @Nested @@ -203,13 +267,14 @@ class StockTest { var minimumLevel = new MinimumLevel(quantity); var minimumShelfLife = new MinimumShelfLife(30); - var stock = Stock.reconstitute(id, articleId, locationId, minimumLevel, minimumShelfLife); + var stock = Stock.reconstitute(id, articleId, locationId, minimumLevel, minimumShelfLife, List.of()); assertThat(stock.id()).isEqualTo(id); assertThat(stock.articleId()).isEqualTo(articleId); assertThat(stock.storageLocationId()).isEqualTo(locationId); assertThat(stock.minimumLevel()).isEqualTo(minimumLevel); assertThat(stock.minimumShelfLife()).isEqualTo(minimumShelfLife); + assertThat(stock.batches()).isEmpty(); } @Test @@ -219,7 +284,7 @@ class StockTest { var articleId = ArticleId.of("article-1"); var locationId = StorageLocationId.of("location-1"); - var stock = Stock.reconstitute(id, articleId, locationId, null, null); + var stock = Stock.reconstitute(id, articleId, locationId, null, null, List.of()); assertThat(stock.minimumLevel()).isNull(); assertThat(stock.minimumShelfLife()).isNull(); @@ -236,8 +301,8 @@ class StockTest { @DisplayName("should be equal if same ID") void shouldBeEqualBySameId() { var id = StockId.generate(); - var stock1 = Stock.reconstitute(id, ArticleId.of("a1"), StorageLocationId.of("l1"), null, null); - var stock2 = Stock.reconstitute(id, ArticleId.of("a2"), StorageLocationId.of("l2"), null, null); + var stock1 = Stock.reconstitute(id, ArticleId.of("a1"), StorageLocationId.of("l1"), null, null, List.of()); + var stock2 = Stock.reconstitute(id, ArticleId.of("a2"), StorageLocationId.of("l2"), null, null, List.of()); assertThat(stock1).isEqualTo(stock2); assertThat(stock1.hashCode()).isEqualTo(stock2.hashCode()); diff --git a/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java index 6b35be0..6ba1225 100644 --- a/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java +++ b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java @@ -2,11 +2,13 @@ package de.effigenix.infrastructure.inventory.web; import de.effigenix.domain.usermanagement.RoleName; import de.effigenix.infrastructure.AbstractIntegrationTest; +import de.effigenix.infrastructure.inventory.web.dto.AddStockBatchRequest; import de.effigenix.infrastructure.inventory.web.dto.CreateStockRequest; import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity; import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; @@ -21,6 +23,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. * * Abgedeckte Testfälle: * - Story 2.1 – Bestandsposition anlegen + * - Story 2.2 – Charge einbuchen (addBatch) */ @DisplayName("Stock Controller Integration Tests") class StockControllerIntegrationTest extends AbstractIntegrationTest { @@ -201,6 +204,147 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest { .andExpect(status().isUnauthorized()); } + // ==================== Charge einbuchen (addBatch) ==================== + + @Nested + @DisplayName("POST /{stockId}/batches – Charge einbuchen") + class AddBatch { + + @Test + @DisplayName("Charge einbuchen → 201") + void addBatch_returns201() throws Exception { + String stockId = createStock(); + var request = new AddStockBatchRequest("BATCH-001", "PRODUCED", "10.5", "KILOGRAM", "2026-12-31"); + + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isNotEmpty()) + .andExpect(jsonPath("$.batchId").value("BATCH-001")) + .andExpect(jsonPath("$.batchType").value("PRODUCED")) + .andExpect(jsonPath("$.quantityAmount").value(10.5)) + .andExpect(jsonPath("$.quantityUnit").value("KILOGRAM")) + .andExpect(jsonPath("$.expiryDate").value("2026-12-31")) + .andExpect(jsonPath("$.status").value("AVAILABLE")) + .andExpect(jsonPath("$.receivedAt").isNotEmpty()); + } + + @Test + @DisplayName("Charge mit PURCHASED Typ → 201") + void addBatch_purchased_returns201() throws Exception { + String stockId = createStock(); + var request = new AddStockBatchRequest("PO-123", "PURCHASED", "5", "PIECE", "2027-01-15"); + + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.batchType").value("PURCHASED")); + } + + @Test + @DisplayName("Doppelte BatchReference → 409") + void addBatch_duplicate_returns409() throws Exception { + String stockId = createStock(); + var request = new AddStockBatchRequest("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31"); + + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DUPLICATE_BATCH_REFERENCE")); + } + + @Test + @DisplayName("Ungültiger BatchType → 400") + void addBatch_invalidBatchType_returns400() throws Exception { + String stockId = createStock(); + var request = new AddStockBatchRequest("BATCH-001", "INVALID", "10", "KILOGRAM", "2026-12-31"); + + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_BATCH_REFERENCE")); + } + + @Test + @DisplayName("Ungültige Menge → 400") + void addBatch_invalidQuantity_returns400() throws Exception { + String stockId = createStock(); + var request = new AddStockBatchRequest("BATCH-001", "PRODUCED", "-1", "KILOGRAM", "2026-12-31"); + + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_QUANTITY")); + } + + @Test + @DisplayName("Ungültiges Ablaufdatum → 400") + void addBatch_invalidExpiryDate_returns400() throws Exception { + String stockId = createStock(); + var request = new AddStockBatchRequest("BATCH-001", "PRODUCED", "10", "KILOGRAM", "31.12.2026"); + + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_EXPIRY_DATE")); + } + + @Test + @DisplayName("Stock nicht gefunden → 404") + void addBatch_stockNotFound_returns404() throws Exception { + var request = new AddStockBatchRequest("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31"); + + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", UUID.randomUUID().toString()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("STOCK_NOT_FOUND")); + } + + @Test + @DisplayName("Charge einbuchen ohne STOCK_WRITE → 403") + void addBatch_withViewerToken_returns403() throws Exception { + String stockId = createStock(); + var request = new AddStockBatchRequest("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31"); + + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId) + .header("Authorization", "Bearer " + viewerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Charge einbuchen ohne Token → 401") + void addBatch_withoutToken_returns401() throws Exception { + var request = new AddStockBatchRequest("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31"); + + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", UUID.randomUUID().toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + } + } + // ==================== Hilfsmethoden ==================== private String createStorageLocation() throws Exception { @@ -217,4 +361,18 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest { return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); } + + private String createStock() throws Exception { + var request = new CreateStockRequest( + UUID.randomUUID().toString(), storageLocationId, null, null, null); + + var result = mockMvc.perform(post("/api/inventory/stocks") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + } }