1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 19:30:16 +01:00

feat(inventory): StorageLocation Aggregate implementieren (#1)

Vertikaler Slice für Story 1.1 – Lagerort anlegen:

Domain: StorageLocation Aggregate mit VOs (StorageLocationId, StorageLocationName,
StorageType, TemperatureRange), StorageLocationError (sealed interface),
Draft-Records und Repository Interface.

Application: CreateStorageLocation UseCase mit Uniqueness-Check.

Infrastructure: JPA Entity, Mapper, Repository, REST Controller
(POST /api/inventory/storage-locations), Liquibase Migration (009),
InventoryErrorHttpStatusMapper, InventoryUseCaseConfiguration.

Tests: 28 Domain-Unit-Tests, 11 Integration-Tests.

Closes #1
This commit is contained in:
Sebastian Frick 2026-02-19 09:51:48 +01:00
parent 554185a012
commit c474388f32
25 changed files with 1527 additions and 0 deletions

View file

@ -0,0 +1,51 @@
package de.effigenix.application.inventory;
import de.effigenix.application.inventory.command.CreateStorageLocationCommand;
import de.effigenix.domain.inventory.*;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public class CreateStorageLocation {
private final StorageLocationRepository storageLocationRepository;
public CreateStorageLocation(StorageLocationRepository storageLocationRepository) {
this.storageLocationRepository = storageLocationRepository;
}
public Result<StorageLocationError, StorageLocation> execute(CreateStorageLocationCommand cmd, ActorId performedBy) {
// 1. Draft aus Command bauen (kein VO-Wissen im Use Case)
var draft = new StorageLocationDraft(
cmd.name(), cmd.storageType(), cmd.minTemperature(), cmd.maxTemperature()
);
// 2. Aggregate erzeugen (validiert intern)
StorageLocation location;
switch (StorageLocation.create(draft)) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var val) -> location = val;
}
// 3. Uniqueness-Check (Application-Concern: braucht Repository)
switch (storageLocationRepository.existsByName(location.name())) {
case Result.Failure(var err) ->
{ return Result.failure(new StorageLocationError.RepositoryFailure(err.message())); }
case Result.Success(var exists) -> {
if (exists) {
return Result.failure(new StorageLocationError.NameAlreadyExists(cmd.name()));
}
}
}
// 4. Speichern
switch (storageLocationRepository.save(location)) {
case Result.Failure(var err) ->
{ return Result.failure(new StorageLocationError.RepositoryFailure(err.message())); }
case Result.Success(var ignored) -> { }
}
return Result.success(location);
}
}

View file

@ -0,0 +1,8 @@
package de.effigenix.application.inventory.command;
public record CreateStorageLocationCommand(
String name,
String storageType,
String minTemperature,
String maxTemperature
) {}

View file

@ -0,0 +1,178 @@
package de.effigenix.domain.inventory;
import de.effigenix.shared.common.Result;
import java.math.BigDecimal;
import java.util.Objects;
import static de.effigenix.shared.common.Result.*;
/**
* StorageLocation aggregate root.
*
* Invariants:
* - Name must not be blank, max 100 chars
* - Name must be unique (Application Layer, Repository-Concern)
* - StorageType is immutable after creation
* - TemperatureRange: minTemperature < maxTemperature, values in range -50°C to +80°C
* - Cannot deactivate if Stock exists at this location (Application Layer)
*/
public class StorageLocation {
private final StorageLocationId id;
private StorageLocationName name;
private final StorageType storageType;
private TemperatureRange temperatureRange;
private boolean active;
private StorageLocation(
StorageLocationId id,
StorageLocationName name,
StorageType storageType,
TemperatureRange temperatureRange,
boolean active
) {
this.id = id;
this.name = name;
this.storageType = storageType;
this.temperatureRange = temperatureRange;
this.active = active;
}
/**
* Factory: Erzeugt eine neue StorageLocation aus rohen Eingaben.
* Orchestriert Validierung aller VOs intern.
*/
public static Result<StorageLocationError, StorageLocation> create(StorageLocationDraft draft) {
// 1. Name validieren (Pflicht)
StorageLocationName name;
switch (StorageLocationName.of(draft.name())) {
case Failure(var err) -> { return Result.failure(err); }
case Success(var val) -> name = val;
}
// 2. StorageType validieren (Pflicht)
StorageType storageType;
try {
storageType = StorageType.valueOf(draft.storageType());
} catch (IllegalArgumentException | NullPointerException e) {
return Result.failure(new StorageLocationError.InvalidStorageType(
draft.storageType() == null ? "null" : draft.storageType()));
}
// 3. TemperatureRange optional
TemperatureRange temperatureRange = null;
if (draft.minTemperature() != null || draft.maxTemperature() != null) {
BigDecimal min;
BigDecimal max;
try {
min = draft.minTemperature() != null ? new BigDecimal(draft.minTemperature()) : null;
max = draft.maxTemperature() != null ? new BigDecimal(draft.maxTemperature()) : null;
} catch (NumberFormatException e) {
return Result.failure(new StorageLocationError.InvalidTemperatureRange(
"Temperature values must be valid numbers"));
}
switch (TemperatureRange.of(min, max)) {
case Failure(var err) -> { return Result.failure(err); }
case Success(var val) -> temperatureRange = val;
}
}
return Result.success(new StorageLocation(
StorageLocationId.generate(), name, storageType, temperatureRange, true
));
}
/**
* Reconstitute from persistence without re-validation.
*/
public static StorageLocation reconstitute(
StorageLocationId id,
StorageLocationName name,
StorageType storageType,
TemperatureRange temperatureRange,
boolean active
) {
return new StorageLocation(id, name, storageType, temperatureRange, active);
}
// ==================== Business Methods ====================
/**
* Wendet partielle Updates an. StorageType ist immutable.
* null-Felder im Draft werden ignoriert.
*/
public Result<StorageLocationError, Void> update(StorageLocationUpdateDraft draft) {
if (draft.name() != null) {
switch (StorageLocationName.of(draft.name())) {
case Failure(var err) -> { return Result.failure(err); }
case Success(var val) -> this.name = val;
}
}
if (draft.minTemperature() != null || draft.maxTemperature() != null) {
BigDecimal min;
BigDecimal max;
try {
min = draft.minTemperature() != null ? new BigDecimal(draft.minTemperature()) : null;
max = draft.maxTemperature() != null ? new BigDecimal(draft.maxTemperature()) : null;
} catch (NumberFormatException e) {
return Result.failure(new StorageLocationError.InvalidTemperatureRange(
"Temperature values must be valid numbers"));
}
switch (TemperatureRange.of(min, max)) {
case Failure(var err) -> { return Result.failure(err); }
case Success(var val) -> this.temperatureRange = val;
}
}
return Result.success(null);
}
public Result<StorageLocationError, Void> deactivate() {
if (!this.active) {
return Result.failure(new StorageLocationError.AlreadyInactive());
}
this.active = false;
return Result.success(null);
}
public Result<StorageLocationError, Void> activate() {
if (this.active) {
return Result.failure(new StorageLocationError.AlreadyActive());
}
this.active = true;
return Result.success(null);
}
// ==================== Query Methods ====================
public boolean isTemperatureControlled() {
return temperatureRange != null;
}
// ==================== Getters ====================
public StorageLocationId id() { return id; }
public StorageLocationName name() { return name; }
public StorageType storageType() { return storageType; }
public TemperatureRange temperatureRange() { return temperatureRange; }
public boolean active() { return active; }
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof StorageLocation other)) return false;
return Objects.equals(id, other.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
@Override
public String toString() {
return "StorageLocation{id=" + id + ", name=" + name + ", type=" + storageType + ", active=" + active + "}";
}
}

View file

