diff --git a/backend/src/main/java/de/effigenix/application/inventory/CreateInventoryCount.java b/backend/src/main/java/de/effigenix/application/inventory/CreateInventoryCount.java index 2a170b1..0a40988 100644 --- a/backend/src/main/java/de/effigenix/application/inventory/CreateInventoryCount.java +++ b/backend/src/main/java/de/effigenix/application/inventory/CreateInventoryCount.java @@ -4,22 +4,31 @@ import de.effigenix.application.inventory.command.CreateInventoryCountCommand; import de.effigenix.domain.inventory.*; import de.effigenix.shared.common.Result; import de.effigenix.shared.persistence.UnitOfWork; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; public class CreateInventoryCount { private final InventoryCountRepository inventoryCountRepository; private final StockRepository stockRepository; private final UnitOfWork unitOfWork; + private final AuthorizationPort authPort; public CreateInventoryCount(InventoryCountRepository inventoryCountRepository, StockRepository stockRepository, - UnitOfWork unitOfWork) { + UnitOfWork unitOfWork, + AuthorizationPort authPort) { this.inventoryCountRepository = inventoryCountRepository; this.stockRepository = stockRepository; this.unitOfWork = unitOfWork; + this.authPort = authPort; } - public Result execute(CreateInventoryCountCommand cmd) { + public Result execute(CreateInventoryCountCommand cmd, ActorId actorId) { + if (!authPort.can(actorId, InventoryAction.INVENTORY_COUNT_WRITE)) { + return Result.failure(new InventoryCountError.Unauthorized("Not authorized to create inventory counts")); + } + // 1. Draft aus Command bauen var draft = new InventoryCountDraft(cmd.storageLocationId(), cmd.countDate(), cmd.initiatedBy()); @@ -48,14 +57,16 @@ public class CreateInventoryCount { { return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); } case Result.Success(var stocks) -> { for (Stock stock : stocks) { - // Gesamtmenge aus Batches berechnen + // Skip stocks without batches – nothing to count + if (stock.batches().isEmpty()) { + continue; + } + var totalAmount = stock.batches().stream() .map(b -> b.quantity().amount()) .reduce(java.math.BigDecimal.ZERO, java.math.BigDecimal::add); - String unit = stock.batches().isEmpty() - ? "KILOGRAM" - : stock.batches().getFirst().quantity().uom().name(); + String unit = stock.batches().getFirst().quantity().uom().name(); var itemDraft = new CountItemDraft( stock.articleId().value(), diff --git a/backend/src/main/java/de/effigenix/application/inventory/GetInventoryCount.java b/backend/src/main/java/de/effigenix/application/inventory/GetInventoryCount.java index f23adbe..5d758c6 100644 --- a/backend/src/main/java/de/effigenix/application/inventory/GetInventoryCount.java +++ b/backend/src/main/java/de/effigenix/application/inventory/GetInventoryCount.java @@ -1,22 +1,31 @@ package de.effigenix.application.inventory; +import de.effigenix.domain.inventory.InventoryAction; import de.effigenix.domain.inventory.InventoryCount; import de.effigenix.domain.inventory.InventoryCountError; import de.effigenix.domain.inventory.InventoryCountId; import de.effigenix.domain.inventory.InventoryCountRepository; import de.effigenix.shared.common.Result; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; public class GetInventoryCount { private final InventoryCountRepository inventoryCountRepository; + private final AuthorizationPort authPort; - public GetInventoryCount(InventoryCountRepository inventoryCountRepository) { + public GetInventoryCount(InventoryCountRepository inventoryCountRepository, AuthorizationPort authPort) { this.inventoryCountRepository = inventoryCountRepository; + this.authPort = authPort; } - public Result execute(String inventoryCountId) { + public Result execute(String inventoryCountId, ActorId actorId) { + if (!authPort.can(actorId, InventoryAction.INVENTORY_COUNT_READ)) { + return Result.failure(new InventoryCountError.Unauthorized("Not authorized to view inventory counts")); + } + if (inventoryCountId == null || inventoryCountId.isBlank()) { - return Result.failure(new InventoryCountError.InventoryCountNotFound(inventoryCountId)); + return Result.failure(new InventoryCountError.InvalidInventoryCountId("must not be blank")); } return switch (inventoryCountRepository.findById(InventoryCountId.of(inventoryCountId))) { diff --git a/backend/src/main/java/de/effigenix/application/inventory/ListInventoryCounts.java b/backend/src/main/java/de/effigenix/application/inventory/ListInventoryCounts.java index 8edf57b..93ddd6b 100644 --- a/backend/src/main/java/de/effigenix/application/inventory/ListInventoryCounts.java +++ b/backend/src/main/java/de/effigenix/application/inventory/ListInventoryCounts.java @@ -3,18 +3,26 @@ package de.effigenix.application.inventory; import de.effigenix.domain.inventory.*; import de.effigenix.shared.common.RepositoryError; import de.effigenix.shared.common.Result; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; import java.util.List; public class ListInventoryCounts { private final InventoryCountRepository inventoryCountRepository; + private final AuthorizationPort authPort; - public ListInventoryCounts(InventoryCountRepository inventoryCountRepository) { + public ListInventoryCounts(InventoryCountRepository inventoryCountRepository, AuthorizationPort authPort) { this.inventoryCountRepository = inventoryCountRepository; + this.authPort = authPort; } - public Result> execute(String storageLocationId) { + public Result> execute(String storageLocationId, ActorId actorId) { + if (!authPort.can(actorId, InventoryAction.INVENTORY_COUNT_READ)) { + return Result.failure(new InventoryCountError.Unauthorized("Not authorized to view inventory counts")); + } + if (storageLocationId != null) { StorageLocationId locId; try { diff --git a/backend/src/main/java/de/effigenix/domain/inventory/CountItem.java b/backend/src/main/java/de/effigenix/domain/inventory/CountItem.java index 1bdb2c6..5080013 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/CountItem.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/CountItem.java @@ -74,12 +74,18 @@ public class CountItem { /** * Computed deviation: actualQuantity - expectedQuantity. - * Returns null if actualQuantity has not been set yet. + * Returns null if actualQuantity has not been set yet or if UOMs are incompatible. + * + * Invariant: actualQuantity and expectedQuantity should always share the same UOM. + * A UOM mismatch indicates a data integrity issue. */ public BigDecimal deviation() { if (actualQuantity == null) { return null; } + if (actualQuantity.uom() != expectedQuantity.uom()) { + return null; + } return actualQuantity.amount().subtract(expectedQuantity.amount()); } diff --git a/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountError.java b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountError.java index 3dfeda0..9df18af 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountError.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountError.java @@ -65,6 +65,11 @@ public sealed interface InventoryCountError { @Override public String message() { return "Counter must not be the same person who initiated the count"; } } + record InvalidInventoryCountId(String reason) implements InventoryCountError { + @Override public String code() { return "INVALID_INVENTORY_COUNT_ID"; } + @Override public String message() { return "Invalid inventory count ID: " + reason; } + } + record InventoryCountNotFound(String id) implements InventoryCountError { @Override public String code() { return "INVENTORY_COUNT_NOT_FOUND"; } @Override public String message() { return "Inventory count not found: " + id; } diff --git a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java index 9a2e760..12ba8a0 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java @@ -159,17 +159,17 @@ public class InventoryUseCaseConfiguration { // ==================== InventoryCount Use Cases ==================== @Bean - public CreateInventoryCount createInventoryCount(InventoryCountRepository inventoryCountRepository, StockRepository stockRepository, UnitOfWork unitOfWork) { - return new CreateInventoryCount(inventoryCountRepository, stockRepository, unitOfWork); + public CreateInventoryCount createInventoryCount(InventoryCountRepository inventoryCountRepository, StockRepository stockRepository, UnitOfWork unitOfWork, AuthorizationPort authorizationPort) { + return new CreateInventoryCount(inventoryCountRepository, stockRepository, unitOfWork, authorizationPort); } @Bean - public GetInventoryCount getInventoryCount(InventoryCountRepository inventoryCountRepository) { - return new GetInventoryCount(inventoryCountRepository); + public GetInventoryCount getInventoryCount(InventoryCountRepository inventoryCountRepository, AuthorizationPort authorizationPort) { + return new GetInventoryCount(inventoryCountRepository, authorizationPort); } @Bean - public ListInventoryCounts listInventoryCounts(InventoryCountRepository inventoryCountRepository) { - return new ListInventoryCounts(inventoryCountRepository); + public ListInventoryCounts listInventoryCounts(InventoryCountRepository inventoryCountRepository, AuthorizationPort authorizationPort) { + return new ListInventoryCounts(inventoryCountRepository, authorizationPort); } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JdbcInventoryCountRepository.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JdbcInventoryCountRepository.java index 3dc3373..c941c70 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JdbcInventoryCountRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JdbcInventoryCountRepository.java @@ -45,7 +45,7 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository { } return Result.success(Optional.of(loadChildren(countOpt.get(), id.value()))); } catch (Exception e) { - logger.trace("Database error in findById", e); + logger.warn("Database error in findById", e); return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); } } @@ -58,7 +58,7 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository { .list(); return Result.success(loadChildrenForAll(counts)); } catch (Exception e) { - logger.trace("Database error in findAll", e); + logger.warn("Database error in findAll", e); return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); } } @@ -72,7 +72,7 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository { .list(); return Result.success(loadChildrenForAll(counts)); } catch (Exception e) { - logger.trace("Database error in findByStorageLocationId", e); + logger.warn("Database error in findByStorageLocationId", e); return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); } } @@ -90,7 +90,7 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository { .single(); return Result.success(count > 0); } catch (Exception e) { - logger.trace("Database error in existsActiveByStorageLocationId", e); + logger.warn("Database error in existsActiveByStorageLocationId", e); return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); } } @@ -122,7 +122,7 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository { return Result.success(null); } catch (Exception e) { - logger.trace("Database error in save", e); + logger.warn("Database error in save", e); return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); } } @@ -142,29 +142,59 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository { private void saveChildren(InventoryCount count) { String countId = count.id().value(); - // Delete + re-insert count items - jdbc.sql("DELETE FROM count_items WHERE inventory_count_id = :countId") - .param("countId", countId) - .update(); + // Remove orphaned items no longer in the aggregate + List currentIds = count.countItems().stream() + .map(item -> item.id().value()) + .toList(); + if (currentIds.isEmpty()) { + jdbc.sql("DELETE FROM count_items WHERE inventory_count_id = :countId") + .param("countId", countId) + .update(); + } else { + jdbc.sql("DELETE FROM count_items WHERE inventory_count_id = :countId AND id NOT IN (:ids)") + .param("countId", countId) + .param("ids", currentIds) + .update(); + } + // Upsert each item (UPDATE → INSERT) for (CountItem item : count.countItems()) { - jdbc.sql(""" - INSERT INTO count_items - (id, inventory_count_id, article_id, - expected_quantity_amount, expected_quantity_unit, - actual_quantity_amount, actual_quantity_unit) - VALUES (:id, :countId, :articleId, - :expectedQuantityAmount, :expectedQuantityUnit, - :actualQuantityAmount, :actualQuantityUnit) + int rows = jdbc.sql(""" + UPDATE count_items + SET article_id = :articleId, + expected_quantity_amount = :expectedQuantityAmount, + expected_quantity_unit = :expectedQuantityUnit, + actual_quantity_amount = :actualQuantityAmount, + actual_quantity_unit = :actualQuantityUnit + WHERE id = :id """) .param("id", item.id().value()) - .param("countId", countId) .param("articleId", item.articleId().value()) .param("expectedQuantityAmount", item.expectedQuantity().amount()) .param("expectedQuantityUnit", item.expectedQuantity().uom().name()) .param("actualQuantityAmount", item.actualQuantity() != null ? item.actualQuantity().amount() : null) .param("actualQuantityUnit", item.actualQuantity() != null ? item.actualQuantity().uom().name() : null) .update(); + + if (rows == 0) { + jdbc.sql(""" + INSERT INTO count_items + (id, inventory_count_id, article_id, + expected_quantity_amount, expected_quantity_unit, + actual_quantity_amount, actual_quantity_unit) + VALUES (:id, :countId, :articleId, + :expectedQuantityAmount, :expectedQuantityUnit, + :actualQuantityAmount, :actualQuantityUnit) + """) + .param("id", item.id().value()) + .param("countId", countId) + .param("articleId", item.articleId().value()) + .param("expectedQuantityAmount", item.expectedQuantity().amount()) + .param("expectedQuantityUnit", item.expectedQuantity().uom().name()) + .param("actualQuantityAmount", item.actualQuantity() != null ? item.actualQuantity().amount() : null) + .param("actualQuantityUnit", item.actualQuantity() != null ? item.actualQuantity().uom().name() : null) + .update(); + } } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/InventoryCountController.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/InventoryCountController.java index 8e356fd..1403dbf 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/InventoryCountController.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/InventoryCountController.java @@ -7,6 +7,7 @@ import de.effigenix.application.inventory.command.CreateInventoryCountCommand; import de.effigenix.domain.inventory.InventoryCountError; import de.effigenix.infrastructure.inventory.web.dto.CreateInventoryCountRequest; import de.effigenix.infrastructure.inventory.web.dto.InventoryCountResponse; +import 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; @@ -48,7 +49,7 @@ public class InventoryCountController { authentication.getName() ); - var result = createInventoryCount.execute(cmd); + var result = createInventoryCount.execute(cmd, ActorId.of(authentication.getName())); if (result.isFailure()) { throw new InventoryCountDomainErrorException(result.unsafeGetError()); @@ -60,8 +61,11 @@ public class InventoryCountController { @GetMapping("/{id}") @PreAuthorize("hasAuthority('INVENTORY_COUNT_READ')") - public ResponseEntity getInventoryCount(@PathVariable String id) { - var result = getInventoryCount.execute(id); + public ResponseEntity getInventoryCount( + @PathVariable String id, + Authentication authentication + ) { + var result = getInventoryCount.execute(id, ActorId.of(authentication.getName())); if (result.isFailure()) { throw new InventoryCountDomainErrorException(result.unsafeGetError()); @@ -73,9 +77,10 @@ public class InventoryCountController { @GetMapping @PreAuthorize("hasAuthority('INVENTORY_COUNT_READ')") public ResponseEntity> listInventoryCounts( - @RequestParam(required = false) String storageLocationId + @RequestParam(required = false) String storageLocationId, + Authentication authentication ) { - var result = listInventoryCounts.execute(storageLocationId); + var result = listInventoryCounts.execute(storageLocationId, ActorId.of(authentication.getName())); if (result.isFailure()) { throw new InventoryCountDomainErrorException(result.unsafeGetError()); diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/exception/InventoryErrorHttpStatusMapper.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/exception/InventoryErrorHttpStatusMapper.java index f4eff44..d5e59e2 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/exception/InventoryErrorHttpStatusMapper.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/exception/InventoryErrorHttpStatusMapper.java @@ -69,6 +69,7 @@ public final class InventoryErrorHttpStatusMapper { case InventoryCountError.InvalidInitiatedBy e -> 400; case InventoryCountError.InvalidArticleId e -> 400; case InventoryCountError.InvalidQuantity e -> 400; + case InventoryCountError.InvalidInventoryCountId e -> 400; case InventoryCountError.Unauthorized e -> 403; case InventoryCountError.RepositoryFailure e -> 500; }; diff --git a/backend/src/main/java/de/effigenix/infrastructure/security/ApiAccessDeniedHandler.java b/backend/src/main/java/de/effigenix/infrastructure/security/ApiAccessDeniedHandler.java index c7ed729..a5fea4e 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/security/ApiAccessDeniedHandler.java +++ b/backend/src/main/java/de/effigenix/infrastructure/security/ApiAccessDeniedHandler.java @@ -1,7 +1,7 @@ package de.effigenix.infrastructure.security; import com.fasterxml.jackson.databind.ObjectMapper; -import de.effigenix.infrastructure.usermanagement.web.dto.ErrorResponse; +import de.effigenix.infrastructure.shared.web.exception.ErrorResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; diff --git a/backend/src/main/java/de/effigenix/infrastructure/security/ApiAuthenticationEntryPoint.java b/backend/src/main/java/de/effigenix/infrastructure/security/ApiAuthenticationEntryPoint.java index ef880ee..1ed3aeb 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/security/ApiAuthenticationEntryPoint.java +++ b/backend/src/main/java/de/effigenix/infrastructure/security/ApiAuthenticationEntryPoint.java @@ -1,7 +1,7 @@ package de.effigenix.infrastructure.security; import com.fasterxml.jackson.databind.ObjectMapper; -import de.effigenix.infrastructure.usermanagement.web.dto.ErrorResponse; +import de.effigenix.infrastructure.shared.web.exception.ErrorResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; diff --git a/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/dto/ErrorResponse.java b/backend/src/main/java/de/effigenix/infrastructure/shared/web/exception/ErrorResponse.java similarity index 97% rename from backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/dto/ErrorResponse.java rename to backend/src/main/java/de/effigenix/infrastructure/shared/web/exception/ErrorResponse.java index 4ebd394..7b79ba8 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/dto/ErrorResponse.java +++ b/backend/src/main/java/de/effigenix/infrastructure/shared/web/exception/ErrorResponse.java @@ -1,4 +1,4 @@ -package de.effigenix.infrastructure.usermanagement.web.dto; +package de.effigenix.infrastructure.shared.web.exception; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java b/backend/src/main/java/de/effigenix/infrastructure/shared/web/exception/GlobalExceptionHandler.java similarity index 92% rename from backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java rename to backend/src/main/java/de/effigenix/infrastructure/shared/web/exception/GlobalExceptionHandler.java index 38e3018..56cbfde 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/de/effigenix/infrastructure/shared/web/exception/GlobalExceptionHandler.java @@ -1,4 +1,4 @@ -package de.effigenix.infrastructure.usermanagement.web.exception; +package de.effigenix.infrastructure.shared.web.exception; import de.effigenix.domain.inventory.InventoryCountError; import de.effigenix.domain.inventory.StockMovementError; @@ -30,7 +30,7 @@ import de.effigenix.infrastructure.masterdata.web.exception.MasterDataErrorHttpS import de.effigenix.infrastructure.usermanagement.web.controller.AuthController; import de.effigenix.infrastructure.usermanagement.web.controller.RoleController; import de.effigenix.infrastructure.usermanagement.web.controller.UserController; -import de.effigenix.infrastructure.usermanagement.web.dto.ErrorResponse; +import de.effigenix.infrastructure.usermanagement.web.exception.UserErrorHttpStatusMapper; import io.sentry.Sentry; import jakarta.servlet.http.HttpServletRequest; @@ -372,10 +372,6 @@ public class GlobalExceptionHandler { * Handles validation errors from @Valid annotations. * * Returns 400 Bad Request with list of validation errors. - * Example: - * - Username is required - * - Email must be valid - * - Password must be at least 8 characters * * @param ex Validation exception * @param request HTTP request @@ -412,17 +408,6 @@ public class GlobalExceptionHandler { .body(errorResponse); } - /** - * Handles authentication errors (e.g., invalid JWT token). - * - * Returns 401 Unauthorized. - * This is typically caught by SecurityConfig's authenticationEntryPoint, - * but included here for completeness. - * - * @param ex Authentication exception - * @param request HTTP request - * @return Error response with 401 status - */ @ExceptionHandler(AuthenticationException.class) public ResponseEntity handleAuthenticationError( AuthenticationException ex, @@ -442,16 +427,6 @@ public class GlobalExceptionHandler { .body(errorResponse); } - /** - * Handles authorization errors (missing permissions). - * - * Returns 403 Forbidden. - * Triggered when user lacks required permission for an action. - * - * @param ex Access denied exception - * @param request HTTP request - * @return Error response with 403 status - */ @ExceptionHandler(AccessDeniedException.class) public ResponseEntity handleAccessDeniedError( AccessDeniedException ex, @@ -471,15 +446,6 @@ public class GlobalExceptionHandler { .body(errorResponse); } - /** - * Handles illegal arguments (e.g., invalid UUID format). - * - * Returns 400 Bad Request. - * - * @param ex Illegal argument exception - * @param request HTTP request - * @return Error response with 400 status - */ @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity handleIllegalArgumentError( IllegalArgumentException ex, @@ -499,18 +465,6 @@ public class GlobalExceptionHandler { .body(errorResponse); } - /** - * Handles unexpected runtime errors. - * - * Returns 500 Internal Server Error. - * Logs full stack trace for debugging. - * - * IMPORTANT: Do not expose internal error details to clients in production! - * - * @param ex Runtime exception - * @param request HTTP request - * @return Error response with 500 status - */ @ExceptionHandler(RuntimeException.class) public ResponseEntity handleRuntimeError( RuntimeException ex, diff --git a/backend/src/main/resources/db/changelog/changes/036-add-inventory-counts-composite-index.xml b/backend/src/main/resources/db/changelog/changes/036-add-inventory-counts-composite-index.xml new file mode 100644 index 0000000..588d78e --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/036-add-inventory-counts-composite-index.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/backend/src/main/resources/db/changelog/db.changelog-master.xml b/backend/src/main/resources/db/changelog/db.changelog-master.xml index 3f347ac..ef93ffe 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -41,5 +41,6 @@ + diff --git a/backend/src/test/java/de/effigenix/application/inventory/CreateInventoryCountTest.java b/backend/src/test/java/de/effigenix/application/inventory/CreateInventoryCountTest.java index 72b2cf8..6063997 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/CreateInventoryCountTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/CreateInventoryCountTest.java @@ -8,6 +8,8 @@ import de.effigenix.shared.common.RepositoryError; import de.effigenix.shared.common.Result; import de.effigenix.shared.common.UnitOfMeasure; import de.effigenix.shared.persistence.UnitOfWork; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -31,12 +33,15 @@ class CreateInventoryCountTest { @Mock private InventoryCountRepository inventoryCountRepository; @Mock private StockRepository stockRepository; @Mock private UnitOfWork unitOfWork; + @Mock private AuthorizationPort authPort; private CreateInventoryCount createInventoryCount; + private final ActorId actorId = ActorId.of("user-1"); @BeforeEach void setUp() { - createInventoryCount = new CreateInventoryCount(inventoryCountRepository, stockRepository, unitOfWork); + createInventoryCount = new CreateInventoryCount(inventoryCountRepository, stockRepository, unitOfWork, authPort); + when(authPort.can(any(ActorId.class), any())).thenReturn(true); } private void stubUnitOfWork() { @@ -86,7 +91,7 @@ class CreateInventoryCountTest { when(inventoryCountRepository.save(any(InventoryCount.class))) .thenReturn(Result.success(null)); - var result = createInventoryCount.execute(cmd); + var result = createInventoryCount.execute(cmd, actorId); assertThat(result.isSuccess()).isTrue(); var count = result.unsafeGetValue(); @@ -111,7 +116,7 @@ class CreateInventoryCountTest { when(inventoryCountRepository.save(any(InventoryCount.class))) .thenReturn(Result.success(null)); - var result = createInventoryCount.execute(cmd); + var result = createInventoryCount.execute(cmd, actorId); assertThat(result.isSuccess()).isTrue(); assertThat(result.unsafeGetValue().countItems()).isEmpty(); @@ -125,7 +130,7 @@ class CreateInventoryCountTest { when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1"))) .thenReturn(Result.success(true)); - var result = createInventoryCount.execute(cmd); + var result = createInventoryCount.execute(cmd, actorId); assertThat(result.isFailure()).isTrue(); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.ActiveCountExists.class); @@ -136,7 +141,7 @@ class CreateInventoryCountTest { void shouldFailWhenStorageLocationIdInvalid() { var cmd = new CreateInventoryCountCommand("", LocalDate.now().toString(), "user-1"); - var result = createInventoryCount.execute(cmd); + var result = createInventoryCount.execute(cmd, actorId); assertThat(result.isFailure()).isTrue(); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStorageLocationId.class); @@ -147,7 +152,7 @@ class CreateInventoryCountTest { void shouldFailWhenCountDateInFuture() { var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().plusDays(1).toString(), "user-1"); - var result = createInventoryCount.execute(cmd); + var result = createInventoryCount.execute(cmd, actorId); assertThat(result.isFailure()).isTrue(); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.CountDateInFuture.class); @@ -161,7 +166,7 @@ class CreateInventoryCountTest { when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1"))) .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); - var result = createInventoryCount.execute(cmd); + var result = createInventoryCount.execute(cmd, actorId); assertThat(result.isFailure()).isTrue(); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); @@ -177,7 +182,7 @@ class CreateInventoryCountTest { when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1"))) .thenReturn(Result.failure(new RepositoryError.DatabaseError("stock db down"))); - var result = createInventoryCount.execute(cmd); + var result = createInventoryCount.execute(cmd, actorId); assertThat(result.isFailure()).isTrue(); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); @@ -196,15 +201,15 @@ class CreateInventoryCountTest { when(inventoryCountRepository.save(any(InventoryCount.class))) .thenReturn(Result.failure(new RepositoryError.DatabaseError("save failed"))); - var result = createInventoryCount.execute(cmd); + var result = createInventoryCount.execute(cmd, actorId); assertThat(result.isFailure()).isTrue(); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); } @Test - @DisplayName("should handle stock without batches (zero quantity)") - void shouldHandleStockWithoutBatches() { + @DisplayName("should skip stocks without batches") + void shouldSkipStocksWithoutBatches() { stubUnitOfWork(); var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), "user-1"); @@ -221,12 +226,10 @@ class CreateInventoryCountTest { when(inventoryCountRepository.save(any(InventoryCount.class))) .thenReturn(Result.success(null)); - var result = createInventoryCount.execute(cmd); + var result = createInventoryCount.execute(cmd, actorId); assertThat(result.isSuccess()).isTrue(); - assertThat(result.unsafeGetValue().countItems()).hasSize(1); - assertThat(result.unsafeGetValue().countItems().getFirst().expectedQuantity().amount()) - .isEqualByComparingTo(BigDecimal.ZERO); + assertThat(result.unsafeGetValue().countItems()).isEmpty(); } @Test @@ -262,7 +265,7 @@ class CreateInventoryCountTest { when(inventoryCountRepository.save(any(InventoryCount.class))) .thenReturn(Result.success(null)); - var result = createInventoryCount.execute(cmd); + var result = createInventoryCount.execute(cmd, actorId); assertThat(result.isSuccess()).isTrue(); assertThat(result.unsafeGetValue().countItems()).hasSize(2); @@ -273,7 +276,7 @@ class CreateInventoryCountTest { void shouldFailWhenInitiatedByNull() { var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), null); - var result = createInventoryCount.execute(cmd); + var result = createInventoryCount.execute(cmd, actorId); assertThat(result.isFailure()).isTrue(); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInitiatedBy.class); @@ -284,7 +287,7 @@ class CreateInventoryCountTest { void shouldFailWhenCountDateUnparseable() { var cmd = new CreateInventoryCountCommand("location-1", "not-a-date", "user-1"); - var result = createInventoryCount.execute(cmd); + var result = createInventoryCount.execute(cmd, actorId); assertThat(result.isFailure()).isTrue(); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidCountDate.class); diff --git a/backend/src/test/java/de/effigenix/application/inventory/GetInventoryCountTest.java b/backend/src/test/java/de/effigenix/application/inventory/GetInventoryCountTest.java index 6d665fb..dee000e 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/GetInventoryCountTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/GetInventoryCountTest.java @@ -3,6 +3,8 @@ package de.effigenix.application.inventory; import de.effigenix.domain.inventory.*; import de.effigenix.shared.common.RepositoryError; import de.effigenix.shared.common.Result; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -16,6 +18,7 @@ import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -23,13 +26,16 @@ import static org.mockito.Mockito.when; class GetInventoryCountTest { @Mock private InventoryCountRepository inventoryCountRepository; + @Mock private AuthorizationPort authPort; private GetInventoryCount getInventoryCount; private InventoryCount existingCount; + private final ActorId actorId = ActorId.of("user-1"); @BeforeEach void setUp() { - getInventoryCount = new GetInventoryCount(inventoryCountRepository); + getInventoryCount = new GetInventoryCount(inventoryCountRepository, authPort); + when(authPort.can(any(ActorId.class), any())).thenReturn(true); existingCount = InventoryCount.reconstitute( InventoryCountId.of("count-1"), @@ -48,7 +54,7 @@ class GetInventoryCountTest { when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) .thenReturn(Result.success(Optional.of(existingCount))); - var result = getInventoryCount.execute("count-1"); + var result = getInventoryCount.execute("count-1", actorId); assertThat(result.isSuccess()).isTrue(); assertThat(result.unsafeGetValue().id().value()).isEqualTo("count-1"); @@ -60,7 +66,7 @@ class GetInventoryCountTest { when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) .thenReturn(Result.success(Optional.empty())); - var result = getInventoryCount.execute("count-1"); + var result = getInventoryCount.execute("count-1", actorId); assertThat(result.isFailure()).isTrue(); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class); @@ -72,36 +78,36 @@ class GetInventoryCountTest { when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); - var result = getInventoryCount.execute("count-1"); + var result = getInventoryCount.execute("count-1", actorId); assertThat(result.isFailure()).isTrue(); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); } @Test - @DisplayName("should fail with InventoryCountNotFound when id is null") + @DisplayName("should fail with InvalidInventoryCountId when id is null") void shouldFailWhenIdIsNull() { - var result = getInventoryCount.execute(null); + var result = getInventoryCount.execute(null, actorId); assertThat(result.isFailure()).isTrue(); - assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInventoryCountId.class); } @Test - @DisplayName("should fail with InventoryCountNotFound when id is blank") + @DisplayName("should fail with InvalidInventoryCountId when id is blank") void shouldFailWhenIdIsBlank() { - var result = getInventoryCount.execute(" "); + var result = getInventoryCount.execute(" ", actorId); assertThat(result.isFailure()).isTrue(); - assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInventoryCountId.class); } @Test - @DisplayName("should fail with InventoryCountNotFound when id is empty string") + @DisplayName("should fail with InvalidInventoryCountId when id is empty string") void shouldFailWhenIdIsEmpty() { - var result = getInventoryCount.execute(""); + var result = getInventoryCount.execute("", actorId); assertThat(result.isFailure()).isTrue(); - assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInventoryCountId.class); } } diff --git a/backend/src/test/java/de/effigenix/application/inventory/ListInventoryCountsTest.java b/backend/src/test/java/de/effigenix/application/inventory/ListInventoryCountsTest.java index b46f49a..96f2d01 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/ListInventoryCountsTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/ListInventoryCountsTest.java @@ -3,6 +3,8 @@ package de.effigenix.application.inventory; import de.effigenix.domain.inventory.*; import de.effigenix.shared.common.RepositoryError; import de.effigenix.shared.common.Result; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -15,6 +17,7 @@ import java.time.LocalDate; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -22,14 +25,17 @@ import static org.mockito.Mockito.*; class ListInventoryCountsTest { @Mock private InventoryCountRepository inventoryCountRepository; + @Mock private AuthorizationPort authPort; private ListInventoryCounts listInventoryCounts; private InventoryCount count1; private InventoryCount count2; + private final ActorId actorId = ActorId.of("user-1"); @BeforeEach void setUp() { - listInventoryCounts = new ListInventoryCounts(inventoryCountRepository); + listInventoryCounts = new ListInventoryCounts(inventoryCountRepository, authPort); + when(authPort.can(any(ActorId.class), any())).thenReturn(true); count1 = InventoryCount.reconstitute( InventoryCountId.of("count-1"), @@ -57,7 +63,7 @@ class ListInventoryCountsTest { void shouldReturnAllCountsWhenNoFilter() { when(inventoryCountRepository.findAll()).thenReturn(Result.success(List.of(count1, count2))); - var result = listInventoryCounts.execute(null); + var result = listInventoryCounts.execute(null, actorId); assertThat(result.isSuccess()).isTrue(); assertThat(result.unsafeGetValue()).hasSize(2); @@ -70,7 +76,7 @@ class ListInventoryCountsTest { when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1"))) .thenReturn(Result.success(List.of(count1))); - var result = listInventoryCounts.execute("location-1"); + var result = listInventoryCounts.execute("location-1", actorId); assertThat(result.isSuccess()).isTrue(); assertThat(result.unsafeGetValue()).hasSize(1); @@ -84,7 +90,7 @@ class ListInventoryCountsTest { when(inventoryCountRepository.findAll()) .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); - var result = listInventoryCounts.execute(null); + var result = listInventoryCounts.execute(null, actorId); assertThat(result.isFailure()).isTrue(); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); @@ -96,7 +102,7 @@ class ListInventoryCountsTest { when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1"))) .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); - var result = listInventoryCounts.execute("location-1"); + var result = listInventoryCounts.execute("location-1", actorId); assertThat(result.isFailure()).isTrue(); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); @@ -108,7 +114,7 @@ class ListInventoryCountsTest { when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("unknown"))) .thenReturn(Result.success(List.of())); - var result = listInventoryCounts.execute("unknown"); + var result = listInventoryCounts.execute("unknown", actorId); assertThat(result.isSuccess()).isTrue(); assertThat(result.unsafeGetValue()).isEmpty(); @@ -117,7 +123,7 @@ class ListInventoryCountsTest { @Test @DisplayName("should fail with InvalidStorageLocationId for blank storageLocationId") void shouldFailWhenBlankStorageLocationId() { - var result = listInventoryCounts.execute(" "); + var result = listInventoryCounts.execute(" ", actorId); assertThat(result.isFailure()).isTrue(); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStorageLocationId.class); diff --git a/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountTest.java b/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountTest.java index a8b6562..b4c1f88 100644 --- a/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountTest.java +++ b/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountTest.java @@ -429,6 +429,20 @@ class InventoryCountTest { assertThat(item.deviation()).isEqualByComparingTo(new BigDecimal("2.5")); } + @Test + @DisplayName("should return null when UOMs differ") + void shouldReturnNullWhenUomsDiffer() { + var item = CountItem.reconstitute( + CountItemId.of("item-1"), + ArticleId.of("article-1"), + Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), + Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.LITER) + ); + + assertThat(item.isCounted()).isTrue(); + assertThat(item.deviation()).isNull(); + } + @Test @DisplayName("should compute zero deviation (actual == expected)") void shouldComputeZeroDeviation() {