mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 15:29:34 +01:00
feat(inventory): Bestandsposition anlegen (#4)
Stock-Aggregate mit MinimumLevel, MinimumShelfLife und StockDraft. Quantity/UnitOfMeasure nach shared.common verschoben für BC-übergreifende Nutzung. REST-Endpoint POST /api/inventory/stocks mit Duplikat-Prüfung, Validierung und Liquibase-Migration.
This commit is contained in:
parent
7079f12475
commit
5219c93dd1
43 changed files with 1340 additions and 18 deletions
|
|
@ -0,0 +1,59 @@
|
||||||
|
package de.effigenix.application.inventory;
|
||||||
|
|
||||||
|
import de.effigenix.application.inventory.command.CreateStockCommand;
|
||||||
|
import de.effigenix.domain.inventory.*;
|
||||||
|
import de.effigenix.shared.common.RepositoryError;
|
||||||
|
import de.effigenix.shared.common.Result;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public class CreateStock {
|
||||||
|
|
||||||
|
private final StockRepository stockRepository;
|
||||||
|
|
||||||
|
public CreateStock(StockRepository stockRepository) {
|
||||||
|
this.stockRepository = stockRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result<StockError, Stock> execute(CreateStockCommand cmd) {
|
||||||
|
// 1. Draft aus Command bauen (kein VO-Wissen im Use Case)
|
||||||
|
var draft = new StockDraft(
|
||||||
|
cmd.articleId(), cmd.storageLocationId(),
|
||||||
|
cmd.minimumLevelAmount(), cmd.minimumLevelUnit(),
|
||||||
|
cmd.minimumShelfLifeDays()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Aggregate erzeugen (validiert intern)
|
||||||
|
Stock stock;
|
||||||
|
switch (Stock.create(draft)) {
|
||||||
|
case Result.Failure(var err) -> { return Result.failure(err); }
|
||||||
|
case Result.Success(var val) -> stock = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Uniqueness-Check (Application-Concern: braucht Repository)
|
||||||
|
switch (stockRepository.existsByArticleIdAndStorageLocationId(stock.articleId(), stock.storageLocationId())) {
|
||||||
|
case Result.Failure(var err) ->
|
||||||
|
{ return Result.failure(new StockError.RepositoryFailure(err.message())); }
|
||||||
|
case Result.Success(var exists) -> {
|
||||||
|
if (exists) {
|
||||||
|
return Result.failure(new StockError.DuplicateStock(
|
||||||
|
cmd.articleId(), cmd.storageLocationId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Speichern (Race-Condition-Schutz: DuplicateEntry → DuplicateStock)
|
||||||
|
switch (stockRepository.save(stock)) {
|
||||||
|
case Result.Failure(var err) -> {
|
||||||
|
if (err instanceof RepositoryError.DuplicateEntry) {
|
||||||
|
return Result.failure(new StockError.DuplicateStock(
|
||||||
|
cmd.articleId(), cmd.storageLocationId()));
|
||||||
|
}
|
||||||
|
return Result.failure(new StockError.RepositoryFailure(err.message()));
|
||||||
|
}
|
||||||
|
case Result.Success(var ignored) -> { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.success(stock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package de.effigenix.application.inventory.command;
|
||||||
|
|
||||||
|
public record CreateStockCommand(
|
||||||
|
String articleId,
|
||||||
|
String storageLocationId,
|
||||||
|
String minimumLevelAmount,
|
||||||
|
String minimumLevelUnit,
|
||||||
|
Integer minimumShelfLifeDays
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
package de.effigenix.domain.inventory;
|
||||||
|
|
||||||
|
import de.effigenix.shared.common.Quantity;
|
||||||
|
import de.effigenix.shared.common.Result;
|
||||||
|
import de.effigenix.shared.common.UnitOfMeasure;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public record MinimumLevel(Quantity quantity) {
|
||||||
|
|
||||||
|
public static Result<StockError, MinimumLevel> of(String amount, String unit) {
|
||||||
|
if (amount == null || amount.isBlank()) {
|
||||||
|
return Result.failure(new StockError.InvalidMinimumLevel("amount must not be blank"));
|
||||||
|
}
|
||||||
|
if (unit == null || unit.isBlank()) {
|
||||||
|
return Result.failure(new StockError.InvalidMinimumLevel("unit must not be blank"));
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal parsedAmount;
|
||||||
|
try {
|
||||||
|
parsedAmount = new BigDecimal(amount);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return Result.failure(new StockError.InvalidMinimumLevel("amount is not a valid number: " + amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedAmount.compareTo(BigDecimal.ZERO) < 0) {
|
||||||
|
return Result.failure(new StockError.InvalidMinimumLevel("amount must be >= 0, was: " + amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
UnitOfMeasure parsedUnit;
|
||||||
|
try {
|
||||||
|
parsedUnit = UnitOfMeasure.valueOf(unit);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return Result.failure(new StockError.InvalidMinimumLevel("invalid unit: " + unit));
|
||||||
|
}
|
||||||
|
|
||||||
|
// MinimumLevel erlaubt amount == 0 (kein Mindestbestand)
|
||||||
|
// Quantity.of() verlangt amount > 0, daher Reconstitute verwenden
|
||||||
|
if (parsedAmount.compareTo(BigDecimal.ZERO) == 0) {
|
||||||
|
return Result.success(new MinimumLevel(Quantity.reconstitute(parsedAmount, parsedUnit, null, null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Quantity.of(parsedAmount, parsedUnit)
|
||||||
|
.map(MinimumLevel::new)
|
||||||
|
.mapError(qErr -> new StockError.InvalidMinimumLevel(qErr.message()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package de.effigenix.domain.inventory;
|
||||||
|
|
||||||
|
import de.effigenix.shared.common.Result;
|
||||||
|
|
||||||
|
public record MinimumShelfLife(int days) {
|
||||||
|
|
||||||
|
public static Result<StockError, MinimumShelfLife> of(Integer days) {
|
||||||
|
if (days == null || days <= 0) {
|
||||||
|
return Result.failure(new StockError.InvalidMinimumShelfLife(days == null ? 0 : days));
|
||||||
|
}
|
||||||
|
return Result.success(new MinimumShelfLife(days));
|
||||||
|
}
|
||||||
|
}
|
||||||
125
backend/src/main/java/de/effigenix/domain/inventory/Stock.java
Normal file
125
backend/src/main/java/de/effigenix/domain/inventory/Stock.java
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
package de.effigenix.domain.inventory;
|
||||||
|
|
||||||
|
import de.effigenix.domain.masterdata.ArticleId;
|
||||||
|
import de.effigenix.shared.common.Result;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stock aggregate root.
|
||||||
|
*
|
||||||
|
* Invariants:
|
||||||
|
* - ArticleId + StorageLocationId = unique (Application Layer, Repository-Concern)
|
||||||
|
* - MinimumLevel optional (quantity amount >= 0)
|
||||||
|
* - MinimumShelfLife optional (days > 0)
|
||||||
|
*/
|
||||||
|
public class Stock {
|
||||||
|
|
||||||
|
private final StockId id;
|
||||||
|
private final ArticleId articleId;
|
||||||
|
private final StorageLocationId storageLocationId;
|
||||||
|
private MinimumLevel minimumLevel;
|
||||||
|
private MinimumShelfLife minimumShelfLife;
|
||||||
|
|
||||||
|
private Stock(
|
||||||
|
StockId id,
|
||||||
|
ArticleId articleId,
|
||||||
|
StorageLocationId storageLocationId,
|
||||||
|
MinimumLevel minimumLevel,
|
||||||
|
MinimumShelfLife minimumShelfLife
|
||||||
|
) {
|
||||||
|
this.id = id;
|
||||||
|
this.articleId = articleId;
|
||||||
|
this.storageLocationId = storageLocationId;
|
||||||
|
this.minimumLevel = minimumLevel;
|
||||||
|
this.minimumShelfLife = minimumShelfLife;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory: Erzeugt ein neues Stock-Aggregat aus rohen Eingaben.
|
||||||
|
* Orchestriert Validierung aller VOs intern.
|
||||||
|
*/
|
||||||
|
public static Result<StockError, Stock> create(StockDraft draft) {
|
||||||
|
// 1. ArticleId validieren (Pflicht)
|
||||||
|
if (draft.articleId() == null || draft.articleId().isBlank()) {
|
||||||
|
return Result.failure(new StockError.InvalidArticleId("must not be blank"));
|
||||||
|
}
|
||||||
|
ArticleId articleId;
|
||||||
|
try {
|
||||||
|
articleId = ArticleId.of(draft.articleId());
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return Result.failure(new StockError.InvalidArticleId(e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. StorageLocationId validieren (Pflicht)
|
||||||
|
if (draft.storageLocationId() == null || draft.storageLocationId().isBlank()) {
|
||||||
|
return Result.failure(new StockError.InvalidStorageLocationId("must not be blank"));
|
||||||
|
}
|
||||||
|
StorageLocationId storageLocationId;
|
||||||
|
try {
|
||||||
|
storageLocationId = StorageLocationId.of(draft.storageLocationId());
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return Result.failure(new StockError.InvalidStorageLocationId(e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. MinimumLevel optional
|
||||||
|
MinimumLevel minimumLevel = null;
|
||||||
|
if (draft.minimumLevelAmount() != null || draft.minimumLevelUnit() != null) {
|
||||||
|
switch (MinimumLevel.of(draft.minimumLevelAmount(), draft.minimumLevelUnit())) {
|
||||||
|
case Result.Failure(var err) -> { return Result.failure(err); }
|
||||||
|
case Result.Success(var val) -> minimumLevel = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. MinimumShelfLife optional
|
||||||
|
MinimumShelfLife minimumShelfLife = null;
|
||||||
|
if (draft.minimumShelfLifeDays() != null) {
|
||||||
|
switch (MinimumShelfLife.of(draft.minimumShelfLifeDays())) {
|
||||||
|
case Result.Failure(var err) -> { return Result.failure(err); }
|
||||||
|
case Result.Success(var val) -> minimumShelfLife = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.success(new Stock(
|
||||||
|
StockId.generate(), articleId, storageLocationId, minimumLevel, minimumShelfLife
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstitute from persistence without re-validation.
|
||||||
|
*/
|
||||||
|
public static Stock reconstitute(
|
||||||
|
StockId id,
|
||||||
|
ArticleId articleId,
|
||||||
|
StorageLocationId storageLocationId,
|
||||||
|
MinimumLevel minimumLevel,
|
||||||
|
MinimumShelfLife minimumShelfLife
|
||||||
|
) {
|
||||||
|
return new Stock(id, articleId, storageLocationId, minimumLevel, minimumShelfLife);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Getters ====================
|
||||||
|
|
||||||
|
public StockId id() { return id; }
|
||||||
|
public ArticleId articleId() { return articleId; }
|
||||||
|
public StorageLocationId storageLocationId() { return storageLocationId; }
|
||||||
|
public MinimumLevel minimumLevel() { return minimumLevel; }
|
||||||
|
public MinimumShelfLife minimumShelfLife() { return minimumShelfLife; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object obj) {
|
||||||
|
if (this == obj) return true;
|
||||||
|
if (!(obj instanceof Stock other)) return false;
|
||||||
|
return Objects.equals(id, other.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "Stock{id=" + id + ", articleId=" + articleId + ", storageLocationId=" + storageLocationId + "}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package de.effigenix.domain.inventory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rohe Eingabe zum Erzeugen eines Stock-Aggregates.
|
||||||
|
* Wird vom Application Layer aus dem Command gebaut und an Stock.create() übergeben.
|
||||||
|
* Das Aggregate ist verantwortlich für Validierung und VO-Konstruktion.
|
||||||
|
*
|
||||||
|
* @param articleId Pflicht
|
||||||
|
* @param storageLocationId Pflicht
|
||||||
|
* @param minimumLevelAmount Optional – BigDecimal als String, nullable
|
||||||
|
* @param minimumLevelUnit Optional – UnitOfMeasure als String, nullable
|
||||||
|
* @param minimumShelfLifeDays Optional – Integer, nullable
|
||||||
|
*/
|
||||||
|
public record StockDraft(
|
||||||
|
String articleId,
|
||||||
|
String storageLocationId,
|
||||||
|
String minimumLevelAmount,
|
||||||
|
String minimumLevelUnit,
|
||||||
|
Integer minimumShelfLifeDays
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
package de.effigenix.domain.inventory;
|
||||||
|
|
||||||
|
public sealed interface StockError {
|
||||||
|
|
||||||
|
String code();
|
||||||
|
String message();
|
||||||
|
|
||||||
|
record DuplicateStock(String articleId, String storageLocationId) implements StockError {
|
||||||
|
@Override public String code() { return "DUPLICATE_STOCK"; }
|
||||||
|
@Override public String message() { return "Stock already exists for article " + articleId + " at location " + storageLocationId; }
|
||||||
|
}
|
||||||
|
|
||||||
|
record InvalidMinimumLevel(String reason) implements StockError {
|
||||||
|
@Override public String code() { return "INVALID_MINIMUM_LEVEL"; }
|
||||||
|
@Override public String message() { return "Invalid minimum level: " + reason; }
|
||||||
|
}
|
||||||
|
|
||||||
|
record InvalidMinimumShelfLife(int days) implements StockError {
|
||||||
|
@Override public String code() { return "INVALID_MINIMUM_SHELF_LIFE"; }
|
||||||
|
@Override public String message() { return "Minimum shelf life must be > 0, was: " + days; }
|
||||||
|
}
|
||||||
|
|
||||||
|
record StockNotFound(String id) implements StockError {
|
||||||
|
@Override public String code() { return "STOCK_NOT_FOUND"; }
|
||||||
|
@Override public String message() { return "Stock not found: " + id; }
|
||||||
|
}
|
||||||
|
|
||||||
|
record InvalidArticleId(String reason) implements StockError {
|
||||||
|
@Override public String code() { return "INVALID_ARTICLE_ID"; }
|
||||||
|
@Override public String message() { return "Invalid article ID: " + reason; }
|
||||||
|
}
|
||||||
|
|
||||||
|
record InvalidStorageLocationId(String reason) implements StockError {
|
||||||
|
@Override public String code() { return "INVALID_STORAGE_LOCATION_ID"; }
|
||||||
|
@Override public String message() { return "Invalid storage location ID: " + reason; }
|
||||||
|
}
|
||||||
|
|
||||||
|
record Unauthorized(String message) implements StockError {
|
||||||
|
@Override public String code() { return "UNAUTHORIZED"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
record RepositoryFailure(String message) implements StockError {
|
||||||
|
@Override public String code() { return "REPOSITORY_ERROR"; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package de.effigenix.domain.inventory;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record StockId(String value) {
|
||||||
|
|
||||||
|
public StockId {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("StockId must not be blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StockId generate() {
|
||||||
|
return new StockId(UUID.randomUUID().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StockId of(String value) {
|
||||||
|
return new StockId(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package de.effigenix.domain.inventory;
|
||||||
|
|
||||||
|
import de.effigenix.domain.masterdata.ArticleId;
|
||||||
|
import de.effigenix.shared.common.RepositoryError;
|
||||||
|
import de.effigenix.shared.common.Result;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface StockRepository {
|
||||||
|
|
||||||
|
Result<RepositoryError, Optional<Stock>> findById(StockId id);
|
||||||
|
|
||||||
|
Result<RepositoryError, Optional<Stock>> findByArticleIdAndStorageLocationId(ArticleId articleId, StorageLocationId storageLocationId);
|
||||||
|
|
||||||
|
Result<RepositoryError, Boolean> existsByArticleIdAndStorageLocationId(ArticleId articleId, StorageLocationId storageLocationId);
|
||||||
|
|
||||||
|
Result<RepositoryError, Void> save(Stock stock);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package de.effigenix.domain.production;
|
package de.effigenix.domain.production;
|
||||||
|
|
||||||
|
import de.effigenix.shared.common.QuantityError;
|
||||||
import de.effigenix.shared.common.Result;
|
import de.effigenix.shared.common.Result;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package de.effigenix.domain.production;
|
package de.effigenix.domain.production;
|
||||||
|
|
||||||
|
import de.effigenix.shared.common.Quantity;
|
||||||
import de.effigenix.shared.common.Result;
|
import de.effigenix.shared.common.Result;
|
||||||
|
import de.effigenix.shared.common.UnitOfMeasure;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package de.effigenix.domain.production;
|
package de.effigenix.domain.production;
|
||||||
|
|
||||||
|
import de.effigenix.shared.common.Quantity;
|
||||||
import de.effigenix.shared.common.Result;
|
import de.effigenix.shared.common.Result;
|
||||||
|
import de.effigenix.shared.common.UnitOfMeasure;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package de.effigenix.domain.production;
|
package de.effigenix.domain.production;
|
||||||
|
|
||||||
|
import de.effigenix.shared.common.Quantity;
|
||||||
|
import de.effigenix.shared.common.QuantityError;
|
||||||
import de.effigenix.shared.common.Result;
|
import de.effigenix.shared.common.Result;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
package de.effigenix.infrastructure.config;
|
package de.effigenix.infrastructure.config;
|
||||||
|
|
||||||
import de.effigenix.application.inventory.ActivateStorageLocation;
|
import de.effigenix.application.inventory.ActivateStorageLocation;
|
||||||
|
import de.effigenix.application.inventory.CreateStock;
|
||||||
import de.effigenix.application.inventory.CreateStorageLocation;
|
import de.effigenix.application.inventory.CreateStorageLocation;
|
||||||
import de.effigenix.application.inventory.DeactivateStorageLocation;
|
import de.effigenix.application.inventory.DeactivateStorageLocation;
|
||||||
import de.effigenix.application.inventory.ListStorageLocations;
|
import de.effigenix.application.inventory.ListStorageLocations;
|
||||||
import de.effigenix.application.inventory.UpdateStorageLocation;
|
import de.effigenix.application.inventory.UpdateStorageLocation;
|
||||||
|
import de.effigenix.domain.inventory.StockRepository;
|
||||||
import de.effigenix.domain.inventory.StorageLocationRepository;
|
import de.effigenix.domain.inventory.StorageLocationRepository;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
@ -38,4 +40,11 @@ public class InventoryUseCaseConfiguration {
|
||||||
public ListStorageLocations listStorageLocations(StorageLocationRepository storageLocationRepository) {
|
public ListStorageLocations listStorageLocations(StorageLocationRepository storageLocationRepository) {
|
||||||
return new ListStorageLocations(storageLocationRepository);
|
return new ListStorageLocations(storageLocationRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Stock Use Cases ====================
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CreateStock createStock(StockRepository stockRepository) {
|
||||||
|
return new CreateStock(stockRepository);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
package de.effigenix.infrastructure.inventory.persistence.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "stocks")
|
||||||
|
public class StockEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "id", nullable = false, length = 36)
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
@Column(name = "article_id", nullable = false, length = 36)
|
||||||
|
private String articleId;
|
||||||
|
|
||||||
|
@Column(name = "storage_location_id", nullable = false, length = 36)
|
||||||
|
private String storageLocationId;
|
||||||
|
|
||||||
|
@Column(name = "minimum_level_amount", precision = 12, scale = 3)
|
||||||
|
private BigDecimal minimumLevelAmount;
|
||||||
|
|
||||||
|
@Column(name = "minimum_level_unit", length = 20)
|
||||||
|
private String minimumLevelUnit;
|
||||||
|
|
||||||
|
@Column(name = "minimum_shelf_life_days")
|
||||||
|
private Integer minimumShelfLifeDays;
|
||||||
|
|
||||||
|
public StockEntity() {}
|
||||||
|
|
||||||
|
// ==================== Getters & Setters ====================
|
||||||
|
|
||||||
|
public String getId() { return id; }
|
||||||
|
public void setId(String id) { this.id = id; }
|
||||||
|
|
||||||
|
public String getArticleId() { return articleId; }
|
||||||
|
public void setArticleId(String articleId) { this.articleId = articleId; }
|
||||||
|
|
||||||
|
public String getStorageLocationId() { return storageLocationId; }
|
||||||
|
public void setStorageLocationId(String storageLocationId) { this.storageLocationId = storageLocationId; }
|
||||||
|
|
||||||
|
public BigDecimal getMinimumLevelAmount() { return minimumLevelAmount; }
|
||||||
|
public void setMinimumLevelAmount(BigDecimal minimumLevelAmount) { this.minimumLevelAmount = minimumLevelAmount; }
|
||||||
|
|
||||||
|
public String getMinimumLevelUnit() { return minimumLevelUnit; }
|
||||||
|
public void setMinimumLevelUnit(String minimumLevelUnit) { this.minimumLevelUnit = minimumLevelUnit; }
|
||||||
|
|
||||||
|
public Integer getMinimumShelfLifeDays() { return minimumShelfLifeDays; }
|
||||||
|
public void setMinimumShelfLifeDays(Integer minimumShelfLifeDays) { this.minimumShelfLifeDays = minimumShelfLifeDays; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
package de.effigenix.infrastructure.inventory.persistence.mapper;
|
||||||
|
|
||||||
|
import de.effigenix.domain.inventory.*;
|
||||||
|
import de.effigenix.domain.masterdata.ArticleId;
|
||||||
|
import de.effigenix.shared.common.Quantity;
|
||||||
|
import de.effigenix.shared.common.UnitOfMeasure;
|
||||||
|
import de.effigenix.infrastructure.inventory.persistence.entity.StockEntity;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class StockMapper {
|
||||||
|
|
||||||
|
public StockEntity toEntity(Stock stock) {
|
||||||
|
var entity = new StockEntity();
|
||||||
|
entity.setId(stock.id().value());
|
||||||
|
entity.setArticleId(stock.articleId().value());
|
||||||
|
entity.setStorageLocationId(stock.storageLocationId().value());
|
||||||
|
|
||||||
|
if (stock.minimumLevel() != null) {
|
||||||
|
entity.setMinimumLevelAmount(stock.minimumLevel().quantity().amount());
|
||||||
|
entity.setMinimumLevelUnit(stock.minimumLevel().quantity().uom().name());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stock.minimumShelfLife() != null) {
|
||||||
|
entity.setMinimumShelfLifeDays(stock.minimumShelfLife().days());
|
||||||
|
}
|
||||||
|
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Stock toDomain(StockEntity entity) {
|
||||||
|
MinimumLevel minimumLevel = null;
|
||||||
|
if (entity.getMinimumLevelAmount() != null && entity.getMinimumLevelUnit() != null) {
|
||||||
|
var quantity = Quantity.reconstitute(
|
||||||
|
entity.getMinimumLevelAmount(),
|
||||||
|
UnitOfMeasure.valueOf(entity.getMinimumLevelUnit()),
|
||||||
|
null, null
|
||||||
|
);
|
||||||
|
minimumLevel = new MinimumLevel(quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
MinimumShelfLife minimumShelfLife = null;
|
||||||
|
if (entity.getMinimumShelfLifeDays() != null) {
|
||||||
|
minimumShelfLife = new MinimumShelfLife(entity.getMinimumShelfLifeDays());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Stock.reconstitute(
|
||||||
|
StockId.of(entity.getId()),
|
||||||
|
ArticleId.of(entity.getArticleId()),
|
||||||
|
StorageLocationId.of(entity.getStorageLocationId()),
|
||||||
|
minimumLevel,
|
||||||
|
minimumShelfLife
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
package de.effigenix.infrastructure.inventory.persistence.repository;
|
||||||
|
|
||||||
|
import de.effigenix.domain.inventory.*;
|
||||||
|
import de.effigenix.domain.masterdata.ArticleId;
|
||||||
|
import de.effigenix.infrastructure.inventory.persistence.mapper.StockMapper;
|
||||||
|
import de.effigenix.shared.common.RepositoryError;
|
||||||
|
import de.effigenix.shared.common.Result;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
@Profile("!no-db")
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class JpaStockRepository implements StockRepository {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(JpaStockRepository.class);
|
||||||
|
|
||||||
|
private final StockJpaRepository jpaRepository;
|
||||||
|
private final StockMapper mapper;
|
||||||
|
|
||||||
|
public JpaStockRepository(StockJpaRepository jpaRepository, StockMapper mapper) {
|
||||||
|
this.jpaRepository = jpaRepository;
|
||||||
|
this.mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Result<RepositoryError, Optional<Stock>> findById(StockId id) {
|
||||||
|
try {
|
||||||
|
Optional<Stock> result = jpaRepository.findById(id.value())
|
||||||
|
.map(mapper::toDomain);
|
||||||
|
return Result.success(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.trace("Database error in findById", e);
|
||||||
|
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Result<RepositoryError, Optional<Stock>> findByArticleIdAndStorageLocationId(ArticleId articleId, StorageLocationId storageLocationId) {
|
||||||
|
try {
|
||||||
|
Optional<Stock> result = jpaRepository.findByArticleIdAndStorageLocationId(articleId.value(), storageLocationId.value())
|
||||||
|
.map(mapper::toDomain);
|
||||||
|
return Result.success(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.trace("Database error in findByArticleIdAndStorageLocationId", e);
|
||||||
|
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Result<RepositoryError, Boolean> existsByArticleIdAndStorageLocationId(ArticleId articleId, StorageLocationId storageLocationId) {
|
||||||
|
try {
|
||||||
|
return Result.success(jpaRepository.existsByArticleIdAndStorageLocationId(articleId.value(), storageLocationId.value()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.trace("Database error in existsByArticleIdAndStorageLocationId", e);
|
||||||
|
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public Result<RepositoryError, Void> save(Stock stock) {
|
||||||
|
try {
|
||||||
|
jpaRepository.save(mapper.toEntity(stock));
|
||||||
|
return Result.success(null);
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
logger.trace("Duplicate entry in save", e);
|
||||||
|
return Result.failure(new RepositoryError.DuplicateEntry(
|
||||||
|
"Stock already exists for article " + stock.articleId().value()
|
||||||
|
+ " at location " + stock.storageLocationId().value()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.trace("Database error in save", e);
|
||||||
|
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package de.effigenix.infrastructure.inventory.persistence.repository;
|
||||||
|
|
||||||
|
import de.effigenix.infrastructure.inventory.persistence.entity.StockEntity;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface StockJpaRepository extends JpaRepository<StockEntity, String> {
|
||||||
|
|
||||||
|
Optional<StockEntity> findByArticleIdAndStorageLocationId(String articleId, String storageLocationId);
|
||||||
|
|
||||||
|
boolean existsByArticleIdAndStorageLocationId(String articleId, String storageLocationId);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
package de.effigenix.infrastructure.inventory.web.controller;
|
||||||
|
|
||||||
|
import de.effigenix.application.inventory.CreateStock;
|
||||||
|
import de.effigenix.application.inventory.command.CreateStockCommand;
|
||||||
|
import de.effigenix.domain.inventory.StockError;
|
||||||
|
import de.effigenix.infrastructure.inventory.web.dto.CreateStockRequest;
|
||||||
|
import de.effigenix.infrastructure.inventory.web.dto.StockResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
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.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/inventory/stocks")
|
||||||
|
@SecurityRequirement(name = "Bearer Authentication")
|
||||||
|
@Tag(name = "Stocks", description = "Stock management endpoints")
|
||||||
|
public class StockController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(StockController.class);
|
||||||
|
|
||||||
|
private final CreateStock createStock;
|
||||||
|
|
||||||
|
public StockController(CreateStock createStock) {
|
||||||
|
this.createStock = createStock;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@PreAuthorize("hasAuthority('STOCK_WRITE')")
|
||||||
|
public ResponseEntity<StockResponse> createStock(
|
||||||
|
@Valid @RequestBody CreateStockRequest request,
|
||||||
|
Authentication authentication
|
||||||
|
) {
|
||||||
|
logger.info("Creating stock for article: {} at location: {} by actor: {}",
|
||||||
|
request.articleId(), request.storageLocationId(), authentication.getName());
|
||||||
|
|
||||||
|
var cmd = new CreateStockCommand(
|
||||||
|
request.articleId(), request.storageLocationId(),
|
||||||
|
request.minimumLevelAmount(), request.minimumLevelUnit(),
|
||||||
|
request.minimumShelfLifeDays()
|
||||||
|
);
|
||||||
|
var result = createStock.execute(cmd);
|
||||||
|
|
||||||
|
if (result.isFailure()) {
|
||||||
|
throw new StockDomainErrorException(result.unsafeGetError());
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Stock created: {}", result.unsafeGetValue().id().value());
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED)
|
||||||
|
.body(StockResponse.from(result.unsafeGetValue()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class StockDomainErrorException extends RuntimeException {
|
||||||
|
private final StockError error;
|
||||||
|
|
||||||
|
public StockDomainErrorException(StockError error) {
|
||||||
|
super(error.message());
|
||||||
|
this.error = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StockError getError() {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package de.effigenix.infrastructure.inventory.web.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record CreateStockRequest(
|
||||||
|
@NotBlank String articleId,
|
||||||
|
@NotBlank String storageLocationId,
|
||||||
|
String minimumLevelAmount,
|
||||||
|
String minimumLevelUnit,
|
||||||
|
Integer minimumShelfLifeDays
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
package de.effigenix.infrastructure.inventory.web.dto;
|
||||||
|
|
||||||
|
import de.effigenix.domain.inventory.Stock;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
@Schema(requiredProperties = {"id", "articleId", "storageLocationId"})
|
||||||
|
public record StockResponse(
|
||||||
|
String id,
|
||||||
|
String articleId,
|
||||||
|
String storageLocationId,
|
||||||
|
@Schema(nullable = true) MinimumLevelResponse minimumLevel,
|
||||||
|
@Schema(nullable = true) Integer minimumShelfLifeDays
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static StockResponse from(Stock stock) {
|
||||||
|
MinimumLevelResponse minimumLevel = null;
|
||||||
|
if (stock.minimumLevel() != null) {
|
||||||
|
minimumLevel = new MinimumLevelResponse(
|
||||||
|
stock.minimumLevel().quantity().amount(),
|
||||||
|
stock.minimumLevel().quantity().uom().name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Integer shelfLifeDays = null;
|
||||||
|
if (stock.minimumShelfLife() != null) {
|
||||||
|
shelfLifeDays = stock.minimumShelfLife().days();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StockResponse(
|
||||||
|
stock.id().value(),
|
||||||
|
stock.articleId().value(),
|
||||||
|
stock.storageLocationId().value(),
|
||||||
|
minimumLevel,
|
||||||
|
shelfLifeDays
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Schema(requiredProperties = {"amount", "unit"})
|
||||||
|
public record MinimumLevelResponse(BigDecimal amount, String unit) {}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package de.effigenix.infrastructure.inventory.web.exception;
|
package de.effigenix.infrastructure.inventory.web.exception;
|
||||||
|
|
||||||
import de.effigenix.domain.inventory.StorageLocationError;
|
import de.effigenix.domain.inventory.StorageLocationError;
|
||||||
|
import de.effigenix.domain.inventory.StockError;
|
||||||
|
|
||||||
public final class InventoryErrorHttpStatusMapper {
|
public final class InventoryErrorHttpStatusMapper {
|
||||||
|
|
||||||
|
|
@ -20,4 +21,17 @@ public final class InventoryErrorHttpStatusMapper {
|
||||||
case StorageLocationError.RepositoryFailure e -> 500;
|
case StorageLocationError.RepositoryFailure e -> 500;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static int toHttpStatus(StockError error) {
|
||||||
|
return switch (error) {
|
||||||
|
case StockError.StockNotFound e -> 404;
|
||||||
|
case StockError.DuplicateStock e -> 409;
|
||||||
|
case StockError.InvalidMinimumLevel e -> 400;
|
||||||
|
case StockError.InvalidMinimumShelfLife e -> 400;
|
||||||
|
case StockError.InvalidArticleId e -> 400;
|
||||||
|
case StockError.InvalidStorageLocationId e -> 400;
|
||||||
|
case StockError.Unauthorized e -> 403;
|
||||||
|
case StockError.RepositoryFailure e -> 500;
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import de.effigenix.domain.production.*;
|
||||||
import de.effigenix.infrastructure.production.persistence.entity.IngredientEntity;
|
import de.effigenix.infrastructure.production.persistence.entity.IngredientEntity;
|
||||||
import de.effigenix.infrastructure.production.persistence.entity.ProductionStepEntity;
|
import de.effigenix.infrastructure.production.persistence.entity.ProductionStepEntity;
|
||||||
import de.effigenix.infrastructure.production.persistence.entity.RecipeEntity;
|
import de.effigenix.infrastructure.production.persistence.entity.RecipeEntity;
|
||||||
|
import de.effigenix.shared.common.Quantity;
|
||||||
|
import de.effigenix.shared.common.UnitOfMeasure;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package de.effigenix.infrastructure.usermanagement.web.exception;
|
package de.effigenix.infrastructure.usermanagement.web.exception;
|
||||||
|
|
||||||
import de.effigenix.domain.inventory.StorageLocationError;
|
import de.effigenix.domain.inventory.StorageLocationError;
|
||||||
|
import de.effigenix.domain.inventory.StockError;
|
||||||
import de.effigenix.domain.masterdata.ArticleError;
|
import de.effigenix.domain.masterdata.ArticleError;
|
||||||
import de.effigenix.domain.masterdata.ProductCategoryError;
|
import de.effigenix.domain.masterdata.ProductCategoryError;
|
||||||
import de.effigenix.domain.masterdata.CustomerError;
|
import de.effigenix.domain.masterdata.CustomerError;
|
||||||
|
|
@ -8,6 +9,7 @@ import de.effigenix.domain.masterdata.SupplierError;
|
||||||
import de.effigenix.domain.production.RecipeError;
|
import de.effigenix.domain.production.RecipeError;
|
||||||
import de.effigenix.domain.usermanagement.UserError;
|
import de.effigenix.domain.usermanagement.UserError;
|
||||||
import de.effigenix.infrastructure.inventory.web.controller.StorageLocationController;
|
import de.effigenix.infrastructure.inventory.web.controller.StorageLocationController;
|
||||||
|
import de.effigenix.infrastructure.inventory.web.controller.StockController;
|
||||||
import de.effigenix.infrastructure.inventory.web.exception.InventoryErrorHttpStatusMapper;
|
import de.effigenix.infrastructure.inventory.web.exception.InventoryErrorHttpStatusMapper;
|
||||||
import de.effigenix.infrastructure.masterdata.web.controller.ArticleController;
|
import de.effigenix.infrastructure.masterdata.web.controller.ArticleController;
|
||||||
import de.effigenix.infrastructure.masterdata.web.controller.CustomerController;
|
import de.effigenix.infrastructure.masterdata.web.controller.CustomerController;
|
||||||
|
|
@ -204,6 +206,29 @@ public class GlobalExceptionHandler {
|
||||||
return ResponseEntity.status(status).body(errorResponse);
|
return ResponseEntity.status(status).body(errorResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(StockController.StockDomainErrorException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleStockDomainError(
|
||||||
|
StockController.StockDomainErrorException ex,
|
||||||
|
HttpServletRequest request
|
||||||
|
) {
|
||||||
|
StockError error = ex.getError();
|
||||||
|
int status = InventoryErrorHttpStatusMapper.toHttpStatus(error);
|
||||||
|
logDomainError("Stock", 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)
|
@ExceptionHandler(RecipeController.RecipeDomainErrorException.class)
|
||||||
public ResponseEntity<ErrorResponse> handleRecipeDomainError(
|
public ResponseEntity<ErrorResponse> handleRecipeDomainError(
|
||||||
RecipeController.RecipeDomainErrorException ex,
|
RecipeController.RecipeDomainErrorException ex,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
package de.effigenix.domain.production;
|
package de.effigenix.shared.common;
|
||||||
|
|
||||||
import de.effigenix.shared.common.Result;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Value Object representing a production quantity with optional Catch-Weight (dual quantity).
|
* Value Object representing a quantity with optional Catch-Weight (dual quantity).
|
||||||
*
|
*
|
||||||
* <p>Primary quantity is always required (amount + unit of measure).
|
* <p>Primary quantity is always required (amount + unit of measure).
|
||||||
* Secondary quantity (catch-weight) is optional and used when goods are counted
|
* Secondary quantity (catch-weight) is optional and used when goods are counted
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package de.effigenix.domain.production;
|
package de.effigenix.shared.common;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Domain errors for Quantity-related Value Objects.
|
* Domain errors for Quantity-related Value Objects.
|
||||||
|
|
@ -16,6 +16,9 @@ public sealed interface RepositoryError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
record DuplicateEntry(String message) implements RepositoryError {
|
||||||
|
}
|
||||||
|
|
||||||
record DatabaseError(String message) implements RepositoryError {
|
record DatabaseError(String message) implements RepositoryError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package de.effigenix.domain.production;
|
package de.effigenix.shared.common;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit of Measure for production quantities.
|
* Unit of Measure for quantities.
|
||||||
* Supports mass, volume, length, and piece-based units.
|
* Supports mass, volume, length, and piece-based units.
|
||||||
*/
|
*/
|
||||||
public enum UnitOfMeasure {
|
public enum UnitOfMeasure {
|
||||||
|
|
@ -6,10 +6,8 @@ springdoc:
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
root: WARN
|
root: INFO
|
||||||
de.effigenix: INFO
|
de.effigenix: INFO
|
||||||
org.springframework.security: WARN
|
|
||||||
org.hibernate.SQL: WARN
|
|
||||||
|
|
||||||
server:
|
server:
|
||||||
error:
|
error:
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,6 @@ logging:
|
||||||
level:
|
level:
|
||||||
root: INFO
|
root: INFO
|
||||||
de.effigenix: DEBUG
|
de.effigenix: DEBUG
|
||||||
org.springframework.security: DEBUG
|
|
||||||
org.hibernate.SQL: DEBUG
|
|
||||||
|
|
||||||
# CORS Configuration
|
# CORS Configuration
|
||||||
effigenix:
|
effigenix:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?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="012-create-stocks-table" author="effigenix">
|
||||||
|
<createTable tableName="stocks">
|
||||||
|
<column name="id" type="VARCHAR(36)">
|
||||||
|
<constraints primaryKey="true" nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="article_id" type="VARCHAR(36)">
|
||||||
|
<constraints nullable="false"
|
||||||
|
foreignKeyName="fk_stocks_article"
|
||||||
|
references="articles(id)"/>
|
||||||
|
</column>
|
||||||
|
<column name="storage_location_id" type="VARCHAR(36)">
|
||||||
|
<constraints nullable="false"
|
||||||
|
foreignKeyName="fk_stocks_storage_location"
|
||||||
|
references="storage_locations(id)"/>
|
||||||
|
</column>
|
||||||
|
<column name="minimum_level_amount" type="DECIMAL(12,3)"/>
|
||||||
|
<column name="minimum_level_unit" type="VARCHAR(20)"/>
|
||||||
|
<column name="minimum_shelf_life_days" type="INTEGER"/>
|
||||||
|
</createTable>
|
||||||
|
<addUniqueConstraint tableName="stocks"
|
||||||
|
columnNames="article_id, storage_location_id"
|
||||||
|
constraintName="uq_stocks_article_location"/>
|
||||||
|
<createIndex tableName="stocks" indexName="idx_stocks_article_id">
|
||||||
|
<column name="article_id"/>
|
||||||
|
</createIndex>
|
||||||
|
<createIndex tableName="stocks" indexName="idx_stocks_storage_location_id">
|
||||||
|
<column name="storage_location_id"/>
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
|
|
@ -17,5 +17,6 @@
|
||||||
<include file="db/changelog/changes/010-create-recipe-schema.xml"/>
|
<include file="db/changelog/changes/010-create-recipe-schema.xml"/>
|
||||||
<include file="db/changelog/changes/011-create-recipe-ingredients-table.xml"/>
|
<include file="db/changelog/changes/011-create-recipe-ingredients-table.xml"/>
|
||||||
<include file="db/changelog/changes/012-create-recipe-production-steps-table.xml"/>
|
<include file="db/changelog/changes/012-create-recipe-production-steps-table.xml"/>
|
||||||
|
<include file="db/changelog/changes/013-create-stock-schema.xml"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,262 @@
|
||||||
|
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 static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class StockTest {
|
||||||
|
|
||||||
|
// ==================== Create ====================
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("create()")
|
||||||
|
class Create {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should create Stock with all fields")
|
||||||
|
void shouldCreateWithAllFields() {
|
||||||
|
var draft = new StockDraft(
|
||||||
|
"article-1", "location-1", "10.5", "KILOGRAM", 30);
|
||||||
|
|
||||||
|
var result = Stock.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
var stock = result.unsafeGetValue();
|
||||||
|
assertThat(stock.id()).isNotNull();
|
||||||
|
assertThat(stock.articleId().value()).isEqualTo("article-1");
|
||||||
|
assertThat(stock.storageLocationId().value()).isEqualTo("location-1");
|
||||||
|
assertThat(stock.minimumLevel()).isNotNull();
|
||||||
|
assertThat(stock.minimumLevel().quantity().amount()).isEqualByComparingTo(new BigDecimal("10.5"));
|
||||||
|
assertThat(stock.minimumLevel().quantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
|
||||||
|
assertThat(stock.minimumShelfLife()).isNotNull();
|
||||||
|
assertThat(stock.minimumShelfLife().days()).isEqualTo(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should create Stock with only required fields")
|
||||||
|
void shouldCreateWithOnlyRequiredFields() {
|
||||||
|
var draft = new StockDraft("article-1", "location-1", null, null, null);
|
||||||
|
|
||||||
|
var result = Stock.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
var stock = result.unsafeGetValue();
|
||||||
|
assertThat(stock.articleId().value()).isEqualTo("article-1");
|
||||||
|
assertThat(stock.storageLocationId().value()).isEqualTo("location-1");
|
||||||
|
assertThat(stock.minimumLevel()).isNull();
|
||||||
|
assertThat(stock.minimumShelfLife()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail when articleId is null")
|
||||||
|
void shouldFailWhenArticleIdNull() {
|
||||||
|
var draft = new StockDraft(null, "location-1", null, null, null);
|
||||||
|
|
||||||
|
var result = Stock.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidArticleId.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail when articleId is blank")
|
||||||
|
void shouldFailWhenArticleIdBlank() {
|
||||||
|
var draft = new StockDraft("", "location-1", null, null, null);
|
||||||
|
|
||||||
|
var result = Stock.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidArticleId.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail when storageLocationId is null")
|
||||||
|
void shouldFailWhenStorageLocationIdNull() {
|
||||||
|
var draft = new StockDraft("article-1", null, null, null, null);
|
||||||
|
|
||||||
|
var result = Stock.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidStorageLocationId.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail when storageLocationId is blank")
|
||||||
|
void shouldFailWhenStorageLocationIdBlank() {
|
||||||
|
var draft = new StockDraft("article-1", "", null, null, null);
|
||||||
|
|
||||||
|
var result = Stock.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidStorageLocationId.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail when minimumLevel amount is negative")
|
||||||
|
void shouldFailWhenMinimumLevelNegative() {
|
||||||
|
var draft = new StockDraft("article-1", "location-1", "-1", "KILOGRAM", null);
|
||||||
|
|
||||||
|
var result = Stock.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumLevel.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail when minimumLevel amount is not a number")
|
||||||
|
void shouldFailWhenMinimumLevelNotNumber() {
|
||||||
|
var draft = new StockDraft("article-1", "location-1", "abc", "KILOGRAM", null);
|
||||||
|
|
||||||
|
var result = Stock.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumLevel.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail when minimumLevel unit is invalid")
|
||||||
|
void shouldFailWhenMinimumLevelUnitInvalid() {
|
||||||
|
var draft = new StockDraft("article-1", "location-1", "10", "INVALID_UNIT", null);
|
||||||
|
|
||||||
|
var result = Stock.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumLevel.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should accept minimumLevel amount of zero")
|
||||||
|
void shouldAcceptMinimumLevelZero() {
|
||||||
|
var draft = new StockDraft("article-1", "location-1", "0", "KILOGRAM", null);
|
||||||
|
|
||||||
|
var result = Stock.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
assertThat(result.unsafeGetValue().minimumLevel().quantity().amount())
|
||||||
|
.isEqualByComparingTo(BigDecimal.ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail when minimumShelfLife is zero")
|
||||||
|
void shouldFailWhenMinimumShelfLifeZero() {
|
||||||
|
var draft = new StockDraft("article-1", "location-1", null, null, 0);
|
||||||
|
|
||||||
|
var result = Stock.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumShelfLife.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail when minimumShelfLife is negative")
|
||||||
|
void shouldFailWhenMinimumShelfLifeNegative() {
|
||||||
|
var draft = new StockDraft("article-1", "location-1", null, null, -5);
|
||||||
|
|
||||||
|
var result = Stock.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumShelfLife.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should accept minimumShelfLife of 1")
|
||||||
|
void shouldAcceptMinimumShelfLifeOne() {
|
||||||
|
var draft = new StockDraft("article-1", "location-1", null, null, 1);
|
||||||
|
|
||||||
|
var result = Stock.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
assertThat(result.unsafeGetValue().minimumShelfLife().days()).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should create with all UnitOfMeasure values")
|
||||||
|
void shouldCreateWithAllUnits() {
|
||||||
|
for (UnitOfMeasure unit : UnitOfMeasure.values()) {
|
||||||
|
var draft = new StockDraft("article-1", "location-1", "5", unit.name(), null);
|
||||||
|
var result = Stock.create(draft);
|
||||||
|
assertThat(result.isSuccess()).as("UnitOfMeasure %s should be valid", unit).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Reconstitute ====================
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("reconstitute()")
|
||||||
|
class Reconstitute {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should reconstitute Stock from persistence")
|
||||||
|
void shouldReconstitute() {
|
||||||
|
var id = StockId.generate();
|
||||||
|
var articleId = ArticleId.of("article-1");
|
||||||
|
var locationId = StorageLocationId.of("location-1");
|
||||||
|
var quantity = Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM, null, null);
|
||||||
|
var minimumLevel = new MinimumLevel(quantity);
|
||||||
|
var minimumShelfLife = new MinimumShelfLife(30);
|
||||||
|
|
||||||
|
var stock = Stock.reconstitute(id, articleId, locationId, minimumLevel, minimumShelfLife);
|
||||||
|
|
||||||
|
assertThat(stock.id()).isEqualTo(id);
|
||||||
|
assertThat(stock.articleId()).isEqualTo(articleId);
|
||||||
|
assertThat(stock.storageLocationId()).isEqualTo(locationId);
|
||||||
|
assertThat(stock.minimumLevel()).isEqualTo(minimumLevel);
|
||||||
|
assertThat(stock.minimumShelfLife()).isEqualTo(minimumShelfLife);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should reconstitute Stock without optional fields")
|
||||||
|
void shouldReconstituteWithoutOptionals() {
|
||||||
|
var id = StockId.generate();
|
||||||
|
var articleId = ArticleId.of("article-1");
|
||||||
|
var locationId = StorageLocationId.of("location-1");
|
||||||
|
|
||||||
|
var stock = Stock.reconstitute(id, articleId, locationId, null, null);
|
||||||
|
|
||||||
|
assertThat(stock.minimumLevel()).isNull();
|
||||||
|
assertThat(stock.minimumShelfLife()).isNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Equality ====================
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("equals / hashCode")
|
||||||
|
class Equality {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should be equal if same ID")
|
||||||
|
void shouldBeEqualBySameId() {
|
||||||
|
var id = StockId.generate();
|
||||||
|
var stock1 = Stock.reconstitute(id, ArticleId.of("a1"), StorageLocationId.of("l1"), null, null);
|
||||||
|
var stock2 = Stock.reconstitute(id, ArticleId.of("a2"), StorageLocationId.of("l2"), null, null);
|
||||||
|
|
||||||
|
assertThat(stock1).isEqualTo(stock2);
|
||||||
|
assertThat(stock1.hashCode()).isEqualTo(stock2.hashCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should not be equal if different ID")
|
||||||
|
void shouldNotBeEqualByDifferentId() {
|
||||||
|
var stock1 = createValidStock();
|
||||||
|
var stock2 = createValidStock();
|
||||||
|
|
||||||
|
assertThat(stock1).isNotEqualTo(stock2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Helpers ====================
|
||||||
|
|
||||||
|
private Stock createValidStock() {
|
||||||
|
var draft = new StockDraft("article-1", "location-1", "10", "KILOGRAM", 30);
|
||||||
|
return Stock.create(draft).unsafeGetValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package de.effigenix.domain.production;
|
package de.effigenix.domain.production;
|
||||||
|
|
||||||
|
import de.effigenix.shared.common.QuantityError;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Nested;
|
import org.junit.jupiter.api.Nested;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package de.effigenix.domain.production;
|
package de.effigenix.domain.production;
|
||||||
|
|
||||||
|
import de.effigenix.shared.common.UnitOfMeasure;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Nested;
|
import org.junit.jupiter.api.Nested;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package de.effigenix.domain.production;
|
package de.effigenix.domain.production;
|
||||||
|
|
||||||
|
import de.effigenix.shared.common.Quantity;
|
||||||
|
import de.effigenix.shared.common.UnitOfMeasure;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Nested;
|
import org.junit.jupiter.api.Nested;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package de.effigenix.domain.production;
|
package de.effigenix.domain.production;
|
||||||
|
|
||||||
|
import de.effigenix.shared.common.UnitOfMeasure;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,10 @@ import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
import static de.effigenix.domain.production.UnitOfMeasure.*;
|
import de.effigenix.shared.common.Quantity;
|
||||||
|
import de.effigenix.shared.common.QuantityError;
|
||||||
|
|
||||||
|
import static de.effigenix.shared.common.UnitOfMeasure.*;
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
package de.effigenix.infrastructure.inventory.web;
|
||||||
|
|
||||||
|
import de.effigenix.domain.usermanagement.RoleName;
|
||||||
|
import de.effigenix.infrastructure.AbstractIntegrationTest;
|
||||||
|
import de.effigenix.infrastructure.inventory.web.dto.CreateStockRequest;
|
||||||
|
import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity;
|
||||||
|
import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integrationstests für StockController.
|
||||||
|
*
|
||||||
|
* Abgedeckte Testfälle:
|
||||||
|
* - Story 2.1 – Bestandsposition anlegen
|
||||||
|
*/
|
||||||
|
@DisplayName("Stock Controller Integration Tests")
|
||||||
|
class StockControllerIntegrationTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private String adminToken;
|
||||||
|
private String viewerToken;
|
||||||
|
|
||||||
|
private String storageLocationId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() throws Exception {
|
||||||
|
RoleEntity adminRole = createRole(RoleName.ADMIN, "Admin");
|
||||||
|
RoleEntity viewerRole = createRole(RoleName.PRODUCTION_WORKER, "Viewer");
|
||||||
|
|
||||||
|
UserEntity admin = createUser("stock.admin", "stock.admin@test.com", Set.of(adminRole), "BRANCH-01");
|
||||||
|
UserEntity viewer = createUser("stock.viewer", "stock.viewer@test.com", Set.of(viewerRole), "BRANCH-01");
|
||||||
|
|
||||||
|
adminToken = generateToken(admin.getId(), "stock.admin", "STOCK_WRITE,STOCK_READ");
|
||||||
|
viewerToken = generateToken(viewer.getId(), "stock.viewer", "USER_READ");
|
||||||
|
|
||||||
|
storageLocationId = createStorageLocation();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Bestandsposition anlegen – Pflichtfelder ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Bestandsposition mit Pflichtfeldern erstellen → 201")
|
||||||
|
void createStock_withRequiredFields_returns201() throws Exception {
|
||||||
|
var request = new CreateStockRequest(
|
||||||
|
UUID.randomUUID().toString(), storageLocationId, null, null, null);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/inventory/stocks")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.id").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.articleId").value(request.articleId()))
|
||||||
|
.andExpect(jsonPath("$.storageLocationId").value(storageLocationId))
|
||||||
|
.andExpect(jsonPath("$.minimumLevel").isEmpty())
|
||||||
|
.andExpect(jsonPath("$.minimumShelfLifeDays").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Bestandsposition mit allen Feldern ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Bestandsposition mit allen Feldern erstellen → 201")
|
||||||
|
void createStock_withAllFields_returns201() throws Exception {
|
||||||
|
var request = new CreateStockRequest(
|
||||||
|
UUID.randomUUID().toString(), storageLocationId, "10.5", "KILOGRAM", 30);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/inventory/stocks")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.articleId").value(request.articleId()))
|
||||||
|
.andExpect(jsonPath("$.storageLocationId").value(storageLocationId))
|
||||||
|
.andExpect(jsonPath("$.minimumLevel.amount").value(10.5))
|
||||||
|
.andExpect(jsonPath("$.minimumLevel.unit").value("KILOGRAM"))
|
||||||
|
.andExpect(jsonPath("$.minimumShelfLifeDays").value(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Duplikat ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Bestandsposition Duplikat (gleiche articleId+storageLocationId) → 409")
|
||||||
|
void createStock_duplicate_returns409() throws Exception {
|
||||||
|
String articleId = UUID.randomUUID().toString();
|
||||||
|
var request = new CreateStockRequest(articleId, storageLocationId, null, null, null);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/inventory/stocks")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/inventory/stocks")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isConflict())
|
||||||
|
.andExpect(jsonPath("$.code").value("DUPLICATE_STOCK"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Validierungsfehler ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Bestandsposition ohne articleId → 400")
|
||||||
|
void createStock_withBlankArticleId_returns400() throws Exception {
|
||||||
|
var request = new CreateStockRequest("", storageLocationId, null, null, null);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/inventory/stocks")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Bestandsposition ohne storageLocationId → 400")
|
||||||
|
void createStock_withBlankStorageLocationId_returns400() throws Exception {
|
||||||
|
var request = new CreateStockRequest(UUID.randomUUID().toString(), "", null, null, null);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/inventory/stocks")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Bestandsposition mit ungültigem MinimumLevel → 400")
|
||||||
|
void createStock_withInvalidMinimumLevel_returns400() throws Exception {
|
||||||
|
var request = new CreateStockRequest(
|
||||||
|
UUID.randomUUID().toString(), storageLocationId, "-1", "KILOGRAM", null);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/inventory/stocks")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("INVALID_MINIMUM_LEVEL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Bestandsposition mit ungültiger Unit → 400")
|
||||||
|
void createStock_withInvalidUnit_returns400() throws Exception {
|
||||||
|
var request = new CreateStockRequest(
|
||||||
|
UUID.randomUUID().toString(), storageLocationId, "10", "INVALID_UNIT", null);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/inventory/stocks")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("INVALID_MINIMUM_LEVEL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Bestandsposition mit ungültigem MinimumShelfLife → 400")
|
||||||
|
void createStock_withInvalidMinimumShelfLife_returns400() throws Exception {
|
||||||
|
var request = new CreateStockRequest(
|
||||||
|
UUID.randomUUID().toString(), storageLocationId, null, null, 0);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/inventory/stocks")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("INVALID_MINIMUM_SHELF_LIFE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Autorisierung ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Bestandsposition erstellen ohne STOCK_WRITE → 403")
|
||||||
|
void createStock_withViewerToken_returns403() throws Exception {
|
||||||
|
var request = new CreateStockRequest(
|
||||||
|
UUID.randomUUID().toString(), storageLocationId, null, null, null);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/inventory/stocks")
|
||||||
|
.header("Authorization", "Bearer " + viewerToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Bestandsposition erstellen ohne Token → 401")
|
||||||
|
void createStock_withoutToken_returns401() throws Exception {
|
||||||
|
var request = new CreateStockRequest(
|
||||||
|
UUID.randomUUID().toString(), storageLocationId, null, null, null);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/inventory/stocks")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Hilfsmethoden ====================
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
package de.effigenix.domain.production;
|
package de.effigenix.shared.common;
|
||||||
|
|
||||||
import de.effigenix.shared.common.Result;
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Nested;
|
import org.junit.jupiter.api.Nested;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
import static de.effigenix.domain.production.UnitOfMeasure.*;
|
import static de.effigenix.shared.common.UnitOfMeasure.*;
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
@DisplayName("Quantity Value Object")
|
@DisplayName("Quantity Value Object")
|
||||||
|
|
@ -9,7 +9,7 @@ spring:
|
||||||
database-platform: org.hibernate.dialect.H2Dialect
|
database-platform: org.hibernate.dialect.H2Dialect
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: create-drop
|
ddl-auto: create-drop
|
||||||
show-sql: true
|
show-sql: false
|
||||||
|
|
||||||
liquibase:
|
liquibase:
|
||||||
enabled: false # Use Hibernate for test schema
|
enabled: false # Use Hibernate for test schema
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -420,6 +420,22 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/inventory/stocks": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["createStock"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/customers": {
|
"/api/customers": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -1173,6 +1189,26 @@ export interface components {
|
||||||
minTemperature?: string;
|
minTemperature?: string;
|
||||||
maxTemperature?: string;
|
maxTemperature?: string;
|
||||||
};
|
};
|
||||||
|
CreateStockRequest: {
|
||||||
|
articleId: string;
|
||||||
|
storageLocationId: string;
|
||||||
|
minimumLevelAmount?: string;
|
||||||
|
minimumLevelUnit?: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
minimumShelfLifeDays?: number;
|
||||||
|
};
|
||||||
|
MinimumLevelResponse: {
|
||||||
|
amount: number;
|
||||||
|
unit: string;
|
||||||
|
} | null;
|
||||||
|
StockResponse: {
|
||||||
|
id: string;
|
||||||
|
articleId: string;
|
||||||
|
storageLocationId: string;
|
||||||
|
minimumLevel?: components["schemas"]["MinimumLevelResponse"];
|
||||||
|
/** Format: int32 */
|
||||||
|
minimumShelfLifeDays?: number | null;
|
||||||
|
};
|
||||||
CreateCustomerRequest: {
|
CreateCustomerRequest: {
|
||||||
name: string;
|
name: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
|
|
@ -2265,6 +2301,30 @@ export interface operations {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
createStock: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateStockRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["StockResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
listCustomers: {
|
listCustomers: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue