From fa6c0c2d70ede8e922208247bce0698f3aada3dd Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Tue, 24 Feb 2026 22:58:57 +0100 Subject: [PATCH] =?UTF-8?q?feat(inventory):=20Bestandsbewegung=20erfassen?= =?UTF-8?q?=20(StockMovement)=20=E2=80=93=20Issue=20#15?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Immutables StockMovement-Aggregate als Audit-Trail für jede Bestandsveränderung. Domain-Invarianten: positive Quantity, Reason bei WASTE/ADJUSTMENT, ReferenceDocumentId bei INTER_BRANCH_TRANSFER, Direction-Ableitung aus MovementType. Domain: StockMovement, MovementType (8 Typen), MovementDirection, StockMovementError Application: RecordStockMovement, GetStockMovement, ListStockMovements Infrastructure: JPA-Persistence, REST-Controller (POST/GET), Liquibase 028+029 Tests: ~40 Domain-Unit-Tests, 18 Application-Tests, ~27 Integrationstests Loadtest: Gatling-Szenarien für Bestandsbewegungen (Seeding, Read, Write) --- .../inventory/GetStockMovement.java | 40 + .../inventory/ListStockMovements.java | 83 ++ .../inventory/RecordStockMovement.java | 53 ++ .../command/RecordStockMovementCommand.java | 16 + .../domain/inventory/MovementDirection.java | 5 + .../domain/inventory/MovementType.java | 12 + .../domain/inventory/StockMovement.java | 267 +++++++ .../domain/inventory/StockMovementDraft.java | 34 + .../domain/inventory/StockMovementError.java | 70 ++ .../domain/inventory/StockMovementId.java | 20 + .../inventory/StockMovementRepository.java | 23 + .../config/InventoryUseCaseConfiguration.java | 21 + .../entity/StockMovementEntity.java | 120 +++ .../mapper/StockMovementMapper.java | 51 ++ .../JpaStockMovementRepository.java | 107 +++ .../StockMovementJpaRepository.java | 15 + .../controller/StockMovementController.java | 121 +++ .../web/dto/RecordStockMovementRequest.java | 17 + .../web/dto/StockMovementResponse.java | 46 ++ .../InventoryErrorHttpStatusMapper.java | 19 + .../web/exception/GlobalExceptionHandler.java | 25 + .../028-create-stock-movements-table.xml | 97 +++ .../029-seed-stock-movement-permissions.xml | 28 + .../db/changelog/db.changelog-master.xml | 2 + .../inventory/GetStockMovementTest.java | 125 +++ .../inventory/ListStockMovementsTest.java | 226 ++++++ .../inventory/RecordStockMovementTest.java | 106 +++ .../domain/inventory/StockMovementTest.java | 722 ++++++++++++++++++ ...tockMovementControllerIntegrationTest.java | 603 +++++++++++++++ .../infrastructure/LoadTestDataSeeder.java | 94 ++- .../loadtest/scenario/InventoryScenario.java | 65 +- .../simulation/FullWorkloadSimulation.java | 5 +- 32 files changed, 3229 insertions(+), 9 deletions(-) create mode 100644 backend/src/main/java/de/effigenix/application/inventory/GetStockMovement.java create mode 100644 backend/src/main/java/de/effigenix/application/inventory/ListStockMovements.java create mode 100644 backend/src/main/java/de/effigenix/application/inventory/RecordStockMovement.java create mode 100644 backend/src/main/java/de/effigenix/application/inventory/command/RecordStockMovementCommand.java create mode 100644 backend/src/main/java/de/effigenix/domain/inventory/MovementDirection.java create mode 100644 backend/src/main/java/de/effigenix/domain/inventory/MovementType.java create mode 100644 backend/src/main/java/de/effigenix/domain/inventory/StockMovement.java create mode 100644 backend/src/main/java/de/effigenix/domain/inventory/StockMovementDraft.java create mode 100644 backend/src/main/java/de/effigenix/domain/inventory/StockMovementError.java create mode 100644 backend/src/main/java/de/effigenix/domain/inventory/StockMovementId.java create mode 100644 backend/src/main/java/de/effigenix/domain/inventory/StockMovementRepository.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/StockMovementEntity.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/mapper/StockMovementMapper.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JpaStockMovementRepository.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/StockMovementJpaRepository.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockMovementController.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/RecordStockMovementRequest.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockMovementResponse.java create mode 100644 backend/src/main/resources/db/changelog/changes/028-create-stock-movements-table.xml create mode 100644 backend/src/main/resources/db/changelog/changes/029-seed-stock-movement-permissions.xml create mode 100644 backend/src/test/java/de/effigenix/application/inventory/GetStockMovementTest.java create mode 100644 backend/src/test/java/de/effigenix/application/inventory/ListStockMovementsTest.java create mode 100644 backend/src/test/java/de/effigenix/application/inventory/RecordStockMovementTest.java create mode 100644 backend/src/test/java/de/effigenix/domain/inventory/StockMovementTest.java create mode 100644 backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockMovementControllerIntegrationTest.java diff --git a/backend/src/main/java/de/effigenix/application/inventory/GetStockMovement.java b/backend/src/main/java/de/effigenix/application/inventory/GetStockMovement.java new file mode 100644 index 0000000..123df50 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/GetStockMovement.java @@ -0,0 +1,40 @@ +package de.effigenix.application.inventory; + +import de.effigenix.domain.inventory.InventoryAction; +import de.effigenix.domain.inventory.StockMovement; +import de.effigenix.domain.inventory.StockMovementError; +import de.effigenix.domain.inventory.StockMovementId; +import de.effigenix.domain.inventory.StockMovementRepository; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; +import org.springframework.transaction.annotation.Transactional; + +@Transactional(readOnly = true) +public class GetStockMovement { + + private final StockMovementRepository stockMovementRepository; + private final AuthorizationPort authPort; + + public GetStockMovement(StockMovementRepository stockMovementRepository, AuthorizationPort authPort) { + this.stockMovementRepository = stockMovementRepository; + this.authPort = authPort; + } + + public Result execute(String stockMovementId, ActorId performedBy) { + if (!authPort.can(performedBy, InventoryAction.STOCK_MOVEMENT_READ)) { + return Result.failure(new StockMovementError.Unauthorized("Not authorized to view stock movements")); + } + + if (stockMovementId == null || stockMovementId.isBlank()) { + return Result.failure(new StockMovementError.StockMovementNotFound(stockMovementId)); + } + + return switch (stockMovementRepository.findById(StockMovementId.of(stockMovementId))) { + case Result.Failure(var err) -> Result.failure(new StockMovementError.RepositoryFailure(err.message())); + case Result.Success(var opt) -> opt + .>map(Result::success) + .orElseGet(() -> Result.failure(new StockMovementError.StockMovementNotFound(stockMovementId))); + }; + } +} diff --git a/backend/src/main/java/de/effigenix/application/inventory/ListStockMovements.java b/backend/src/main/java/de/effigenix/application/inventory/ListStockMovements.java new file mode 100644 index 0000000..38a4890 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/ListStockMovements.java @@ -0,0 +1,83 @@ +package de.effigenix.application.inventory; + +import de.effigenix.domain.inventory.InventoryAction; +import de.effigenix.domain.inventory.MovementType; +import de.effigenix.domain.inventory.StockId; +import de.effigenix.domain.inventory.StockMovement; +import de.effigenix.domain.inventory.StockMovementError; +import de.effigenix.domain.inventory.StockMovementRepository; +import de.effigenix.domain.masterdata.ArticleId; +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.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Transactional(readOnly = true) +public class ListStockMovements { + + private final StockMovementRepository stockMovementRepository; + private final AuthorizationPort authPort; + + public ListStockMovements(StockMovementRepository stockMovementRepository, AuthorizationPort authPort) { + this.stockMovementRepository = stockMovementRepository; + this.authPort = authPort; + } + + /** + * Lists stock movements with optional filtering. + * Filter priority (only one filter applied): stockId > articleId > movementType. + * If multiple filters are provided, only the highest-priority filter is used. + */ + public Result> execute( + String stockId, String articleId, String movementType, ActorId performedBy) { + + if (!authPort.can(performedBy, InventoryAction.STOCK_MOVEMENT_READ)) { + return Result.failure(new StockMovementError.Unauthorized("Not authorized to list stock movements")); + } + + + if (stockId != null) { + StockId sid; + try { + sid = StockId.of(stockId); + } catch (IllegalArgumentException e) { + return Result.failure(new StockMovementError.InvalidStockId(e.getMessage())); + } + return mapResult(stockMovementRepository.findAllByStockId(sid)); + } + + if (articleId != null) { + ArticleId aid; + try { + aid = ArticleId.of(articleId); + } catch (IllegalArgumentException e) { + return Result.failure(new StockMovementError.InvalidArticleId(e.getMessage())); + } + return mapResult(stockMovementRepository.findAllByArticleId(aid)); + } + + if (movementType != null) { + MovementType type; + try { + type = MovementType.valueOf(movementType); + } catch (IllegalArgumentException e) { + return Result.failure(new StockMovementError.InvalidMovementType( + "Invalid movement type: " + movementType)); + } + return mapResult(stockMovementRepository.findAllByMovementType(type)); + } + + return mapResult(stockMovementRepository.findAll()); + } + + private Result> mapResult( + Result> result) { + return switch (result) { + case Result.Failure(var err) -> Result.failure(new StockMovementError.RepositoryFailure(err.message())); + case Result.Success(var movements) -> Result.success(movements); + }; + } +} diff --git a/backend/src/main/java/de/effigenix/application/inventory/RecordStockMovement.java b/backend/src/main/java/de/effigenix/application/inventory/RecordStockMovement.java new file mode 100644 index 0000000..cee956c --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/RecordStockMovement.java @@ -0,0 +1,53 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.RecordStockMovementCommand; +import de.effigenix.domain.inventory.InventoryAction; +import de.effigenix.domain.inventory.StockMovement; +import de.effigenix.domain.inventory.StockMovementDraft; +import de.effigenix.domain.inventory.StockMovementError; +import de.effigenix.domain.inventory.StockMovementRepository; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +public class RecordStockMovement { + + private final StockMovementRepository stockMovementRepository; + private final AuthorizationPort authPort; + + public RecordStockMovement(StockMovementRepository stockMovementRepository, AuthorizationPort authPort) { + this.stockMovementRepository = stockMovementRepository; + this.authPort = authPort; + } + + public Result execute(RecordStockMovementCommand cmd, ActorId performedBy) { + if (!authPort.can(performedBy, InventoryAction.STOCK_MOVEMENT_WRITE)) { + return Result.failure(new StockMovementError.Unauthorized("Not authorized to record stock movements")); + } + + var draft = new StockMovementDraft( + cmd.stockId(), cmd.articleId(), cmd.stockBatchId(), + cmd.batchId(), cmd.batchType(), + cmd.movementType(), cmd.direction(), + cmd.quantityAmount(), cmd.quantityUnit(), + cmd.reason(), cmd.referenceDocumentId(), + cmd.performedBy() + ); + + StockMovement movement; + switch (StockMovement.record(draft)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var val) -> movement = val; + } + + switch (stockMovementRepository.save(movement)) { + case Result.Failure(var err) -> + { return Result.failure(new StockMovementError.RepositoryFailure(err.message())); } + case Result.Success(var ignored) -> { } + } + + return Result.success(movement); + } +} diff --git a/backend/src/main/java/de/effigenix/application/inventory/command/RecordStockMovementCommand.java b/backend/src/main/java/de/effigenix/application/inventory/command/RecordStockMovementCommand.java new file mode 100644 index 0000000..105a4e1 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/command/RecordStockMovementCommand.java @@ -0,0 +1,16 @@ +package de.effigenix.application.inventory.command; + +public record RecordStockMovementCommand( + String stockId, + String articleId, + String stockBatchId, + String batchId, + String batchType, + String movementType, + String direction, + String quantityAmount, + String quantityUnit, + String reason, + String referenceDocumentId, + String performedBy +) {} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/MovementDirection.java b/backend/src/main/java/de/effigenix/domain/inventory/MovementDirection.java new file mode 100644 index 0000000..3f0cce6 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/MovementDirection.java @@ -0,0 +1,5 @@ +package de.effigenix.domain.inventory; + +public enum MovementDirection { + IN, OUT +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/MovementType.java b/backend/src/main/java/de/effigenix/domain/inventory/MovementType.java new file mode 100644 index 0000000..64d9ac5 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/MovementType.java @@ -0,0 +1,12 @@ +package de.effigenix.domain.inventory; + +public enum MovementType { + GOODS_RECEIPT, + PRODUCTION_OUTPUT, + PRODUCTION_CONSUMPTION, + SALE, + INTER_BRANCH_TRANSFER, + WASTE, + ADJUSTMENT, + RETURN +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StockMovement.java b/backend/src/main/java/de/effigenix/domain/inventory/StockMovement.java new file mode 100644 index 0000000..38d70ff --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/StockMovement.java @@ -0,0 +1,267 @@ +package de.effigenix.domain.inventory; + +import de.effigenix.domain.masterdata.ArticleId; +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.Instant; +import java.util.Objects; + +/** + * StockMovement aggregate root – immutable after creation. + * + * Invariants: + * - StockId, ArticleId, StockBatchId are required and non-blank + * - BatchReference (batchId + batchType) is required + * - MovementType is required + * - Direction is derived from MovementType, except ADJUSTMENT where it must be provided + * - Quantity must be positive + * - WASTE and ADJUSTMENT require a non-empty reason + * - INTER_BRANCH_TRANSFER requires a referenceDocumentId; always recorded as OUT + * (the receiving branch records a separate GOODS_RECEIPT movement) + * - PerformedBy is required and non-blank + */ +public class StockMovement { + + private final StockMovementId id; + private final StockId stockId; + private final ArticleId articleId; + private final StockBatchId stockBatchId; + private final BatchReference batchReference; + private final MovementType movementType; + private final MovementDirection direction; + private final Quantity quantity; + private final String reason; + private final String referenceDocumentId; + private final String performedBy; + private final Instant performedAt; + + private StockMovement( + StockMovementId id, + StockId stockId, + ArticleId articleId, + StockBatchId stockBatchId, + BatchReference batchReference, + MovementType movementType, + MovementDirection direction, + Quantity quantity, + String reason, + String referenceDocumentId, + String performedBy, + Instant performedAt + ) { + this.id = id; + this.stockId = stockId; + this.articleId = articleId; + this.stockBatchId = stockBatchId; + this.batchReference = batchReference; + this.movementType = movementType; + this.direction = direction; + this.quantity = quantity; + this.reason = reason; + this.referenceDocumentId = referenceDocumentId; + this.performedBy = performedBy; + this.performedAt = performedAt; + } + + /** + * Factory: Erzeugt eine neue StockMovement aus rohen Eingaben. + * Orchestriert Validierung aller VOs intern. + */ + public static Result record(StockMovementDraft draft) { + // 1. StockId validieren + StockId stockId; + try { + stockId = StockId.of(draft.stockId()); + } catch (IllegalArgumentException e) { + return Result.failure(new StockMovementError.InvalidStockId(e.getMessage())); + } + + // 2. ArticleId validieren + ArticleId articleId; + try { + articleId = ArticleId.of(draft.articleId()); + } catch (IllegalArgumentException e) { + return Result.failure(new StockMovementError.InvalidArticleId(e.getMessage())); + } + + // 3. StockBatchId validieren + StockBatchId stockBatchId; + try { + stockBatchId = StockBatchId.of(draft.stockBatchId()); + } catch (IllegalArgumentException e) { + return Result.failure(new StockMovementError.InvalidStockBatchId(e.getMessage())); + } + + // 4. BatchReference validieren + if (draft.batchId() == null || draft.batchId().isBlank()) { + return Result.failure(new StockMovementError.InvalidBatchReference("batchId must not be blank")); + } + BatchType batchType; + try { + batchType = BatchType.valueOf(draft.batchType()); + } catch (IllegalArgumentException | NullPointerException e) { + return Result.failure(new StockMovementError.InvalidBatchReference( + "Invalid batchType: " + draft.batchType() + ". Allowed values: PRODUCED, PURCHASED")); + } + var batchReference = new BatchReference(draft.batchId(), batchType); + + // 5. MovementType validieren + MovementType movementType; + try { + movementType = MovementType.valueOf(draft.movementType()); + } catch (IllegalArgumentException | NullPointerException e) { + return Result.failure(new StockMovementError.InvalidMovementType( + "Invalid movement type: " + draft.movementType())); + } + + // 6. Direction ableiten oder validieren + MovementDirection direction; + if (movementType == MovementType.ADJUSTMENT) { + if (draft.direction() == null || draft.direction().isBlank()) { + return Result.failure(new StockMovementError.InvalidDirection( + "Direction is required for ADJUSTMENT movements")); + } + try { + direction = MovementDirection.valueOf(draft.direction()); + } catch (IllegalArgumentException e) { + return Result.failure(new StockMovementError.InvalidDirection( + "Invalid direction: " + draft.direction() + ". Allowed values: IN, OUT")); + } + } else { + direction = deriveDirection(movementType); + } + + // 7. Quantity validieren + Quantity quantity; + try { + BigDecimal amount = new BigDecimal(draft.quantityAmount()); + UnitOfMeasure uom; + try { + uom = UnitOfMeasure.valueOf(draft.quantityUnit()); + } catch (IllegalArgumentException | NullPointerException e) { + return Result.failure(new StockMovementError.InvalidQuantity("Invalid unit: " + draft.quantityUnit())); + } + switch (Quantity.of(amount, uom)) { + case Result.Failure(var err) -> { + return Result.failure(new StockMovementError.InvalidQuantity(err.message())); + } + case Result.Success(var val) -> quantity = val; + } + } catch (NumberFormatException | NullPointerException e) { + return Result.failure(new StockMovementError.InvalidQuantity( + "Invalid quantity amount: " + draft.quantityAmount())); + } + + // 8. Reason-Pflicht bei WASTE und ADJUSTMENT + if ((movementType == MovementType.WASTE || movementType == MovementType.ADJUSTMENT) + && (draft.reason() == null || draft.reason().isBlank())) { + return Result.failure(new StockMovementError.ReasonRequired(movementType.name())); + } + + // 9. ReferenceDocumentId-Pflicht bei INTER_BRANCH_TRANSFER + if (movementType == MovementType.INTER_BRANCH_TRANSFER + && (draft.referenceDocumentId() == null || draft.referenceDocumentId().isBlank())) { + return Result.failure(new StockMovementError.ReferenceDocumentRequired(movementType.name())); + } + + // 10. PerformedBy validieren + if (draft.performedBy() == null || draft.performedBy().isBlank()) { + return Result.failure(new StockMovementError.InvalidPerformedBy("must not be blank")); + } + + return Result.success(new StockMovement( + StockMovementId.generate(), + stockId, + articleId, + stockBatchId, + batchReference, + movementType, + direction, + quantity, + draft.reason(), + draft.referenceDocumentId(), + draft.performedBy(), + Instant.now() + )); + } + + /** + * Reconstitute from persistence without re-validation. + */ + public static StockMovement reconstitute( + StockMovementId id, + StockId stockId, + ArticleId articleId, + StockBatchId stockBatchId, + BatchReference batchReference, + MovementType movementType, + MovementDirection direction, + Quantity quantity, + String reason, + String referenceDocumentId, + String performedBy, + Instant performedAt + ) { + return new StockMovement(id, stockId, articleId, stockBatchId, batchReference, + movementType, direction, quantity, reason, referenceDocumentId, performedBy, performedAt); + } + + // ==================== Queries ==================== + + public boolean isIncoming() { + return direction == MovementDirection.IN; + } + + public boolean isOutgoing() { + return direction == MovementDirection.OUT; + } + + // ==================== Getters ==================== + + public StockMovementId id() { return id; } + public StockId stockId() { return stockId; } + public ArticleId articleId() { return articleId; } + public StockBatchId stockBatchId() { return stockBatchId; } + public BatchReference batchReference() { return batchReference; } + public MovementType movementType() { return movementType; } + public MovementDirection direction() { return direction; } + public Quantity quantity() { return quantity; } + public String reason() { return reason; } + public String referenceDocumentId() { return referenceDocumentId; } + public String performedBy() { return performedBy; } + public Instant performedAt() { return performedAt; } + + // ==================== Helpers ==================== + + /** + * Derives direction from movement type. Must NOT be called for ADJUSTMENT + * (which requires explicit direction and is handled before this method is called). + */ + private static MovementDirection deriveDirection(MovementType type) { + return switch (type) { + case GOODS_RECEIPT, PRODUCTION_OUTPUT, RETURN -> MovementDirection.IN; + case PRODUCTION_CONSUMPTION, SALE, WASTE, INTER_BRANCH_TRANSFER -> MovementDirection.OUT; + case ADJUSTMENT -> MovementDirection.IN; // unreachable – ADJUSTMENT handled before call + }; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof StockMovement other)) return false; + return Objects.equals(id, other.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return "StockMovement{id=" + id + ", type=" + movementType + ", direction=" + direction + "}"; + } +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StockMovementDraft.java b/backend/src/main/java/de/effigenix/domain/inventory/StockMovementDraft.java new file mode 100644 index 0000000..10d5f67 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/StockMovementDraft.java @@ -0,0 +1,34 @@ +package de.effigenix.domain.inventory; + +/** + * Rohe Eingabe zum Erzeugen einer StockMovement. + * Wird vom Application Layer aus dem Command gebaut und an StockMovement.record() übergeben. + * Das Aggregate ist verantwortlich für Validierung und VO-Konstruktion. + * + * @param stockId Pflicht + * @param articleId Pflicht + * @param stockBatchId Pflicht + * @param batchId Pflicht – Chargen-Kennung + * @param batchType Pflicht – PRODUCED oder PURCHASED + * @param movementType Pflicht – einer der 8 MovementTypes + * @param direction Nur bei ADJUSTMENT Pflicht (IN/OUT), sonst ignoriert + * @param quantityAmount Pflicht – positiv + * @param quantityUnit Pflicht – UnitOfMeasure als String + * @param reason Pflicht bei WASTE und ADJUSTMENT, optional sonst + * @param referenceDocumentId Pflicht bei INTER_BRANCH_TRANSFER, optional sonst + * @param performedBy Pflicht – User-ID + */ +public record StockMovementDraft( + String stockId, + String articleId, + String stockBatchId, + String batchId, + String batchType, + String movementType, + String direction, + String quantityAmount, + String quantityUnit, + String reason, + String referenceDocumentId, + String performedBy +) {} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StockMovementError.java b/backend/src/main/java/de/effigenix/domain/inventory/StockMovementError.java new file mode 100644 index 0000000..6234f5c --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/StockMovementError.java @@ -0,0 +1,70 @@ +package de.effigenix.domain.inventory; + +public sealed interface StockMovementError { + + String code(); + String message(); + + record InvalidStockId(String reason) implements StockMovementError { + @Override public String code() { return "INVALID_STOCK_ID"; } + @Override public String message() { return "Invalid stock ID: " + reason; } + } + + record InvalidArticleId(String reason) implements StockMovementError { + @Override public String code() { return "INVALID_ARTICLE_ID"; } + @Override public String message() { return "Invalid article ID: " + reason; } + } + + record InvalidStockBatchId(String reason) implements StockMovementError { + @Override public String code() { return "INVALID_STOCK_BATCH_ID"; } + @Override public String message() { return "Invalid stock batch ID: " + reason; } + } + + record InvalidBatchReference(String reason) implements StockMovementError { + @Override public String code() { return "INVALID_BATCH_REFERENCE"; } + @Override public String message() { return "Invalid batch reference: " + reason; } + } + + record InvalidMovementType(String reason) implements StockMovementError { + @Override public String code() { return "INVALID_MOVEMENT_TYPE"; } + @Override public String message() { return "Invalid movement type: " + reason; } + } + + record InvalidDirection(String reason) implements StockMovementError { + @Override public String code() { return "INVALID_DIRECTION"; } + @Override public String message() { return "Invalid direction: " + reason; } + } + + record InvalidQuantity(String reason) implements StockMovementError { + @Override public String code() { return "INVALID_QUANTITY"; } + @Override public String message() { return "Invalid quantity: " + reason; } + } + + record ReasonRequired(String movementType) implements StockMovementError { + @Override public String code() { return "REASON_REQUIRED"; } + @Override public String message() { return "Reason is required for movement type: " + movementType; } + } + + record ReferenceDocumentRequired(String movementType) implements StockMovementError { + @Override public String code() { return "REFERENCE_DOCUMENT_REQUIRED"; } + @Override public String message() { return "Reference document ID is required for movement type: " + movementType; } + } + + record InvalidPerformedBy(String reason) implements StockMovementError { + @Override public String code() { return "INVALID_PERFORMED_BY"; } + @Override public String message() { return "Invalid performed by: " + reason; } + } + + record StockMovementNotFound(String id) implements StockMovementError { + @Override public String code() { return "STOCK_MOVEMENT_NOT_FOUND"; } + @Override public String message() { return "Stock movement not found: " + id; } + } + + record Unauthorized(String message) implements StockMovementError { + @Override public String code() { return "UNAUTHORIZED"; } + } + + record RepositoryFailure(String message) implements StockMovementError { + @Override public String code() { return "REPOSITORY_ERROR"; } + } +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StockMovementId.java b/backend/src/main/java/de/effigenix/domain/inventory/StockMovementId.java new file mode 100644 index 0000000..594582f --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/StockMovementId.java @@ -0,0 +1,20 @@ +package de.effigenix.domain.inventory; + +import java.util.UUID; + +public record StockMovementId(String value) { + + public StockMovementId { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("StockMovementId must not be blank"); + } + } + + public static StockMovementId generate() { + return new StockMovementId(UUID.randomUUID().toString()); + } + + public static StockMovementId of(String value) { + return new StockMovementId(value); + } +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StockMovementRepository.java b/backend/src/main/java/de/effigenix/domain/inventory/StockMovementRepository.java new file mode 100644 index 0000000..be3da4f --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/StockMovementRepository.java @@ -0,0 +1,23 @@ +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.List; +import java.util.Optional; + +public interface StockMovementRepository { + + Result> findById(StockMovementId id); + + Result> findAll(); + + Result> findAllByStockId(StockId stockId); + + Result> findAllByArticleId(ArticleId articleId); + + Result> findAllByMovementType(MovementType movementType); + + Result save(StockMovement stockMovement); +} 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 857ef38..3fac362 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java @@ -1,5 +1,8 @@ package de.effigenix.infrastructure.config; +import de.effigenix.application.inventory.GetStockMovement; +import de.effigenix.application.inventory.ListStockMovements; +import de.effigenix.application.inventory.RecordStockMovement; import de.effigenix.application.inventory.ActivateStorageLocation; import de.effigenix.application.inventory.AddStockBatch; import de.effigenix.application.inventory.BlockStockBatch; @@ -19,6 +22,7 @@ import de.effigenix.application.inventory.GetStorageLocation; import de.effigenix.application.inventory.ListStorageLocations; import de.effigenix.application.inventory.UpdateStorageLocation; import de.effigenix.application.usermanagement.AuditLogger; +import de.effigenix.domain.inventory.StockMovementRepository; import de.effigenix.domain.inventory.StockRepository; import de.effigenix.domain.inventory.StorageLocationRepository; import de.effigenix.shared.security.AuthorizationPort; @@ -121,4 +125,21 @@ public class InventoryUseCaseConfiguration { public ListStocksBelowMinimum listStocksBelowMinimum(StockRepository stockRepository, AuthorizationPort authorizationPort) { return new ListStocksBelowMinimum(stockRepository, authorizationPort); } + + // ==================== StockMovement Use Cases ==================== + + @Bean + public RecordStockMovement recordStockMovement(StockMovementRepository stockMovementRepository, AuthorizationPort authorizationPort) { + return new RecordStockMovement(stockMovementRepository, authorizationPort); + } + + @Bean + public GetStockMovement getStockMovement(StockMovementRepository stockMovementRepository, AuthorizationPort authorizationPort) { + return new GetStockMovement(stockMovementRepository, authorizationPort); + } + + @Bean + public ListStockMovements listStockMovements(StockMovementRepository stockMovementRepository, AuthorizationPort authorizationPort) { + return new ListStockMovements(stockMovementRepository, authorizationPort); + } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/StockMovementEntity.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/StockMovementEntity.java new file mode 100644 index 0000000..97ced4b --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/StockMovementEntity.java @@ -0,0 +1,120 @@ +package de.effigenix.infrastructure.inventory.persistence.entity; + +import jakarta.persistence.*; + +import java.math.BigDecimal; +import java.time.Instant; + +@Entity +@Table(name = "stock_movements") +public class StockMovementEntity { + + @Id + @Column(name = "id", nullable = false, length = 36) + private String id; + + @Column(name = "stock_id", nullable = false, length = 36) + private String stockId; + + @Column(name = "article_id", nullable = false, length = 36) + private String articleId; + + @Column(name = "stock_batch_id", nullable = false, length = 36) + private String stockBatchId; + + @Column(name = "batch_id", nullable = false, length = 100) + private String batchId; + + @Column(name = "batch_type", nullable = false, length = 20) + private String batchType; + + @Column(name = "movement_type", nullable = false, length = 30) + private String movementType; + + @Column(name = "direction", nullable = false, length = 10) + private String direction; + + @Column(name = "quantity_amount", nullable = false, precision = 19, scale = 6) + private BigDecimal quantityAmount; + + @Column(name = "quantity_unit", nullable = false, length = 20) + private String quantityUnit; + + @Column(name = "reason", length = 500) + private String reason; + + @Column(name = "reference_document_id", length = 100) + private String referenceDocumentId; + + @Column(name = "performed_by", nullable = false, length = 36) + private String performedBy; + + @Column(name = "performed_at", nullable = false) + private Instant performedAt; + + public StockMovementEntity() {} + + public StockMovementEntity(String id, String stockId, String articleId, String stockBatchId, + String batchId, String batchType, String movementType, String direction, + BigDecimal quantityAmount, String quantityUnit, String reason, + String referenceDocumentId, String performedBy, Instant performedAt) { + this.id = id; + this.stockId = stockId; + this.articleId = articleId; + this.stockBatchId = stockBatchId; + this.batchId = batchId; + this.batchType = batchType; + this.movementType = movementType; + this.direction = direction; + this.quantityAmount = quantityAmount; + this.quantityUnit = quantityUnit; + this.reason = reason; + this.referenceDocumentId = referenceDocumentId; + this.performedBy = performedBy; + this.performedAt = performedAt; + } + + // ==================== Getters & Setters ==================== + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getStockId() { return stockId; } + public void setStockId(String stockId) { this.stockId = stockId; } + + public String getArticleId() { return articleId; } + public void setArticleId(String articleId) { this.articleId = articleId; } + + public String getStockBatchId() { return stockBatchId; } + public void setStockBatchId(String stockBatchId) { this.stockBatchId = stockBatchId; } + + public String getBatchId() { return batchId; } + public void setBatchId(String batchId) { this.batchId = batchId; } + + public String getBatchType() { return batchType; } + public void setBatchType(String batchType) { this.batchType = batchType; } + + public String getMovementType() { return movementType; } + public void setMovementType(String movementType) { this.movementType = movementType; } + + public String getDirection() { return direction; } + public void setDirection(String direction) { this.direction = direction; } + + public BigDecimal getQuantityAmount() { return quantityAmount; } + public void setQuantityAmount(BigDecimal quantityAmount) { this.quantityAmount = quantityAmount; } + + public String getQuantityUnit() { return quantityUnit; } + public void setQuantityUnit(String quantityUnit) { this.quantityUnit = quantityUnit; } + + public String getReason() { return reason; } + public void setReason(String reason) { this.reason = reason; } + + public String getReferenceDocumentId() { return referenceDocumentId; } + public void setReferenceDocumentId(String referenceDocumentId) { this.referenceDocumentId = referenceDocumentId; } + + public String getPerformedBy() { return performedBy; } + public void setPerformedBy(String performedBy) { this.performedBy = performedBy; } + + public Instant getPerformedAt() { return performedAt; } + public void setPerformedAt(Instant performedAt) { this.performedAt = performedAt; } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/mapper/StockMovementMapper.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/mapper/StockMovementMapper.java new file mode 100644 index 0000000..7c7a3fa --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/mapper/StockMovementMapper.java @@ -0,0 +1,51 @@ +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.StockMovementEntity; +import org.springframework.stereotype.Component; + +@Component +public class StockMovementMapper { + + public StockMovementEntity toEntity(StockMovement movement) { + return new StockMovementEntity( + movement.id().value(), + movement.stockId().value(), + movement.articleId().value(), + movement.stockBatchId().value(), + movement.batchReference().batchId(), + movement.batchReference().batchType().name(), + movement.movementType().name(), + movement.direction().name(), + movement.quantity().amount(), + movement.quantity().uom().name(), + movement.reason(), + movement.referenceDocumentId(), + movement.performedBy(), + movement.performedAt() + ); + } + + public StockMovement toDomain(StockMovementEntity entity) { + return StockMovement.reconstitute( + StockMovementId.of(entity.getId()), + StockId.of(entity.getStockId()), + ArticleId.of(entity.getArticleId()), + StockBatchId.of(entity.getStockBatchId()), + new BatchReference(entity.getBatchId(), BatchType.valueOf(entity.getBatchType())), + MovementType.valueOf(entity.getMovementType()), + MovementDirection.valueOf(entity.getDirection()), + Quantity.reconstitute( + entity.getQuantityAmount(), + UnitOfMeasure.valueOf(entity.getQuantityUnit()) + ), + entity.getReason(), + entity.getReferenceDocumentId(), + entity.getPerformedBy(), + entity.getPerformedAt() + ); + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JpaStockMovementRepository.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JpaStockMovementRepository.java new file mode 100644 index 0000000..5aea26c --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JpaStockMovementRepository.java @@ -0,0 +1,107 @@ +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.StockMovementMapper; +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; + +@Repository +@Profile("!no-db") +@Transactional(readOnly = true) +public class JpaStockMovementRepository implements StockMovementRepository { + + private static final Logger logger = LoggerFactory.getLogger(JpaStockMovementRepository.class); + + private final StockMovementJpaRepository jpaRepository; + private final StockMovementMapper mapper; + + public JpaStockMovementRepository(StockMovementJpaRepository jpaRepository, StockMovementMapper mapper) { + this.jpaRepository = jpaRepository; + this.mapper = mapper; + } + + @Override + public Result> findById(StockMovementId id) { + try { + Optional result = jpaRepository.findById(id.value()) + .map(mapper::toDomain); + return Result.success(result); + } catch (Exception e) { + logger.warn("Database error in findById", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result> findAll() { + try { + List result = jpaRepository.findAll().stream() + .map(mapper::toDomain) + .toList(); + return Result.success(result); + } catch (Exception e) { + logger.warn("Database error in findAll", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result> findAllByStockId(StockId stockId) { + try { + List result = jpaRepository.findAllByStockId(stockId.value()).stream() + .map(mapper::toDomain) + .toList(); + return Result.success(result); + } catch (Exception e) { + logger.warn("Database error in findAllByStockId", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result> findAllByArticleId(ArticleId articleId) { + try { + List result = jpaRepository.findAllByArticleId(articleId.value()).stream() + .map(mapper::toDomain) + .toList(); + return Result.success(result); + } catch (Exception e) { + logger.warn("Database error in findAllByArticleId", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result> findAllByMovementType(MovementType movementType) { + try { + List result = jpaRepository.findAllByMovementType(movementType.name()).stream() + .map(mapper::toDomain) + .toList(); + return Result.success(result); + } catch (Exception e) { + logger.warn("Database error in findAllByMovementType", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + @Transactional + public Result save(StockMovement stockMovement) { + try { + jpaRepository.save(mapper.toEntity(stockMovement)); + return Result.success(null); + } catch (Exception e) { + logger.warn("Database error in save", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/StockMovementJpaRepository.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/StockMovementJpaRepository.java new file mode 100644 index 0000000..094167b --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/StockMovementJpaRepository.java @@ -0,0 +1,15 @@ +package de.effigenix.infrastructure.inventory.persistence.repository; + +import de.effigenix.infrastructure.inventory.persistence.entity.StockMovementEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface StockMovementJpaRepository extends JpaRepository { + + List findAllByStockId(String stockId); + + List findAllByArticleId(String articleId); + + List findAllByMovementType(String movementType); +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockMovementController.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockMovementController.java new file mode 100644 index 0000000..9393383 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockMovementController.java @@ -0,0 +1,121 @@ +package de.effigenix.infrastructure.inventory.web.controller; + +import de.effigenix.application.inventory.GetStockMovement; +import de.effigenix.application.inventory.ListStockMovements; +import de.effigenix.application.inventory.RecordStockMovement; +import de.effigenix.application.inventory.command.RecordStockMovementCommand; +import de.effigenix.domain.inventory.StockMovementError; +import de.effigenix.infrastructure.inventory.web.dto.RecordStockMovementRequest; +import de.effigenix.infrastructure.inventory.web.dto.StockMovementResponse; +import de.effigenix.shared.security.ActorId; +import io.swagger.v3.oas.annotations.Operation; +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.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/inventory/stock-movements") +@SecurityRequirement(name = "Bearer Authentication") +@Tag(name = "Stock Movements", description = "Stock movement tracking endpoints") +public class StockMovementController { + + private static final Logger logger = LoggerFactory.getLogger(StockMovementController.class); + + private final RecordStockMovement recordStockMovement; + private final GetStockMovement getStockMovement; + private final ListStockMovements listStockMovements; + + public StockMovementController(RecordStockMovement recordStockMovement, + GetStockMovement getStockMovement, + ListStockMovements listStockMovements) { + this.recordStockMovement = recordStockMovement; + this.getStockMovement = getStockMovement; + this.listStockMovements = listStockMovements; + } + + @PostMapping + @PreAuthorize("hasAuthority('STOCK_MOVEMENT_WRITE')") + public ResponseEntity recordMovement( + @Valid @RequestBody RecordStockMovementRequest request, + Authentication authentication + ) { + logger.info("Recording stock movement type: {} for stock: {} by actor: {}", + request.movementType(), request.stockId(), authentication.getName()); + + var cmd = new RecordStockMovementCommand( + request.stockId(), request.articleId(), request.stockBatchId(), + request.batchId(), request.batchType(), + request.movementType(), request.direction(), + request.quantityAmount(), request.quantityUnit(), + request.reason(), request.referenceDocumentId(), + authentication.getName() + ); + var result = recordStockMovement.execute(cmd, ActorId.of(authentication.getName())); + + if (result.isFailure()) { + throw new StockMovementDomainErrorException(result.unsafeGetError()); + } + + logger.info("Stock movement recorded: {}", result.unsafeGetValue().id().value()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(StockMovementResponse.from(result.unsafeGetValue())); + } + + @GetMapping + @PreAuthorize("hasAuthority('STOCK_MOVEMENT_READ')") + @Operation(summary = "List stock movements", + description = "Filter priority (only one filter applied): stockId > articleId > movementType") + public ResponseEntity> listMovements( + @RequestParam(required = false) String stockId, + @RequestParam(required = false) String articleId, + @RequestParam(required = false) String movementType, + Authentication authentication + ) { + var result = listStockMovements.execute(stockId, articleId, movementType, + ActorId.of(authentication.getName())); + + if (result.isFailure()) { + throw new StockMovementDomainErrorException(result.unsafeGetError()); + } + + List responses = result.unsafeGetValue().stream() + .map(StockMovementResponse::from) + .toList(); + return ResponseEntity.ok(responses); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAuthority('STOCK_MOVEMENT_READ')") + public ResponseEntity getMovement(@PathVariable String id, + Authentication authentication) { + var result = getStockMovement.execute(id, ActorId.of(authentication.getName())); + + if (result.isFailure()) { + throw new StockMovementDomainErrorException(result.unsafeGetError()); + } + + return ResponseEntity.ok(StockMovementResponse.from(result.unsafeGetValue())); + } + + public static class StockMovementDomainErrorException extends RuntimeException { + private final StockMovementError error; + + public StockMovementDomainErrorException(StockMovementError error) { + super(error.message()); + this.error = error; + } + + public StockMovementError getError() { + return error; + } + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/RecordStockMovementRequest.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/RecordStockMovementRequest.java new file mode 100644 index 0000000..8a5068e --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/RecordStockMovementRequest.java @@ -0,0 +1,17 @@ +package de.effigenix.infrastructure.inventory.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record RecordStockMovementRequest( + @NotBlank String stockId, + @NotBlank String articleId, + @NotBlank String stockBatchId, + @NotBlank String batchId, + @NotBlank String batchType, + @NotBlank String movementType, + String direction, + @NotBlank String quantityAmount, + @NotBlank String quantityUnit, + String reason, + String referenceDocumentId +) {} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockMovementResponse.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockMovementResponse.java new file mode 100644 index 0000000..fae4036 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockMovementResponse.java @@ -0,0 +1,46 @@ +package de.effigenix.infrastructure.inventory.web.dto; + +import de.effigenix.domain.inventory.StockMovement; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; +import java.time.Instant; + +@Schema(requiredProperties = {"id", "stockId", "articleId", "stockBatchId", "batchId", "batchType", + "movementType", "direction", "quantityAmount", "quantityUnit", "performedBy", "performedAt"}) +public record StockMovementResponse( + String id, + String stockId, + String articleId, + String stockBatchId, + String batchId, + String batchType, + String movementType, + String direction, + BigDecimal quantityAmount, + String quantityUnit, + @Schema(nullable = true) String reason, + @Schema(nullable = true) String referenceDocumentId, + String performedBy, + Instant performedAt +) { + + public static StockMovementResponse from(StockMovement movement) { + return new StockMovementResponse( + movement.id().value(), + movement.stockId().value(), + movement.articleId().value(), + movement.stockBatchId().value(), + movement.batchReference().batchId(), + movement.batchReference().batchType().name(), + movement.movementType().name(), + movement.direction().name(), + movement.quantity().amount(), + movement.quantity().uom().name(), + movement.reason(), + movement.referenceDocumentId(), + movement.performedBy(), + movement.performedAt() + ); + } +} 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 b158999..a420a7c 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 @@ -1,5 +1,6 @@ package de.effigenix.infrastructure.inventory.web.exception; +import de.effigenix.domain.inventory.StockMovementError; import de.effigenix.domain.inventory.StorageLocationError; import de.effigenix.domain.inventory.StockError; @@ -49,4 +50,22 @@ public final class InventoryErrorHttpStatusMapper { case StockError.RepositoryFailure e -> 500; }; } + + public static int toHttpStatus(StockMovementError error) { + return switch (error) { + case StockMovementError.StockMovementNotFound e -> 404; + case StockMovementError.InvalidStockId e -> 400; + case StockMovementError.InvalidArticleId e -> 400; + case StockMovementError.InvalidStockBatchId e -> 400; + case StockMovementError.InvalidBatchReference e -> 400; + case StockMovementError.InvalidMovementType e -> 400; + case StockMovementError.InvalidDirection e -> 400; + case StockMovementError.InvalidQuantity e -> 400; + case StockMovementError.ReasonRequired e -> 400; + case StockMovementError.ReferenceDocumentRequired e -> 400; + case StockMovementError.InvalidPerformedBy e -> 400; + case StockMovementError.Unauthorized e -> 403; + case StockMovementError.RepositoryFailure e -> 500; + }; + } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java b/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java index e8ccd30..c2f68ac 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java @@ -1,5 +1,6 @@ package de.effigenix.infrastructure.usermanagement.web.exception; +import de.effigenix.domain.inventory.StockMovementError; import de.effigenix.domain.inventory.StorageLocationError; import de.effigenix.domain.inventory.StockError; import de.effigenix.domain.masterdata.ArticleError; @@ -10,6 +11,7 @@ import de.effigenix.domain.production.BatchError; import de.effigenix.domain.production.ProductionOrderError; import de.effigenix.domain.production.RecipeError; import de.effigenix.domain.usermanagement.UserError; +import de.effigenix.infrastructure.inventory.web.controller.StockMovementController; import de.effigenix.infrastructure.inventory.web.controller.StorageLocationController; import de.effigenix.infrastructure.inventory.web.controller.StockController; import de.effigenix.infrastructure.inventory.web.exception.InventoryErrorHttpStatusMapper; @@ -211,6 +213,29 @@ public class GlobalExceptionHandler { return ResponseEntity.status(status).body(errorResponse); } + @ExceptionHandler(StockMovementController.StockMovementDomainErrorException.class) + public ResponseEntity handleStockMovementDomainError( + StockMovementController.StockMovementDomainErrorException ex, + HttpServletRequest request + ) { + StockMovementError error = ex.getError(); + int status = InventoryErrorHttpStatusMapper.toHttpStatus(error); + logDomainError("StockMovement", 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(StockController.StockDomainErrorException.class) public ResponseEntity handleStockDomainError( StockController.StockDomainErrorException ex, diff --git a/backend/src/main/resources/db/changelog/changes/028-create-stock-movements-table.xml b/backend/src/main/resources/db/changelog/changes/028-create-stock-movements-table.xml new file mode 100644 index 0000000..df99150 --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/028-create-stock-movements-table.xml @@ -0,0 +1,97 @@ + + + + + Create stock_movements table for immutable stock movement audit trail + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ALTER TABLE stock_movements ADD CONSTRAINT chk_stock_movements_batch_type + CHECK (batch_type IN ('PRODUCED', 'PURCHASED')); + + ALTER TABLE stock_movements ADD CONSTRAINT chk_stock_movements_movement_type + CHECK (movement_type IN ('GOODS_RECEIPT', 'PRODUCTION_OUTPUT', 'PRODUCTION_CONSUMPTION', + 'SALE', 'INTER_BRANCH_TRANSFER', 'WASTE', 'ADJUSTMENT', 'RETURN')); + + ALTER TABLE stock_movements ADD CONSTRAINT chk_stock_movements_direction + CHECK (direction IN ('IN', 'OUT')); + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/db/changelog/changes/029-seed-stock-movement-permissions.xml b/backend/src/main/resources/db/changelog/changes/029-seed-stock-movement-permissions.xml new file mode 100644 index 0000000..c477450 --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/029-seed-stock-movement-permissions.xml @@ -0,0 +1,28 @@ + + + + + + + + SELECT COUNT(*) FROM role_permissions + WHERE role_id = 'c0a80121-0000-0000-0000-000000000001' + AND permission = 'STOCK_MOVEMENT_READ' + + + + Add STOCK_MOVEMENT_READ and STOCK_MOVEMENT_WRITE permissions for ADMIN role + + + INSERT INTO role_permissions (role_id, permission) VALUES + ('c0a80121-0000-0000-0000-000000000001', 'STOCK_MOVEMENT_READ'), + ('c0a80121-0000-0000-0000-000000000001', 'STOCK_MOVEMENT_WRITE') + ON CONFLICT DO NOTHING; + + + + 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 400f7cb..72fe12b 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -32,5 +32,7 @@ + + diff --git a/backend/src/test/java/de/effigenix/application/inventory/GetStockMovementTest.java b/backend/src/test/java/de/effigenix/application/inventory/GetStockMovementTest.java new file mode 100644 index 0000000..83cdb22 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/GetStockMovementTest.java @@ -0,0 +1,125 @@ +package de.effigenix.application.inventory; + +import de.effigenix.domain.inventory.*; +import de.effigenix.domain.masterdata.ArticleId; +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.common.UnitOfMeasure; +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.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.Instant; +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) +@DisplayName("GetStockMovement Use Case") +class GetStockMovementTest { + + @Mock private StockMovementRepository repository; + @Mock private AuthorizationPort authPort; + + private GetStockMovement useCase; + private StockMovement existingMovement; + private final ActorId actor = ActorId.of("user-1"); + + @BeforeEach + void setUp() { + useCase = new GetStockMovement(repository, authPort); + when(authPort.can(any(ActorId.class), any())).thenReturn(true); + + existingMovement = StockMovement.reconstitute( + StockMovementId.of("movement-1"), + StockId.of("stock-1"), + ArticleId.of("article-1"), + StockBatchId.of("batch-1"), + new BatchReference("CHARGE-001", BatchType.PRODUCED), + MovementType.GOODS_RECEIPT, + MovementDirection.IN, + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM), + null, null, "user-1", Instant.now() + ); + } + + @Test + @DisplayName("should return movement when found") + void shouldReturnMovementWhenFound() { + when(repository.findById(StockMovementId.of("movement-1"))) + .thenReturn(Result.success(Optional.of(existingMovement))); + + var result = useCase.execute("movement-1", actor); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().id().value()).isEqualTo("movement-1"); + } + + @Test + @DisplayName("should fail with StockMovementNotFound when not found") + void shouldFailWhenNotFound() { + when(repository.findById(StockMovementId.of("movement-1"))) + .thenReturn(Result.success(Optional.empty())); + + var result = useCase.execute("movement-1", actor); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.StockMovementNotFound.class); + } + + @Test + @DisplayName("should fail with RepositoryFailure when repository fails") + void shouldFailWhenRepositoryFails() { + when(repository.findById(StockMovementId.of("movement-1"))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = useCase.execute("movement-1", actor); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail with StockMovementNotFound when id is null") + void shouldFailWhenIdIsNull() { + var result = useCase.execute(null, actor); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.StockMovementNotFound.class); + } + + @Test + @DisplayName("should fail with StockMovementNotFound when id is blank") + void shouldFailWhenIdIsBlank() { + var result = useCase.execute(" ", actor); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.StockMovementNotFound.class); + } + + @Nested + @DisplayName("Authorization") + class Authorization { + + @Test + @DisplayName("should fail with Unauthorized when actor lacks STOCK_MOVEMENT_READ") + void shouldFailWhenUnauthorized() { + when(authPort.can(actor, InventoryAction.STOCK_MOVEMENT_READ)).thenReturn(false); + + var result = useCase.execute("movement-1", actor); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.Unauthorized.class); + } + } +} diff --git a/backend/src/test/java/de/effigenix/application/inventory/ListStockMovementsTest.java b/backend/src/test/java/de/effigenix/application/inventory/ListStockMovementsTest.java new file mode 100644 index 0000000..75e2f50 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/ListStockMovementsTest.java @@ -0,0 +1,226 @@ +package de.effigenix.application.inventory; + +import de.effigenix.domain.inventory.*; +import de.effigenix.domain.masterdata.ArticleId; +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.common.UnitOfMeasure; +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.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.Instant; +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) +@DisplayName("ListStockMovements Use Case") +class ListStockMovementsTest { + + @Mock private StockMovementRepository repository; + @Mock private AuthorizationPort authPort; + + private ListStockMovements useCase; + private StockMovement sampleMovement; + private final ActorId actor = ActorId.of("user-1"); + + @BeforeEach + void setUp() { + useCase = new ListStockMovements(repository, authPort); + when(authPort.can(any(ActorId.class), any())).thenReturn(true); + + sampleMovement = StockMovement.reconstitute( + StockMovementId.generate(), + StockId.of("stock-1"), + ArticleId.of("article-1"), + StockBatchId.of("batch-1"), + new BatchReference("CHARGE-001", BatchType.PRODUCED), + MovementType.GOODS_RECEIPT, + MovementDirection.IN, + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM), + null, null, "user-1", Instant.now() + ); + } + + @Nested + @DisplayName("No filters") + class NoFilter { + + @Test + @DisplayName("should return all movements when no filter") + void shouldReturnAll() { + when(repository.findAll()).thenReturn(Result.success(List.of(sampleMovement))); + + var result = useCase.execute(null, null, null, actor); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(1); + verify(repository).findAll(); + } + + @Test + @DisplayName("should return empty list when no movements exist") + void shouldReturnEmptyList() { + when(repository.findAll()).thenReturn(Result.success(List.of())); + + var result = useCase.execute(null, null, null, actor); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).isEmpty(); + } + + @Test + @DisplayName("should fail when repository fails") + void shouldFailWhenRepositoryFails() { + when(repository.findAll()).thenReturn( + Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = useCase.execute(null, null, null, actor); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.RepositoryFailure.class); + } + } + + @Nested + @DisplayName("Filter by stockId") + class StockIdFilter { + + @Test + @DisplayName("should filter by stockId") + void shouldFilterByStockId() { + when(repository.findAllByStockId(StockId.of("stock-1"))) + .thenReturn(Result.success(List.of(sampleMovement))); + + var result = useCase.execute("stock-1", null, null, actor); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(1); + verify(repository).findAllByStockId(StockId.of("stock-1")); + verify(repository, never()).findAll(); + } + + @Test + @DisplayName("should fail with InvalidStockId when format invalid") + void shouldFailWhenStockIdInvalid() { + var result = useCase.execute(" ", null, null, actor); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidStockId.class); + } + } + + @Nested + @DisplayName("Filter by articleId") + class ArticleIdFilter { + + @Test + @DisplayName("should filter by articleId") + void shouldFilterByArticleId() { + when(repository.findAllByArticleId(ArticleId.of("article-1"))) + .thenReturn(Result.success(List.of(sampleMovement))); + + var result = useCase.execute(null, "article-1", null, actor); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(1); + verify(repository).findAllByArticleId(ArticleId.of("article-1")); + } + + @Test + @DisplayName("should fail with InvalidArticleId when format invalid") + void shouldFailWhenArticleIdInvalid() { + var result = useCase.execute(null, " ", null, actor); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidArticleId.class); + } + } + + @Nested + @DisplayName("Filter by movementType") + class MovementTypeFilter { + + @Test + @DisplayName("should filter by movementType") + void shouldFilterByMovementType() { + when(repository.findAllByMovementType(MovementType.GOODS_RECEIPT)) + .thenReturn(Result.success(List.of(sampleMovement))); + + var result = useCase.execute(null, null, "GOODS_RECEIPT", actor); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(1); + verify(repository).findAllByMovementType(MovementType.GOODS_RECEIPT); + } + + @Test + @DisplayName("should fail with InvalidMovementType when type invalid") + void shouldFailWhenMovementTypeInvalid() { + var result = useCase.execute(null, null, "INVALID_TYPE", actor); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidMovementType.class); + } + } + + @Nested + @DisplayName("Filter priority") + class FilterPriority { + + @Test + @DisplayName("stockId takes priority over articleId and movementType") + void stockIdTakesPriority() { + when(repository.findAllByStockId(StockId.of("stock-1"))) + .thenReturn(Result.success(List.of())); + + var result = useCase.execute("stock-1", "article-1", "GOODS_RECEIPT", actor); + + assertThat(result.isSuccess()).isTrue(); + verify(repository).findAllByStockId(StockId.of("stock-1")); + verify(repository, never()).findAllByArticleId(any()); + verify(repository, never()).findAllByMovementType(any()); + } + + @Test + @DisplayName("articleId takes priority over movementType") + void articleIdTakesPriorityOverMovementType() { + when(repository.findAllByArticleId(ArticleId.of("article-1"))) + .thenReturn(Result.success(List.of())); + + var result = useCase.execute(null, "article-1", "GOODS_RECEIPT", actor); + + assertThat(result.isSuccess()).isTrue(); + verify(repository).findAllByArticleId(ArticleId.of("article-1")); + verify(repository, never()).findAllByMovementType(any()); + } + } + + @Nested + @DisplayName("Authorization") + class Authorization { + + @Test + @DisplayName("should fail with Unauthorized when actor lacks STOCK_MOVEMENT_READ") + void shouldFailWhenUnauthorized() { + when(authPort.can(actor, InventoryAction.STOCK_MOVEMENT_READ)).thenReturn(false); + + var result = useCase.execute(null, null, null, actor); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.Unauthorized.class); + verify(repository, never()).findAll(); + } + } +} diff --git a/backend/src/test/java/de/effigenix/application/inventory/RecordStockMovementTest.java b/backend/src/test/java/de/effigenix/application/inventory/RecordStockMovementTest.java new file mode 100644 index 0000000..2463186 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/RecordStockMovementTest.java @@ -0,0 +1,106 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.RecordStockMovementCommand; +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.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class RecordStockMovementTest { + + private StockMovementRepository repository; + private AuthorizationPort authPort; + private RecordStockMovement useCase; + private final ActorId actor = ActorId.of("user-1"); + + @BeforeEach + void setUp() { + repository = mock(StockMovementRepository.class); + authPort = mock(AuthorizationPort.class); + useCase = new RecordStockMovement(repository, authPort); + when(authPort.can(any(ActorId.class), any())).thenReturn(true); + } + + private RecordStockMovementCommand validCommand() { + return new RecordStockMovementCommand( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "GOODS_RECEIPT", null, + "10.0", "KILOGRAM", + null, null, "user-1" + ); + } + + @Test + @DisplayName("should record stock movement successfully") + void shouldRecordSuccessfully() { + when(repository.save(any())).thenReturn(Result.success(null)); + + var result = useCase.execute(validCommand(), actor); + + assertThat(result.isSuccess()).isTrue(); + var movement = result.unsafeGetValue(); + assertThat(movement.movementType()).isEqualTo(MovementType.GOODS_RECEIPT); + assertThat(movement.direction()).isEqualTo(MovementDirection.IN); + + var captor = org.mockito.ArgumentCaptor.forClass(StockMovement.class); + verify(repository).save(captor.capture()); + assertThat(captor.getValue().id()).isEqualTo(movement.id()); + } + + @Test + @DisplayName("should return domain error when validation fails") + void shouldReturnDomainErrorOnValidationFailure() { + var cmd = new RecordStockMovementCommand( + null, "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "GOODS_RECEIPT", null, + "10.0", "KILOGRAM", + null, null, "user-1" + ); + + var result = useCase.execute(cmd, actor); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidStockId.class); + verify(repository, never()).save(any()); + } + + @Test + @DisplayName("should return repository failure on save error") + void shouldReturnRepositoryFailureOnSaveError() { + when(repository.save(any())).thenReturn( + Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = useCase.execute(validCommand(), actor); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.RepositoryFailure.class); + } + + @Nested + @DisplayName("Authorization") + class Authorization { + + @Test + @DisplayName("should fail with Unauthorized when actor lacks STOCK_MOVEMENT_WRITE") + void shouldFailWhenUnauthorized() { + when(authPort.can(actor, InventoryAction.STOCK_MOVEMENT_WRITE)).thenReturn(false); + + var result = useCase.execute(validCommand(), actor); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.Unauthorized.class); + verify(repository, never()).save(any()); + } + } +} diff --git a/backend/src/test/java/de/effigenix/domain/inventory/StockMovementTest.java b/backend/src/test/java/de/effigenix/domain/inventory/StockMovementTest.java new file mode 100644 index 0000000..83957fe --- /dev/null +++ b/backend/src/test/java/de/effigenix/domain/inventory/StockMovementTest.java @@ -0,0 +1,722 @@ +package de.effigenix.domain.inventory; + +import de.effigenix.domain.masterdata.ArticleId; +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.UnitOfMeasure; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +class StockMovementTest { + + private StockMovementDraft validDraft(String movementType, String direction) { + return new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + movementType, direction, + "10.5", "KILOGRAM", + null, null, + "user-1" + ); + } + + private StockMovementDraft validDraft(String movementType) { + return validDraft(movementType, null); + } + + // ==================== Factory: record() ==================== + + @Nested + @DisplayName("record() – Positive Cases") + class PositiveCases { + + @Test + @DisplayName("should record GOODS_RECEIPT with direction IN") + void shouldRecordGoodsReceipt() { + var result = StockMovement.record(validDraft("GOODS_RECEIPT")); + + assertThat(result.isSuccess()).isTrue(); + var movement = result.unsafeGetValue(); + assertThat(movement.id()).isNotNull(); + assertThat(movement.movementType()).isEqualTo(MovementType.GOODS_RECEIPT); + assertThat(movement.direction()).isEqualTo(MovementDirection.IN); + assertThat(movement.isIncoming()).isTrue(); + assertThat(movement.isOutgoing()).isFalse(); + assertThat(movement.quantity().amount()).isEqualByComparingTo(new BigDecimal("10.5")); + } + + @Test + @DisplayName("should record PRODUCTION_OUTPUT with direction IN") + void shouldRecordProductionOutput() { + var result = StockMovement.record(validDraft("PRODUCTION_OUTPUT")); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().direction()).isEqualTo(MovementDirection.IN); + } + + @Test + @DisplayName("should record RETURN with direction IN") + void shouldRecordReturn() { + var result = StockMovement.record(validDraft("RETURN")); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().direction()).isEqualTo(MovementDirection.IN); + } + + @Test + @DisplayName("should record PRODUCTION_CONSUMPTION with direction OUT") + void shouldRecordProductionConsumption() { + var result = StockMovement.record(validDraft("PRODUCTION_CONSUMPTION")); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().direction()).isEqualTo(MovementDirection.OUT); + } + + @Test + @DisplayName("should record SALE with direction OUT") + void shouldRecordSale() { + var result = StockMovement.record(validDraft("SALE")); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().direction()).isEqualTo(MovementDirection.OUT); + } + + @Test + @DisplayName("should record WASTE with reason and direction OUT") + void shouldRecordWaste() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "WASTE", null, + "5.0", "KILOGRAM", + "Expired product", null, + "user-1" + ); + var result = StockMovement.record(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().direction()).isEqualTo(MovementDirection.OUT); + assertThat(result.unsafeGetValue().reason()).isEqualTo("Expired product"); + } + + @Test + @DisplayName("should record ADJUSTMENT IN with reason") + void shouldRecordAdjustmentIn() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "ADJUSTMENT", "IN", + "2.0", "KILOGRAM", + "Inventory count correction", null, + "user-1" + ); + var result = StockMovement.record(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().direction()).isEqualTo(MovementDirection.IN); + assertThat(result.unsafeGetValue().reason()).isEqualTo("Inventory count correction"); + } + + @Test + @DisplayName("should record ADJUSTMENT OUT with reason") + void shouldRecordAdjustmentOut() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "ADJUSTMENT", "OUT", + "1.0", "KILOGRAM", + "Inventory count correction", null, + "user-1" + ); + var result = StockMovement.record(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().direction()).isEqualTo(MovementDirection.OUT); + } + + @Test + @DisplayName("should record INTER_BRANCH_TRANSFER with referenceDocumentId") + void shouldRecordInterBranchTransfer() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "INTER_BRANCH_TRANSFER", null, + "3.0", "KILOGRAM", + null, "TRANSFER-001", + "user-1" + ); + var result = StockMovement.record(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().direction()).isEqualTo(MovementDirection.OUT); + assertThat(result.unsafeGetValue().referenceDocumentId()).isEqualTo("TRANSFER-001"); + } + + @Test + @DisplayName("should ignore direction field for non-ADJUSTMENT types") + void shouldIgnoreDirectionForNonAdjustment() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "GOODS_RECEIPT", "OUT", + "10.0", "KILOGRAM", + null, null, + "user-1" + ); + var result = StockMovement.record(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().direction()).isEqualTo(MovementDirection.IN); + } + } + + // ==================== Factory: record() – Negative Cases ==================== + + @Nested + @DisplayName("record() – Validation Errors") + class ValidationErrors { + + @Test + @DisplayName("should fail when stockId is null") + void shouldFailWhenStockIdNull() { + var draft = new StockMovementDraft( + null, "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "GOODS_RECEIPT", null, + "10.0", "KILOGRAM", + null, null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidStockId.class); + } + + @Test + @DisplayName("should fail when stockId is blank") + void shouldFailWhenStockIdBlank() { + var draft = new StockMovementDraft( + "", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "GOODS_RECEIPT", null, + "10.0", "KILOGRAM", + null, null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidStockId.class); + } + + @Test + @DisplayName("should fail when articleId is blank") + void shouldFailWhenArticleIdBlank() { + var draft = new StockMovementDraft( + "stock-1", "", "batch-id-1", + "CHARGE-001", "PRODUCED", + "GOODS_RECEIPT", null, + "10.0", "KILOGRAM", + null, null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidArticleId.class); + } + + @Test + @DisplayName("should fail when articleId is null") + void shouldFailWhenArticleIdNull() { + var draft = new StockMovementDraft( + "stock-1", null, "batch-id-1", + "CHARGE-001", "PRODUCED", + "GOODS_RECEIPT", null, + "10.0", "KILOGRAM", + null, null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidArticleId.class); + } + + @Test + @DisplayName("should fail when stockBatchId is blank") + void shouldFailWhenStockBatchIdBlank() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "", + "CHARGE-001", "PRODUCED", + "GOODS_RECEIPT", null, + "10.0", "KILOGRAM", + null, null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidStockBatchId.class); + } + + @Test + @DisplayName("should fail when stockBatchId is null") + void shouldFailWhenStockBatchIdNull() { + var draft = new StockMovementDraft( + "stock-1", "article-1", null, + "CHARGE-001", "PRODUCED", + "GOODS_RECEIPT", null, + "10.0", "KILOGRAM", + null, null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidStockBatchId.class); + } + + @Test + @DisplayName("should fail when batchId is null") + void shouldFailWhenBatchIdNull() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + null, "PRODUCED", + "GOODS_RECEIPT", null, + "10.0", "KILOGRAM", + null, null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidBatchReference.class); + } + + @Test + @DisplayName("should fail when batchId is blank") + void shouldFailWhenBatchIdBlank() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "", "PRODUCED", + "GOODS_RECEIPT", null, + "10.0", "KILOGRAM", + null, null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidBatchReference.class); + } + + @Test + @DisplayName("should fail when batchType is null") + void shouldFailWhenBatchTypeNull() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", null, + "GOODS_RECEIPT", null, + "10.0", "KILOGRAM", + null, null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidBatchReference.class); + } + + @Test + @DisplayName("should fail when batchType is invalid") + void shouldFailWhenBatchTypeInvalid() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "INVALID", + "GOODS_RECEIPT", null, + "10.0", "KILOGRAM", + null, null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidBatchReference.class); + } + + @Test + @DisplayName("should fail when movementType is null") + void shouldFailWhenMovementTypeNull() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + null, null, + "10.0", "KILOGRAM", + null, null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidMovementType.class); + } + + @Test + @DisplayName("should fail when movementType is invalid") + void shouldFailWhenMovementTypeInvalid() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "INVALID_TYPE", null, + "10.0", "KILOGRAM", + null, null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidMovementType.class); + } + + @Test + @DisplayName("should fail when ADJUSTMENT has blank direction") + void shouldFailWhenAdjustmentHasBlankDirection() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "ADJUSTMENT", " ", + "10.0", "KILOGRAM", + "Correction", null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidDirection.class); + } + + @Test + @DisplayName("should fail when ADJUSTMENT has no direction") + void shouldFailWhenAdjustmentHasNoDirection() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "ADJUSTMENT", null, + "10.0", "KILOGRAM", + "Correction", null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidDirection.class); + } + + @Test + @DisplayName("should fail when ADJUSTMENT has invalid direction") + void shouldFailWhenAdjustmentHasInvalidDirection() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "ADJUSTMENT", "SIDEWAYS", + "10.0", "KILOGRAM", + "Correction", null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidDirection.class); + } + + @Test + @DisplayName("should fail when quantityAmount is null") + void shouldFailWhenQuantityAmountNull() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "GOODS_RECEIPT", null, + null, "KILOGRAM", + null, null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidQuantity.class); + } + + @Test + @DisplayName("should fail when quantityUnit is null") + void shouldFailWhenQuantityUnitNull() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "GOODS_RECEIPT", null, + "10.0", null, + null, null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidQuantity.class); + } + + @Test + @DisplayName("should fail when quantity is negative") + void shouldFailWhenQuantityNegative() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "GOODS_RECEIPT", null, + "-5.0", "KILOGRAM", + null, null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidQuantity.class); + } + + @Test + @DisplayName("should fail when quantity is zero") + void shouldFailWhenQuantityZero() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "GOODS_RECEIPT", null, + "0", "KILOGRAM", + null, null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidQuantity.class); + } + + @Test + @DisplayName("should fail when quantityAmount is not a number") + void shouldFailWhenQuantityAmountNotNumber() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "GOODS_RECEIPT", null, + "abc", "KILOGRAM", + null, null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidQuantity.class); + } + + @Test + @DisplayName("should fail when quantityUnit is invalid") + void shouldFailWhenQuantityUnitInvalid() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "GOODS_RECEIPT", null, + "10.0", "INVALID_UNIT", + null, null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidQuantity.class); + } + + @Test + @DisplayName("should fail when WASTE has no reason") + void shouldFailWhenWasteHasNoReason() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "WASTE", null, + "5.0", "KILOGRAM", + null, null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.ReasonRequired.class); + } + + @Test + @DisplayName("should fail when WASTE has blank reason") + void shouldFailWhenWasteHasBlankReason() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "WASTE", null, + "5.0", "KILOGRAM", + " ", null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.ReasonRequired.class); + } + + @Test + @DisplayName("should fail when ADJUSTMENT has no reason") + void shouldFailWhenAdjustmentHasNoReason() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "ADJUSTMENT", "IN", + "2.0", "KILOGRAM", + null, null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.ReasonRequired.class); + } + + @Test + @DisplayName("should fail when INTER_BRANCH_TRANSFER has no referenceDocumentId") + void shouldFailWhenTransferHasNoReferenceDocument() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "INTER_BRANCH_TRANSFER", null, + "3.0", "KILOGRAM", + null, null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.ReferenceDocumentRequired.class); + } + + @Test + @DisplayName("should fail when INTER_BRANCH_TRANSFER has blank referenceDocumentId") + void shouldFailWhenTransferHasBlankReferenceDocument() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "INTER_BRANCH_TRANSFER", null, + "3.0", "KILOGRAM", + null, " ", "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.ReferenceDocumentRequired.class); + } + + @Test + @DisplayName("should fail when performedBy is null") + void shouldFailWhenPerformedByNull() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "GOODS_RECEIPT", null, + "10.0", "KILOGRAM", + null, null, null + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidPerformedBy.class); + } + + @Test + @DisplayName("should fail when performedBy is blank") + void shouldFailWhenPerformedByBlank() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "GOODS_RECEIPT", null, + "10.0", "KILOGRAM", + null, null, " " + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidPerformedBy.class); + } + + @Test + @DisplayName("should fail when ADJUSTMENT has blank reason") + void shouldFailWhenAdjustmentHasBlankReason() { + var draft = new StockMovementDraft( + "stock-1", "article-1", "batch-id-1", + "CHARGE-001", "PRODUCED", + "ADJUSTMENT", "IN", + "2.0", "KILOGRAM", + " ", null, "user-1" + ); + var result = StockMovement.record(draft); + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.ReasonRequired.class); + } + } + + // ==================== reconstitute() ==================== + + @Nested + @DisplayName("reconstitute()") + class Reconstitute { + + @Test + @DisplayName("should reconstitute all fields from persistence") + void shouldReconstituteAllFields() { + var id = StockMovementId.generate(); + var stockId = StockId.of("stock-1"); + var articleId = ArticleId.of("article-1"); + var stockBatchId = StockBatchId.of("batch-id-1"); + var batchRef = new BatchReference("CHARGE-001", BatchType.PRODUCED); + var quantity = Quantity.reconstitute(new BigDecimal("10.5"), UnitOfMeasure.KILOGRAM); + var performedAt = Instant.now(); + + var movement = StockMovement.reconstitute( + id, stockId, articleId, stockBatchId, batchRef, + MovementType.WASTE, MovementDirection.OUT, quantity, + "Expired", "REF-001", "user-1", performedAt + ); + + assertThat(movement.id()).isEqualTo(id); + assertThat(movement.stockId()).isEqualTo(stockId); + assertThat(movement.articleId()).isEqualTo(articleId); + assertThat(movement.stockBatchId()).isEqualTo(stockBatchId); + assertThat(movement.batchReference()).isEqualTo(batchRef); + assertThat(movement.movementType()).isEqualTo(MovementType.WASTE); + assertThat(movement.direction()).isEqualTo(MovementDirection.OUT); + assertThat(movement.quantity()).isEqualTo(quantity); + assertThat(movement.reason()).isEqualTo("Expired"); + assertThat(movement.referenceDocumentId()).isEqualTo("REF-001"); + assertThat(movement.performedBy()).isEqualTo("user-1"); + assertThat(movement.performedAt()).isEqualTo(performedAt); + assertThat(movement.isOutgoing()).isTrue(); + assertThat(movement.isIncoming()).isFalse(); + } + + @Test + @DisplayName("should reconstitute with null optional fields") + void shouldReconstituteWithNullOptionals() { + var movement = StockMovement.reconstitute( + StockMovementId.generate(), + StockId.of("stock-1"), + ArticleId.of("article-1"), + StockBatchId.of("batch-id-1"), + new BatchReference("CHARGE-001", BatchType.PURCHASED), + MovementType.GOODS_RECEIPT, + MovementDirection.IN, + Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.PIECE), + null, null, "user-1", Instant.now() + ); + + assertThat(movement.reason()).isNull(); + assertThat(movement.referenceDocumentId()).isNull(); + assertThat(movement.isIncoming()).isTrue(); + } + } + + // ==================== equals/hashCode ==================== + + @Nested + @DisplayName("equals() and hashCode()") + class Identity { + + @Test + @DisplayName("movements with same ID are equal") + void sameIdAreEqual() { + var id = StockMovementId.generate(); + var performedAt = Instant.now(); + var qty = Quantity.reconstitute(new BigDecimal("1"), UnitOfMeasure.KILOGRAM); + var batchRef = new BatchReference("C1", BatchType.PRODUCED); + + var m1 = StockMovement.reconstitute(id, StockId.of("s1"), ArticleId.of("a1"), + StockBatchId.of("b1"), batchRef, MovementType.SALE, MovementDirection.OUT, + qty, null, null, "u1", performedAt); + var m2 = StockMovement.reconstitute(id, StockId.of("s2"), ArticleId.of("a2"), + StockBatchId.of("b2"), batchRef, MovementType.RETURN, MovementDirection.IN, + qty, null, null, "u2", performedAt); + + assertThat(m1).isEqualTo(m2); + assertThat(m1.hashCode()).isEqualTo(m2.hashCode()); + } + + @Test + @DisplayName("movements with different ID are not equal") + void differentIdAreNotEqual() { + var performedAt = Instant.now(); + var qty = Quantity.reconstitute(new BigDecimal("1"), UnitOfMeasure.KILOGRAM); + var batchRef = new BatchReference("C1", BatchType.PRODUCED); + + var m1 = StockMovement.reconstitute(StockMovementId.generate(), StockId.of("s1"), + ArticleId.of("a1"), StockBatchId.of("b1"), batchRef, + MovementType.SALE, MovementDirection.OUT, qty, null, null, "u1", performedAt); + var m2 = StockMovement.reconstitute(StockMovementId.generate(), StockId.of("s1"), + ArticleId.of("a1"), StockBatchId.of("b1"), batchRef, + MovementType.SALE, MovementDirection.OUT, qty, null, null, "u1", performedAt); + + assertThat(m1).isNotEqualTo(m2); + } + } +} diff --git a/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockMovementControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockMovementControllerIntegrationTest.java new file mode 100644 index 0000000..9b10f2d --- /dev/null +++ b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockMovementControllerIntegrationTest.java @@ -0,0 +1,603 @@ +package de.effigenix.infrastructure.inventory.web; + +import de.effigenix.domain.usermanagement.RoleName; +import de.effigenix.infrastructure.AbstractIntegrationTest; +import de.effigenix.infrastructure.inventory.web.dto.AddStockBatchRequest; +import de.effigenix.infrastructure.inventory.web.dto.CreateStockRequest; +import de.effigenix.infrastructure.inventory.web.dto.RecordStockMovementRequest; +import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity; +import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +import java.util.Set; +import java.util.UUID; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@DisplayName("StockMovement Controller Integration Tests") +class StockMovementControllerIntegrationTest extends AbstractIntegrationTest { + + private String adminToken; + private String viewerToken; + + private String storageLocationId; + private String stockId; + private String articleId; + private String stockBatchId; + + @BeforeEach + void setUp() throws Exception { + RoleEntity adminRole = createRole(RoleName.ADMIN, "Admin"); + RoleEntity viewerRole = createRole(RoleName.PRODUCTION_WORKER, "Viewer"); + + UserEntity admin = createUser("movement.admin", "movement.admin@test.com", Set.of(adminRole), "BRANCH-01"); + UserEntity viewer = createUser("movement.viewer", "movement.viewer@test.com", Set.of(viewerRole), "BRANCH-01"); + + adminToken = generateToken(admin.getId(), "movement.admin", + "STOCK_WRITE,STOCK_READ,STOCK_MOVEMENT_WRITE,STOCK_MOVEMENT_READ"); + viewerToken = generateToken(viewer.getId(), "movement.viewer", "USER_READ"); + + storageLocationId = createStorageLocation(); + articleId = UUID.randomUUID().toString(); + stockId = createStock(articleId); + stockBatchId = addBatchToStock(stockId); + } + + // ==================== Bewegung erfassen ==================== + + @Nested + @DisplayName("POST /api/inventory/stock-movements") + class RecordMovement { + + @Test + @DisplayName("GOODS_RECEIPT erfassen → 201") + void recordGoodsReceipt_returns201() throws Exception { + var request = new RecordStockMovementRequest( + stockId, articleId, stockBatchId, + "CHARGE-001", "PRODUCED", + "GOODS_RECEIPT", null, + "10.5", "KILOGRAM", + null, null + ); + + mockMvc.perform(post("/api/inventory/stock-movements") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isNotEmpty()) + .andExpect(jsonPath("$.stockId").value(stockId)) + .andExpect(jsonPath("$.articleId").value(articleId)) + .andExpect(jsonPath("$.movementType").value("GOODS_RECEIPT")) + .andExpect(jsonPath("$.direction").value("IN")) + .andExpect(jsonPath("$.quantityAmount").value(10.5)) + .andExpect(jsonPath("$.quantityUnit").value("KILOGRAM")) + .andExpect(jsonPath("$.performedBy").isNotEmpty()) + .andExpect(jsonPath("$.performedAt").isNotEmpty()); + } + + @Test + @DisplayName("WASTE ohne Reason → 400") + void recordWasteWithoutReason_returns400() throws Exception { + var request = new RecordStockMovementRequest( + stockId, articleId, stockBatchId, + "CHARGE-001", "PRODUCED", + "WASTE", null, + "5.0", "KILOGRAM", + null, null + ); + + mockMvc.perform(post("/api/inventory/stock-movements") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("REASON_REQUIRED")); + } + + @Test + @DisplayName("WASTE mit Reason → 201") + void recordWasteWithReason_returns201() throws Exception { + var request = new RecordStockMovementRequest( + stockId, articleId, stockBatchId, + "CHARGE-001", "PRODUCED", + "WASTE", null, + "2.0", "KILOGRAM", + "Expired product", null + ); + + mockMvc.perform(post("/api/inventory/stock-movements") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.movementType").value("WASTE")) + .andExpect(jsonPath("$.direction").value("OUT")) + .andExpect(jsonPath("$.reason").value("Expired product")); + } + + @Test + @DisplayName("ADJUSTMENT IN mit Reason → 201") + void recordAdjustmentIn_returns201() throws Exception { + var request = new RecordStockMovementRequest( + stockId, articleId, stockBatchId, + "CHARGE-001", "PRODUCED", + "ADJUSTMENT", "IN", + "1.0", "KILOGRAM", + "Inventory correction", null + ); + + mockMvc.perform(post("/api/inventory/stock-movements") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.movementType").value("ADJUSTMENT")) + .andExpect(jsonPath("$.direction").value("IN")); + } + + @Test + @DisplayName("INTER_BRANCH_TRANSFER ohne referenceDocumentId → 400") + void recordTransferWithoutRefDoc_returns400() throws Exception { + var request = new RecordStockMovementRequest( + stockId, articleId, stockBatchId, + "CHARGE-001", "PRODUCED", + "INTER_BRANCH_TRANSFER", null, + "3.0", "KILOGRAM", + null, null + ); + + mockMvc.perform(post("/api/inventory/stock-movements") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("REFERENCE_DOCUMENT_REQUIRED")); + } + + @Test + @DisplayName("PRODUCTION_OUTPUT erfassen → 201 (IN)") + void recordProductionOutput_returns201() throws Exception { + var request = new RecordStockMovementRequest( + stockId, articleId, stockBatchId, + "CHARGE-001", "PRODUCED", + "PRODUCTION_OUTPUT", null, + "8.0", "KILOGRAM", + null, null + ); + + mockMvc.perform(post("/api/inventory/stock-movements") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.movementType").value("PRODUCTION_OUTPUT")) + .andExpect(jsonPath("$.direction").value("IN")); + } + + @Test + @DisplayName("PRODUCTION_CONSUMPTION erfassen → 201 (OUT)") + void recordProductionConsumption_returns201() throws Exception { + var request = new RecordStockMovementRequest( + stockId, articleId, stockBatchId, + "CHARGE-001", "PRODUCED", + "PRODUCTION_CONSUMPTION", null, + "4.0", "KILOGRAM", + null, null + ); + + mockMvc.perform(post("/api/inventory/stock-movements") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.movementType").value("PRODUCTION_CONSUMPTION")) + .andExpect(jsonPath("$.direction").value("OUT")); + } + + @Test + @DisplayName("SALE erfassen → 201 (OUT)") + void recordSale_returns201() throws Exception { + var request = new RecordStockMovementRequest( + stockId, articleId, stockBatchId, + "CHARGE-001", "PRODUCED", + "SALE", null, + "2.0", "KILOGRAM", + null, null + ); + + mockMvc.perform(post("/api/inventory/stock-movements") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.movementType").value("SALE")) + .andExpect(jsonPath("$.direction").value("OUT")); + } + + @Test + @DisplayName("RETURN erfassen → 201 (IN)") + void recordReturn_returns201() throws Exception { + var request = new RecordStockMovementRequest( + stockId, articleId, stockBatchId, + "CHARGE-001", "PRODUCED", + "RETURN", null, + "1.0", "KILOGRAM", + null, null + ); + + mockMvc.perform(post("/api/inventory/stock-movements") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.movementType").value("RETURN")) + .andExpect(jsonPath("$.direction").value("IN")); + } + + @Test + @DisplayName("INTER_BRANCH_TRANSFER mit referenceDocumentId → 201 (OUT)") + void recordTransferWithRefDoc_returns201() throws Exception { + var request = new RecordStockMovementRequest( + stockId, articleId, stockBatchId, + "CHARGE-001", "PRODUCED", + "INTER_BRANCH_TRANSFER", null, + "3.0", "KILOGRAM", + null, "TRANSFER-001" + ); + + mockMvc.perform(post("/api/inventory/stock-movements") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.movementType").value("INTER_BRANCH_TRANSFER")) + .andExpect(jsonPath("$.direction").value("OUT")) + .andExpect(jsonPath("$.referenceDocumentId").value("TRANSFER-001")); + } + + @Test + @DisplayName("ADJUSTMENT OUT mit Reason → 201") + void recordAdjustmentOut_returns201() throws Exception { + var request = new RecordStockMovementRequest( + stockId, articleId, stockBatchId, + "CHARGE-001", "PRODUCED", + "ADJUSTMENT", "OUT", + "0.5", "KILOGRAM", + "Shrinkage correction", null + ); + + mockMvc.perform(post("/api/inventory/stock-movements") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.movementType").value("ADJUSTMENT")) + .andExpect(jsonPath("$.direction").value("OUT")) + .andExpect(jsonPath("$.reason").value("Shrinkage correction")); + } + + @Test + @DisplayName("ADJUSTMENT ohne Direction → 400") + void recordAdjustmentWithoutDirection_returns400() throws Exception { + var request = new RecordStockMovementRequest( + stockId, articleId, stockBatchId, + "CHARGE-001", "PRODUCED", + "ADJUSTMENT", null, + "1.0", "KILOGRAM", + "Correction", null + ); + + mockMvc.perform(post("/api/inventory/stock-movements") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_DIRECTION")); + } + + @Test + @DisplayName("Negative Menge → 400") + void recordNegativeQuantity_returns400() throws Exception { + var request = new RecordStockMovementRequest( + stockId, articleId, stockBatchId, + "CHARGE-001", "PRODUCED", + "GOODS_RECEIPT", null, + "-5.0", "KILOGRAM", + null, null + ); + + mockMvc.perform(post("/api/inventory/stock-movements") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_QUANTITY")); + } + + @Test + @DisplayName("Ungültige Einheit → 400") + void recordInvalidUnit_returns400() throws Exception { + var request = new RecordStockMovementRequest( + stockId, articleId, stockBatchId, + "CHARGE-001", "PRODUCED", + "GOODS_RECEIPT", null, + "10.0", "INVALID_UNIT", + null, null + ); + + mockMvc.perform(post("/api/inventory/stock-movements") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_QUANTITY")); + } + + @Test + @DisplayName("Ungültiger movementType → 400") + void recordInvalidMovementType_returns400() throws Exception { + var request = new RecordStockMovementRequest( + stockId, articleId, stockBatchId, + "CHARGE-001", "PRODUCED", + "INVALID_TYPE", null, + "10.0", "KILOGRAM", + null, null + ); + + mockMvc.perform(post("/api/inventory/stock-movements") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_MOVEMENT_TYPE")); + } + + @Test + @DisplayName("Ohne STOCK_MOVEMENT_WRITE → 403") + void recordWithoutPermission_returns403() throws Exception { + var request = new RecordStockMovementRequest( + stockId, articleId, stockBatchId, + "CHARGE-001", "PRODUCED", + "GOODS_RECEIPT", null, + "10.0", "KILOGRAM", + null, null + ); + + mockMvc.perform(post("/api/inventory/stock-movements") + .header("Authorization", "Bearer " + viewerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Ohne Token → 401") + void recordWithoutToken_returns401() throws Exception { + var request = new RecordStockMovementRequest( + stockId, articleId, stockBatchId, + "CHARGE-001", "PRODUCED", + "GOODS_RECEIPT", null, + "10.0", "KILOGRAM", + null, null + ); + + mockMvc.perform(post("/api/inventory/stock-movements") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + } + } + + // ==================== Bewegungen abfragen ==================== + + @Nested + @DisplayName("GET /api/inventory/stock-movements") + class ListMovements { + + @Test + @DisplayName("Alle Bewegungen auflisten → 200") + void listAll_returns200() throws Exception { + recordMovement("GOODS_RECEIPT"); + + mockMvc.perform(get("/api/inventory/stock-movements") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(1)); + } + + @Test + @DisplayName("Nach stockId filtern → 200") + void filterByStockId_returns200() throws Exception { + recordMovement("GOODS_RECEIPT"); + + mockMvc.perform(get("/api/inventory/stock-movements") + .param("stockId", stockId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + @Test + @DisplayName("Nach movementType filtern → 200") + void filterByMovementType_returns200() throws Exception { + recordMovement("GOODS_RECEIPT"); + + mockMvc.perform(get("/api/inventory/stock-movements") + .param("movementType", "GOODS_RECEIPT") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + @Test + @DisplayName("Nach articleId filtern → 200") + void filterByArticleId_returns200() throws Exception { + recordMovement("GOODS_RECEIPT"); + + mockMvc.perform(get("/api/inventory/stock-movements") + .param("articleId", articleId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(1)); + } + + @Test + @DisplayName("Ungültiger movementType-Filter → 400") + void filterByInvalidMovementType_returns400() throws Exception { + mockMvc.perform(get("/api/inventory/stock-movements") + .param("movementType", "INVALID") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_MOVEMENT_TYPE")); + } + + @Test + @DisplayName("Ohne STOCK_MOVEMENT_READ → 403") + void listWithoutPermission_returns403() throws Exception { + mockMvc.perform(get("/api/inventory/stock-movements") + .header("Authorization", "Bearer " + viewerToken)) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Leere Liste → 200 mit []") + void listEmpty_returns200() throws Exception { + mockMvc.perform(get("/api/inventory/stock-movements") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(0)); + } + } + + // ==================== Einzelne Bewegung ==================== + + @Nested + @DisplayName("GET /api/inventory/stock-movements/{id}") + class GetMovement { + + @Test + @DisplayName("Einzelne Bewegung per ID → 200") + void getById_returns200() throws Exception { + String movementId = recordMovement("GOODS_RECEIPT"); + + mockMvc.perform(get("/api/inventory/stock-movements/{id}", movementId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(movementId)) + .andExpect(jsonPath("$.movementType").value("GOODS_RECEIPT")); + } + + @Test + @DisplayName("Nicht vorhandene ID → 404") + void getByInvalidId_returns404() throws Exception { + mockMvc.perform(get("/api/inventory/stock-movements/{id}", UUID.randomUUID().toString()) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("STOCK_MOVEMENT_NOT_FOUND")); + } + + @Test + @DisplayName("Ohne STOCK_MOVEMENT_READ → 403") + void getWithoutPermission_returns403() throws Exception { + mockMvc.perform(get("/api/inventory/stock-movements/{id}", UUID.randomUUID().toString()) + .header("Authorization", "Bearer " + viewerToken)) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Vollständige Response-Struktur verifizieren") + void getById_fullResponseValidation() throws Exception { + String movementId = recordMovement("GOODS_RECEIPT"); + + mockMvc.perform(get("/api/inventory/stock-movements/{id}", movementId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(movementId)) + .andExpect(jsonPath("$.stockId").value(stockId)) + .andExpect(jsonPath("$.articleId").value(articleId)) + .andExpect(jsonPath("$.stockBatchId").value(stockBatchId)) + .andExpect(jsonPath("$.batchId").value("CHARGE-001")) + .andExpect(jsonPath("$.batchType").value("PRODUCED")) + .andExpect(jsonPath("$.movementType").value("GOODS_RECEIPT")) + .andExpect(jsonPath("$.direction").value("IN")) + .andExpect(jsonPath("$.quantityAmount").value(10.0)) + .andExpect(jsonPath("$.quantityUnit").value("KILOGRAM")) + .andExpect(jsonPath("$.performedBy").isNotEmpty()) + .andExpect(jsonPath("$.performedAt").isNotEmpty()); + } + } + + // ==================== Helpers ==================== + + private String createStorageLocation() throws Exception { + String json = """ + {"name": "Testlager-%s", "storageType": "DRY_STORAGE"} + """.formatted(UUID.randomUUID().toString().substring(0, 8)); + + var result = mockMvc.perform(post("/api/inventory/storage-locations") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isCreated()) + .andReturn(); + + return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + } + + private String createStock(String artId) throws Exception { + var request = new CreateStockRequest(artId, storageLocationId, null, null, null); + + var result = mockMvc.perform(post("/api/inventory/stocks") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + } + + private String addBatchToStock(String sId) throws Exception { + var request = new AddStockBatchRequest( + "BATCH-" + UUID.randomUUID().toString().substring(0, 8), + "PRODUCED", "100", "KILOGRAM", "2026-12-31"); + + var result = mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", sId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + } + + private String recordMovement(String movementType) throws Exception { + var request = new RecordStockMovementRequest( + stockId, articleId, stockBatchId, + "CHARGE-001", "PRODUCED", + movementType, null, + "10.0", "KILOGRAM", + null, null + ); + + var result = mockMvc.perform(post("/api/inventory/stock-movements") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + } +} diff --git a/loadtest/src/test/java/de/effigenix/loadtest/infrastructure/LoadTestDataSeeder.java b/loadtest/src/test/java/de/effigenix/loadtest/infrastructure/LoadTestDataSeeder.java index 73af035..741686c 100644 --- a/loadtest/src/test/java/de/effigenix/loadtest/infrastructure/LoadTestDataSeeder.java +++ b/loadtest/src/test/java/de/effigenix/loadtest/infrastructure/LoadTestDataSeeder.java @@ -41,6 +41,9 @@ public final class LoadTestDataSeeder { private final List recipeIds = new ArrayList<>(); private final List batchIds = new ArrayList<>(); private final List productionOrderIds = new ArrayList<>(); + private final List stockIds = new ArrayList<>(); + private final List stockBatchIds = new ArrayList<>(); + private final List stockMovementIds = new ArrayList<>(); // Statische Felder für Zugriff aus Szenarien private static List seededCategoryIds; @@ -51,6 +54,9 @@ public final class LoadTestDataSeeder { private static List seededRecipeIds; private static List seededBatchIds; private static List seededProductionOrderIds; + private static List seededStockIds; + private static List seededStockBatchIds; + private static List seededStockMovementIds; public LoadTestDataSeeder(ConfigurableApplicationContext appContext) { int port = appContext.getEnvironment() @@ -72,6 +78,8 @@ public final class LoadTestDataSeeder { seedRecipes(); seedBatchesForYear(); seedProductionOrders(); + seedStocksAndBatches(); + seedStockMovements(); // Statische Referenzen setzen seededCategoryIds = List.copyOf(categoryIds); @@ -82,14 +90,19 @@ public final class LoadTestDataSeeder { seededRecipeIds = List.copyOf(recipeIds); seededBatchIds = List.copyOf(batchIds); seededProductionOrderIds = List.copyOf(productionOrderIds); + seededStockIds = List.copyOf(stockIds); + seededStockBatchIds = List.copyOf(stockBatchIds); + seededStockMovementIds = List.copyOf(stockMovementIds); long duration = System.currentTimeMillis() - start; System.out.printf( "Seeded in %dms: %d Kategorien, %d Artikel, %d Lieferanten, %d Kunden, " - + "%d Lagerorte, %d Rezepte, %d Chargen, %d Produktionsaufträge%n", + + "%d Lagerorte, %d Rezepte, %d Chargen, %d Produktionsaufträge, " + + "%d Bestände, %d Bestandsbewegungen%n", duration, categoryIds.size(), articleIds.size(), supplierIds.size(), customerIds.size(), storageLocationIds.size(), recipeIds.size(), - batchIds.size(), productionOrderIds.size()); + batchIds.size(), productionOrderIds.size(), + stockIds.size(), stockMovementIds.size()); } catch (Exception e) { throw new RuntimeException("Testdaten-Seeding fehlgeschlagen", e); } @@ -562,6 +575,80 @@ public final class LoadTestDataSeeder { } } + // ---- Bestände & Chargen (20 Stocks mit je 2-3 Batches) ---- + + private void seedStocksAndBatches() throws Exception { + var rnd = ThreadLocalRandom.current(); + var today = LocalDate.now(); + + // 20 Bestände: jeweils ein Artikel an einem Lagerort + int stockCount = Math.min(20, articleIds.size()); + for (int i = 0; i < stockCount; i++) { + String articleId = articleIds.get(i); + String locationId = storageLocationIds.get(i % storageLocationIds.size()); + + String body = """ + {"articleId":"%s","storageLocationId":"%s","minimumLevelAmount":"5.0","minimumLevelUnit":"KILOGRAM"}""" + .formatted(articleId, locationId); + + try { + var json = mapper.readTree(post("/api/inventory/stocks", body, adminToken)); + String stockId = json.get("id").asText(); + stockIds.add(stockId); + + // 2-3 Batches pro Stock + int batchCount = 2 + (i % 2); + for (int j = 0; j < batchCount; j++) { + String batchType = (j % 2 == 0) ? "PRODUCED" : "PURCHASED"; + String batchRef = "CHARGE-%05d".formatted(i * 10 + j); + LocalDate expiry = today.plusDays(rnd.nextInt(14, 180)); + int qty = rnd.nextInt(5, 50); + + String batchBody = """ + {"batchId":"%s","batchType":"%s","quantityAmount":"%d.0","quantityUnit":"KILOGRAM","expiryDate":"%s"}""" + .formatted(batchRef, batchType, qty, expiry); + + try { + var batchJson = mapper.readTree( + post("/api/inventory/stocks/" + stockId + "/batches", batchBody, adminToken)); + stockBatchIds.add(batchJson.get("id").asText()); + } catch (Exception ignored) {} + } + } catch (Exception e) { + // Stock-Duplikat (gleicher Artikel/Lagerort) ignorieren + } + } + } + + // ---- Bestandsbewegungen (50 Movements) ---- + + private void seedStockMovements() throws Exception { + if (stockIds.isEmpty() || stockBatchIds.isEmpty()) return; + + var rnd = ThreadLocalRandom.current(); + String[] movementTypes = {"GOODS_RECEIPT", "PRODUCTION_OUTPUT", "PRODUCTION_CONSUMPTION", "SALE", "RETURN"}; + + for (int i = 0; i < 50; i++) { + int stockIdx = i % stockIds.size(); + String stockId = stockIds.get(stockIdx); + String articleId = articleIds.get(stockIdx); + String stockBatchId = stockBatchIds.get(i % stockBatchIds.size()); + String batchRef = "CHARGE-%05d".formatted(i); + String batchType = (i % 2 == 0) ? "PRODUCED" : "PURCHASED"; + String movementType = movementTypes[rnd.nextInt(movementTypes.length)]; + int qty = rnd.nextInt(1, 20); + + String body = """ + {"stockId":"%s","articleId":"%s","stockBatchId":"%s","batchId":"%s","batchType":"%s","movementType":"%s","quantityAmount":"%d.0","quantityUnit":"KILOGRAM"}""" + .formatted(stockId, articleId, stockBatchId, batchRef, batchType, movementType, qty); + + try { + var json = mapper.readTree(post("/api/inventory/stock-movements", body, adminToken)); + stockMovementIds.add(json.get("id").asText()); + } catch (Exception ignored) {} + } + } + // ---- HTTP Helper ---- private String post(String path, String jsonBody, String token) throws Exception { @@ -599,4 +686,7 @@ public final class LoadTestDataSeeder { public static List recipeIds() { return seededRecipeIds; } public static List batchIds() { return seededBatchIds; } public static List productionOrderIds() { return seededProductionOrderIds; } + public static List stockIds() { return seededStockIds; } + public static List stockBatchIds() { return seededStockBatchIds; } + public static List stockMovementIds() { return seededStockMovementIds; } } diff --git a/loadtest/src/test/java/de/effigenix/loadtest/scenario/InventoryScenario.java b/loadtest/src/test/java/de/effigenix/loadtest/scenario/InventoryScenario.java index aba6fe9..b550621 100644 --- a/loadtest/src/test/java/de/effigenix/loadtest/scenario/InventoryScenario.java +++ b/loadtest/src/test/java/de/effigenix/loadtest/scenario/InventoryScenario.java @@ -8,6 +8,7 @@ import java.util.concurrent.ThreadLocalRandom; import static io.gatling.javaapi.core.CoreDsl.*; import static io.gatling.javaapi.http.HttpDsl.*; +import static io.gatling.javaapi.core.CoreDsl.StringBody; /** * Lagerverwaltungs-Szenario: Lagerorte und Bestände verwalten. @@ -69,19 +70,71 @@ public final class InventoryScenario { ); } + public static ChainBuilder listStockMovements() { + return exec( + http("Bestandsbewegungen auflisten") + .get("/api/inventory/stock-movements") + .header("Authorization", "Bearer #{accessToken}") + .check(status().is(200)) + ); + } + + public static ChainBuilder listStockMovementsByStock() { + return exec(session -> { + var ids = LoadTestDataSeeder.stockIds(); + if (ids == null || ids.isEmpty()) return session; + String id = ids.get(ThreadLocalRandom.current().nextInt(ids.size())); + return session.set("filterMovementStockId", id); + }).exec( + http("Bestandsbewegungen nach Bestand") + .get("/api/inventory/stock-movements?stockId=#{filterMovementStockId}") + .header("Authorization", "Bearer #{accessToken}") + .check(status().is(200)) + ); + } + + public static ChainBuilder recordStockMovement() { + return exec(session -> { + var rnd = ThreadLocalRandom.current(); + var stockIds = LoadTestDataSeeder.stockIds(); + var articleIds = LoadTestDataSeeder.articleIds(); + var stockBatchIds = LoadTestDataSeeder.stockBatchIds(); + if (stockIds == null || stockIds.isEmpty() + || stockBatchIds == null || stockBatchIds.isEmpty()) return session; + + int idx = rnd.nextInt(stockIds.size()); + return session + .set("mvStockId", stockIds.get(idx)) + .set("mvArticleId", articleIds.get(idx % articleIds.size())) + .set("mvStockBatchId", stockBatchIds.get(rnd.nextInt(stockBatchIds.size()))) + .set("mvBatchRef", "LT-CHARGE-%06d".formatted(rnd.nextInt(999999))) + .set("mvQty", "%d.0".formatted(rnd.nextInt(1, 30))); + }).exec( + http("Bestandsbewegung erfassen") + .post("/api/inventory/stock-movements") + .header("Authorization", "Bearer #{accessToken}") + .body(StringBody(""" + {"stockId":"#{mvStockId}","articleId":"#{mvArticleId}","stockBatchId":"#{mvStockBatchId}","batchId":"#{mvBatchRef}","batchType":"PRODUCED","movementType":"GOODS_RECEIPT","quantityAmount":"#{mvQty}","quantityUnit":"KILOGRAM"}""")) + .check(status().is(201)) + ); + } + /** - * Lagerverwaltungs-Workflow: Überwiegend Lese-Operationen. + * Lagerverwaltungs-Workflow: Überwiegend Lese-Operationen mit Bestandsbewegungen. */ public static ScenarioBuilder inventoryWorkflow() { return scenario("Lagerverwaltung") .exec(AuthenticationScenario.login("admin", "admin123")) .repeat(15).on( randomSwitch().on( - percent(25.0).then(listStocks()), - percent(20.0).then(listStorageLocations()), - percent(20.0).then(getRandomStorageLocation()), - percent(20.0).then(listStocksByLocation()), - percent(15.0).then(listStocksBelowMinimum()) + percent(20.0).then(listStocks()), + percent(15.0).then(listStorageLocations()), + percent(15.0).then(getRandomStorageLocation()), + percent(15.0).then(listStocksByLocation()), + percent(10.0).then(listStocksBelowMinimum()), + percent(10.0).then(listStockMovements()), + percent(5.0).then(listStockMovementsByStock()), + percent(10.0).then(recordStockMovement()) ).pause(1, 3) ); } diff --git a/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java b/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java index 707c367..ae8483e 100644 --- a/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java +++ b/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java @@ -101,6 +101,8 @@ public class FullWorkloadSimulation extends Simulation { details("Bestände nach Lagerort").responseTime().mean().lt(35), details("Lieferanten auflisten").responseTime().mean().lt(35), details("Kategorien auflisten").responseTime().mean().lt(35), + details("Bestandsbewegungen auflisten").responseTime().mean().lt(35), + details("Bestandsbewegungen nach Bestand").responseTime().mean().lt(35), // Listen mit viel Daten (50-300 Einträge): mean < 75ms details("Chargen auflisten").responseTime().mean().lt(75), @@ -112,7 +114,8 @@ public class FullWorkloadSimulation extends Simulation { details("Charge starten").responseTime().mean().lt(50), details("Charge abschließen").responseTime().mean().lt(50), details("Produktionsauftrag anlegen").responseTime().mean().lt(50), - details("Produktionsauftrag freigeben").responseTime().mean().lt(50) + details("Produktionsauftrag freigeben").responseTime().mean().lt(50), + details("Bestandsbewegung erfassen").responseTime().mean().lt(50) ); } }