1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 19:30:16 +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>