@ -0,0 +1,18 @@
package de.effigenix.domain.inventory;
/**
* Rohe Eingabe zum Erzeugen einer StorageLocation.
* Wird vom Application Layer aus dem Command gebaut und an StorageLocation.create() übergeben.
* Das Aggregate ist verantwortlich für Validierung und VO-Konstruktion.
*
* @param name Pflicht
* @param storageType Pflicht (StorageType enum als String)
* @param minTemperature Optional BigDecimal als String, nullable
* @param maxTemperature Optional BigDecimal als String, nullable
*/
public record StorageLocationDraft(
String name,
String storageType,
String minTemperature,
String maxTemperature
) {}

View file

@ -0,0 +1,55 @@
package de.effigenix.domain.inventory;
public sealed interface StorageLocationError {
String code();
String message();
record NameAlreadyExists(String name) implements StorageLocationError {
@Override public String code() { return "STORAGE_LOCATION_NAME_EXISTS"; }
@Override public String message() { return "Storage location name already exists: " + name; }
}
record InvalidName(String reason) implements StorageLocationError {
@Override public String code() { return "INVALID_STORAGE_LOCATION_NAME"; }
@Override public String message() { return "Invalid storage location name: " + reason; }
}
record InvalidTemperatureRange(String reason) implements StorageLocationError {
@Override public String code() { return "INVALID_TEMPERATURE_RANGE"; }
@Override public String message() { return "Invalid temperature range: " + reason; }
}
record InvalidStorageType(String value) implements StorageLocationError {
@Override public String code() { return "INVALID_STORAGE_TYPE"; }
@Override public String message() { return "Invalid storage type: " + value; }
}
record StorageLocationNotFound(String id) implements StorageLocationError {
@Override public String code() { return "STORAGE_LOCATION_NOT_FOUND"; }
@Override public String message() { return "Storage location not found: " + id; }
}
record StockExistsAtLocation(String locationId) implements StorageLocationError {
@Override public String code() { return "STOCK_EXISTS_AT_LOCATION"; }
@Override public String message() { return "Cannot deactivate: stock exists at location " + locationId; }
}
record AlreadyActive() implements StorageLocationError {
@Override public String code() { return "ALREADY_ACTIVE"; }
@Override public String message() { return "Storage location is already active"; }
}
record AlreadyInactive() implements StorageLocationError {
@Override public String code() { return "ALREADY_INACTIVE"; }
@Override public String message() { return "Storage location is already inactive"; }
}
record Unauthorized(String message) implements StorageLocationError {
@Override public String code() { return "UNAUTHORIZED"; }
}
record RepositoryFailure(String message) implements StorageLocationError {
@Override public String code() { return "REPOSITORY_ERROR"; }
}
}

View file

@ -0,0 +1,20 @@
package de.effigenix.domain.inventory;
import java.util.UUID;
public record StorageLocationId(String value) {
public StorageLocationId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("StorageLocationId must not be blank");
}
}
public static StorageLocationId generate() {
return new StorageLocationId(UUID.randomUUID().toString());
}
public static StorageLocationId of(String value) {
return new StorageLocationId(value);
}
}

View file

@ -0,0 +1,16 @@
package de.effigenix.domain.inventory;
import de.effigenix.shared.common.Result;
public record StorageLocationName(String value) {
public static Result<StorageLocationError, StorageLocationName> of(String value) {
if (value == null || value.isBlank()) {
return Result.failure(new StorageLocationError.InvalidName("Name must not be blank"));
}
if (value.length() > 100) {
return Result.failure(new StorageLocationError.InvalidName("Name must not exceed 100 characters"));
}
return Result.success(new StorageLocationName(value.trim()));
}
}

View file

@ -0,0 +1,22 @@
package de.effigenix.domain.inventory;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import java.util.List;
import java.util.Optional;
public interface StorageLocationRepository {
Result<RepositoryError, Optional<StorageLocation>> findById(StorageLocationId id);
Result<RepositoryError, List<StorageLocation>> findAll();
Result<RepositoryError, List<StorageLocation>> findByStorageType(StorageType storageType);
Result<RepositoryError, List<StorageLocation>> findActive();
Result<RepositoryError, Boolean> existsByName(StorageLocationName name);
Result<RepositoryError, Void> save(StorageLocation location);
}

View file

@ -0,0 +1,16 @@
package de.effigenix.domain.inventory;
/**
* Rohe Eingabe für partielle StorageLocation-Updates.
* null-Felder bedeuten "nicht ändern".
* StorageType ist immutable und daher nicht Teil des UpdateDraft.
*
* @param name null = nicht ändern
* @param minTemperature BigDecimal als String, nullable
* @param maxTemperature BigDecimal als String, nullable
*/
public record StorageLocationUpdateDraft(
String name,
String minTemperature,
String maxTemperature
) {}

View file

@ -0,0 +1,9 @@
package de.effigenix.domain.inventory;
public enum StorageType {
COLD_ROOM,
FREEZER,
DRY_STORAGE,
DISPLAY_COUNTER,
PRODUCTION_AREA
}

View file

@ -0,0 +1,37 @@
package de.effigenix.domain.inventory;
import de.effigenix.shared.common.Result;
import java.math.BigDecimal;
public record TemperatureRange(BigDecimal minTemperature, BigDecimal maxTemperature) {
private static final BigDecimal MIN_ALLOWED = new BigDecimal("-50");
private static final BigDecimal MAX_ALLOWED = new BigDecimal("80");
public static Result<StorageLocationError, TemperatureRange> of(
BigDecimal minTemperature, BigDecimal maxTemperature) {
if (minTemperature == null || maxTemperature == null) {
return Result.failure(new StorageLocationError.InvalidTemperatureRange(
"Both min and max temperature must be provided"));
}
if (minTemperature.compareTo(maxTemperature) >= 0) {
return Result.failure(new StorageLocationError.InvalidTemperatureRange(
"Min temperature must be less than max temperature"));
}
if (minTemperature.compareTo(MIN_ALLOWED) < 0 || minTemperature.compareTo(MAX_ALLOWED) > 0) {
return Result.failure(new StorageLocationError.InvalidTemperatureRange(
"Min temperature must be between -50°C and +80°C"));
}
if (maxTemperature.compareTo(MIN_ALLOWED) < 0 || maxTemperature.compareTo(MAX_ALLOWED) > 0) {
return Result.failure(new StorageLocationError.InvalidTemperatureRange(
"Max temperature must be between -50°C and +80°C"));
}
return Result.success(new TemperatureRange(minTemperature, maxTemperature));
}
public boolean contains(BigDecimal temperature) {
return temperature.compareTo(minTemperature) >= 0
&& temperature.compareTo(maxTemperature) <= 0;
}
}

View file

@ -0,0 +1,17 @@
package de.effigenix.infrastructure.config;
import de.effigenix.application.inventory.CreateStorageLocation;
import de.effigenix.domain.inventory.StorageLocationRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class InventoryUseCaseConfiguration {
// ==================== StorageLocation Use Cases ====================
@Bean
public CreateStorageLocation createStorageLocation(StorageLocationRepository storageLocationRepository) {
return new CreateStorageLocation(storageLocationRepository);
}
}

View file

