1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 06:29:35 +01:00

feat(inventory): Inventur anlegen und Zählpositionen befüllen (US-6.1)

InventoryCount-Aggregate mit CountItem-Entities, Auto-Populate aus
Stock-Daten, vollständige DDD-Schichten inkl. Edge-Case-Tests und
Jazzer-Fuzz-Test. 1909 Tests grün.
This commit is contained in:
Sebastian Frick 2026-02-26 11:59:39 +01:00
parent 600d0f9f06
commit c047ca93de
31 changed files with 2745 additions and 0 deletions

View file

@ -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<InventoryCountError, InventoryCount> 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);
});
}
}

View file

@ -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<InventoryCountError, InventoryCount> 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
.<Result<InventoryCountError, InventoryCount>>map(Result::success)
.orElseGet(() -> Result.failure(new InventoryCountError.InventoryCountNotFound(inventoryCountId)));
};
}
}

View file

@ -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<InventoryCountError, List<InventoryCount>> 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<InventoryCountError, List<InventoryCount>> mapResult(Result<RepositoryError, List<InventoryCount>> result) {
return switch (result) {
case Result.Failure(var err) -> Result.failure(new InventoryCountError.RepositoryFailure(err.message()));
case Result.Success(var counts) -> Result.success(counts);
};
}
}

View file

@ -0,0 +1,7 @@
package de.effigenix.application.inventory.command;
public record CreateInventoryCountCommand(
String storageLocationId,
String countDate,
String initiatedBy
) {}

View file

@ -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<InventoryCountError, CountItem> 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);
}
}

View file

@ -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
) {}

View file

@ -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);
}
}

View file

@ -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<CountItem> countItems;
private InventoryCount(
InventoryCountId id,
StorageLocationId storageLocationId,
LocalDate countDate,
String initiatedBy,
InventoryCountStatus status,
Instant createdAt,
List<CountItem> 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<InventoryCountError, InventoryCount> 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<CountItem> countItems
) {
return new InventoryCount(id, storageLocationId, countDate, initiatedBy, status, createdAt, countItems);
}
// ==================== Count Item Management ====================
public Result<InventoryCountError, CountItem> 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<CountItem> 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 + "}";
}
}

View file

@ -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
) {}

View file

@ -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"; }
}
}

View file

@ -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);
}
}

View file

@ -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<RepositoryError, Optional<InventoryCount>> findById(InventoryCountId id);
Result<RepositoryError, List<InventoryCount>> findAll();
Result<RepositoryError, List<InventoryCount>> findByStorageLocationId(StorageLocationId storageLocationId);
Result<RepositoryError, Boolean> existsActiveByStorageLocationId(StorageLocationId storageLocationId);
Result<RepositoryError, Void> save(InventoryCount inventoryCount);
}

View file

@ -0,0 +1,8 @@
package de.effigenix.domain.inventory;
public enum InventoryCountStatus {
OPEN,
COUNTING,
COMPLETED,
CANCELLED
}

View file

@ -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);
}
}

View file

@ -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<RepositoryError, Optional<InventoryCount>> 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<RepositoryError, List<InventoryCount>> 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<RepositoryError, List<InventoryCount>> 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<RepositoryError, Boolean> 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<RepositoryError, Void> 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<String, Object> countParams(InventoryCount count) {
var params = new java.util.LinkedHashMap<String, Object>();
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<InventoryCount> loadChildrenForAll(List<InventoryCount> 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
);
}
}

View file

@ -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<InventoryCountResponse> 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<InventoryCountResponse> 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<List<InventoryCountResponse>> listInventoryCounts(
@RequestParam(required = false) String storageLocationId
) {
var result = listInventoryCounts.execute(storageLocationId);
if (result.isFailure()) {
throw new InventoryCountDomainErrorException(result.unsafeGetError());
}
List<InventoryCountResponse> 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;
}
}
}

View file

@ -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()
);
}
}

View file

@ -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
) {}

View file

@ -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<CountItemResponse> 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()
);
}
}

