diff --git a/backend/src/main/java/de/effigenix/application/inventory/CreateInventoryCount.java b/backend/src/main/java/de/effigenix/application/inventory/CreateInventoryCount.java new file mode 100644 index 0000000..2a170b1 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/CreateInventoryCount.java @@ -0,0 +1,84 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.CreateInventoryCountCommand; +import de.effigenix.domain.inventory.*; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.persistence.UnitOfWork; + +public class CreateInventoryCount { + + private final InventoryCountRepository inventoryCountRepository; + private final StockRepository stockRepository; + private final UnitOfWork unitOfWork; + + public CreateInventoryCount(InventoryCountRepository inventoryCountRepository, + StockRepository stockRepository, + UnitOfWork unitOfWork) { + this.inventoryCountRepository = inventoryCountRepository; + this.stockRepository = stockRepository; + this.unitOfWork = unitOfWork; + } + + public Result execute(CreateInventoryCountCommand cmd) { + // 1. Draft aus Command bauen + var draft = new InventoryCountDraft(cmd.storageLocationId(), cmd.countDate(), cmd.initiatedBy()); + + // 2. Aggregate erzeugen (validiert intern) + InventoryCount count; + switch (InventoryCount.create(draft)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var val) -> count = val; + } + + // 3. Uniqueness-Check: max. eine aktive Inventur pro StorageLocation + switch (inventoryCountRepository.existsActiveByStorageLocationId(count.storageLocationId())) { + case Result.Failure(var err) -> + { return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); } + case Result.Success(var exists) -> { + if (exists) { + return Result.failure(new InventoryCountError.ActiveCountExists(cmd.storageLocationId())); + } + } + } + + // 4. Auto-Populate: Stocks für StorageLocation laden + var stocksResult = stockRepository.findAllByStorageLocationId(count.storageLocationId()); + switch (stocksResult) { + case Result.Failure(var err) -> + { return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); } + case Result.Success(var stocks) -> { + for (Stock stock : stocks) { + // Gesamtmenge aus Batches berechnen + var totalAmount = stock.batches().stream() + .map(b -> b.quantity().amount()) + .reduce(java.math.BigDecimal.ZERO, java.math.BigDecimal::add); + + String unit = stock.batches().isEmpty() + ? "KILOGRAM" + : stock.batches().getFirst().quantity().uom().name(); + + var itemDraft = new CountItemDraft( + stock.articleId().value(), + totalAmount.toPlainString(), + unit + ); + + switch (count.addCountItem(itemDraft)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var ignored) -> { } + } + } + } + } + + // 5. Atomar speichern + return unitOfWork.executeAtomically(() -> { + switch (inventoryCountRepository.save(count)) { + case Result.Failure(var err) -> + { return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); } + case Result.Success(var ignored) -> { } + } + return Result.success(count); + }); + } +} diff --git a/backend/src/main/java/de/effigenix/application/inventory/GetInventoryCount.java b/backend/src/main/java/de/effigenix/application/inventory/GetInventoryCount.java new file mode 100644 index 0000000..f23adbe --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/GetInventoryCount.java @@ -0,0 +1,29 @@ +package de.effigenix.application.inventory; + +import de.effigenix.domain.inventory.InventoryCount; +import de.effigenix.domain.inventory.InventoryCountError; +import de.effigenix.domain.inventory.InventoryCountId; +import de.effigenix.domain.inventory.InventoryCountRepository; +import de.effigenix.shared.common.Result; + +public class GetInventoryCount { + + private final InventoryCountRepository inventoryCountRepository; + + public GetInventoryCount(InventoryCountRepository inventoryCountRepository) { + this.inventoryCountRepository = inventoryCountRepository; + } + + public Result execute(String inventoryCountId) { + if (inventoryCountId == null || inventoryCountId.isBlank()) { + return Result.failure(new InventoryCountError.InventoryCountNotFound(inventoryCountId)); + } + + return switch (inventoryCountRepository.findById(InventoryCountId.of(inventoryCountId))) { + case Result.Failure(var err) -> Result.failure(new InventoryCountError.RepositoryFailure(err.message())); + case Result.Success(var opt) -> opt + .>map(Result::success) + .orElseGet(() -> Result.failure(new InventoryCountError.InventoryCountNotFound(inventoryCountId))); + }; + } +} diff --git a/backend/src/main/java/de/effigenix/application/inventory/ListInventoryCounts.java b/backend/src/main/java/de/effigenix/application/inventory/ListInventoryCounts.java new file mode 100644 index 0000000..8edf57b --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/ListInventoryCounts.java @@ -0,0 +1,37 @@ +package de.effigenix.application.inventory; + +import de.effigenix.domain.inventory.*; +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; + +import java.util.List; + +public class ListInventoryCounts { + + private final InventoryCountRepository inventoryCountRepository; + + public ListInventoryCounts(InventoryCountRepository inventoryCountRepository) { + this.inventoryCountRepository = inventoryCountRepository; + } + + public Result> execute(String storageLocationId) { + if (storageLocationId != null) { + StorageLocationId locId; + try { + locId = StorageLocationId.of(storageLocationId); + } catch (IllegalArgumentException e) { + return Result.failure(new InventoryCountError.InvalidStorageLocationId(e.getMessage())); + } + return mapResult(inventoryCountRepository.findByStorageLocationId(locId)); + } + + return mapResult(inventoryCountRepository.findAll()); + } + + private Result> mapResult(Result> result) { + return switch (result) { + case Result.Failure(var err) -> Result.failure(new InventoryCountError.RepositoryFailure(err.message())); + case Result.Success(var counts) -> Result.success(counts); + }; + } +} diff --git a/backend/src/main/java/de/effigenix/application/inventory/command/CreateInventoryCountCommand.java b/backend/src/main/java/de/effigenix/application/inventory/command/CreateInventoryCountCommand.java new file mode 100644 index 0000000..709c1c9 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/command/CreateInventoryCountCommand.java @@ -0,0 +1,7 @@ +package de.effigenix.application.inventory.command; + +public record CreateInventoryCountCommand( + String storageLocationId, + String countDate, + String initiatedBy +) {} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/CountItem.java b/backend/src/main/java/de/effigenix/domain/inventory/CountItem.java new file mode 100644 index 0000000..1bdb2c6 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/CountItem.java @@ -0,0 +1,108 @@ +package de.effigenix.domain.inventory; + +import de.effigenix.domain.masterdata.ArticleId; +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.common.UnitOfMeasure; + +import java.math.BigDecimal; +import java.util.Objects; + +/** + * Child entity of InventoryCount representing a single count position. + * + * Invariants: + * - ArticleId must be valid + * - ExpectedQuantity must be non-negative (>= 0) + * - ActualQuantity is nullable until counted + * - Deviation = actualQuantity - expectedQuantity (computed, only when actualQuantity is set) + */ +public class CountItem { + + private final CountItemId id; + private final ArticleId articleId; + private final Quantity expectedQuantity; + private Quantity actualQuantity; + + private CountItem(CountItemId id, ArticleId articleId, Quantity expectedQuantity, Quantity actualQuantity) { + this.id = id; + this.articleId = articleId; + this.expectedQuantity = expectedQuantity; + this.actualQuantity = actualQuantity; + } + + public static Result create(CountItemDraft draft) { + // 1. ArticleId validieren + if (draft.articleId() == null || draft.articleId().isBlank()) { + return Result.failure(new InventoryCountError.InvalidArticleId("must not be blank")); + } + ArticleId articleId; + try { + articleId = ArticleId.of(draft.articleId()); + } catch (IllegalArgumentException e) { + return Result.failure(new InventoryCountError.InvalidArticleId(e.getMessage())); + } + + // 2. ExpectedQuantity validieren (>= 0) + Quantity expectedQuantity; + try { + BigDecimal amount = new BigDecimal(draft.expectedQuantityAmount()); + if (amount.compareTo(BigDecimal.ZERO) < 0) { + return Result.failure(new InventoryCountError.InvalidQuantity("Expected quantity must be >= 0")); + } + UnitOfMeasure uom; + try { + uom = UnitOfMeasure.valueOf(draft.expectedQuantityUnit()); + } catch (IllegalArgumentException | NullPointerException e) { + return Result.failure(new InventoryCountError.InvalidQuantity("Invalid unit: " + draft.expectedQuantityUnit())); + } + expectedQuantity = Quantity.reconstitute(amount, uom); + } catch (NumberFormatException | NullPointerException e) { + return Result.failure(new InventoryCountError.InvalidQuantity( + "Invalid quantity amount: " + draft.expectedQuantityAmount())); + } + + return Result.success(new CountItem( + CountItemId.generate(), articleId, expectedQuantity, null + )); + } + + public static CountItem reconstitute(CountItemId id, ArticleId articleId, + Quantity expectedQuantity, Quantity actualQuantity) { + return new CountItem(id, articleId, expectedQuantity, actualQuantity); + } + + /** + * Computed deviation: actualQuantity - expectedQuantity. + * Returns null if actualQuantity has not been set yet. + */ + public BigDecimal deviation() { + if (actualQuantity == null) { + return null; + } + return actualQuantity.amount().subtract(expectedQuantity.amount()); + } + + public boolean isCounted() { + return actualQuantity != null; + } + + // ==================== Getters ==================== + + public CountItemId id() { return id; } + public ArticleId articleId() { return articleId; } + public Quantity expectedQuantity() { return expectedQuantity; } + public Quantity actualQuantity() { return actualQuantity; } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof CountItem 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/CountItemDraft.java b/backend/src/main/java/de/effigenix/domain/inventory/CountItemDraft.java new file mode 100644 index 0000000..6580cdf --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/CountItemDraft.java @@ -0,0 +1,14 @@ +package de.effigenix.domain.inventory; + +/** + * Rohe Eingabe zum Erzeugen eines CountItem. + * + * @param articleId Pflicht + * @param expectedQuantityAmount Pflicht – BigDecimal als String + * @param expectedQuantityUnit Pflicht – UnitOfMeasure als String + */ +public record CountItemDraft( + String articleId, + String expectedQuantityAmount, + String expectedQuantityUnit +) {} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/CountItemId.java b/backend/src/main/java/de/effigenix/domain/inventory/CountItemId.java new file mode 100644 index 0000000..f6c91e1 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/CountItemId.java @@ -0,0 +1,20 @@ +package de.effigenix.domain.inventory; + +import java.util.UUID; + +public record CountItemId(String value) { + + public CountItemId { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("CountItemId must not be blank"); + } + } + + public static CountItemId generate() { + return new CountItemId(UUID.randomUUID().toString()); + } + + public static CountItemId of(String value) { + return new CountItemId(value); + } +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/InventoryCount.java b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCount.java new file mode 100644 index 0000000..47f3fb5 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCount.java @@ -0,0 +1,168 @@ +package de.effigenix.domain.inventory; + +import de.effigenix.shared.common.Result; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * InventoryCount aggregate root. + * + * Invariants: + * - StorageLocationId must be valid + * - CountDate must be valid ISO date, not in the future + * - InitiatedBy must not be blank + * - Max. one active count (OPEN/COUNTING) per StorageLocation (Application Layer) + * - ArticleId unique within countItems + * - addCountItem only in status OPEN + */ +public class InventoryCount { + + private final InventoryCountId id; + private final StorageLocationId storageLocationId; + private final LocalDate countDate; + private final String initiatedBy; + private final InventoryCountStatus status; + private final Instant createdAt; + private final List countItems; + + private InventoryCount( + InventoryCountId id, + StorageLocationId storageLocationId, + LocalDate countDate, + String initiatedBy, + InventoryCountStatus status, + Instant createdAt, + List countItems + ) { + this.id = id; + this.storageLocationId = storageLocationId; + this.countDate = countDate; + this.initiatedBy = initiatedBy; + this.status = status; + this.createdAt = createdAt; + this.countItems = new ArrayList<>(countItems); + } + + /** + * Factory: Erzeugt eine neue Inventur aus rohen Eingaben. + */ + public static Result create(InventoryCountDraft draft) { + // 1. StorageLocationId validieren + if (draft.storageLocationId() == null || draft.storageLocationId().isBlank()) { + return Result.failure(new InventoryCountError.InvalidStorageLocationId("must not be blank")); + } + StorageLocationId storageLocationId; + try { + storageLocationId = StorageLocationId.of(draft.storageLocationId()); + } catch (IllegalArgumentException e) { + return Result.failure(new InventoryCountError.InvalidStorageLocationId(e.getMessage())); + } + + // 2. CountDate validieren + if (draft.countDate() == null || draft.countDate().isBlank()) { + return Result.failure(new InventoryCountError.InvalidCountDate("must not be blank")); + } + LocalDate countDate; + try { + countDate = LocalDate.parse(draft.countDate()); + } catch (DateTimeParseException e) { + return Result.failure(new InventoryCountError.InvalidCountDate("Invalid ISO date: " + draft.countDate())); + } + if (countDate.isAfter(LocalDate.now())) { + return Result.failure(new InventoryCountError.CountDateInFuture(draft.countDate())); + } + + // 3. InitiatedBy validieren + if (draft.initiatedBy() == null || draft.initiatedBy().isBlank()) { + return Result.failure(new InventoryCountError.InvalidInitiatedBy("must not be blank")); + } + + return Result.success(new InventoryCount( + InventoryCountId.generate(), storageLocationId, countDate, + draft.initiatedBy(), InventoryCountStatus.OPEN, Instant.now(), List.of() + )); + } + + /** + * Reconstitute from persistence without re-validation. + */ + public static InventoryCount reconstitute( + InventoryCountId id, + StorageLocationId storageLocationId, + LocalDate countDate, + String initiatedBy, + InventoryCountStatus status, + Instant createdAt, + List countItems + ) { + return new InventoryCount(id, storageLocationId, countDate, initiatedBy, status, createdAt, countItems); + } + + // ==================== Count Item Management ==================== + + public Result addCountItem(CountItemDraft draft) { + if (status != InventoryCountStatus.OPEN) { + return Result.failure(new InventoryCountError.InvalidStatusTransition( + status.name(), "addCountItem requires OPEN")); + } + + CountItem item; + switch (CountItem.create(draft)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var val) -> item = val; + } + + if (hasArticle(item.articleId())) { + return Result.failure(new InventoryCountError.DuplicateArticle(draft.articleId())); + } + + this.countItems.add(item); + return Result.success(item); + } + + // ==================== Queries ==================== + + public boolean isActive() { + return status == InventoryCountStatus.OPEN || status == InventoryCountStatus.COUNTING; + } + + // ==================== Getters ==================== + + public InventoryCountId id() { return id; } + public StorageLocationId storageLocationId() { return storageLocationId; } + public LocalDate countDate() { return countDate; } + public String initiatedBy() { return initiatedBy; } + public InventoryCountStatus status() { return status; } + public Instant createdAt() { return createdAt; } + public List countItems() { return Collections.unmodifiableList(countItems); } + + // ==================== Helpers ==================== + + private boolean hasArticle(de.effigenix.domain.masterdata.ArticleId articleId) { + return countItems.stream().anyMatch(item -> item.articleId().equals(articleId)); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof InventoryCount other)) return false; + return Objects.equals(id, other.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return "InventoryCount{id=" + id + ", storageLocationId=" + storageLocationId + + ", countDate=" + countDate + ", status=" + status + "}"; + } +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountDraft.java b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountDraft.java new file mode 100644 index 0000000..7d29eab --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountDraft.java @@ -0,0 +1,15 @@ +package de.effigenix.domain.inventory; + +/** + * Rohe Eingabe zum Erzeugen eines InventoryCount-Aggregates. + * Wird vom Application Layer aus dem Command gebaut und an InventoryCount.create() übergeben. + * + * @param storageLocationId Pflicht + * @param countDate Pflicht – ISO-Datum als String + * @param initiatedBy Pflicht – User-ID + */ +public record InventoryCountDraft( + String storageLocationId, + String countDate, + String initiatedBy +) {} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountError.java b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountError.java new file mode 100644 index 0000000..3dfeda0 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountError.java @@ -0,0 +1,85 @@ +package de.effigenix.domain.inventory; + +public sealed interface InventoryCountError { + + String code(); + String message(); + + record InvalidStorageLocationId(String reason) implements InventoryCountError { + @Override public String code() { return "INVALID_STORAGE_LOCATION_ID"; } + @Override public String message() { return "Invalid storage location ID: " + reason; } + } + + record InvalidCountDate(String reason) implements InventoryCountError { + @Override public String code() { return "INVALID_COUNT_DATE"; } + @Override public String message() { return "Invalid count date: " + reason; } + } + + record CountDateInFuture(String date) implements InventoryCountError { + @Override public String code() { return "COUNT_DATE_IN_FUTURE"; } + @Override public String message() { return "Count date must not be in the future: " + date; } + } + + record InvalidInitiatedBy(String reason) implements InventoryCountError { + @Override public String code() { return "INVALID_INITIATED_BY"; } + @Override public String message() { return "Invalid initiatedBy: " + reason; } + } + + record InvalidArticleId(String reason) implements InventoryCountError { + @Override public String code() { return "INVALID_ARTICLE_ID"; } + @Override public String message() { return "Invalid article ID: " + reason; } + } + + record InvalidQuantity(String reason) implements InventoryCountError { + @Override public String code() { return "INVALID_QUANTITY"; } + @Override public String message() { return "Invalid quantity: " + reason; } + } + + record DuplicateArticle(String articleId) implements InventoryCountError { + @Override public String code() { return "DUPLICATE_ARTICLE"; } + @Override public String message() { return "Article already exists in count: " + articleId; } + } + + record InvalidStatusTransition(String from, String to) implements InventoryCountError { + @Override public String code() { return "INVALID_STATUS_TRANSITION"; } + @Override public String message() { return "Cannot transition from " + from + " to " + to; } + } + + record NoCountItems() implements InventoryCountError { + @Override public String code() { return "NO_COUNT_ITEMS"; } + @Override public String message() { return "Inventory count has no count items"; } + } + + record IncompleteCountItems() implements InventoryCountError { + @Override public String code() { return "INCOMPLETE_COUNT_ITEMS"; } + @Override public String message() { return "Not all count items have been counted"; } + } + + record CountItemNotFound(String id) implements InventoryCountError { + @Override public String code() { return "COUNT_ITEM_NOT_FOUND"; } + @Override public String message() { return "Count item not found: " + id; } + } + + record SamePersonViolation() implements InventoryCountError { + @Override public String code() { return "SAME_PERSON_VIOLATION"; } + @Override public String message() { return "Counter must not be the same person who initiated the count"; } + } + + record InventoryCountNotFound(String id) implements InventoryCountError { + @Override public String code() { return "INVENTORY_COUNT_NOT_FOUND"; } + @Override public String message() { return "Inventory count not found: " + id; } + } + + record ActiveCountExists(String storageLocationId) implements InventoryCountError { + @Override public String code() { return "ACTIVE_COUNT_EXISTS"; } + @Override public String message() { return "An active inventory count already exists for storage location: " + storageLocationId; } + } + + record Unauthorized(String message) implements InventoryCountError { + @Override public String code() { return "UNAUTHORIZED"; } + } + + record RepositoryFailure(String message) implements InventoryCountError { + @Override public String code() { return "REPOSITORY_ERROR"; } + } +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountId.java b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountId.java new file mode 100644 index 0000000..626c212 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountId.java @@ -0,0 +1,20 @@ +package de.effigenix.domain.inventory; + +import java.util.UUID; + +public record InventoryCountId(String value) { + + public InventoryCountId { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("InventoryCountId must not be blank"); + } + } + + public static InventoryCountId generate() { + return new InventoryCountId(UUID.randomUUID().toString()); + } + + public static InventoryCountId of(String value) { + return new InventoryCountId(value); + } +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountRepository.java b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountRepository.java new file mode 100644 index 0000000..d64176b --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountRepository.java @@ -0,0 +1,20 @@ +package de.effigenix.domain.inventory; + +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; + +import java.util.List; +import java.util.Optional; + +public interface InventoryCountRepository { + + Result> findById(InventoryCountId id); + + Result> findAll(); + + Result> findByStorageLocationId(StorageLocationId storageLocationId); + + Result existsActiveByStorageLocationId(StorageLocationId storageLocationId); + + Result save(InventoryCount inventoryCount); +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountStatus.java b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountStatus.java new file mode 100644 index 0000000..a181e29 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountStatus.java @@ -0,0 +1,8 @@ +package de.effigenix.domain.inventory; + +public enum InventoryCountStatus { + OPEN, + COUNTING, + COMPLETED, + CANCELLED +} 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 31833f3..9a2e760 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java @@ -1,5 +1,8 @@ package de.effigenix.infrastructure.config; +import de.effigenix.application.inventory.CreateInventoryCount; +import de.effigenix.application.inventory.GetInventoryCount; +import de.effigenix.application.inventory.ListInventoryCounts; import de.effigenix.application.inventory.GetStockMovement; import de.effigenix.application.inventory.ListStockMovements; import de.effigenix.application.inventory.ConfirmReservation; @@ -23,6 +26,7 @@ import de.effigenix.application.inventory.GetStorageLocation; import de.effigenix.application.inventory.ListStorageLocations; import de.effigenix.application.inventory.UpdateStorageLocation; import de.effigenix.application.usermanagement.AuditLogger; +import de.effigenix.domain.inventory.InventoryCountRepository; import de.effigenix.domain.inventory.StockMovementRepository; import de.effigenix.domain.inventory.StockRepository; import de.effigenix.domain.inventory.StorageLocationRepository; @@ -151,4 +155,21 @@ public class InventoryUseCaseConfiguration { public ListStockMovements listStockMovements(StockMovementRepository stockMovementRepository, AuthorizationPort authorizationPort) { return new ListStockMovements(stockMovementRepository, authorizationPort); } + + // ==================== InventoryCount Use Cases ==================== + + @Bean + public CreateInventoryCount createInventoryCount(InventoryCountRepository inventoryCountRepository, StockRepository stockRepository, UnitOfWork unitOfWork) { + return new CreateInventoryCount(inventoryCountRepository, stockRepository, unitOfWork); + } + + @Bean + public GetInventoryCount getInventoryCount(InventoryCountRepository inventoryCountRepository) { + return new GetInventoryCount(inventoryCountRepository); + } + + @Bean + public ListInventoryCounts listInventoryCounts(InventoryCountRepository inventoryCountRepository) { + return new ListInventoryCounts(inventoryCountRepository); + } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JdbcInventoryCountRepository.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JdbcInventoryCountRepository.java new file mode 100644 index 0000000..3dc3373 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JdbcInventoryCountRepository.java @@ -0,0 +1,222 @@ +package de.effigenix.infrastructure.inventory.persistence.repository; + +import de.effigenix.domain.inventory.*; +import de.effigenix.domain.masterdata.ArticleId; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; + +@Repository +@Profile("!no-db") +public class JdbcInventoryCountRepository implements InventoryCountRepository { + + private static final Logger logger = LoggerFactory.getLogger(JdbcInventoryCountRepository.class); + + private final JdbcClient jdbc; + + public JdbcInventoryCountRepository(JdbcClient jdbc) { + this.jdbc = jdbc; + } + + @Override + public Result> findById(InventoryCountId id) { + try { + var countOpt = jdbc.sql("SELECT * FROM inventory_counts WHERE id = :id") + .param("id", id.value()) + .query(this::mapCountRow) + .optional(); + if (countOpt.isEmpty()) { + return Result.success(Optional.empty()); + } + return Result.success(Optional.of(loadChildren(countOpt.get(), id.value()))); + } catch (Exception e) { + logger.trace("Database error in findById", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result> findAll() { + try { + var counts = jdbc.sql("SELECT * FROM inventory_counts ORDER BY created_at DESC") + .query(this::mapCountRow) + .list(); + return Result.success(loadChildrenForAll(counts)); + } catch (Exception e) { + logger.trace("Database error in findAll", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result> findByStorageLocationId(StorageLocationId storageLocationId) { + try { + var counts = jdbc.sql("SELECT * FROM inventory_counts WHERE storage_location_id = :storageLocationId ORDER BY created_at DESC") + .param("storageLocationId", storageLocationId.value()) + .query(this::mapCountRow) + .list(); + return Result.success(loadChildrenForAll(counts)); + } catch (Exception e) { + logger.trace("Database error in findByStorageLocationId", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result existsActiveByStorageLocationId(StorageLocationId storageLocationId) { + try { + int count = jdbc.sql(""" + SELECT COUNT(*) FROM inventory_counts + WHERE storage_location_id = :storageLocationId + AND status IN ('OPEN', 'COUNTING') + """) + .param("storageLocationId", storageLocationId.value()) + .query(Integer.class) + .single(); + return Result.success(count > 0); + } catch (Exception e) { + logger.trace("Database error in existsActiveByStorageLocationId", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result save(InventoryCount inventoryCount) { + try { + int rows = jdbc.sql(""" + UPDATE inventory_counts + SET storage_location_id = :storageLocationId, count_date = :countDate, + initiated_by = :initiatedBy, status = :status, created_at = :createdAt + WHERE id = :id + """) + .param("id", inventoryCount.id().value()) + .params(countParams(inventoryCount)) + .update(); + + if (rows == 0) { + jdbc.sql(""" + INSERT INTO inventory_counts (id, storage_location_id, count_date, initiated_by, status, created_at) + VALUES (:id, :storageLocationId, :countDate, :initiatedBy, :status, :createdAt) + """) + .param("id", inventoryCount.id().value()) + .params(countParams(inventoryCount)) + .update(); + } + + saveChildren(inventoryCount); + + return Result.success(null); + } catch (Exception e) { + logger.trace("Database error in save", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + // ==================== Private Helpers ==================== + + private java.util.Map countParams(InventoryCount count) { + var params = new java.util.LinkedHashMap(); + params.put("storageLocationId", count.storageLocationId().value()); + params.put("countDate", count.countDate()); + params.put("initiatedBy", count.initiatedBy()); + params.put("status", count.status().name()); + params.put("createdAt", count.createdAt().atOffset(ZoneOffset.UTC)); + return params; + } + + private void saveChildren(InventoryCount count) { + String countId = count.id().value(); + + // Delete + re-insert count items + jdbc.sql("DELETE FROM count_items WHERE inventory_count_id = :countId") + .param("countId", countId) + .update(); + + for (CountItem item : count.countItems()) { + jdbc.sql(""" + INSERT INTO count_items + (id, inventory_count_id, article_id, + expected_quantity_amount, expected_quantity_unit, + actual_quantity_amount, actual_quantity_unit) + VALUES (:id, :countId, :articleId, + :expectedQuantityAmount, :expectedQuantityUnit, + :actualQuantityAmount, :actualQuantityUnit) + """) + .param("id", item.id().value()) + .param("countId", countId) + .param("articleId", item.articleId().value()) + .param("expectedQuantityAmount", item.expectedQuantity().amount()) + .param("expectedQuantityUnit", item.expectedQuantity().uom().name()) + .param("actualQuantityAmount", item.actualQuantity() != null ? item.actualQuantity().amount() : null) + .param("actualQuantityUnit", item.actualQuantity() != null ? item.actualQuantity().uom().name() : null) + .update(); + } + } + + private InventoryCount loadChildren(InventoryCount count, String countId) { + var items = jdbc.sql("SELECT * FROM count_items WHERE inventory_count_id = :countId") + .param("countId", countId) + .query(this::mapCountItemRow) + .list(); + + return InventoryCount.reconstitute( + count.id(), count.storageLocationId(), count.countDate(), + count.initiatedBy(), count.status(), count.createdAt(), items + ); + } + + private List loadChildrenForAll(List counts) { + return counts.stream() + .map(c -> loadChildren(c, c.id().value())) + .toList(); + } + + // ==================== Row Mappers ==================== + + private InventoryCount mapCountRow(ResultSet rs, int rowNum) throws SQLException { + return InventoryCount.reconstitute( + InventoryCountId.of(rs.getString("id")), + StorageLocationId.of(rs.getString("storage_location_id")), + rs.getObject("count_date", LocalDate.class), + rs.getString("initiated_by"), + InventoryCountStatus.valueOf(rs.getString("status")), + rs.getObject("created_at", OffsetDateTime.class).toInstant(), + List.of() + ); + } + + private CountItem mapCountItemRow(ResultSet rs, int rowNum) throws SQLException { + BigDecimal actualAmount = rs.getBigDecimal("actual_quantity_amount"); + String actualUnit = rs.getString("actual_quantity_unit"); + + Quantity actualQuantity = null; + if (actualAmount != null && actualUnit != null) { + actualQuantity = Quantity.reconstitute(actualAmount, UnitOfMeasure.valueOf(actualUnit)); + } + + return CountItem.reconstitute( + CountItemId.of(rs.getString("id")), + ArticleId.of(rs.getString("article_id")), + Quantity.reconstitute( + rs.getBigDecimal("expected_quantity_amount"), + UnitOfMeasure.valueOf(rs.getString("expected_quantity_unit")) + ), + actualQuantity + ); + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/InventoryCountController.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/InventoryCountController.java new file mode 100644 index 0000000..8e356fd --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/InventoryCountController.java @@ -0,0 +1,104 @@ +package de.effigenix.infrastructure.inventory.web.controller; + +import de.effigenix.application.inventory.CreateInventoryCount; +import de.effigenix.application.inventory.GetInventoryCount; +import de.effigenix.application.inventory.ListInventoryCounts; +import de.effigenix.application.inventory.command.CreateInventoryCountCommand; +import de.effigenix.domain.inventory.InventoryCountError; +import de.effigenix.infrastructure.inventory.web.dto.CreateInventoryCountRequest; +import de.effigenix.infrastructure.inventory.web.dto.InventoryCountResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/inventory/inventory-counts") +@SecurityRequirement(name = "Bearer Authentication") +@Tag(name = "Inventory Counts", description = "Inventory count management endpoints") +public class InventoryCountController { + + private final CreateInventoryCount createInventoryCount; + private final GetInventoryCount getInventoryCount; + private final ListInventoryCounts listInventoryCounts; + + public InventoryCountController(CreateInventoryCount createInventoryCount, + GetInventoryCount getInventoryCount, + ListInventoryCounts listInventoryCounts) { + this.createInventoryCount = createInventoryCount; + this.getInventoryCount = getInventoryCount; + this.listInventoryCounts = listInventoryCounts; + } + + @PostMapping + @PreAuthorize("hasAuthority('INVENTORY_COUNT_WRITE')") + public ResponseEntity createInventoryCount( + @Valid @RequestBody CreateInventoryCountRequest request, + Authentication authentication + ) { + var cmd = new CreateInventoryCountCommand( + request.storageLocationId(), + request.countDate(), + authentication.getName() + ); + + var result = createInventoryCount.execute(cmd); + + if (result.isFailure()) { + throw new InventoryCountDomainErrorException(result.unsafeGetError()); + } + + return ResponseEntity.status(HttpStatus.CREATED) + .body(InventoryCountResponse.from(result.unsafeGetValue())); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAuthority('INVENTORY_COUNT_READ')") + public ResponseEntity getInventoryCount(@PathVariable String id) { + var result = getInventoryCount.execute(id); + + if (result.isFailure()) { + throw new InventoryCountDomainErrorException(result.unsafeGetError()); + } + + return ResponseEntity.ok(InventoryCountResponse.from(result.unsafeGetValue())); + } + + @GetMapping + @PreAuthorize("hasAuthority('INVENTORY_COUNT_READ')") + public ResponseEntity> listInventoryCounts( + @RequestParam(required = false) String storageLocationId + ) { + var result = listInventoryCounts.execute(storageLocationId); + + if (result.isFailure()) { + throw new InventoryCountDomainErrorException(result.unsafeGetError()); + } + + List responses = result.unsafeGetValue().stream() + .map(InventoryCountResponse::from) + .toList(); + return ResponseEntity.ok(responses); + } + + // ==================== Exception Wrapper ==================== + + public static class InventoryCountDomainErrorException extends RuntimeException { + private final InventoryCountError error; + + public InventoryCountDomainErrorException(InventoryCountError error) { + super(error.message()); + this.error = error; + } + + public InventoryCountError getError() { + return error; + } + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/CountItemResponse.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/CountItemResponse.java new file mode 100644 index 0000000..95966c7 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/CountItemResponse.java @@ -0,0 +1,27 @@ +package de.effigenix.infrastructure.inventory.web.dto; + +import de.effigenix.domain.inventory.CountItem; + +import java.math.BigDecimal; + +public record CountItemResponse( + String id, + String articleId, + BigDecimal expectedQuantityAmount, + String expectedQuantityUnit, + BigDecimal actualQuantityAmount, + String actualQuantityUnit, + BigDecimal deviation +) { + public static CountItemResponse from(CountItem item) { + return new CountItemResponse( + item.id().value(), + item.articleId().value(), + item.expectedQuantity().amount(), + item.expectedQuantity().uom().name(), + item.actualQuantity() != null ? item.actualQuantity().amount() : null, + item.actualQuantity() != null ? item.actualQuantity().uom().name() : null, + item.deviation() + ); + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/CreateInventoryCountRequest.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/CreateInventoryCountRequest.java new file mode 100644 index 0000000..9217386 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/CreateInventoryCountRequest.java @@ -0,0 +1,8 @@ +package de.effigenix.infrastructure.inventory.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record CreateInventoryCountRequest( + @NotBlank String storageLocationId, + @NotBlank String countDate +) {} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/InventoryCountResponse.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/InventoryCountResponse.java new file mode 100644 index 0000000..ba079c8 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/InventoryCountResponse.java @@ -0,0 +1,31 @@ +package de.effigenix.infrastructure.inventory.web.dto; + +import de.effigenix.domain.inventory.InventoryCount; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +public record InventoryCountResponse( + String id, + String storageLocationId, + LocalDate countDate, + String initiatedBy, + String status, + Instant createdAt, + List countItems +) { + public static InventoryCountResponse from(InventoryCount count) { + return new InventoryCountResponse( + count.id().value(), + count.storageLocationId().value(), + count.countDate(), + count.initiatedBy(), + count.status().name(), + count.createdAt(), + count.countItems().stream() + .map(CountItemResponse::from) + .toList() + ); + } +} 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 4cca45a..f4eff44 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 @@ -1,5 +1,6 @@ package de.effigenix.infrastructure.inventory.web.exception; +import de.effigenix.domain.inventory.InventoryCountError; import de.effigenix.domain.inventory.StockMovementError; import de.effigenix.domain.inventory.StorageLocationError; import de.effigenix.domain.inventory.StockError; @@ -52,6 +53,27 @@ public final class InventoryErrorHttpStatusMapper { }; } + public static int toHttpStatus(InventoryCountError error) { + return switch (error) { + case InventoryCountError.InventoryCountNotFound e -> 404; + case InventoryCountError.CountItemNotFound e -> 404; + case InventoryCountError.ActiveCountExists e -> 409; + case InventoryCountError.DuplicateArticle e -> 409; + case InventoryCountError.InvalidStatusTransition e -> 409; + case InventoryCountError.NoCountItems e -> 409; + case InventoryCountError.IncompleteCountItems e -> 409; + case InventoryCountError.SamePersonViolation e -> 409; + case InventoryCountError.CountDateInFuture e -> 400; + case InventoryCountError.InvalidStorageLocationId e -> 400; + case InventoryCountError.InvalidCountDate e -> 400; + case InventoryCountError.InvalidInitiatedBy e -> 400; + case InventoryCountError.InvalidArticleId e -> 400; + case InventoryCountError.InvalidQuantity e -> 400; + case InventoryCountError.Unauthorized e -> 403; + case InventoryCountError.RepositoryFailure e -> 500; + }; + } + public static int toHttpStatus(StockMovementError error) { return switch (error) { case StockMovementError.StockMovementNotFound e -> 404; diff --git a/backend/src/main/java/de/effigenix/infrastructure/stub/StubInventoryCountRepository.java b/backend/src/main/java/de/effigenix/infrastructure/stub/StubInventoryCountRepository.java new file mode 100644 index 0000000..a487d33 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/stub/StubInventoryCountRepository.java @@ -0,0 +1,46 @@ +package de.effigenix.infrastructure.stub; + +import de.effigenix.domain.inventory.InventoryCount; +import de.effigenix.domain.inventory.InventoryCountId; +import de.effigenix.domain.inventory.InventoryCountRepository; +import de.effigenix.domain.inventory.StorageLocationId; +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.List; +import java.util.Optional; + +@Repository +@Profile("no-db") +public class StubInventoryCountRepository implements InventoryCountRepository { + + private static final RepositoryError.DatabaseError STUB_ERROR = + new RepositoryError.DatabaseError("Stub-Modus: keine Datenbankverbindung"); + + @Override + public Result> findById(InventoryCountId id) { + return Result.failure(STUB_ERROR); + } + + @Override + public Result> findAll() { + return Result.failure(STUB_ERROR); + } + + @Override + public Result> findByStorageLocationId(StorageLocationId storageLocationId) { + return Result.failure(STUB_ERROR); + } + + @Override + public Result existsActiveByStorageLocationId(StorageLocationId storageLocationId) { + return Result.failure(STUB_ERROR); + } + + @Override + public Result save(InventoryCount inventoryCount) { + return Result.failure(STUB_ERROR); + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java b/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java index c2f68ac..38e3018 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java @@ -1,5 +1,6 @@ package de.effigenix.infrastructure.usermanagement.web.exception; +import de.effigenix.domain.inventory.InventoryCountError; import de.effigenix.domain.inventory.StockMovementError; import de.effigenix.domain.inventory.StorageLocationError; import de.effigenix.domain.inventory.StockError; @@ -11,6 +12,7 @@ import de.effigenix.domain.production.BatchError; import de.effigenix.domain.production.ProductionOrderError; import de.effigenix.domain.production.RecipeError; import de.effigenix.domain.usermanagement.UserError; +import de.effigenix.infrastructure.inventory.web.controller.InventoryCountController; import de.effigenix.infrastructure.inventory.web.controller.StockMovementController; import de.effigenix.infrastructure.inventory.web.controller.StorageLocationController; import de.effigenix.infrastructure.inventory.web.controller.StockController; @@ -259,6 +261,29 @@ public class GlobalExceptionHandler { return ResponseEntity.status(status).body(errorResponse); } + @ExceptionHandler(InventoryCountController.InventoryCountDomainErrorException.class) + public ResponseEntity handleInventoryCountDomainError( + InventoryCountController.InventoryCountDomainErrorException ex, + HttpServletRequest request + ) { + InventoryCountError error = ex.getError(); + int status = InventoryErrorHttpStatusMapper.toHttpStatus(error); + logDomainError("InventoryCount", error.code(), error.message(), status); + + String clientMessage = status >= 500 + ? "An internal error occurred" + : error.message(); + + ErrorResponse errorResponse = ErrorResponse.from( + error.code(), + clientMessage, + status, + request.getRequestURI() + ); + + return ResponseEntity.status(status).body(errorResponse); + } + @ExceptionHandler(RecipeController.RecipeDomainErrorException.class) public ResponseEntity handleRecipeDomainError( RecipeController.RecipeDomainErrorException ex, diff --git a/backend/src/main/resources/db/changelog/changes/034-create-inventory-counts-schema.xml b/backend/src/main/resources/db/changelog/changes/034-create-inventory-counts-schema.xml new file mode 100644 index 0000000..fca933f --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/034-create-inventory-counts-schema.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/db/changelog/changes/035-seed-inventory-count-permissions.xml b/backend/src/main/resources/db/changelog/changes/035-seed-inventory-count-permissions.xml new file mode 100644 index 0000000..404fee9 --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/035-seed-inventory-count-permissions.xml @@ -0,0 +1,34 @@ + + + + + + + + SELECT COUNT(*) FROM role_permissions + WHERE role_id = 'c0a80121-0000-0000-0000-000000000001' + AND permission = 'INVENTORY_COUNT_READ' + + + + Add INVENTORY_COUNT_READ and INVENTORY_COUNT_WRITE permissions for ADMIN role + + + INSERT INTO role_permissions (role_id, permission) VALUES + ('c0a80121-0000-0000-0000-000000000001', 'INVENTORY_COUNT_READ'), + ('c0a80121-0000-0000-0000-000000000001', 'INVENTORY_COUNT_WRITE') + ON CONFLICT DO NOTHING; + + + MERGE INTO role_permissions (role_id, permission) KEY (role_id, permission) VALUES + ('c0a80121-0000-0000-0000-000000000001', 'INVENTORY_COUNT_READ'); + MERGE INTO role_permissions (role_id, permission) KEY (role_id, permission) VALUES + ('c0a80121-0000-0000-0000-000000000001', 'INVENTORY_COUNT_WRITE'); + + + + 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 61150cc..3f347ac 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -39,4 +39,7 @@ + + + diff --git a/backend/src/test/java/de/effigenix/application/inventory/CreateInventoryCountTest.java b/backend/src/test/java/de/effigenix/application/inventory/CreateInventoryCountTest.java new file mode 100644 index 0000000..72b2cf8 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/CreateInventoryCountTest.java @@ -0,0 +1,292 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.CreateInventoryCountCommand; +import de.effigenix.domain.inventory.*; +import de.effigenix.domain.masterdata.ArticleId; +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 de.effigenix.shared.persistence.UnitOfWork; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CreateInventoryCount Use Case") +class CreateInventoryCountTest { + + @Mock private InventoryCountRepository inventoryCountRepository; + @Mock private StockRepository stockRepository; + @Mock private UnitOfWork unitOfWork; + + private CreateInventoryCount createInventoryCount; + + @BeforeEach + void setUp() { + createInventoryCount = new CreateInventoryCount(inventoryCountRepository, stockRepository, unitOfWork); + } + + private void stubUnitOfWork() { + when(unitOfWork.executeAtomically(any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + var supplier = (java.util.function.Supplier>) invocation.getArgument(0); + return supplier.get(); + }); + } + + @Test + @DisplayName("should create inventory count and auto-populate from stocks") + void shouldCreateAndAutoPopulate() { + stubUnitOfWork(); + var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), "user-1"); + + when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1"))) + .thenReturn(Result.success(false)); + + var stock = Stock.reconstitute( + StockId.of("stock-1"), ArticleId.of("article-1"), StorageLocationId.of("location-1"), + null, null, + List.of( + StockBatch.reconstitute( + StockBatchId.of("batch-1"), + new BatchReference("B001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10.5"), UnitOfMeasure.KILOGRAM), + LocalDate.now().plusDays(30), + StockBatchStatus.AVAILABLE, + Instant.now() + ), + StockBatch.reconstitute( + StockBatchId.of("batch-2"), + new BatchReference("B002", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("5.0"), UnitOfMeasure.KILOGRAM), + LocalDate.now().plusDays(60), + StockBatchStatus.AVAILABLE, + Instant.now() + ) + ), + List.of() + ); + + when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1"))) + .thenReturn(Result.success(List.of(stock))); + + when(inventoryCountRepository.save(any(InventoryCount.class))) + .thenReturn(Result.success(null)); + + var result = createInventoryCount.execute(cmd); + + assertThat(result.isSuccess()).isTrue(); + var count = result.unsafeGetValue(); + assertThat(count.storageLocationId().value()).isEqualTo("location-1"); + assertThat(count.status()).isEqualTo(InventoryCountStatus.OPEN); + assertThat(count.countItems()).hasSize(1); + assertThat(count.countItems().getFirst().articleId().value()).isEqualTo("article-1"); + assertThat(count.countItems().getFirst().expectedQuantity().amount()) + .isEqualByComparingTo(new BigDecimal("15.5")); + } + + @Test + @DisplayName("should create empty inventory count when no stocks exist") + void shouldCreateEmptyWhenNoStocks() { + stubUnitOfWork(); + var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), "user-1"); + + when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1"))) + .thenReturn(Result.success(false)); + when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1"))) + .thenReturn(Result.success(List.of())); + when(inventoryCountRepository.save(any(InventoryCount.class))) + .thenReturn(Result.success(null)); + + var result = createInventoryCount.execute(cmd); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().countItems()).isEmpty(); + } + + @Test + @DisplayName("should fail when active count already exists") + void shouldFailWhenActiveCountExists() { + var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), "user-1"); + + when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1"))) + .thenReturn(Result.success(true)); + + var result = createInventoryCount.execute(cmd); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.ActiveCountExists.class); + } + + @Test + @DisplayName("should fail when storageLocationId is invalid") + void shouldFailWhenStorageLocationIdInvalid() { + var cmd = new CreateInventoryCountCommand("", LocalDate.now().toString(), "user-1"); + + var result = createInventoryCount.execute(cmd); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStorageLocationId.class); + } + + @Test + @DisplayName("should fail when countDate is in the future") + void shouldFailWhenCountDateInFuture() { + var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().plusDays(1).toString(), "user-1"); + + var result = createInventoryCount.execute(cmd); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.CountDateInFuture.class); + } + + @Test + @DisplayName("should fail when repository check fails") + void shouldFailWhenRepositoryCheckFails() { + var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), "user-1"); + + when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1"))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = createInventoryCount.execute(cmd); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail when stock repository fails") + void shouldFailWhenStockRepositoryFails() { + var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), "user-1"); + + when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1"))) + .thenReturn(Result.success(false)); + when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1"))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("stock db down"))); + + var result = createInventoryCount.execute(cmd); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail when save fails") + void shouldFailWhenSaveFails() { + stubUnitOfWork(); + var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), "user-1"); + + when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1"))) + .thenReturn(Result.success(false)); + when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1"))) + .thenReturn(Result.success(List.of())); + when(inventoryCountRepository.save(any(InventoryCount.class))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("save failed"))); + + var result = createInventoryCount.execute(cmd); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); + } + + @Test + @DisplayName("should handle stock without batches (zero quantity)") + void shouldHandleStockWithoutBatches() { + stubUnitOfWork(); + var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), "user-1"); + + when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1"))) + .thenReturn(Result.success(false)); + + var stock = Stock.reconstitute( + StockId.of("stock-1"), ArticleId.of("article-1"), StorageLocationId.of("location-1"), + null, null, List.of(), List.of() + ); + + when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1"))) + .thenReturn(Result.success(List.of(stock))); + when(inventoryCountRepository.save(any(InventoryCount.class))) + .thenReturn(Result.success(null)); + + var result = createInventoryCount.execute(cmd); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().countItems()).hasSize(1); + assertThat(result.unsafeGetValue().countItems().getFirst().expectedQuantity().amount()) + .isEqualByComparingTo(BigDecimal.ZERO); + } + + @Test + @DisplayName("should handle multiple stocks for same location") + void shouldHandleMultipleStocks() { + stubUnitOfWork(); + var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), "user-1"); + + when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1"))) + .thenReturn(Result.success(false)); + + var stock1 = Stock.reconstitute( + StockId.of("stock-1"), ArticleId.of("article-1"), StorageLocationId.of("location-1"), + null, null, + List.of(StockBatch.reconstitute( + StockBatchId.of("batch-1"), new BatchReference("B001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), + LocalDate.now().plusDays(30), StockBatchStatus.AVAILABLE, Instant.now() + )), List.of() + ); + var stock2 = Stock.reconstitute( + StockId.of("stock-2"), ArticleId.of("article-2"), StorageLocationId.of("location-1"), + null, null, + List.of(StockBatch.reconstitute( + StockBatchId.of("batch-2"), new BatchReference("B002", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("20.0"), UnitOfMeasure.LITER), + LocalDate.now().plusDays(60), StockBatchStatus.AVAILABLE, Instant.now() + )), List.of() + ); + + when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1"))) + .thenReturn(Result.success(List.of(stock1, stock2))); + when(inventoryCountRepository.save(any(InventoryCount.class))) + .thenReturn(Result.success(null)); + + var result = createInventoryCount.execute(cmd); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().countItems()).hasSize(2); + } + + @Test + @DisplayName("should fail when initiatedBy is null") + void shouldFailWhenInitiatedByNull() { + var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), null); + + var result = createInventoryCount.execute(cmd); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInitiatedBy.class); + } + + @Test + @DisplayName("should fail when countDate is unparseable") + void shouldFailWhenCountDateUnparseable() { + var cmd = new CreateInventoryCountCommand("location-1", "not-a-date", "user-1"); + + var result = createInventoryCount.execute(cmd); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidCountDate.class); + } +} diff --git a/backend/src/test/java/de/effigenix/application/inventory/GetInventoryCountTest.java b/backend/src/test/java/de/effigenix/application/inventory/GetInventoryCountTest.java new file mode 100644 index 0000000..6d665fb --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/GetInventoryCountTest.java @@ -0,0 +1,107 @@ +package de.effigenix.application.inventory; + +import de.effigenix.domain.inventory.*; +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; +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.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.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("GetInventoryCount Use Case") +class GetInventoryCountTest { + + @Mock private InventoryCountRepository inventoryCountRepository; + + private GetInventoryCount getInventoryCount; + private InventoryCount existingCount; + + @BeforeEach + void setUp() { + getInventoryCount = new GetInventoryCount(inventoryCountRepository); + + existingCount = InventoryCount.reconstitute( + InventoryCountId.of("count-1"), + StorageLocationId.of("location-1"), + LocalDate.now(), + "user-1", + InventoryCountStatus.OPEN, + Instant.now(), + List.of() + ); + } + + @Test + @DisplayName("should return inventory count when found") + void shouldReturnCountWhenFound() { + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.success(Optional.of(existingCount))); + + var result = getInventoryCount.execute("count-1"); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().id().value()).isEqualTo("count-1"); + } + + @Test + @DisplayName("should fail with InventoryCountNotFound when not found") + void shouldFailWhenNotFound() { + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.success(Optional.empty())); + + var result = getInventoryCount.execute("count-1"); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class); + } + + @Test + @DisplayName("should fail with RepositoryFailure when repository fails") + void shouldFailWhenRepositoryFails() { + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = getInventoryCount.execute("count-1"); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail with InventoryCountNotFound when id is null") + void shouldFailWhenIdIsNull() { + var result = getInventoryCount.execute(null); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class); + } + + @Test + @DisplayName("should fail with InventoryCountNotFound when id is blank") + void shouldFailWhenIdIsBlank() { + var result = getInventoryCount.execute(" "); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class); + } + + @Test + @DisplayName("should fail with InventoryCountNotFound when id is empty string") + void shouldFailWhenIdIsEmpty() { + var result = getInventoryCount.execute(""); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class); + } +} diff --git a/backend/src/test/java/de/effigenix/application/inventory/ListInventoryCountsTest.java b/backend/src/test/java/de/effigenix/application/inventory/ListInventoryCountsTest.java new file mode 100644 index 0000000..b46f49a --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/ListInventoryCountsTest.java @@ -0,0 +1,125 @@ +package de.effigenix.application.inventory; + +import de.effigenix.domain.inventory.*; +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; +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.time.Instant; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ListInventoryCounts Use Case") +class ListInventoryCountsTest { + + @Mock private InventoryCountRepository inventoryCountRepository; + + private ListInventoryCounts listInventoryCounts; + private InventoryCount count1; + private InventoryCount count2; + + @BeforeEach + void setUp() { + listInventoryCounts = new ListInventoryCounts(inventoryCountRepository); + + count1 = InventoryCount.reconstitute( + InventoryCountId.of("count-1"), + StorageLocationId.of("location-1"), + LocalDate.now(), + "user-1", + InventoryCountStatus.OPEN, + Instant.now(), + List.of() + ); + + count2 = InventoryCount.reconstitute( + InventoryCountId.of("count-2"), + StorageLocationId.of("location-2"), + LocalDate.now(), + "user-1", + InventoryCountStatus.COMPLETED, + Instant.now(), + List.of() + ); + } + + @Test + @DisplayName("should return all counts when no filter provided") + void shouldReturnAllCountsWhenNoFilter() { + when(inventoryCountRepository.findAll()).thenReturn(Result.success(List.of(count1, count2))); + + var result = listInventoryCounts.execute(null); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(2); + verify(inventoryCountRepository).findAll(); + } + + @Test + @DisplayName("should filter by storageLocationId") + void shouldFilterByStorageLocationId() { + when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1"))) + .thenReturn(Result.success(List.of(count1))); + + var result = listInventoryCounts.execute("location-1"); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(1); + verify(inventoryCountRepository).findByStorageLocationId(StorageLocationId.of("location-1")); + verify(inventoryCountRepository, never()).findAll(); + } + + @Test + @DisplayName("should fail with RepositoryFailure when repository fails for findAll") + void shouldFailWhenRepositoryFailsForFindAll() { + when(inventoryCountRepository.findAll()) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = listInventoryCounts.execute(null); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail with RepositoryFailure when repository fails for storageLocationId filter") + void shouldFailWhenRepositoryFailsForFilter() { + when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1"))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = listInventoryCounts.execute("location-1"); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); + } + + @Test + @DisplayName("should return empty list when no counts match") + void shouldReturnEmptyListWhenNoCountsMatch() { + when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("unknown"))) + .thenReturn(Result.success(List.of())); + + var result = listInventoryCounts.execute("unknown"); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).isEmpty(); + } + + @Test + @DisplayName("should fail with InvalidStorageLocationId for blank storageLocationId") + void shouldFailWhenBlankStorageLocationId() { + var result = listInventoryCounts.execute(" "); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStorageLocationId.class); + } +} diff --git a/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountFuzzTest.java b/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountFuzzTest.java new file mode 100644 index 0000000..34f8b58 --- /dev/null +++ b/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountFuzzTest.java @@ -0,0 +1,61 @@ +package de.effigenix.domain.inventory; + +import com.code_intelligence.jazzer.api.FuzzedDataProvider; +import com.code_intelligence.jazzer.junit.FuzzTest; +import de.effigenix.shared.common.Result; + +/** + * Fuzz test for the InventoryCount aggregate. + * + * Exercises create() with fuzzed drafts, then applies a random sequence of + * addCountItem() operations. Verifies that no input combination causes an + * unhandled exception – all invalid inputs must be caught and returned as + * Result.Failure. + * + * Run: make fuzz | make fuzz/single TEST=InventoryCountFuzzTest + */ +class InventoryCountFuzzTest { + + @FuzzTest(maxDuration = "5m") + void fuzzInventoryCount(FuzzedDataProvider data) { + var draft = new InventoryCountDraft( + data.consumeString(50), + data.consumeString(30), + data.consumeString(30) + ); + + switch (InventoryCount.create(draft)) { + case Result.Failure(var err) -> { } + case Result.Success(var count) -> { + int ops = data.consumeInt(1, 20); + for (int i = 0; i < ops; i++) { + var itemDraft = new CountItemDraft( + data.consumeString(50), + data.consumeString(30), + data.consumeString(20) + ); + switch (count.addCountItem(itemDraft)) { + case Result.Failure(var err) -> { } + case Result.Success(var item) -> { + // Verify computed properties don't throw + item.deviation(); + item.isCounted(); + item.id(); + item.articleId(); + item.expectedQuantity(); + } + } + } + // Verify aggregate getters don't throw + count.isActive(); + count.countItems(); + count.id(); + count.storageLocationId(); + count.countDate(); + count.status(); + count.initiatedBy(); + count.createdAt(); + } + } + } +} diff --git a/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountTest.java b/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountTest.java new file mode 100644 index 0000000..a8b6562 --- /dev/null +++ b/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountTest.java @@ -0,0 +1,588 @@ +package de.effigenix.domain.inventory; + +import de.effigenix.domain.masterdata.ArticleId; +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.UnitOfMeasure; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + + +class InventoryCountTest { + + // ==================== Create ==================== + + @Nested + @DisplayName("create()") + class Create { + + @Test + @DisplayName("should create InventoryCount with valid inputs") + void shouldCreateWithValidInputs() { + var draft = new InventoryCountDraft("location-1", LocalDate.now().toString(), "user-1"); + + var result = InventoryCount.create(draft); + + assertThat(result.isSuccess()).isTrue(); + var count = result.unsafeGetValue(); + assertThat(count.id()).isNotNull(); + assertThat(count.storageLocationId().value()).isEqualTo("location-1"); + assertThat(count.countDate()).isEqualTo(LocalDate.now()); + assertThat(count.initiatedBy()).isEqualTo("user-1"); + assertThat(count.status()).isEqualTo(InventoryCountStatus.OPEN); + assertThat(count.countItems()).isEmpty(); + } + + @Test + @DisplayName("should create InventoryCount with past date") + void shouldCreateWithPastDate() { + var draft = new InventoryCountDraft("location-1", "2025-01-15", "user-1"); + + var result = InventoryCount.create(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().countDate()).isEqualTo(LocalDate.of(2025, 1, 15)); + } + + @Test + @DisplayName("should fail when storageLocationId is null") + void shouldFailWhenStorageLocationIdNull() { + var draft = new InventoryCountDraft(null, LocalDate.now().toString(), "user-1"); + + var result = InventoryCount.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStorageLocationId.class); + } + + @Test + @DisplayName("should fail when storageLocationId is blank") + void shouldFailWhenStorageLocationIdBlank() { + var draft = new InventoryCountDraft(" ", LocalDate.now().toString(), "user-1"); + + var result = InventoryCount.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStorageLocationId.class); + } + + @Test + @DisplayName("should fail when countDate is null") + void shouldFailWhenCountDateNull() { + var draft = new InventoryCountDraft("location-1", null, "user-1"); + + var result = InventoryCount.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidCountDate.class); + } + + @Test + @DisplayName("should fail when countDate is invalid") + void shouldFailWhenCountDateInvalid() { + var draft = new InventoryCountDraft("location-1", "not-a-date", "user-1"); + + var result = InventoryCount.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidCountDate.class); + } + + @Test + @DisplayName("should fail when countDate is in the future") + void shouldFailWhenCountDateInFuture() { + var futureDate = LocalDate.now().plusDays(1).toString(); + var draft = new InventoryCountDraft("location-1", futureDate, "user-1"); + + var result = InventoryCount.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.CountDateInFuture.class); + } + + @Test + @DisplayName("should fail when initiatedBy is null") + void shouldFailWhenInitiatedByNull() { + var draft = new InventoryCountDraft("location-1", LocalDate.now().toString(), null); + + var result = InventoryCount.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInitiatedBy.class); + } + + @Test + @DisplayName("should fail when initiatedBy is blank") + void shouldFailWhenInitiatedByBlank() { + var draft = new InventoryCountDraft("location-1", LocalDate.now().toString(), " "); + + var result = InventoryCount.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInitiatedBy.class); + } + + @Test + @DisplayName("should fail when storageLocationId is empty string") + void shouldFailWhenStorageLocationIdEmpty() { + var draft = new InventoryCountDraft("", LocalDate.now().toString(), "user-1"); + + var result = InventoryCount.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStorageLocationId.class); + } + + @Test + @DisplayName("should fail when initiatedBy is empty string") + void shouldFailWhenInitiatedByEmpty() { + var draft = new InventoryCountDraft("location-1", LocalDate.now().toString(), ""); + + var result = InventoryCount.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInitiatedBy.class); + } + + @Test + @DisplayName("should fail when countDate is blank") + void shouldFailWhenCountDateBlank() { + var draft = new InventoryCountDraft("location-1", " ", "user-1"); + + var result = InventoryCount.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidCountDate.class); + } + + @Test + @DisplayName("should accept today as countDate (boundary)") + void shouldAcceptTodayAsCountDate() { + var draft = new InventoryCountDraft("location-1", LocalDate.now().toString(), "user-1"); + + var result = InventoryCount.create(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().countDate()).isEqualTo(LocalDate.now()); + } + + @Test + @DisplayName("should fail with invalid date like 2025-13-01") + void shouldFailWhenDateHasInvalidMonth() { + var draft = new InventoryCountDraft("location-1", "2025-13-01", "user-1"); + + var result = InventoryCount.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidCountDate.class); + } + + @Test + @DisplayName("should fail with invalid date like 2025-02-30") + void shouldFailWhenDateHasInvalidDay() { + var draft = new InventoryCountDraft("location-1", "2025-02-30", "user-1"); + + var result = InventoryCount.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidCountDate.class); + } + + @Test + @DisplayName("should accept leap year date 2024-02-29") + void shouldAcceptLeapYearDate() { + var draft = new InventoryCountDraft("location-1", "2024-02-29", "user-1"); + + var result = InventoryCount.create(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().countDate()).isEqualTo(LocalDate.of(2024, 2, 29)); + } + } + + // ==================== addCountItem ==================== + + @Nested + @DisplayName("addCountItem()") + class AddCountItem { + + @Test + @DisplayName("should add count item to OPEN count") + void shouldAddCountItemToOpenCount() { + var count = createOpenCount(); + var itemDraft = new CountItemDraft("article-1", "10.0", "KILOGRAM"); + + var result = count.addCountItem(itemDraft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(count.countItems()).hasSize(1); + assertThat(result.unsafeGetValue().articleId().value()).isEqualTo("article-1"); + } + + @Test + @DisplayName("should add multiple count items with different articles") + void shouldAddMultipleCountItems() { + var count = createOpenCount(); + + count.addCountItem(new CountItemDraft("article-1", "10.0", "KILOGRAM")); + count.addCountItem(new CountItemDraft("article-2", "5.0", "LITER")); + + assertThat(count.countItems()).hasSize(2); + } + + @Test + @DisplayName("should fail when adding duplicate articleId") + void shouldFailWhenDuplicateArticle() { + var count = createOpenCount(); + count.addCountItem(new CountItemDraft("article-1", "10.0", "KILOGRAM")); + + var result = count.addCountItem(new CountItemDraft("article-1", "5.0", "KILOGRAM")); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.DuplicateArticle.class); + } + + @Test + @DisplayName("should fail when count is not OPEN") + void shouldFailWhenNotOpen() { + var count = InventoryCount.reconstitute( + InventoryCountId.of("count-1"), + StorageLocationId.of("location-1"), + LocalDate.now(), + "user-1", + InventoryCountStatus.COUNTING, + java.time.Instant.now(), + java.util.List.of() + ); + + var result = count.addCountItem(new CountItemDraft("article-1", "10.0", "KILOGRAM")); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class); + } + + @Test + @DisplayName("should fail when articleId is blank") + void shouldFailWhenArticleIdBlank() { + var count = createOpenCount(); + + var result = count.addCountItem(new CountItemDraft("", "10.0", "KILOGRAM")); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidArticleId.class); + } + + @Test + @DisplayName("should fail when quantity amount is invalid") + void shouldFailWhenQuantityInvalid() { + var count = createOpenCount(); + + var result = count.addCountItem(new CountItemDraft("article-1", "not-a-number", "KILOGRAM")); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidQuantity.class); + } + + @Test + @DisplayName("should fail when unit is invalid") + void shouldFailWhenUnitInvalid() { + var count = createOpenCount(); + + var result = count.addCountItem(new CountItemDraft("article-1", "10.0", "INVALID_UNIT")); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidQuantity.class); + } + + @Test + @DisplayName("should allow zero expected quantity") + void shouldAllowZeroExpectedQuantity() { + var count = createOpenCount(); + + var result = count.addCountItem(new CountItemDraft("article-1", "0", "KILOGRAM")); + + assertThat(result.isSuccess()).isTrue(); + } + + @Test + @DisplayName("should fail when count is COMPLETED") + void shouldFailWhenCompleted() { + var count = reconstitute(InventoryCountStatus.COMPLETED); + + var result = count.addCountItem(new CountItemDraft("article-1", "10.0", "KILOGRAM")); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class); + } + + @Test + @DisplayName("should fail when count is CANCELLED") + void shouldFailWhenCancelled() { + var count = reconstitute(InventoryCountStatus.CANCELLED); + + var result = count.addCountItem(new CountItemDraft("article-1", "10.0", "KILOGRAM")); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class); + } + + @Test + @DisplayName("should fail when quantity is negative") + void shouldFailWhenNegativeQuantity() { + var count = createOpenCount(); + + var result = count.addCountItem(new CountItemDraft("article-1", "-5.0", "KILOGRAM")); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidQuantity.class); + } + + @Test + @DisplayName("should fail when articleId is null") + void shouldFailWhenArticleIdNull() { + var count = createOpenCount(); + + var result = count.addCountItem(new CountItemDraft(null, "10.0", "KILOGRAM")); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidArticleId.class); + } + + @Test + @DisplayName("should fail when quantity amount is null") + void shouldFailWhenQuantityAmountNull() { + var count = createOpenCount(); + + var result = count.addCountItem(new CountItemDraft("article-1", null, "KILOGRAM")); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidQuantity.class); + } + + @Test + @DisplayName("should fail when unit is null") + void shouldFailWhenUnitNull() { + var count = createOpenCount(); + + var result = count.addCountItem(new CountItemDraft("article-1", "10.0", null)); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidQuantity.class); + } + + private InventoryCount reconstitute(InventoryCountStatus status) { + return InventoryCount.reconstitute( + InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), + LocalDate.now(), "user-1", status, + Instant.now(), List.of() + ); + } + } + + // ==================== CountItem deviation ==================== + + @Nested + @DisplayName("CountItem deviation()") + class Deviation { + + @Test + @DisplayName("should return null when not yet counted") + void shouldReturnNullWhenNotCounted() { + var item = CountItem.create(new CountItemDraft("article-1", "10.0", "KILOGRAM")).unsafeGetValue(); + + assertThat(item.deviation()).isNull(); + assertThat(item.isCounted()).isFalse(); + } + + @Test + @DisplayName("should compute negative deviation (actual < expected)") + void shouldComputeNegativeDeviation() { + var item = CountItem.reconstitute( + CountItemId.of("item-1"), + ArticleId.of("article-1"), + Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), + Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.KILOGRAM) + ); + + assertThat(item.isCounted()).isTrue(); + assertThat(item.deviation()).isEqualByComparingTo(new BigDecimal("-2.0")); + } + + @Test + @DisplayName("should compute positive deviation (actual > expected)") + void shouldComputePositiveDeviation() { + var item = CountItem.reconstitute( + CountItemId.of("item-1"), + ArticleId.of("article-1"), + Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), + Quantity.reconstitute(new BigDecimal("12.5"), UnitOfMeasure.KILOGRAM) + ); + + assertThat(item.isCounted()).isTrue(); + assertThat(item.deviation()).isEqualByComparingTo(new BigDecimal("2.5")); + } + + @Test + @DisplayName("should compute zero deviation (actual == expected)") + void shouldComputeZeroDeviation() { + var item = CountItem.reconstitute( + CountItemId.of("item-1"), + ArticleId.of("article-1"), + Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), + Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM) + ); + + assertThat(item.isCounted()).isTrue(); + assertThat(item.deviation()).isEqualByComparingTo(BigDecimal.ZERO); + } + } + + // ==================== isActive ==================== + + @Nested + @DisplayName("isActive()") + class IsActive { + + @Test + @DisplayName("OPEN should be active") + void openShouldBeActive() { + var count = createOpenCount(); + assertThat(count.isActive()).isTrue(); + } + + @Test + @DisplayName("COUNTING should be active") + void countingShouldBeActive() { + var count = InventoryCount.reconstitute( + InventoryCountId.of("c1"), StorageLocationId.of("l1"), + LocalDate.now(), "user-1", InventoryCountStatus.COUNTING, + Instant.now(), List.of() + ); + assertThat(count.isActive()).isTrue(); + } + + @Test + @DisplayName("COMPLETED should not be active") + void completedShouldNotBeActive() { + var count = InventoryCount.reconstitute( + InventoryCountId.of("c1"), StorageLocationId.of("l1"), + LocalDate.now(), "user-1", InventoryCountStatus.COMPLETED, + Instant.now(), List.of() + ); + assertThat(count.isActive()).isFalse(); + } + + @Test + @DisplayName("CANCELLED should not be active") + void cancelledShouldNotBeActive() { + var count = InventoryCount.reconstitute( + InventoryCountId.of("c1"), StorageLocationId.of("l1"), + LocalDate.now(), "user-1", InventoryCountStatus.CANCELLED, + Instant.now(), List.of() + ); + assertThat(count.isActive()).isFalse(); + } + } + + // ==================== reconstitute ==================== + + @Nested + @DisplayName("reconstitute()") + class Reconstitute { + + @Test + @DisplayName("should reconstitute with countItems") + void shouldReconstituteWithCountItems() { + var items = List.of( + CountItem.reconstitute( + CountItemId.of("item-1"), ArticleId.of("article-1"), + Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null + ), + CountItem.reconstitute( + CountItemId.of("item-2"), ArticleId.of("article-2"), + Quantity.reconstitute(new BigDecimal("5.0"), UnitOfMeasure.LITER), null + ) + ); + + var count = InventoryCount.reconstitute( + InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), + LocalDate.of(2025, 6, 15), "user-1", InventoryCountStatus.COUNTING, + Instant.now(), items + ); + + assertThat(count.id().value()).isEqualTo("count-1"); + assertThat(count.status()).isEqualTo(InventoryCountStatus.COUNTING); + assertThat(count.countItems()).hasSize(2); + } + + @Test + @DisplayName("countItems should be unmodifiable") + void countItemsShouldBeUnmodifiable() { + var count = createOpenCount(); + + assertThatThrownBy(() -> count.countItems().add( + CountItem.reconstitute( + CountItemId.of("item-1"), ArticleId.of("article-1"), + Quantity.reconstitute(BigDecimal.TEN, UnitOfMeasure.KILOGRAM), null + ) + )).isInstanceOf(UnsupportedOperationException.class); + } + } + + // ==================== equals / hashCode ==================== + + @Nested + @DisplayName("equals() / hashCode()") + class EqualsAndHashCode { + + @Test + @DisplayName("same id → equal") + void sameIdShouldBeEqual() { + var a = InventoryCount.reconstitute( + InventoryCountId.of("count-1"), StorageLocationId.of("loc-a"), + LocalDate.now(), "user-a", InventoryCountStatus.OPEN, + Instant.now(), List.of() + ); + var b = InventoryCount.reconstitute( + InventoryCountId.of("count-1"), StorageLocationId.of("loc-b"), + LocalDate.now().minusDays(1), "user-b", InventoryCountStatus.COMPLETED, + Instant.now(), List.of() + ); + + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + @DisplayName("different id → not equal") + void differentIdShouldNotBeEqual() { + var a = InventoryCount.reconstitute( + InventoryCountId.of("count-1"), StorageLocationId.of("loc-1"), + LocalDate.now(), "user-1", InventoryCountStatus.OPEN, + Instant.now(), List.of() + ); + var b = InventoryCount.reconstitute( + InventoryCountId.of("count-2"), StorageLocationId.of("loc-1"), + LocalDate.now(), "user-1", InventoryCountStatus.OPEN, + Instant.now(), List.of() + ); + + assertThat(a).isNotEqualTo(b); + } + } + + // ==================== Helpers ==================== + + private InventoryCount createOpenCount() { + return InventoryCount.create( + new InventoryCountDraft("location-1", LocalDate.now().toString(), "user-1") + ).unsafeGetValue(); + } +} diff --git a/backend/src/test/java/de/effigenix/infrastructure/inventory/web/InventoryCountControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/InventoryCountControllerIntegrationTest.java new file mode 100644 index 0000000..aaa5d5e --- /dev/null +++ b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/InventoryCountControllerIntegrationTest.java @@ -0,0 +1,339 @@ +package de.effigenix.infrastructure.inventory.web; + +import de.effigenix.domain.usermanagement.RoleName; +import de.effigenix.infrastructure.AbstractIntegrationTest; +import de.effigenix.infrastructure.inventory.web.dto.CreateInventoryCountRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +import java.time.LocalDate; +import java.util.Set; +import java.util.UUID; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Integrationstests für InventoryCountController. + * + * Abgedeckte Testfälle: + * - US-6.1 – Inventur anlegen und Zählpositionen befüllen + */ +@DisplayName("InventoryCount Controller Integration Tests") +class InventoryCountControllerIntegrationTest extends AbstractIntegrationTest { + + private String adminToken; + private String viewerToken; + private String storageLocationId; + + @BeforeEach + void setUp() throws Exception { + String adminRoleId = createRole(RoleName.ADMIN, "Admin"); + String viewerRoleId = createRole(RoleName.PRODUCTION_WORKER, "Viewer"); + + String adminId = createUser("ic.admin", "ic.admin@test.com", Set.of(adminRoleId), "BRANCH-01"); + String viewerId = createUser("ic.viewer", "ic.viewer@test.com", Set.of(viewerRoleId), "BRANCH-01"); + + adminToken = generateToken(adminId, "ic.admin", "INVENTORY_COUNT_WRITE,INVENTORY_COUNT_READ,STOCK_WRITE,STOCK_READ"); + viewerToken = generateToken(viewerId, "ic.viewer", "USER_READ"); + + storageLocationId = createStorageLocation(); + } + + // ==================== Inventur anlegen ==================== + + @Test + @DisplayName("Inventur anlegen → 201 mit Pflichtfeldern") + void createInventoryCount_returns201() throws Exception { + var request = new CreateInventoryCountRequest(storageLocationId, LocalDate.now().toString()); + + mockMvc.perform(post("/api/inventory/inventory-counts") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isNotEmpty()) + .andExpect(jsonPath("$.storageLocationId").value(storageLocationId)) + .andExpect(jsonPath("$.countDate").value(LocalDate.now().toString())) + .andExpect(jsonPath("$.status").value("OPEN")) + .andExpect(jsonPath("$.countItems").isArray()); + } + + // ==================== Auto-Populate ==================== + + @Test + @DisplayName("Inventur mit vorhandenen Stocks → countItems werden befüllt") + void createInventoryCount_withStocks_autoPopulates() throws Exception { + // Stock mit Batch anlegen + String articleId = createArticleId(); + createStockWithBatch(articleId, storageLocationId); + + var request = new CreateInventoryCountRequest(storageLocationId, LocalDate.now().toString()); + + mockMvc.perform(post("/api/inventory/inventory-counts") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.countItems.length()").value(1)) + .andExpect(jsonPath("$.countItems[0].articleId").value(articleId)) + .andExpect(jsonPath("$.countItems[0].expectedQuantityAmount").isNumber()); + } + + // ==================== Duplikat (aktive Inventur) ==================== + + @Test + @DisplayName("Zweite aktive Inventur für gleichen Lagerort → 409") + void createInventoryCount_activeExists_returns409() throws Exception { + var request = new CreateInventoryCountRequest(storageLocationId, LocalDate.now().toString()); + + // Erste Inventur anlegen + mockMvc.perform(post("/api/inventory/inventory-counts") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + + // Zweite Inventur für gleichen Lagerort → 409 + mockMvc.perform(post("/api/inventory/inventory-counts") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("ACTIVE_COUNT_EXISTS")); + } + + // ==================== GET by ID ==================== + + @Test + @DisplayName("Inventur per ID abfragen → 200") + void getInventoryCount_returns200() throws Exception { + var request = new CreateInventoryCountRequest(storageLocationId, LocalDate.now().toString()); + + var createResult = mockMvc.perform(post("/api/inventory/inventory-counts") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + String id = objectMapper.readTree(createResult.getResponse().getContentAsString()).get("id").asText(); + + mockMvc.perform(get("/api/inventory/inventory-counts/" + id) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(id)) + .andExpect(jsonPath("$.storageLocationId").value(storageLocationId)); + } + + @Test + @DisplayName("Inventur mit unbekannter ID → 404") + void getInventoryCount_notFound_returns404() throws Exception { + mockMvc.perform(get("/api/inventory/inventory-counts/" + UUID.randomUUID()) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("INVENTORY_COUNT_NOT_FOUND")); + } + + // ==================== GET all / filtered ==================== + + @Test + @DisplayName("Alle Inventuren auflisten → 200") + void listInventoryCounts_returns200() throws Exception { + var request = new CreateInventoryCountRequest(storageLocationId, LocalDate.now().toString()); + + mockMvc.perform(post("/api/inventory/inventory-counts") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + + mockMvc.perform(get("/api/inventory/inventory-counts") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)); + } + + @Test + @DisplayName("Inventuren nach storageLocationId filtern → 200") + void listInventoryCounts_filterByStorageLocation_returns200() throws Exception { + var request = new CreateInventoryCountRequest(storageLocationId, LocalDate.now().toString()); + + mockMvc.perform(post("/api/inventory/inventory-counts") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + + mockMvc.perform(get("/api/inventory/inventory-counts") + .param("storageLocationId", storageLocationId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].storageLocationId").value(storageLocationId)); + } + + // ==================== Security ==================== + + @Test + @DisplayName("Ohne Token → 401") + void createInventoryCount_noToken_returns401() throws Exception { + var request = new CreateInventoryCountRequest(storageLocationId, LocalDate.now().toString()); + + mockMvc.perform(post("/api/inventory/inventory-counts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("Ohne INVENTORY_COUNT_WRITE Permission → 403") + void createInventoryCount_noPermission_returns403() throws Exception { + var request = new CreateInventoryCountRequest(storageLocationId, LocalDate.now().toString()); + + mockMvc.perform(post("/api/inventory/inventory-counts") + .header("Authorization", "Bearer " + viewerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + // ==================== Validation Edge Cases ==================== + + @Test + @DisplayName("Inventur mit zukünftigem Datum → 400") + void createInventoryCount_futureDate_returns400() throws Exception { + var request = new CreateInventoryCountRequest(storageLocationId, LocalDate.now().plusDays(1).toString()); + + mockMvc.perform(post("/api/inventory/inventory-counts") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COUNT_DATE_IN_FUTURE")); + } + + @Test + @DisplayName("Inventur mit ungültigem Datumsformat → 400") + void createInventoryCount_invalidDateFormat_returns400() throws Exception { + String json = """ + {"storageLocationId": "%s", "countDate": "not-a-date"} + """.formatted(storageLocationId); + + mockMvc.perform(post("/api/inventory/inventory-counts") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_COUNT_DATE")); + } + + @Test + @DisplayName("Inventur ohne storageLocationId → 400") + void createInventoryCount_missingStorageLocationId_returns400() throws Exception { + String json = """ + {"countDate": "%s"} + """.formatted(LocalDate.now()); + + mockMvc.perform(post("/api/inventory/inventory-counts") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("Inventur ohne countDate → 400") + void createInventoryCount_missingCountDate_returns400() throws Exception { + String json = """ + {"storageLocationId": "%s"} + """.formatted(storageLocationId); + + mockMvc.perform(post("/api/inventory/inventory-counts") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()); + } + + // ==================== GET Security Edge Cases ==================== + + @Test + @DisplayName("GET by ID ohne Token → 401") + void getInventoryCount_noToken_returns401() throws Exception { + mockMvc.perform(get("/api/inventory/inventory-counts/" + UUID.randomUUID())) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("GET by ID ohne INVENTORY_COUNT_READ Permission → 403") + void getInventoryCount_noPermission_returns403() throws Exception { + mockMvc.perform(get("/api/inventory/inventory-counts/" + UUID.randomUUID()) + .header("Authorization", "Bearer " + viewerToken)) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("GET all ohne Token → 401") + void listInventoryCounts_noToken_returns401() throws Exception { + mockMvc.perform(get("/api/inventory/inventory-counts")) + .andExpect(status().isUnauthorized()); + } + + // ==================== Leere Liste ==================== + + @Test + @DisplayName("Liste ohne Inventuren → leere Liste 200") + void listInventoryCounts_empty_returnsEmptyList() throws Exception { + mockMvc.perform(get("/api/inventory/inventory-counts") + .param("storageLocationId", UUID.randomUUID().toString()) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + } + + // ==================== Helpers ==================== + + private String createStorageLocation() throws Exception { + String json = """ + {"name": "Testlager-%s", "storageType": "DRY_STORAGE"} + """.formatted(UUID.randomUUID().toString().substring(0, 8)); + + var result = mockMvc.perform(post("/api/inventory/storage-locations") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isCreated()) + .andReturn(); + + return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + } + + private void createStockWithBatch(String articleId, String storageLocationId) throws Exception { + String stockJson = """ + {"articleId": "%s", "storageLocationId": "%s"} + """.formatted(articleId, storageLocationId); + + var stockResult = mockMvc.perform(post("/api/inventory/stocks") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(stockJson)) + .andExpect(status().isCreated()) + .andReturn(); + + String stockId = objectMapper.readTree(stockResult.getResponse().getContentAsString()).get("id").asText(); + + String batchJson = """ + {"batchId": "B-%s", "batchType": "PRODUCED", "quantityAmount": "25.0", "quantityUnit": "KILOGRAM", "expiryDate": "%s"} + """.formatted(UUID.randomUUID().toString().substring(0, 8), LocalDate.now().plusDays(30)); + + mockMvc.perform(post("/api/inventory/stocks/" + stockId + "/batches") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(batchJson)) + .andExpect(status().isCreated()); + } +}