@ -0,0 +1,51 @@
package de.effigenix.infrastructure.inventory.persistence.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "storage_locations")
public class StorageLocationEntity {
@Id
@Column(name = "id", nullable = false, length = 36)
private String id;
@Column(name = "name", nullable = false, unique = true, length = 100)
private String name;
@Column(name = "storage_type", nullable = false, length = 30)
private String storageType;
@Column(name = "min_temperature", precision = 5, scale = 1)
private BigDecimal minTemperature;
@Column(name = "max_temperature", precision = 5, scale = 1)
private BigDecimal maxTemperature;
@Column(name = "active", nullable = false)
private boolean active;
public StorageLocationEntity() {}
// ==================== Getters & Setters ====================
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getStorageType() { return storageType; }
public void setStorageType(String storageType) { this.storageType = storageType; }
public BigDecimal getMinTemperature() { return minTemperature; }
public void setMinTemperature(BigDecimal minTemperature) { this.minTemperature = minTemperature; }
public BigDecimal getMaxTemperature() { return maxTemperature; }
public void setMaxTemperature(BigDecimal maxTemperature) { this.maxTemperature = maxTemperature; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
}

View file

@ -0,0 +1,40 @@
package de.effigenix.infrastructure.inventory.persistence.mapper;
import de.effigenix.domain.inventory.*;
import de.effigenix.infrastructure.inventory.persistence.entity.StorageLocationEntity;
import org.springframework.stereotype.Component;
@Component
public class StorageLocationMapper {
public StorageLocationEntity toEntity(StorageLocation location) {
var entity = new StorageLocationEntity();
entity.setId(location.id().value());
entity.setName(location.name().value());
entity.setStorageType(location.storageType().name());
entity.setActive(location.active());
var range = location.temperatureRange();
if (range != null) {
entity.setMinTemperature(range.minTemperature());
entity.setMaxTemperature(range.maxTemperature());
}
return entity;
}
public StorageLocation toDomain(StorageLocationEntity entity) {
TemperatureRange temperatureRange = null;
if (entity.getMinTemperature() != null && entity.getMaxTemperature() != null) {
temperatureRange = new TemperatureRange(entity.getMinTemperature(), entity.getMaxTemperature());
}
return StorageLocation.reconstitute(
StorageLocationId.of(entity.getId()),
new StorageLocationName(entity.getName()),
StorageType.valueOf(entity.getStorageType()),
temperatureRange,
entity.isActive()
);
}
}

View file

@ -0,0 +1,104 @@
package de.effigenix.infrastructure.inventory.persistence.repository;
import de.effigenix.domain.inventory.*;
import de.effigenix.infrastructure.inventory.persistence.mapper.StorageLocationMapper;
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.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
@Profile("!no-db")
@Transactional(readOnly = true)
public class JpaStorageLocationRepository implements StorageLocationRepository {
private static final Logger logger = LoggerFactory.getLogger(JpaStorageLocationRepository.class);
private final StorageLocationJpaRepository jpaRepository;
private final StorageLocationMapper mapper;
public JpaStorageLocationRepository(StorageLocationJpaRepository jpaRepository, StorageLocationMapper mapper) {
this.jpaRepository = jpaRepository;
this.mapper = mapper;
}
@Override
public Result<RepositoryError, Optional<StorageLocation>> findById(StorageLocationId id) {
try {
Optional<StorageLocation> 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, List<StorageLocation>> findAll() {
try {
List<StorageLocation> result = jpaRepository.findAll().stream()
.map(mapper::toDomain)
.collect(Collectors.toList());
return Result.success(result);
} catch (Exception e) {
logger.trace("Database error in findAll", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, List<StorageLocation>> findByStorageType(StorageType storageType) {
try {
List<StorageLocation> result = jpaRepository.findByStorageType(storageType.name()).stream()
.map(mapper::toDomain)
.collect(Collectors.toList());
return Result.success(result);
} catch (Exception e) {
logger.trace("Database error in findByStorageType", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, List<StorageLocation>> findActive() {
try {
List<StorageLocation> result = jpaRepository.findByActiveTrue().stream()
.map(mapper::toDomain)
.collect(Collectors.toList());
return Result.success(result);
} catch (Exception e) {
logger.trace("Database error in findActive", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, Boolean> existsByName(StorageLocationName name) {
try {
return Result.success(jpaRepository.existsByName(name.value()));
} catch (Exception e) {
logger.trace("Database error in existsByName", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
@Transactional
public Result<RepositoryError, Void> save(StorageLocation location) {
try {
jpaRepository.save(mapper.toEntity(location));
return Result.success(null);
} catch (Exception e) {
logger.trace("Database error in save", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
}

View file

@ -0,0 +1,15 @@
package de.effigenix.infrastructure.inventory.persistence.repository;
import de.effigenix.infrastructure.inventory.persistence.entity.StorageLocationEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface StorageLocationJpaRepository extends JpaRepository<StorageLocationEntity, String> {
List<StorageLocationEntity> findByStorageType(String storageType);
List<StorageLocationEntity> findByActiveTrue();
boolean existsByName(String name);
}

View file

@ -0,0 +1,77 @@
package de.effigenix.infrastructure.inventory.web.controller;
import de.effigenix.application.inventory.CreateStorageLocation;
import de.effigenix.application.inventory.command.CreateStorageLocationCommand;
import de.effigenix.domain.inventory.StorageLocationError;
import de.effigenix.infrastructure.inventory.web.dto.CreateStorageLocationRequest;
import de.effigenix.infrastructure.inventory.web.dto.StorageLocationResponse;
import de.effigenix.shared.security.ActorId;
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/storage-locations")
@SecurityRequirement(name = "Bearer Authentication")
@Tag(name = "Storage Locations", description = "Storage location management endpoints")
public class StorageLocationController {
private static final Logger logger = LoggerFactory.getLogger(StorageLocationController.class);
private final CreateStorageLocation createStorageLocation;
public StorageLocationController(CreateStorageLocation createStorageLocation) {
this.createStorageLocation = createStorageLocation;
}
@PostMapping
@PreAuthorize("hasAuthority('STOCK_WRITE')")
public ResponseEntity<StorageLocationResponse> createStorageLocation(
@Valid @RequestBody CreateStorageLocationRequest request,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Creating storage location: {} by actor: {}", request.name(), actorId.value());
var cmd = new CreateStorageLocationCommand(
request.name(), request.storageType(),
request.minTemperature(), request.maxTemperature()
);
var result = createStorageLocation.execute(cmd, actorId);
if (result.isFailure()) {
throw new StorageLocationDomainErrorException(result.unsafeGetError());
}
logger.info("Storage location created: {}", request.name());
return ResponseEntity.status(HttpStatus.CREATED)
.body(StorageLocationResponse.from(result.unsafeGetValue()));
}
private ActorId extractActorId(Authentication authentication) {
if (authentication == null || authentication.getName() == null) {
throw new IllegalStateException("No authentication found in SecurityContext");
}
return ActorId.of(authentication.getName());
}
public static class StorageLocationDomainErrorException extends RuntimeException {
private final StorageLocationError error;
public StorageLocationDomainErrorException(StorageLocationError error) {
super(error.message());
this.error = error;
}
public StorageLocationError getError() {
return error;
}
}
}

View file

@ -0,0 +1,10 @@
package de.effigenix.infrastructure.inventory.web.dto;
import jakarta.validation.constraints.NotBlank;
public record CreateStorageLocationRequest(
@NotBlank String name,
@NotBlank String storageType,
String minTemperature,
String maxTemperature
) {}

View file

@ -0,0 +1,34 @@
package de.effigenix.infrastructure.inventory.web.dto;
import de.effigenix.domain.inventory.StorageLocation;
import java.math.BigDecimal;
public record StorageLocationResponse(
String id,
String name,
String storageType,
TemperatureRangeResponse temperatureRange,
boolean active
) {
public static StorageLocationResponse from(StorageLocation location) {
TemperatureRangeResponse tempRange = null;
if (location.temperatureRange() != null) {
tempRange = new TemperatureRangeResponse(
location.temperatureRange().minTemperature(),
location.temperatureRange().maxTemperature()
);
}
return new StorageLocationResponse(
location.id().value(),
location.name().value(),
location.storageType().name(),
tempRange,
location.active()
);
}
public record TemperatureRangeResponse(BigDecimal minTemperature, BigDecimal maxTemperature) {}
}

View file

@ -0,0 +1,23 @@
package de.effigenix.infrastructure.inventory.web.exception;
import de.effigenix.domain.inventory.StorageLocationError;
public final class InventoryErrorHttpStatusMapper {
private InventoryErrorHttpStatusMapper() {}
public static int toHttpStatus(StorageLocationError error) {
return switch (error) {
case StorageLocationError.StorageLocationNotFound e -> 404;
case StorageLocationError.NameAlreadyExists e -> 409;
case StorageLocationError.StockExistsAtLocation e -> 409;
case StorageLocationError.AlreadyActive e -> 409;
case StorageLocationError.AlreadyInactive e -> 409;
case StorageLocationError.InvalidName e -> 400;
case StorageLocationError.InvalidTemperatureRange e -> 400;
case StorageLocationError.InvalidStorageType e -> 400;
case StorageLocationError.Unauthorized e -> 403;
case StorageLocationError.RepositoryFailure e -> 500;
};
}
}

View file

@ -1,10 +1,13 @@
package de.effigenix.infrastructure.usermanagement.web.exception;
import de.effigenix.domain.inventory.StorageLocationError;
import de.effigenix.domain.masterdata.ArticleError;
import de.effigenix.domain.masterdata.ProductCategoryError;
import de.effigenix.domain.masterdata.CustomerError;
import de.effigenix.domain.masterdata.SupplierError;
import de.effigenix.domain.usermanagement.UserError;
import de.effigenix.infrastructure.inventory.web.controller.StorageLocationController;
import de.effigenix.infrastructure.inventory.web.exception.InventoryErrorHttpStatusMapper;
import de.effigenix.infrastructure.masterdata.web.controller.ArticleController;
import de.effigenix.infrastructure.masterdata.web.controller.CustomerController;
import de.effigenix.infrastructure.masterdata.web.controller.ProductCategoryController;
@ -179,6 +182,25 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(status).body(errorResponse);
}
@ExceptionHandler(StorageLocationController.StorageLocationDomainErrorException.class)
public ResponseEntity<ErrorResponse> handleStorageLocationDomainError(
StorageLocationController.StorageLocationDomainErrorException ex,
HttpServletRequest request
) {
StorageLocationError error = ex.getError();
int status = InventoryErrorHttpStatusMapper.toHttpStatus(error);
logDomainError("StorageLocation", error.code(), error.message(), status);
ErrorResponse errorResponse = ErrorResponse.from(
error.code(),
error.message(),
status,
request.getRequestURI()
);
return ResponseEntity.status(status).body(errorResponse);
}
@ExceptionHandler(RoleController.RoleDomainErrorException.class)
public ResponseEntity<ErrorResponse> handleRoleDomainError(
RoleController.RoleDomainErrorException ex,

View file

@ -0,0 +1,41 @@
<?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="009-create-storage-locations-table" author="effigenix">
<createTable tableName="storage_locations">
<column name="id" type="VARCHAR(36)">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="name" type="VARCHAR(100)">
<constraints nullable="false" unique="true"/>
</column>
<column name="storage_type" type="VARCHAR(30)">
<constraints nullable="false"/>
</column>
<column name="min_temperature" type="DECIMAL(5,1)"/>
<column name="max_temperature" type="DECIMAL(5,1)"/>
<column name="active" type="BOOLEAN" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
</createTable>
<sql>
ALTER TABLE storage_locations ADD CONSTRAINT chk_storage_type CHECK (storage_type IN (
'COLD_ROOM', 'FREEZER', 'DRY_STORAGE', 'DISPLAY_COUNTER', 'PRODUCTION_AREA'
));
</sql>
<createIndex tableName="storage_locations" indexName="idx_storage_locations_name">
<column name="name"/>
</createIndex>
<createIndex tableName="storage_locations" indexName="idx_storage_locations_type">
<column name="storage_type"/>
</createIndex>
<createIndex tableName="storage_locations" indexName="idx_storage_locations_active">
<column name="active"/>
</createIndex>
</changeSet>
</databaseChangeLog>

View file

@ -13,5 +13,6 @@
<include file="db/changelog/changes/006-create-supplier-schema.xml"/>
<include file="db/changelog/changes/007-create-customer-schema.xml"/>
<include file="db/changelog/changes/008-add-masterdata-permissions.xml"/>
<include file="db/changelog/changes/009-create-storage-location-schema.xml"/>
</databaseChangeLog>