View file

@ -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;

View file

@ -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<RepositoryError, Optional<InventoryCount>> findById(InventoryCountId id) {
return Result.failure(STUB_ERROR);
}
@Override
public Result<RepositoryError, List<InventoryCount>> findAll() {
return Result.failure(STUB_ERROR);
}
@Override
public Result<RepositoryError, List<InventoryCount>> findByStorageLocationId(StorageLocationId storageLocationId) {
return Result.failure(STUB_ERROR);
}
@Override
public Result<RepositoryError, Boolean> existsActiveByStorageLocationId(StorageLocationId storageLocationId) {
return Result.failure(STUB_ERROR);
}
@Override
public Result<RepositoryError, Void> save(InventoryCount inventoryCount) {
return Result.failure(STUB_ERROR);
}
}

View file

@ -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<ErrorResponse> 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<ErrorResponse> handleRecipeDomainError(
RecipeController.RecipeDomainErrorException ex,

View file

@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="034-create-inventory-counts-table" author="effigenix">
<createTable tableName="inventory_counts">
<column name="id" type="VARCHAR(36)">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="storage_location_id" type="VARCHAR(36)">
<constraints nullable="false"
foreignKeyName="fk_inventory_counts_storage_location"
references="storage_locations(id)"/>
</column>
<column name="count_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="initiated_by" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="status" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="created_at" type="TIMESTAMPTZ">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex tableName="inventory_counts" indexName="idx_inventory_counts_storage_location">
<column name="storage_location_id"/>
</createIndex>
<createIndex tableName="inventory_counts" indexName="idx_inventory_counts_status">
<column name="status"/>
</createIndex>
</changeSet>
<changeSet id="034-create-count-items-table" author="effigenix">
<createTable tableName="count_items">
<column name="id" type="VARCHAR(36)">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="inventory_count_id" type="VARCHAR(36)">
<constraints nullable="false"
foreignKeyName="fk_count_items_inventory_count"
references="inventory_counts(id)"/>
</column>
<column name="article_id" type="VARCHAR(36)">
<constraints nullable="false"
foreignKeyName="fk_count_items_article"
references="articles(id)"/>
</column>
<column name="expected_quantity_amount" type="DECIMAL(12,3)">
<constraints nullable="false"/>
</column>
<column name="expected_quantity_unit" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="actual_quantity_amount" type="DECIMAL(12,3)"/>
<column name="actual_quantity_unit" type="VARCHAR(20)"/>
</createTable>
<addUniqueConstraint tableName="count_items"
columnNames="inventory_count_id, article_id"
constraintName="uq_count_items_count_article"/>
<createIndex tableName="count_items" indexName="idx_count_items_inventory_count">
<column name="inventory_count_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="035-seed-inventory-count-permissions" author="effigenix">
<preConditions onFail="MARK_RAN">
<not>
<sqlCheck expectedResult="1">
SELECT COUNT(*) FROM role_permissions
WHERE role_id = 'c0a80121-0000-0000-0000-000000000001'
AND permission = 'INVENTORY_COUNT_READ'
</sqlCheck>
</not>
</preConditions>
<comment>Add INVENTORY_COUNT_READ and INVENTORY_COUNT_WRITE permissions for ADMIN role</comment>
<sql dbms="postgresql">
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;
</sql>
<sql dbms="h2">
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');
</sql>
</changeSet>
</databaseChangeLog>

View file

@ -39,4 +39,7 @@
<include file="db/changelog/changes/032-add-batch-id-to-production-orders.xml"/>
<include file="db/changelog/changes/033-add-cancelled-reason-to-production-orders.xml"/>
<include file="db/changelog/changes/034-create-inventory-counts-schema.xml"/>
<include file="db/changelog/changes/035-seed-inventory-count-permissions.xml"/>
</databaseChangeLog>

View file

@ -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<Result<?, ?>>) 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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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();
}
}
}
}

View file

@ -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();
}
}

View file

@ -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());
}
}