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:
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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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/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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue