mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 15:49: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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue