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:
parent
85f96d685e
commit
fa6c0c2d70
32 changed files with 3229 additions and 9 deletions
|
|
@ -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)));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package de.effigenix.domain.inventory;
|
||||||
|
|
||||||
|
public enum MovementDirection {
|
||||||
|
IN, OUT
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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 + "}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -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"; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
package de.effigenix.infrastructure.config;
|
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.ActivateStorageLocation;
|
||||||
import de.effigenix.application.inventory.AddStockBatch;
|
import de.effigenix.application.inventory.AddStockBatch;
|
||||||
import de.effigenix.application.inventory.BlockStockBatch;
|
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.ListStorageLocations;
|
||||||
import de.effigenix.application.inventory.UpdateStorageLocation;
|
import de.effigenix.application.inventory.UpdateStorageLocation;
|
||||||
import de.effigenix.application.usermanagement.AuditLogger;
|
import de.effigenix.application.usermanagement.AuditLogger;
|
||||||
|
import de.effigenix.domain.inventory.StockMovementRepository;
|
||||||
import de.effigenix.domain.inventory.StockRepository;
|
import de.effigenix.domain.inventory.StockRepository;
|
||||||
import de.effigenix.domain.inventory.StorageLocationRepository;
|
import de.effigenix.domain.inventory.StorageLocationRepository;
|
||||||
import de.effigenix.shared.security.AuthorizationPort;
|
import de.effigenix.shared.security.AuthorizationPort;
|
||||||
|
|
@ -121,4 +125,21 @@ public class InventoryUseCaseConfiguration {
|
||||||
public ListStocksBelowMinimum listStocksBelowMinimum(StockRepository stockRepository, AuthorizationPort authorizationPort) {
|
public ListStocksBelowMinimum listStocksBelowMinimum(StockRepository stockRepository, AuthorizationPort authorizationPort) {
|
||||||
return new ListStocksBelowMinimum(stockRepository, 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package de.effigenix.infrastructure.inventory.web.exception;
|
package de.effigenix.infrastructure.inventory.web.exception;
|
||||||
|
|
||||||
|
import de.effigenix.domain.inventory.StockMovementError;
|
||||||
import de.effigenix.domain.inventory.StorageLocationError;
|
import de.effigenix.domain.inventory.StorageLocationError;
|
||||||
import de.effigenix.domain.inventory.StockError;
|
import de.effigenix.domain.inventory.StockError;
|
||||||
|
|
||||||
|
|
@ -49,4 +50,22 @@ public final class InventoryErrorHttpStatusMapper {
|
||||||
case StockError.RepositoryFailure e -> 500;
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package de.effigenix.infrastructure.usermanagement.web.exception;
|
package de.effigenix.infrastructure.usermanagement.web.exception;
|
||||||
|
|
||||||
|
import de.effigenix.domain.inventory.StockMovementError;
|
||||||
import de.effigenix.domain.inventory.StorageLocationError;
|
import de.effigenix.domain.inventory.StorageLocationError;
|
||||||
import de.effigenix.domain.inventory.StockError;
|
import de.effigenix.domain.inventory.StockError;
|
||||||
import de.effigenix.domain.masterdata.ArticleError;
|
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.ProductionOrderError;
|
||||||
import de.effigenix.domain.production.RecipeError;
|
import de.effigenix.domain.production.RecipeError;
|
||||||
import de.effigenix.domain.usermanagement.UserError;
|
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.StorageLocationController;
|
||||||
import de.effigenix.infrastructure.inventory.web.controller.StockController;
|
import de.effigenix.infrastructure.inventory.web.controller.StockController;
|
||||||
import de.effigenix.infrastructure.inventory.web.exception.InventoryErrorHttpStatusMapper;
|
import de.effigenix.infrastructure.inventory.web.exception.InventoryErrorHttpStatusMapper;
|
||||||
|
|
@ -211,6 +213,29 @@ public class GlobalExceptionHandler {
|
||||||
return ResponseEntity.status(status).body(errorResponse);
|
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)
|
@ExceptionHandler(StockController.StockDomainErrorException.class)
|
||||||
public ResponseEntity<ErrorResponse> handleStockDomainError(
|
public ResponseEntity<ErrorResponse> handleStockDomainError(
|
||||||
StockController.StockDomainErrorException ex,
|
StockController.StockDomainErrorException ex,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -32,5 +32,7 @@
|
||||||
<include file="db/changelog/changes/025-seed-production-order-permissions.xml"/>
|
<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/026-create-reservations-schema.xml"/>
|
||||||
<include file="db/changelog/changes/027-add-released-status-to-production-orders.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>
|
</databaseChangeLog>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -41,6 +41,9 @@ public final class LoadTestDataSeeder {
|
||||||
private final List<String> recipeIds = new ArrayList<>();
|
private final List<String> recipeIds = new ArrayList<>();
|
||||||
private final List<String> batchIds = new ArrayList<>();
|
private final List<String> batchIds = new ArrayList<>();
|
||||||
private final List<String> productionOrderIds = 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
|
// Statische Felder für Zugriff aus Szenarien
|
||||||
private static List<String> seededCategoryIds;
|
private static List<String> seededCategoryIds;
|
||||||
|
|
@ -51,6 +54,9 @@ public final class LoadTestDataSeeder {
|
||||||
private static List<String> seededRecipeIds;
|
private static List<String> seededRecipeIds;
|
||||||
private static List<String> seededBatchIds;
|
private static List<String> seededBatchIds;
|
||||||
private static List<String> seededProductionOrderIds;
|
private static List<String> seededProductionOrderIds;
|
||||||
|
private static List<String> seededStockIds;
|
||||||
|
private static List<String> seededStockBatchIds;
|
||||||
|
private static List<String> seededStockMovementIds;
|
||||||
|
|
||||||
public LoadTestDataSeeder(ConfigurableApplicationContext appContext) {
|
public LoadTestDataSeeder(ConfigurableApplicationContext appContext) {
|
||||||
int port = appContext.getEnvironment()
|
int port = appContext.getEnvironment()
|
||||||
|
|
@ -72,6 +78,8 @@ public final class LoadTestDataSeeder {
|
||||||
seedRecipes();
|
seedRecipes();
|
||||||
seedBatchesForYear();
|
seedBatchesForYear();
|
||||||
seedProductionOrders();
|
seedProductionOrders();
|
||||||
|
seedStocksAndBatches();
|
||||||
|
seedStockMovements();
|
||||||
|
|
||||||
// Statische Referenzen setzen
|
// Statische Referenzen setzen
|
||||||
seededCategoryIds = List.copyOf(categoryIds);
|
seededCategoryIds = List.copyOf(categoryIds);
|
||||||
|
|
@ -82,14 +90,19 @@ public final class LoadTestDataSeeder {
|
||||||
seededRecipeIds = List.copyOf(recipeIds);
|
seededRecipeIds = List.copyOf(recipeIds);
|
||||||
seededBatchIds = List.copyOf(batchIds);
|
seededBatchIds = List.copyOf(batchIds);
|
||||||
seededProductionOrderIds = List.copyOf(productionOrderIds);
|
seededProductionOrderIds = List.copyOf(productionOrderIds);
|
||||||
|
seededStockIds = List.copyOf(stockIds);
|
||||||
|
seededStockBatchIds = List.copyOf(stockBatchIds);
|
||||||
|
seededStockMovementIds = List.copyOf(stockMovementIds);
|
||||||
|
|
||||||
long duration = System.currentTimeMillis() - start;
|
long duration = System.currentTimeMillis() - start;
|
||||||
System.out.printf(
|
System.out.printf(
|
||||||
"Seeded in %dms: %d Kategorien, %d Artikel, %d Lieferanten, %d Kunden, "
|
"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(),
|
duration, categoryIds.size(), articleIds.size(), supplierIds.size(),
|
||||||
customerIds.size(), storageLocationIds.size(), recipeIds.size(),
|
customerIds.size(), storageLocationIds.size(), recipeIds.size(),
|
||||||
batchIds.size(), productionOrderIds.size());
|
batchIds.size(), productionOrderIds.size(),
|
||||||
|
stockIds.size(), stockMovementIds.size());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new RuntimeException("Testdaten-Seeding fehlgeschlagen", 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 ----
|
// ---- HTTP Helper ----
|
||||||
|
|
||||||
private String post(String path, String jsonBody, String token) throws Exception {
|
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> recipeIds() { return seededRecipeIds; }
|
||||||
public static List<String> batchIds() { return seededBatchIds; }
|
public static List<String> batchIds() { return seededBatchIds; }
|
||||||
public static List<String> productionOrderIds() { return seededProductionOrderIds; }
|
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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
|
||||||
import static io.gatling.javaapi.core.CoreDsl.*;
|
import static io.gatling.javaapi.core.CoreDsl.*;
|
||||||
import static io.gatling.javaapi.http.HttpDsl.*;
|
import static io.gatling.javaapi.http.HttpDsl.*;
|
||||||
|
import static io.gatling.javaapi.core.CoreDsl.StringBody;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lagerverwaltungs-Szenario: Lagerorte und Bestände verwalten.
|
* 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() {
|
public static ScenarioBuilder inventoryWorkflow() {
|
||||||
return scenario("Lagerverwaltung")
|
return scenario("Lagerverwaltung")
|
||||||
.exec(AuthenticationScenario.login("admin", "admin123"))
|
.exec(AuthenticationScenario.login("admin", "admin123"))
|
||||||
.repeat(15).on(
|
.repeat(15).on(
|
||||||
randomSwitch().on(
|
randomSwitch().on(
|
||||||
percent(25.0).then(listStocks()),
|
percent(20.0).then(listStocks()),
|
||||||
percent(20.0).then(listStorageLocations()),
|
percent(15.0).then(listStorageLocations()),
|
||||||
percent(20.0).then(getRandomStorageLocation()),
|
percent(15.0).then(getRandomStorageLocation()),
|
||||||
percent(20.0).then(listStocksByLocation()),
|
percent(15.0).then(listStocksByLocation()),
|
||||||
percent(15.0).then(listStocksBelowMinimum())
|
percent(10.0).then(listStocksBelowMinimum()),
|
||||||
|
percent(10.0).then(listStockMovements()),
|
||||||
|
percent(5.0).then(listStockMovementsByStock()),
|
||||||
|
percent(10.0).then(recordStockMovement())
|
||||||
).pause(1, 3)
|
).pause(1, 3)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,8 @@ public class FullWorkloadSimulation extends Simulation {
|
||||||
details("Bestände nach Lagerort").responseTime().mean().lt(35),
|
details("Bestände nach Lagerort").responseTime().mean().lt(35),
|
||||||
details("Lieferanten auflisten").responseTime().mean().lt(35),
|
details("Lieferanten auflisten").responseTime().mean().lt(35),
|
||||||
details("Kategorien 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
|
// Listen mit viel Daten (50-300 Einträge): mean < 75ms
|
||||||
details("Chargen auflisten").responseTime().mean().lt(75),
|
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 starten").responseTime().mean().lt(50),
|
||||||
details("Charge abschließen").responseTime().mean().lt(50),
|
details("Charge abschließen").responseTime().mean().lt(50),
|
||||||
details("Produktionsauftrag anlegen").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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue