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

feat(inventory): Bestandsbewegung erfassen (StockMovement) – Issue #15

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)
This commit is contained in:
Sebastian Frick 2026-02-24 22:58:57 +01:00
parent 85f96d685e
commit fa6c0c2d70
32 changed files with 3229 additions and 9 deletions

View file

@ -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<StockMovementError, StockMovement> 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
.<Result<StockMovementError, StockMovement>>map(Result::success)
.orElseGet(() -> Result.failure(new StockMovementError.StockMovementNotFound(stockMovementId)));
};
}
}

View file

@ -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<StockMovementError, List<StockMovement>> 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<StockMovementError, List<StockMovement>> mapResult(
Result<RepositoryError, List<StockMovement>> result) {
return switch (result) {
case Result.Failure(var err) -> Result.failure(new StockMovementError.RepositoryFailure(err.message()));
case Result.Success(var movements) -> Result.success(movements);
};
}
}

View file

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

View file

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

View file

@ -0,0 +1,5 @@
package de.effigenix.domain.inventory;
public enum MovementDirection {
IN, OUT
}

View file

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

View file

@ -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<StockMovementError, StockMovement> 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 + "}";
}
}

View file

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

View file

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

View file

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

View file

@ -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<RepositoryError, Optional<StockMovement>> findById(StockMovementId id);
Result<RepositoryError, List<StockMovement>> findAll();
Result<RepositoryError, List<StockMovement>> findAllByStockId(StockId stockId);
Result<RepositoryError, List<StockMovement>> findAllByArticleId(ArticleId articleId);
Result<RepositoryError, List<StockMovement>> findAllByMovementType(MovementType movementType);
Result<RepositoryError, Void> save(StockMovement stockMovement);
}

View file

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

View file

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

View file

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

View file

@ -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<RepositoryError, Optional<StockMovement>> findById(StockMovementId id) {
try {
Optional<StockMovement> 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<RepositoryError, List<StockMovement>> findAll() {
try {
List<StockMovement> 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<RepositoryError, List<StockMovement>> findAllByStockId(StockId stockId) {
try {
List<StockMovement> 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<RepositoryError, List<StockMovement>> findAllByArticleId(ArticleId articleId) {
try {
List<StockMovement> 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<RepositoryError, List<StockMovement>> findAllByMovementType(MovementType movementType) {
try {
List<StockMovement> 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<RepositoryError, Void> 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()));
}
}
}

View file

@ -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<StockMovementEntity, String> {
List<StockMovementEntity> findAllByStockId(String stockId);
List<StockMovementEntity> findAllByArticleId(String articleId);
List<StockMovementEntity> findAllByMovementType(String movementType);
}

View file

@ -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<StockMovementResponse> 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<List<StockMovementResponse>> 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<StockMovementResponse> responses = result.unsafeGetValue().stream()
.map(StockMovementResponse::from)
.toList();
return ResponseEntity.ok(responses);
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('STOCK_MOVEMENT_READ')")
public ResponseEntity<StockMovementResponse> 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;
}
}
}

View file

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

View file

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

View file

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

View file

@ -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<ErrorResponse> 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<ErrorResponse> handleStockDomainError(
StockController.StockDomainErrorException ex,

View file

@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="028-create-stock-movements-table" author="effigenix">
<comment>Create stock_movements table for immutable stock movement audit trail</comment>
<createTable tableName="stock_movements">
<column name="id" type="VARCHAR(36)">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="stock_id" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="article_id" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="stock_batch_id" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="batch_id" type="VARCHAR(100)">
<constraints nullable="false"/>
</column>
<column name="batch_type" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="movement_type" type="VARCHAR(30)">
<constraints nullable="false"/>
</column>
<column name="direction" type="VARCHAR(10)">
<constraints nullable="false"/>
</column>
<column name="quantity_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="quantity_unit" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="reason" type="VARCHAR(500)"/>
<column name="reference_document_id" type="VARCHAR(100)"/>
<column name="performed_by" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="performed_at" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint baseTableName="stock_movements" baseColumnNames="stock_id"
referencedTableName="stocks" referencedColumnNames="id"
constraintName="fk_stock_movements_stock"/>
<addForeignKeyConstraint baseTableName="stock_movements" baseColumnNames="article_id"
referencedTableName="articles" referencedColumnNames="id"
constraintName="fk_stock_movements_article"/>
<addForeignKeyConstraint baseTableName="stock_movements" baseColumnNames="stock_batch_id"
referencedTableName="stock_batches" referencedColumnNames="id"
constraintName="fk_stock_movements_stock_batch"/>
<sql>
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'));
</sql>
<createIndex tableName="stock_movements" indexName="idx_stock_movements_stock_id">
<column name="stock_id"/>
</createIndex>
<createIndex tableName="stock_movements" indexName="idx_stock_movements_article_id">
<column name="article_id"/>
</createIndex>
<createIndex tableName="stock_movements" indexName="idx_stock_movements_movement_type">
<column name="movement_type"/>
</createIndex>
<createIndex tableName="stock_movements" indexName="idx_stock_movements_performed_at">
<column name="performed_at"/>
</createIndex>
<createIndex tableName="stock_movements" indexName="idx_stock_movements_stock_batch_id">
<column name="stock_batch_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="029-seed-stock-movement-permissions" author="effigenix">
<preConditions onFail="MARK_RAN">
<not>
<sqlCheck expectedResult="1">
SELECT COUNT(*) FROM role_permissions
WHERE role_id = 'c0a80121-0000-0000-0000-000000000001'
AND permission = 'STOCK_MOVEMENT_READ'
</sqlCheck>
</not>
</preConditions>
<comment>Add STOCK_MOVEMENT_READ and STOCK_MOVEMENT_WRITE permissions for ADMIN role</comment>
<sql>
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;
</sql>
</changeSet>
</databaseChangeLog>

View file

@ -32,5 +32,7 @@
<include file="db/changelog/changes/025-seed-production-order-permissions.xml"/>
<include file="db/changelog/changes/026-create-reservations-schema.xml"/>
<include file="db/changelog/changes/027-add-released-status-to-production-orders.xml"/>
<include file="db/changelog/changes/028-create-stock-movements-table.xml"/>
<include file="db/changelog/changes/029-seed-stock-movement-permissions.xml"/>
</databaseChangeLog>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -41,6 +41,9 @@ public final class LoadTestDataSeeder {
private final List<String> recipeIds = new ArrayList<>();
private final List<String> batchIds = new ArrayList<>();
private final List<String> productionOrderIds = new ArrayList<>();
private final List<String> stockIds = new ArrayList<>();
private final List<String> stockBatchIds = new ArrayList<>();
private final List<String> stockMovementIds = new ArrayList<>();
// Statische Felder für Zugriff aus Szenarien
private static List<String> seededCategoryIds;
@ -51,6 +54,9 @@ public final class LoadTestDataSeeder {
private static List<String> seededRecipeIds;
private static List<String> seededBatchIds;
private static List<String> seededProductionOrderIds;
private static List<String> seededStockIds;
private static List<String> seededStockBatchIds;
private static List<String> 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<String> recipeIds() { return seededRecipeIds; }
public static List<String> batchIds() { return seededBatchIds; }
public static List<String> productionOrderIds() { return seededProductionOrderIds; }
public static List<String> stockIds() { return seededStockIds; }
public static List<String> stockBatchIds() { return seededStockBatchIds; }
public static List<String> stockMovementIds() { return seededStockMovementIds; }
}

View file

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

View file

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