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

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

Immutables StockMovement-Aggregate als Audit-Trail für jede Bestandsveränderung.
Domain-Invarianten: positive Quantity, Reason bei WASTE/ADJUSTMENT,
ReferenceDocumentId bei INTER_BRANCH_TRANSFER, Direction-Ableitung aus MovementType.

Domain: StockMovement, MovementType (8 Typen), MovementDirection, StockMovementError
Application: RecordStockMovement, GetStockMovement, ListStockMovements
Infrastructure: JPA-Persistence, REST-Controller (POST/GET), Liquibase 028+029
Tests: ~40 Domain-Unit-Tests, 18 Application-Tests, ~27 Integrationstests
Loadtest: Gatling-Szenarien für Bestandsbewegungen (Seeding, Read, Write)
This commit is contained in:
Sebastian Frick 2026-02-24 22:58:57 +01:00
parent 85f96d685e
commit fa6c0c2d70
32 changed files with 3229 additions and 9 deletions

View file

@ -0,0 +1,40 @@
package de.effigenix.application.inventory;
import de.effigenix.domain.inventory.InventoryAction;
import de.effigenix.domain.inventory.StockMovement;
import de.effigenix.domain.inventory.StockMovementError;
import de.effigenix.domain.inventory.StockMovementId;
import de.effigenix.domain.inventory.StockMovementRepository;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true)
public class GetStockMovement {
private final StockMovementRepository stockMovementRepository;
private final AuthorizationPort authPort;
public GetStockMovement(StockMovementRepository stockMovementRepository, AuthorizationPort authPort) {
this.stockMovementRepository = stockMovementRepository;
this.authPort = authPort;
}
public Result<StockMovementError, StockMovement> execute(String stockMovementId, ActorId performedBy) {
if (!authPort.can(performedBy, InventoryAction.STOCK_MOVEMENT_READ)) {
return Result.failure(new StockMovementError.Unauthorized("Not authorized to view stock movements"));
}
if (stockMovementId == null || stockMovementId.isBlank()) {
return Result.failure(new StockMovementError.StockMovementNotFound(stockMovementId));
}
return switch (stockMovementRepository.findById(StockMovementId.of(stockMovementId))) {
case Result.Failure(var err) -> Result.failure(new StockMovementError.RepositoryFailure(err.message()));
case Result.Success(var opt) -> opt
.<Result<StockMovementError, StockMovement>>map(Result::success)
.orElseGet(() -> Result.failure(new StockMovementError.StockMovementNotFound(stockMovementId)));
};
}
}

View file

@ -0,0 +1,83 @@
package de.effigenix.application.inventory;
import de.effigenix.domain.inventory.InventoryAction;
import de.effigenix.domain.inventory.MovementType;
import de.effigenix.domain.inventory.StockId;
import de.effigenix.domain.inventory.StockMovement;
import de.effigenix.domain.inventory.StockMovementError;
import de.effigenix.domain.inventory.StockMovementRepository;
import de.effigenix.domain.masterdata.ArticleId;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Transactional(readOnly = true)
public class ListStockMovements {
private final StockMovementRepository stockMovementRepository;
private final AuthorizationPort authPort;
public ListStockMovements(StockMovementRepository stockMovementRepository, AuthorizationPort authPort) {
this.stockMovementRepository = stockMovementRepository;
this.authPort = authPort;
}
/**
* Lists stock movements with optional filtering.
* Filter priority (only one filter applied): stockId > articleId > movementType.
* If multiple filters are provided, only the highest-priority filter is used.
*/
public Result<StockMovementError, List<StockMovement>> execute(
String stockId, String articleId, String movementType, ActorId performedBy) {
if (!authPort.can(performedBy, InventoryAction.STOCK_MOVEMENT_READ)) {
return Result.failure(new StockMovementError.Unauthorized("Not authorized to list stock movements"));
}
if (stockId != null) {
StockId sid;
try {
sid = StockId.of(stockId);
} catch (IllegalArgumentException e) {
return Result.failure(new StockMovementError.InvalidStockId(e.getMessage()));
}
return mapResult(stockMovementRepository.findAllByStockId(sid));
}
if (articleId != null) {
ArticleId aid;
try {
aid = ArticleId.of(articleId);
} catch (IllegalArgumentException e) {
return Result.failure(new StockMovementError.InvalidArticleId(e.getMessage()));
}
return mapResult(stockMovementRepository.findAllByArticleId(aid));
}
if (movementType != null) {
MovementType type;
try {
type = MovementType.valueOf(movementType);
} catch (IllegalArgumentException e) {
return Result.failure(new StockMovementError.InvalidMovementType(
"Invalid movement type: " + movementType));
}
return mapResult(stockMovementRepository.findAllByMovementType(type));
}
return mapResult(stockMovementRepository.findAll());
}
private Result<StockMovementError, List<StockMovement>> mapResult(
Result<RepositoryError, List<StockMovement>> result) {
return switch (result) {
case Result.Failure(var err) -> Result.failure(new StockMovementError.RepositoryFailure(err.message()));
case Result.Success(var movements) -> Result.success(movements);
};
}
}

View file

@ -0,0 +1,53 @@
package de.effigenix.application.inventory;
import de.effigenix.application.inventory.command.RecordStockMovementCommand;
import de.effigenix.domain.inventory.InventoryAction;
import de.effigenix.domain.inventory.StockMovement;
import de.effigenix.domain.inventory.StockMovementDraft;
import de.effigenix.domain.inventory.StockMovementError;
import de.effigenix.domain.inventory.StockMovementRepository;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public class RecordStockMovement {
private final StockMovementRepository stockMovementRepository;
private final AuthorizationPort authPort;
public RecordStockMovement(StockMovementRepository stockMovementRepository, AuthorizationPort authPort) {
this.stockMovementRepository = stockMovementRepository;
this.authPort = authPort;
}
public Result<StockMovementError, StockMovement> execute(RecordStockMovementCommand cmd, ActorId performedBy) {
if (!authPort.can(performedBy, InventoryAction.STOCK_MOVEMENT_WRITE)) {
return Result.failure(new StockMovementError.Unauthorized("Not authorized to record stock movements"));
}
var draft = new StockMovementDraft(
cmd.stockId(), cmd.articleId(), cmd.stockBatchId(),
cmd.batchId(), cmd.batchType(),
cmd.movementType(), cmd.direction(),
cmd.quantityAmount(), cmd.quantityUnit(),
cmd.reason(), cmd.referenceDocumentId(),
cmd.performedBy()
);
StockMovement movement;
switch (StockMovement.record(draft)) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var val) -> movement = val;
}
switch (stockMovementRepository.save(movement)) {
case Result.Failure(var err) ->
{ return Result.failure(new StockMovementError.RepositoryFailure(err.message())); }
case Result.Success(var ignored) -> { }
}
return Result.success(movement);
}
}

View file

@ -0,0 +1,16 @@
package de.effigenix.application.inventory.command;
public record RecordStockMovementCommand(
String stockId,
String articleId,
String stockBatchId,
String batchId,
String batchType,
String movementType,
String direction,
String quantityAmount,
String quantityUnit,
String reason,
String referenceDocumentId,
String performedBy
) {}

View file

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

View file

@ -0,0 +1,12 @@
package de.effigenix.domain.inventory;
public enum MovementType {
GOODS_RECEIPT,
PRODUCTION_OUTPUT,
PRODUCTION_CONSUMPTION,
SALE,
INTER_BRANCH_TRANSFER,
WASTE,
ADJUSTMENT,
RETURN
}

View file

@ -0,0 +1,267 @@
package de.effigenix.domain.inventory;
import de.effigenix.domain.masterdata.ArticleId;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Objects;
/**
* StockMovement aggregate root immutable after creation.
*
* Invariants:
* - StockId, ArticleId, StockBatchId are required and non-blank
* - BatchReference (batchId + batchType) is required
* - MovementType is required
* - Direction is derived from MovementType, except ADJUSTMENT where it must be provided
* - Quantity must be positive
* - WASTE and ADJUSTMENT require a non-empty reason
* - INTER_BRANCH_TRANSFER requires a referenceDocumentId; always recorded as OUT
* (the receiving branch records a separate GOODS_RECEIPT movement)
* - PerformedBy is required and non-blank
*/
public class StockMovement {
private final StockMovementId id;
private final StockId stockId;
private final ArticleId articleId;
private final StockBatchId stockBatchId;
private final BatchReference batchReference;
private final MovementType movementType;
private final MovementDirection direction;
private final Quantity quantity;
private final String reason;
private final String referenceDocumentId;
private final String performedBy;
private final Instant performedAt;
private StockMovement(
StockMovementId id,
StockId stockId,
ArticleId articleId,
StockBatchId stockBatchId,
BatchReference batchReference,
MovementType movementType,
MovementDirection direction,
Quantity quantity,
String reason,
String referenceDocumentId,
String performedBy,
Instant performedAt
) {
this.id = id;
this.stockId = stockId;
this.articleId = articleId;
this.stockBatchId = stockBatchId;
this.batchReference = batchReference;
this.movementType = movementType;
this.direction = direction;
this.quantity = quantity;
this.reason = reason;
this.referenceDocumentId = referenceDocumentId;
this.performedBy = performedBy;
this.performedAt = performedAt;
}
/**
* Factory: Erzeugt eine neue StockMovement aus rohen Eingaben.
* Orchestriert Validierung aller VOs intern.
*/
public static Result<StockMovementError, StockMovement> record(StockMovementDraft draft) {
// 1. StockId validieren
StockId stockId;
try {
stockId = StockId.of(draft.stockId());
} catch (IllegalArgumentException e) {
return Result.failure(new StockMovementError.InvalidStockId(e.getMessage()));
}
// 2. ArticleId validieren
ArticleId articleId;
try {
articleId = ArticleId.of(draft.articleId());
} catch (IllegalArgumentException e) {
return Result.failure(new StockMovementError.InvalidArticleId(e.getMessage()));
}
// 3. StockBatchId validieren
StockBatchId stockBatchId;
try {
stockBatchId = StockBatchId.of(draft.stockBatchId());
} catch (IllegalArgumentException e) {
return Result.failure(new StockMovementError.InvalidStockBatchId(e.getMessage()));
}
// 4. BatchReference validieren
if (draft.batchId() == null || draft.batchId().isBlank()) {
return Result.failure(new StockMovementError.InvalidBatchReference("batchId must not be blank"));
}
BatchType batchType;
try {
batchType = BatchType.valueOf(draft.batchType());
} catch (IllegalArgumentException | NullPointerException e) {
return Result.failure(new StockMovementError.InvalidBatchReference(
"Invalid batchType: " + draft.batchType() + ". Allowed values: PRODUCED, PURCHASED"));
}
var batchReference = new BatchReference(draft.batchId(), batchType);
// 5. MovementType validieren
MovementType movementType;
try {
movementType = MovementType.valueOf(draft.movementType());
} catch (IllegalArgumentException | NullPointerException e) {
return Result.failure(new StockMovementError.InvalidMovementType(
"Invalid movement type: " + draft.movementType()));
}
// 6. Direction ableiten oder validieren
MovementDirection direction;
if (movementType == MovementType.ADJUSTMENT) {
if (draft.direction() == null || draft.direction().isBlank()) {
return Result.failure(new StockMovementError.InvalidDirection(
"Direction is required for ADJUSTMENT movements"));
}
try {
direction = MovementDirection.valueOf(draft.direction());
} catch (IllegalArgumentException e) {
return Result.failure(new StockMovementError.InvalidDirection(
"Invalid direction: " + draft.direction() + ". Allowed values: IN, OUT"));
}
} else {
direction = deriveDirection(movementType);
}
// 7. Quantity validieren
Quantity quantity;
try {
BigDecimal amount = new BigDecimal(draft.quantityAmount());
UnitOfMeasure uom;
try {
uom = UnitOfMeasure.valueOf(draft.quantityUnit());
} catch (IllegalArgumentException | NullPointerException e) {
return Result.failure(new StockMovementError.InvalidQuantity("Invalid unit: " + draft.quantityUnit()));
}
switch (Quantity.of(amount, uom)) {
case Result.Failure(var err) -> {
return Result.failure(new StockMovementError.InvalidQuantity(err.message()));
}
case Result.Success(var val) -> quantity = val;
}
} catch (NumberFormatException | NullPointerException e) {
return Result.failure(new StockMovementError.InvalidQuantity(
"Invalid quantity amount: " + draft.quantityAmount()));
}
// 8. Reason-Pflicht bei WASTE und ADJUSTMENT
if ((movementType == MovementType.WASTE || movementType == MovementType.ADJUSTMENT)
&& (draft.reason() == null || draft.reason().isBlank())) {
return Result.failure(new StockMovementError.ReasonRequired(movementType.name()));
}
// 9. ReferenceDocumentId-Pflicht bei INTER_BRANCH_TRANSFER
if (movementType == MovementType.INTER_BRANCH_TRANSFER
&& (draft.referenceDocumentId() == null || draft.referenceDocumentId().isBlank())) {
return Result.failure(new StockMovementError.ReferenceDocumentRequired(movementType.name()));
}
// 10. PerformedBy validieren
if (draft.performedBy() == null || draft.performedBy().isBlank()) {
return Result.failure(new StockMovementError.InvalidPerformedBy("must not be blank"));
}
return Result.success(new StockMovement(
StockMovementId.generate(),
stockId,
articleId,
stockBatchId,
batchReference,
movementType,
direction,
quantity,
draft.reason(),
draft.referenceDocumentId(),
draft.performedBy(),
Instant.now()
));
}
/**
* Reconstitute from persistence without re-validation.
*/
public static StockMovement reconstitute(
StockMovementId id,
StockId stockId,
ArticleId articleId,
StockBatchId stockBatchId,
BatchReference batchReference,
MovementType movementType,
MovementDirection direction,
Quantity quantity,
String reason,
String referenceDocumentId,
String performedBy,
Instant performedAt
) {
return new StockMovement(id, stockId, articleId, stockBatchId, batchReference,
movementType, direction, quantity, reason, referenceDocumentId, performedBy, performedAt);
}
// ==================== Queries ====================
public boolean isIncoming() {
return direction == MovementDirection.IN;
}
public boolean isOutgoing() {
return direction == MovementDirection.OUT;
}
// ==================== Getters ====================
public StockMovementId id() { return id; }
public StockId stockId() { return stockId; }
public ArticleId articleId() { return articleId; }
public StockBatchId stockBatchId() { return stockBatchId; }
public BatchReference batchReference() { return batchReference; }
public MovementType movementType() { return movementType; }
public MovementDirection direction() { return direction; }
public Quantity quantity() { return quantity; }
public String reason() { return reason; }
public String referenceDocumentId() { return referenceDocumentId; }
public String performedBy() { return performedBy; }
public Instant performedAt() { return performedAt; }
// ==================== Helpers ====================
/**
* Derives direction from movement type. Must NOT be called for ADJUSTMENT
* (which requires explicit direction and is handled before this method is called).
*/
private static MovementDirection deriveDirection(MovementType type) {
return switch (type) {
case GOODS_RECEIPT, PRODUCTION_OUTPUT, RETURN -> MovementDirection.IN;
case PRODUCTION_CONSUMPTION, SALE, WASTE, INTER_BRANCH_TRANSFER -> MovementDirection.OUT;
case ADJUSTMENT -> MovementDirection.IN; // unreachable ADJUSTMENT handled before call
};
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof StockMovement other)) return false;
return Objects.equals(id, other.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
@Override
public String toString() {
return "StockMovement{id=" + id + ", type=" + movementType + ", direction=" + direction + "}";
}
}

View file

@ -0,0 +1,34 @@
package de.effigenix.domain.inventory;
/**
* Rohe Eingabe zum Erzeugen einer StockMovement.
* Wird vom Application Layer aus dem Command gebaut und an StockMovement.record() übergeben.
* Das Aggregate ist verantwortlich für Validierung und VO-Konstruktion.
*
* @param stockId Pflicht
* @param articleId Pflicht
* @param stockBatchId Pflicht
* @param batchId Pflicht Chargen-Kennung
* @param batchType Pflicht PRODUCED oder PURCHASED
* @param movementType Pflicht einer der 8 MovementTypes
* @param direction Nur bei ADJUSTMENT Pflicht (IN/OUT), sonst ignoriert
* @param quantityAmount Pflicht positiv
* @param quantityUnit Pflicht UnitOfMeasure als String
* @param reason Pflicht bei WASTE und ADJUSTMENT, optional sonst
* @param referenceDocumentId Pflicht bei INTER_BRANCH_TRANSFER, optional sonst
* @param performedBy Pflicht User-ID
*/
public record StockMovementDraft(
String stockId,
String articleId,
String stockBatchId,
String batchId,
String batchType,
String movementType,
String direction,
String quantityAmount,
String quantityUnit,
String reason,
String referenceDocumentId,
String performedBy
) {}

View file

@ -0,0 +1,70 @@
package de.effigenix.domain.inventory;
public sealed interface StockMovementError {
String code();
String message();
record InvalidStockId(String reason) implements StockMovementError {
@Override public String code() { return "INVALID_STOCK_ID"; }
@Override public String message() { return "Invalid stock ID: " + reason; }
}
record InvalidArticleId(String reason) implements StockMovementError {
@Override public String code() { return "INVALID_ARTICLE_ID"; }
@Override public String message() { return "Invalid article ID: " + reason; }
}
record InvalidStockBatchId(String reason) implements StockMovementError {
@Override public String code() { return "INVALID_STOCK_BATCH_ID"; }
@Override public String message() { return "Invalid stock batch ID: " + reason; }
}
record InvalidBatchReference(String reason) implements StockMovementError {
@Override public String code() { return "INVALID_BATCH_REFERENCE"; }
@Override public String message() { return "Invalid batch reference: " + reason; }
}
record InvalidMovementType(String reason) implements StockMovementError {
@Override public String code() { return "INVALID_MOVEMENT_TYPE"; }
@Override public String message() { return "Invalid movement type: " + reason; }
}
record InvalidDirection(String reason) implements StockMovementError {
@Override public String code() { return "INVALID_DIRECTION"; }
@Override public String message() { return "Invalid direction: " + reason; }
}
record InvalidQuantity(String reason) implements StockMovementError {
@Override public String code() { return "INVALID_QUANTITY"; }
@Override public String message() { return "Invalid quantity: " + reason; }
}
record ReasonRequired(String movementType) implements StockMovementError {
@Override public String code() { return "REASON_REQUIRED"; }
@Override public String message() { return "Reason is required for movement type: " + movementType; }
}
record ReferenceDocumentRequired(String movementType) implements StockMovementError {
@Override public String code() { return "REFERENCE_DOCUMENT_REQUIRED"; }
@Override public String message() { return "Reference document ID is required for movement type: " + movementType; }
}
record InvalidPerformedBy(String reason) implements StockMovementError {
@Override public String code() { return "INVALID_PERFORMED_BY"; }
@Override public String message() { return "Invalid performed by: " + reason; }
}
record StockMovementNotFound(String id) implements StockMovementError {
@Override public String code() { return "STOCK_MOVEMENT_NOT_FOUND"; }
@Override public String message() { return "Stock movement not found: " + id; }
}
record Unauthorized(String message) implements StockMovementError {
@Override public String code() { return "UNAUTHORIZED"; }
}
record RepositoryFailure(String message) implements StockMovementError {
@Override public String code() { return "REPOSITORY_ERROR"; }
}
}

View file

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

View file

@ -0,0 +1,23 @@
package de.effigenix.domain.inventory;
import de.effigenix.domain.masterdata.ArticleId;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import java.util.List;
import java.util.Optional;
public interface StockMovementRepository {
Result<RepositoryError, Optional<StockMovement>> findById(StockMovementId id);
Result<RepositoryError, List<StockMovement>> findAll();
Result<RepositoryError, List<StockMovement>> findAllByStockId(StockId stockId);
Result<RepositoryError, List<StockMovement>> findAllByArticleId(ArticleId articleId);
Result<RepositoryError, List<StockMovement>> findAllByMovementType(MovementType movementType);
Result<RepositoryError, Void> save(StockMovement stockMovement);
}

View file

@ -1,5 +1,8 @@
package de.effigenix.infrastructure.config;
import de.effigenix.application.inventory.GetStockMovement;
import de.effigenix.application.inventory.ListStockMovements;
import de.effigenix.application.inventory.RecordStockMovement;
import de.effigenix.application.inventory.ActivateStorageLocation;
import de.effigenix.application.inventory.AddStockBatch;
import de.effigenix.application.inventory.BlockStockBatch;
@ -19,6 +22,7 @@ import de.effigenix.application.inventory.GetStorageLocation;
import de.effigenix.application.inventory.ListStorageLocations;
import de.effigenix.application.inventory.UpdateStorageLocation;
import de.effigenix.application.usermanagement.AuditLogger;
import de.effigenix.domain.inventory.StockMovementRepository;
import de.effigenix.domain.inventory.StockRepository;
import de.effigenix.domain.inventory.StorageLocationRepository;
import de.effigenix.shared.security.AuthorizationPort;
@ -121,4 +125,21 @@ public class InventoryUseCaseConfiguration {
public ListStocksBelowMinimum listStocksBelowMinimum(StockRepository stockRepository, AuthorizationPort authorizationPort) {
return new ListStocksBelowMinimum(stockRepository, authorizationPort);
}
// ==================== StockMovement Use Cases ====================
@Bean
public RecordStockMovement recordStockMovement(StockMovementRepository stockMovementRepository, AuthorizationPort authorizationPort) {
return new RecordStockMovement(stockMovementRepository, authorizationPort);
}
@Bean
public GetStockMovement getStockMovement(StockMovementRepository stockMovementRepository, AuthorizationPort authorizationPort) {
return new GetStockMovement(stockMovementRepository, authorizationPort);
}
@Bean
public ListStockMovements listStockMovements(StockMovementRepository stockMovementRepository, AuthorizationPort authorizationPort) {
return new ListStockMovements(stockMovementRepository, authorizationPort);
}
}

View file

@ -0,0 +1,120 @@
package de.effigenix.infrastructure.inventory.persistence.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
@Entity
@Table(name = "stock_movements")
public class StockMovementEntity {
@Id
@Column(name = "id", nullable = false, length = 36)
private String id;
@Column(name = "stock_id", nullable = false, length = 36)
private String stockId;
@Column(name = "article_id", nullable = false, length = 36)
private String articleId;
@Column(name = "stock_batch_id", nullable = false, length = 36)
private String stockBatchId;
@Column(name = "batch_id", nullable = false, length = 100)
private String batchId;
@Column(name = "batch_type", nullable = false, length = 20)
private String batchType;
@Column(name = "movement_type", nullable = false, length = 30)
private String movementType;
@Column(name = "direction", nullable = false, length = 10)
private String direction;
@Column(name = "quantity_amount", nullable = false, precision = 19, scale = 6)
private BigDecimal quantityAmount;
@Column(name = "quantity_unit", nullable = false, length = 20)
private String quantityUnit;
@Column(name = "reason", length = 500)
private String reason;
@Column(name = "reference_document_id", length = 100)
private String referenceDocumentId;
@Column(name = "performed_by", nullable = false, length = 36)
private String performedBy;
@Column(name = "performed_at", nullable = false)
private Instant performedAt;
public StockMovementEntity() {}
public StockMovementEntity(String id, String stockId, String articleId, String stockBatchId,
String batchId, String batchType, String movementType, String direction,
BigDecimal quantityAmount, String quantityUnit, String reason,
String referenceDocumentId, String performedBy, Instant performedAt) {
this.id = id;
this.stockId = stockId;
this.articleId = articleId;
this.stockBatchId = stockBatchId;
this.batchId = batchId;
this.batchType = batchType;
this.movementType = movementType;
this.direction = direction;
this.quantityAmount = quantityAmount;
this.quantityUnit = quantityUnit;
this.reason = reason;
this.referenceDocumentId = referenceDocumentId;
this.performedBy = performedBy;
this.performedAt = performedAt;
}
// ==================== Getters & Setters ====================
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getStockId() { return stockId; }
public void setStockId(String stockId) { this.stockId = stockId; }
public String getArticleId() { return articleId; }
public void setArticleId(String articleId) { this.articleId = articleId; }
public String getStockBatchId() { return stockBatchId; }
public void setStockBatchId(String stockBatchId) { this.stockBatchId = stockBatchId; }
public String getBatchId() { return batchId; }
public void setBatchId(String batchId) { this.batchId = batchId; }
public String getBatchType() { return batchType; }
public void setBatchType(String batchType) { this.batchType = batchType; }
public String getMovementType() { return movementType; }
public void setMovementType(String movementType) { this.movementType = movementType; }
public String getDirection() { return direction; }
public void setDirection(String direction) { this.direction = direction; }
public BigDecimal getQuantityAmount() { return quantityAmount; }
public void setQuantityAmount(BigDecimal quantityAmount) { this.quantityAmount = quantityAmount; }
public String getQuantityUnit() { return quantityUnit; }
public void setQuantityUnit(String quantityUnit) { this.quantityUnit = quantityUnit; }
public String getReason() { return reason; }
public void setReason(String reason) { this.reason = reason; }
public String getReferenceDocumentId() { return referenceDocumentId; }
public void setReferenceDocumentId(String referenceDocumentId) { this.referenceDocumentId = referenceDocumentId; }
public String getPerformedBy() { return performedBy; }
public void setPerformedBy(String performedBy) { this.performedBy = performedBy; }
public Instant getPerformedAt() { return performedAt; }
public void setPerformedAt(Instant performedAt) { this.performedAt = performedAt; }
}

View file

@ -0,0 +1,51 @@
package de.effigenix.infrastructure.inventory.persistence.mapper;
import de.effigenix.domain.inventory.*;
import de.effigenix.domain.masterdata.ArticleId;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.UnitOfMeasure;
import de.effigenix.infrastructure.inventory.persistence.entity.StockMovementEntity;
import org.springframework.stereotype.Component;
@Component
public class StockMovementMapper {
public StockMovementEntity toEntity(StockMovement movement) {
return new StockMovementEntity(
movement.id().value(),
movement.stockId().value(),
movement.articleId().value(),
movement.stockBatchId().value(),
movement.batchReference().batchId(),
movement.batchReference().batchType().name(),
movement.movementType().name(),
movement.direction().name(),
movement.quantity().amount(),
movement.quantity().uom().name(),
movement.reason(),
movement.referenceDocumentId(),
movement.performedBy(),
movement.performedAt()
);
}
public StockMovement toDomain(StockMovementEntity entity) {
return StockMovement.reconstitute(
StockMovementId.of(entity.getId()),
StockId.of(entity.getStockId()),
ArticleId.of(entity.getArticleId()),
StockBatchId.of(entity.getStockBatchId()),
new BatchReference(entity.getBatchId(), BatchType.valueOf(entity.getBatchType())),
MovementType.valueOf(entity.getMovementType()),
MovementDirection.valueOf(entity.getDirection()),
Quantity.reconstitute(
entity.getQuantityAmount(),
UnitOfMeasure.valueOf(entity.getQuantityUnit())
),
entity.getReason(),
entity.getReferenceDocumentId(),
entity.getPerformedBy(),
entity.getPerformedAt()
);
}
}

View file

@ -0,0 +1,107 @@
package de.effigenix.infrastructure.inventory.persistence.repository;
import de.effigenix.domain.inventory.*;
import de.effigenix.domain.masterdata.ArticleId;
import de.effigenix.infrastructure.inventory.persistence.mapper.StockMovementMapper;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Repository
@Profile("!no-db")
@Transactional(readOnly = true)
public class JpaStockMovementRepository implements StockMovementRepository {
private static final Logger logger = LoggerFactory.getLogger(JpaStockMovementRepository.class);
private final StockMovementJpaRepository jpaRepository;
private final StockMovementMapper mapper;
public JpaStockMovementRepository(StockMovementJpaRepository jpaRepository, StockMovementMapper mapper) {
this.jpaRepository = jpaRepository;
this.mapper = mapper;
}
@Override
public Result<RepositoryError, Optional<StockMovement>> findById(StockMovementId id) {
try {
Optional<StockMovement> result = jpaRepository.findById(id.value())
.map(mapper::toDomain);
return Result.success(result);
} catch (Exception e) {
logger.warn("Database error in findById", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, List<StockMovement>> findAll() {
try {
List<StockMovement> result = jpaRepository.findAll().stream()
.map(mapper::toDomain)
.toList();
return Result.success(result);
} catch (Exception e) {
logger.warn("Database error in findAll", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, List<StockMovement>> findAllByStockId(StockId stockId) {
try {
List<StockMovement> result = jpaRepository.findAllByStockId(stockId.value()).stream()
.map(mapper::toDomain)
.toList();
return Result.success(result);
} catch (Exception e) {
logger.warn("Database error in findAllByStockId", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, List<StockMovement>> findAllByArticleId(ArticleId articleId) {
try {
List<StockMovement> result = jpaRepository.findAllByArticleId(articleId.value()).stream()
.map(mapper::toDomain)
.toList();
return Result.success(result);
} catch (Exception e) {
logger.warn("Database error in findAllByArticleId", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, List<StockMovement>> findAllByMovementType(MovementType movementType) {
try {
List<StockMovement> result = jpaRepository.findAllByMovementType(movementType.name()).stream()
.map(mapper::toDomain)
.toList();
return Result.success(result);
} catch (Exception e) {
logger.warn("Database error in findAllByMovementType", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
@Transactional
public Result<RepositoryError, Void> save(StockMovement stockMovement) {
try {
jpaRepository.save(mapper.toEntity(stockMovement));
return Result.success(null);
} catch (Exception e) {
logger.warn("Database error in save", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
}

View file

@ -0,0 +1,15 @@
package de.effigenix.infrastructure.inventory.persistence.repository;
import de.effigenix.infrastructure.inventory.persistence.entity.StockMovementEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface StockMovementJpaRepository extends JpaRepository<StockMovementEntity, String> {
List<StockMovementEntity> findAllByStockId(String stockId);
List<StockMovementEntity> findAllByArticleId(String articleId);
List<StockMovementEntity> findAllByMovementType(String movementType);
}

View file

@ -0,0 +1,121 @@
package de.effigenix.infrastructure.inventory.web.controller;
import de.effigenix.application.inventory.GetStockMovement;
import de.effigenix.application.inventory.ListStockMovements;
import de.effigenix.application.inventory.RecordStockMovement;
import de.effigenix.application.inventory.command.RecordStockMovementCommand;
import de.effigenix.domain.inventory.StockMovementError;
import de.effigenix.infrastructure.inventory.web.dto.RecordStockMovementRequest;
import de.effigenix.infrastructure.inventory.web.dto.StockMovementResponse;
import de.effigenix.shared.security.ActorId;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/inventory/stock-movements")
@SecurityRequirement(name = "Bearer Authentication")
@Tag(name = "Stock Movements", description = "Stock movement tracking endpoints")
public class StockMovementController {
private static final Logger logger = LoggerFactory.getLogger(StockMovementController.class);
private final RecordStockMovement recordStockMovement;
private final GetStockMovement getStockMovement;
private final ListStockMovements listStockMovements;
public StockMovementController(RecordStockMovement recordStockMovement,
GetStockMovement getStockMovement,
ListStockMovements listStockMovements) {
this.recordStockMovement = recordStockMovement;
this.getStockMovement = getStockMovement;
this.listStockMovements = listStockMovements;
}
@PostMapping
@PreAuthorize("hasAuthority('STOCK_MOVEMENT_WRITE')")
public ResponseEntity<StockMovementResponse> recordMovement(
@Valid @RequestBody RecordStockMovementRequest request,
Authentication authentication
) {
logger.info("Recording stock movement type: {} for stock: {} by actor: {}",
request.movementType(), request.stockId(), authentication.getName());
var cmd = new RecordStockMovementCommand(
request.stockId(), request.articleId(), request.stockBatchId(),
request.batchId(), request.batchType(),
request.movementType(), request.direction(),
request.quantityAmount(), request.quantityUnit(),
request.reason(), request.referenceDocumentId(),
authentication.getName()
);
var result = recordStockMovement.execute(cmd, ActorId.of(authentication.getName()));
if (result.isFailure()) {
throw new StockMovementDomainErrorException(result.unsafeGetError());
}
logger.info("Stock movement recorded: {}", result.unsafeGetValue().id().value());
return ResponseEntity.status(HttpStatus.CREATED)
.body(StockMovementResponse.from(result.unsafeGetValue()));
}
@GetMapping
@PreAuthorize("hasAuthority('STOCK_MOVEMENT_READ')")
@Operation(summary = "List stock movements",
description = "Filter priority (only one filter applied): stockId > articleId > movementType")
public ResponseEntity<List<StockMovementResponse>> listMovements(
@RequestParam(required = false) String stockId,
@RequestParam(required = false) String articleId,
@RequestParam(required = false) String movementType,
Authentication authentication
) {
var result = listStockMovements.execute(stockId, articleId, movementType,
ActorId.of(authentication.getName()));
if (result.isFailure()) {
throw new StockMovementDomainErrorException(result.unsafeGetError());
}
List<StockMovementResponse> responses = result.unsafeGetValue().stream()
.map(StockMovementResponse::from)
.toList();
return ResponseEntity.ok(responses);
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('STOCK_MOVEMENT_READ')")
public ResponseEntity<StockMovementResponse> getMovement(@PathVariable String id,
Authentication authentication) {
var result = getStockMovement.execute(id, ActorId.of(authentication.getName()));
if (result.isFailure()) {
throw new StockMovementDomainErrorException(result.unsafeGetError());
}
return ResponseEntity.ok(StockMovementResponse.from(result.unsafeGetValue()));
}
public static class StockMovementDomainErrorException extends RuntimeException {
private final StockMovementError error;
public StockMovementDomainErrorException(StockMovementError error) {
super(error.message());
this.error = error;
}
public StockMovementError getError() {
return error;
}
}
}

View file

@ -0,0 +1,17 @@
package de.effigenix.infrastructure.inventory.web.dto;
import jakarta.validation.constraints.NotBlank;
public record RecordStockMovementRequest(
@NotBlank String stockId,
@NotBlank String articleId,
@NotBlank String stockBatchId,
@NotBlank String batchId,
@NotBlank String batchType,
@NotBlank String movementType,
String direction,
@NotBlank String quantityAmount,
@NotBlank String quantityUnit,
String reason,
String referenceDocumentId
) {}

View file

@ -0,0 +1,46 @@
package de.effigenix.infrastructure.inventory.web.dto;
import de.effigenix.domain.inventory.StockMovement;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.time.Instant;
@Schema(requiredProperties = {"id", "stockId", "articleId", "stockBatchId", "batchId", "batchType",
"movementType", "direction", "quantityAmount", "quantityUnit", "performedBy", "performedAt"})
public record StockMovementResponse(
String id,
String stockId,
String articleId,
String stockBatchId,
String batchId,
String batchType,
String movementType,
String direction,
BigDecimal quantityAmount,
String quantityUnit,
@Schema(nullable = true) String reason,
@Schema(nullable = true) String referenceDocumentId,
String performedBy,
Instant performedAt
) {
public static StockMovementResponse from(StockMovement movement) {
return new StockMovementResponse(
movement.id().value(),
movement.stockId().value(),
movement.articleId().value(),
movement.stockBatchId().value(),
movement.batchReference().batchId(),
movement.batchReference().batchType().name(),
movement.movementType().name(),
movement.direction().name(),
movement.quantity().amount(),
movement.quantity().uom().name(),
movement.reason(),
movement.referenceDocumentId(),
movement.performedBy(),
movement.performedAt()
);
}
}

View file

@ -1,5 +1,6 @@
package de.effigenix.infrastructure.inventory.web.exception;
import de.effigenix.domain.inventory.StockMovementError;
import de.effigenix.domain.inventory.StorageLocationError;
import de.effigenix.domain.inventory.StockError;
@ -49,4 +50,22 @@ public final class InventoryErrorHttpStatusMapper {
case StockError.RepositoryFailure e -> 500;
};
}
public static int toHttpStatus(StockMovementError error) {
return switch (error) {
case StockMovementError.StockMovementNotFound e -> 404;
case StockMovementError.InvalidStockId e -> 400;
case StockMovementError.InvalidArticleId e -> 400;
case StockMovementError.InvalidStockBatchId e -> 400;
case StockMovementError.InvalidBatchReference e -> 400;
case StockMovementError.InvalidMovementType e -> 400;
case StockMovementError.InvalidDirection e -> 400;
case StockMovementError.InvalidQuantity e -> 400;
case StockMovementError.ReasonRequired e -> 400;
case StockMovementError.ReferenceDocumentRequired e -> 400;
case StockMovementError.InvalidPerformedBy e -> 400;
case StockMovementError.Unauthorized e -> 403;
case StockMovementError.RepositoryFailure e -> 500;
};
}
}

View file

@ -1,5 +1,6 @@
package de.effigenix.infrastructure.usermanagement.web.exception;
import de.effigenix.domain.inventory.StockMovementError;
import de.effigenix.domain.inventory.StorageLocationError;
import de.effigenix.domain.inventory.StockError;
import de.effigenix.domain.masterdata.ArticleError;
@ -10,6 +11,7 @@ import de.effigenix.domain.production.BatchError;
import de.effigenix.domain.production.ProductionOrderError;
import de.effigenix.domain.production.RecipeError;
import de.effigenix.domain.usermanagement.UserError;
import de.effigenix.infrastructure.inventory.web.controller.StockMovementController;
import de.effigenix.infrastructure.inventory.web.controller.StorageLocationController;
import de.effigenix.infrastructure.inventory.web.controller.StockController;
import de.effigenix.infrastructure.inventory.web.exception.InventoryErrorHttpStatusMapper;
@ -211,6 +213,29 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(status).body(errorResponse);
}
@ExceptionHandler(StockMovementController.StockMovementDomainErrorException.class)
public ResponseEntity<ErrorResponse> handleStockMovementDomainError(
StockMovementController.StockMovementDomainErrorException ex,
HttpServletRequest request
) {
StockMovementError error = ex.getError();
int status = InventoryErrorHttpStatusMapper.toHttpStatus(error);
logDomainError("StockMovement", error.code(), error.message(), status);
String clientMessage = status >= 500
? "An internal error occurred"
: error.message();
ErrorResponse errorResponse = ErrorResponse.from(
error.code(),
clientMessage,
status,
request.getRequestURI()
);
return ResponseEntity.status(status).body(errorResponse);
}
@ExceptionHandler(StockController.StockDomainErrorException.class)
public ResponseEntity<ErrorResponse> handleStockDomainError(
StockController.StockDomainErrorException ex,

View file

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

View file

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

View file

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