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:
parent
600d0f9f06
commit
c047ca93de
31 changed files with 2745 additions and 0 deletions
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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)));
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package de.effigenix.application.inventory.command;
|
||||
|
||||
public record CreateInventoryCountCommand(
|
||||
String storageLocationId,
|
||||
String countDate,
|
||||
String initiatedBy
|
||||
) {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 + "}";
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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"; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package de.effigenix.domain.inventory;
|
||||
|
||||
public enum InventoryCountStatus {
|
||||
OPEN,
|
||||
COUNTING,
|
||||
COMPLETED,
|
||||
CANCELLED
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue