1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 08:29:36 +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:
Sebastian Frick 2026-02-19 21:33:29 +01:00
parent 7079f12475
commit 5219c93dd1
43 changed files with 1340 additions and 18 deletions

View file

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

View file

@ -0,0 +1,9 @@
package de.effigenix.application.inventory.command;
public record CreateStockCommand(
String articleId,
String storageLocationId,
String minimumLevelAmount,
String minimumLevelUnit,
Integer minimumShelfLifeDays
) {}

View file

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

View file

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

View 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 + "}";
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
package de.effigenix.domain.production;
import de.effigenix.shared.common.QuantityError;
import de.effigenix.shared.common.Result;
import java.time.LocalDate;

View file

@ -1,6 +1,8 @@
package de.effigenix.domain.production;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import java.math.BigDecimal;

View file

@ -1,6 +1,8 @@
package de.effigenix.domain.production;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import java.math.BigDecimal;
import java.time.LocalDateTime;

View file

@ -1,5 +1,7 @@
package de.effigenix.domain.production;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.QuantityError;
import de.effigenix.shared.common.Result;
import java.math.BigDecimal;

View file

@ -1,10 +1,12 @@
package de.effigenix.infrastructure.config;
import de.effigenix.application.inventory.ActivateStorageLocation;
import de.effigenix.application.inventory.CreateStock;
import de.effigenix.application.inventory.CreateStorageLocation;
import de.effigenix.application.inventory.DeactivateStorageLocation;
import de.effigenix.application.inventory.ListStorageLocations;
import de.effigenix.application.inventory.UpdateStorageLocation;
import de.effigenix.domain.inventory.StockRepository;
import de.effigenix.domain.inventory.StorageLocationRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -38,4 +40,11 @@ public class InventoryUseCaseConfiguration {
public ListStorageLocations listStorageLocations(StorageLocationRepository storageLocationRepository) {
return new ListStorageLocations(storageLocationRepository);
}
// ==================== Stock Use Cases ====================
@Bean
public CreateStock createStock(StockRepository stockRepository) {
return new CreateStock(stockRepository);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
package de.effigenix.infrastructure.inventory.web.exception;
import de.effigenix.domain.inventory.StorageLocationError;
import de.effigenix.domain.inventory.StockError;
public final class InventoryErrorHttpStatusMapper {
@ -20,4 +21,17 @@ public final class InventoryErrorHttpStatusMapper {
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;
};
}
}

View file

@ -4,6 +4,8 @@ import de.effigenix.domain.production.*;
import de.effigenix.infrastructure.production.persistence.entity.IngredientEntity;
import de.effigenix.infrastructure.production.persistence.entity.ProductionStepEntity;
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 java.util.List;

View file

@ -1,6 +1,7 @@
package de.effigenix.infrastructure.usermanagement.web.exception;
import de.effigenix.domain.inventory.StorageLocationError;
import de.effigenix.domain.inventory.StockError;
import de.effigenix.domain.masterdata.ArticleError;
import de.effigenix.domain.masterdata.ProductCategoryError;
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.usermanagement.UserError;
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.masterdata.web.controller.ArticleController;
import de.effigenix.infrastructure.masterdata.web.controller.CustomerController;
@ -204,6 +206,29 @@ public class GlobalExceptionHandler {
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)
public ResponseEntity<ErrorResponse> handleRecipeDomainError(
RecipeController.RecipeDomainErrorException ex,

View file

@ -1,13 +1,11 @@
package de.effigenix.domain.production;
import de.effigenix.shared.common.Result;
package de.effigenix.shared.common;
import java.math.BigDecimal;
import java.math.RoundingMode;
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).
* Secondary quantity (catch-weight) is optional and used when goods are counted

View file

@ -1,4 +1,4 @@
package de.effigenix.domain.production;
package de.effigenix.shared.common;
/**
* Domain errors for Quantity-related Value Objects.

View file

@ -16,6 +16,9 @@ public sealed interface RepositoryError {
}
}
record DuplicateEntry(String message) implements RepositoryError {
}
record DatabaseError(String message) implements RepositoryError {
}
}

View file

@ -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.
*/
public enum UnitOfMeasure {

View file

@ -6,10 +6,8 @@ springdoc:
logging:
level:
root: WARN
root: INFO
de.effigenix: INFO
org.springframework.security: WARN
org.hibernate.SQL: WARN
server:
error:

View file

@ -44,8 +44,6 @@ logging:
level:
root: INFO
de.effigenix: DEBUG
org.springframework.security: DEBUG
org.hibernate.SQL: DEBUG
# CORS Configuration
effigenix:

View file

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

View file

@ -17,5 +17,6 @@
<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/012-create-recipe-production-steps-table.xml"/>
<include file="db/changelog/changes/013-create-stock-schema.xml"/>
</databaseChangeLog>

View file

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

View file

@ -1,5 +1,6 @@
package de.effigenix.domain.production;
import de.effigenix.shared.common.QuantityError;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

View file

@ -1,5 +1,6 @@
package de.effigenix.domain.production;
import de.effigenix.shared.common.UnitOfMeasure;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

View file

@ -1,5 +1,7 @@
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.Nested;
import org.junit.jupiter.api.Test;

View file

@ -1,5 +1,6 @@
package de.effigenix.domain.production;
import de.effigenix.shared.common.UnitOfMeasure;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

View file

@ -8,7 +8,10 @@ import org.junit.jupiter.params.provider.ValueSource;
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.assertThatThrownBy;

View file

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

View file

@ -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.Nested;
import org.junit.jupiter.api.Test;
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;
@DisplayName("Quantity Value Object")

View file

@ -9,7 +9,7 @@ spring:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: create-drop
show-sql: true
show-sql: false
liquibase:
enabled: false # Use Hibernate for test schema

File diff suppressed because one or more lines are too long

View file

@ -420,6 +420,22 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@ -1173,6 +1189,26 @@ export interface components {
minTemperature?: 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: {
name: 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: {
parameters: {
query?: {