mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 17:29:58 +01:00
feat(inventory): Charge einbuchen (addBatch) (#5)
StockBatch als Child-Entity im Stock-Aggregat mit BatchReference
(batchId + batchType), Quantity, ExpiryDate und Status AVAILABLE.
POST /api/inventory/stocks/{stockId}/batches → 201.
This commit is contained in:
parent
5224001dd7
commit
6feb3a9f1c
26 changed files with 1325 additions and 10 deletions
|
|
@ -0,0 +1,53 @@
|
|||
package de.effigenix.application.inventory;
|
||||
|
||||
import de.effigenix.application.inventory.command.AddStockBatchCommand;
|
||||
import de.effigenix.domain.inventory.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Transactional
|
||||
public class AddStockBatch {
|
||||
|
||||
private final StockRepository stockRepository;
|
||||
|
||||
public AddStockBatch(StockRepository stockRepository) {
|
||||
this.stockRepository = stockRepository;
|
||||
}
|
||||
|
||||
public Result<StockError, StockBatch> execute(AddStockBatchCommand cmd) {
|
||||
// 1. Stock laden
|
||||
Stock stock;
|
||||
switch (stockRepository.findById(StockId.of(cmd.stockId()))) {
|
||||
case Result.Failure(var err) ->
|
||||
{ return Result.failure(new StockError.RepositoryFailure(err.message())); }
|
||||
case Result.Success(var opt) -> {
|
||||
if (opt.isEmpty()) {
|
||||
return Result.failure(new StockError.StockNotFound(cmd.stockId()));
|
||||
}
|
||||
stock = opt.get();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Batch hinzufügen (Domain validiert)
|
||||
var draft = new StockBatchDraft(
|
||||
cmd.batchId(), cmd.batchType(),
|
||||
cmd.quantityAmount(), cmd.quantityUnit(),
|
||||
cmd.expiryDate()
|
||||
);
|
||||
|
||||
StockBatch batch;
|
||||
switch (stock.addBatch(draft)) {
|
||||
case Result.Failure(var err) -> { return Result.failure(err); }
|
||||
case Result.Success(var val) -> batch = val;
|
||||
}
|
||||
|
||||
// 3. Stock speichern
|
||||
switch (stockRepository.save(stock)) {
|
||||
case Result.Failure(var err) ->
|
||||
{ return Result.failure(new StockError.RepositoryFailure(err.message())); }
|
||||
case Result.Success(var ignored) -> { }
|
||||
}
|
||||
|
||||
return Result.success(batch);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package de.effigenix.application.inventory.command;
|
||||
|
||||
public record AddStockBatchCommand(
|
||||
String stockId,
|
||||
String batchId,
|
||||
String batchType,
|
||||
String quantityAmount,
|
||||
String quantityUnit,
|
||||
String expiryDate
|
||||
) {}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package de.effigenix.domain.inventory;
|
||||
|
||||
import de.effigenix.shared.common.Result;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public record BatchReference(String batchId, BatchType batchType) {
|
||||
|
||||
public BatchReference {
|
||||
Objects.requireNonNull(batchId, "batchId must not be null");
|
||||
Objects.requireNonNull(batchType, "batchType must not be null");
|
||||
}
|
||||
|
||||
public static Result<StockError, BatchReference> of(String batchId, String batchType) {
|
||||
if (batchId == null || batchId.isBlank()) {
|
||||
return Result.failure(new StockError.InvalidBatchReference("batchId must not be blank"));
|
||||
}
|
||||
|
||||
BatchType type;
|
||||
try {
|
||||
type = BatchType.valueOf(batchType);
|
||||
} catch (IllegalArgumentException | NullPointerException e) {
|
||||
return Result.failure(new StockError.InvalidBatchReference(
|
||||
"Invalid batchType: " + batchType + ". Allowed values: PRODUCED, PURCHASED"));
|
||||
}
|
||||
|
||||
return Result.success(new BatchReference(batchId, type));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package de.effigenix.domain.inventory;
|
||||
|
||||
public enum BatchType {
|
||||
PRODUCED, PURCHASED
|
||||
}
|
||||
|
|
@ -3,6 +3,9 @@ package de.effigenix.domain.inventory;
|
|||
import de.effigenix.domain.masterdata.ArticleId;
|
||||
import de.effigenix.shared.common.Result;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
|
|
@ -12,6 +15,7 @@ import java.util.Objects;
|
|||
* - ArticleId + StorageLocationId = unique (Application Layer, Repository-Concern)
|
||||
* - MinimumLevel optional (quantity amount >= 0)
|
||||
* - MinimumShelfLife optional (days > 0)
|
||||
* - BatchReference (batchId + batchType) unique within batches
|
||||
*/
|
||||
public class Stock {
|
||||
|
||||
|
|
@ -20,19 +24,22 @@ public class Stock {
|
|||
private final StorageLocationId storageLocationId;
|
||||
private MinimumLevel minimumLevel;
|
||||
private MinimumShelfLife minimumShelfLife;
|
||||
private final List<StockBatch> batches;
|
||||
|
||||
private Stock(
|
||||
StockId id,
|
||||
ArticleId articleId,
|
||||
StorageLocationId storageLocationId,
|
||||
MinimumLevel minimumLevel,
|
||||
MinimumShelfLife minimumShelfLife
|
||||
MinimumShelfLife minimumShelfLife,
|
||||
List<StockBatch> batches
|
||||
) {
|
||||
this.id = id;
|
||||
this.articleId = articleId;
|
||||
this.storageLocationId = storageLocationId;
|
||||
this.minimumLevel = minimumLevel;
|
||||
this.minimumShelfLife = minimumShelfLife;
|
||||
this.batches = new ArrayList<>(batches);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -81,7 +88,7 @@ public class Stock {
|
|||
}
|
||||
|
||||
return Result.success(new Stock(
|
||||
StockId.generate(), articleId, storageLocationId, minimumLevel, minimumShelfLife
|
||||
StockId.generate(), articleId, storageLocationId, minimumLevel, minimumShelfLife, List.of()
|
||||
));
|
||||
}
|
||||
|
||||
|
|
@ -93,9 +100,28 @@ public class Stock {
|
|||
ArticleId articleId,
|
||||
StorageLocationId storageLocationId,
|
||||
MinimumLevel minimumLevel,
|
||||
MinimumShelfLife minimumShelfLife
|
||||
MinimumShelfLife minimumShelfLife,
|
||||
List<StockBatch> batches
|
||||
) {
|
||||
return new Stock(id, articleId, storageLocationId, minimumLevel, minimumShelfLife);
|
||||
return new Stock(id, articleId, storageLocationId, minimumLevel, minimumShelfLife, batches);
|
||||
}
|
||||
|
||||
// ==================== Batch Management ====================
|
||||
|
||||
public Result<StockError, StockBatch> addBatch(StockBatchDraft draft) {
|
||||
StockBatch batch;
|
||||
switch (StockBatch.create(draft)) {
|
||||
case Result.Failure(var err) -> { return Result.failure(err); }
|
||||
case Result.Success(var val) -> batch = val;
|
||||
}
|
||||
|
||||
if (hasBatchReference(batch.batchReference())) {
|
||||
return Result.failure(new StockError.DuplicateBatchReference(
|
||||
batch.batchReference().batchId(), batch.batchReference().batchType().name()));
|
||||
}
|
||||
|
||||
this.batches.add(batch);
|
||||
return Result.success(batch);
|
||||
}
|
||||
|
||||
// ==================== Getters ====================
|
||||
|
|
@ -105,6 +131,13 @@ public class Stock {
|
|||
public StorageLocationId storageLocationId() { return storageLocationId; }
|
||||
public MinimumLevel minimumLevel() { return minimumLevel; }
|
||||
public MinimumShelfLife minimumShelfLife() { return minimumShelfLife; }
|
||||
public List<StockBatch> batches() { return Collections.unmodifiableList(batches); }
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
private boolean hasBatchReference(BatchReference ref) {
|
||||
return batches.stream().anyMatch(b -> b.batchReference().equals(ref));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
package de.effigenix.domain.inventory;
|
||||
|
||||
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.time.LocalDate;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Child entity of Stock representing a single batch (Charge).
|
||||
*
|
||||
* Invariants:
|
||||
* - BatchReference (batchId + batchType) must be valid
|
||||
* - Quantity must be positive
|
||||
* - ExpiryDate must be a valid ISO date
|
||||
* - New batches always have status AVAILABLE
|
||||
* - receivedAt is set automatically on creation
|
||||
*/
|
||||
public class StockBatch {
|
||||
|
||||
private final StockBatchId id;
|
||||
private final BatchReference batchReference;
|
||||
private final Quantity quantity;
|
||||
private final LocalDate expiryDate;
|
||||
private final StockBatchStatus status;
|
||||
private final Instant receivedAt;
|
||||
|
||||
private StockBatch(StockBatchId id, BatchReference batchReference, Quantity quantity,
|
||||
LocalDate expiryDate, StockBatchStatus status, Instant receivedAt) {
|
||||
this.id = id;
|
||||
this.batchReference = batchReference;
|
||||
this.quantity = quantity;
|
||||
this.expiryDate = expiryDate;
|
||||
this.status = status;
|
||||
this.receivedAt = receivedAt;
|
||||
}
|
||||
|
||||
public static Result<StockError, StockBatch> create(StockBatchDraft draft) {
|
||||
// 1. BatchReference validieren
|
||||
BatchReference batchReference;
|
||||
switch (BatchReference.of(draft.batchId(), draft.batchType())) {
|
||||
case Result.Failure(var err) -> { return Result.failure(err); }
|
||||
case Result.Success(var val) -> batchReference = val;
|
||||
}
|
||||
|
||||
// 2. Quantity validieren (Pflicht, positiv)
|
||||
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 StockError.InvalidQuantity("Invalid unit: " + draft.quantityUnit()));
|
||||
}
|
||||
switch (Quantity.of(amount, uom)) {
|
||||
case Result.Failure(var err) -> {
|
||||
return Result.failure(new StockError.InvalidQuantity(err.message()));
|
||||
}
|
||||
case Result.Success(var val) -> quantity = val;
|
||||
}
|
||||
} catch (NumberFormatException | NullPointerException e) {
|
||||
return Result.failure(new StockError.InvalidQuantity(
|
||||
"Invalid quantity amount: " + draft.quantityAmount()));
|
||||
}
|
||||
|
||||
// 3. ExpiryDate validieren (Pflicht)
|
||||
LocalDate expiryDate;
|
||||
try {
|
||||
if (draft.expiryDate() == null || draft.expiryDate().isBlank()) {
|
||||
return Result.failure(new StockError.InvalidExpiryDate("expiryDate must not be blank"));
|
||||
}
|
||||
expiryDate = LocalDate.parse(draft.expiryDate());
|
||||
} catch (DateTimeParseException e) {
|
||||
return Result.failure(new StockError.InvalidExpiryDate(
|
||||
"Invalid ISO date: " + draft.expiryDate()));
|
||||
}
|
||||
|
||||
return Result.success(new StockBatch(
|
||||
StockBatchId.generate(), batchReference, quantity,
|
||||
expiryDate, StockBatchStatus.AVAILABLE, Instant.now()
|
||||
));
|
||||
}
|
||||
|
||||
public static StockBatch reconstitute(StockBatchId id, BatchReference batchReference,
|
||||
Quantity quantity, LocalDate expiryDate,
|
||||
StockBatchStatus status, Instant receivedAt) {
|
||||
return new StockBatch(id, batchReference, quantity, expiryDate, status, receivedAt);
|
||||
}
|
||||
|
||||
// ==================== Getters ====================
|
||||
|
||||
public StockBatchId id() { return id; }
|
||||
public BatchReference batchReference() { return batchReference; }
|
||||
public Quantity quantity() { return quantity; }
|
||||
public LocalDate expiryDate() { return expiryDate; }
|
||||
public StockBatchStatus status() { return status; }
|
||||
public Instant receivedAt() { return receivedAt; }
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj) return true;
|
||||
if (!(obj instanceof StockBatch other)) return false;
|
||||
return Objects.equals(id, other.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package de.effigenix.domain.inventory;
|
||||
|
||||
public record StockBatchDraft(
|
||||
String batchId,
|
||||
String batchType,
|
||||
String quantityAmount,
|
||||
String quantityUnit,
|
||||
String expiryDate
|
||||
) {}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package de.effigenix.domain.inventory;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record StockBatchId(String value) {
|
||||
|
||||
public StockBatchId {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("StockBatchId must not be blank");
|
||||
}
|
||||
}
|
||||
|
||||
public static StockBatchId generate() {
|
||||
return new StockBatchId(UUID.randomUUID().toString());
|
||||
}
|
||||
|
||||
public static StockBatchId of(String value) {
|
||||
return new StockBatchId(value);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package de.effigenix.domain.inventory;
|
||||
|
||||
public enum StockBatchStatus {
|
||||
AVAILABLE, EXPIRING_SOON, BLOCKED, EXPIRED
|
||||
}
|
||||
|
|
@ -35,6 +35,31 @@ public sealed interface StockError {
|
|||
@Override public String message() { return "Invalid storage location ID: " + reason; }
|
||||
}
|
||||
|
||||
record InvalidBatchReference(String reason) implements StockError {
|
||||
@Override public String code() { return "INVALID_BATCH_REFERENCE"; }
|
||||
@Override public String message() { return "Invalid batch reference: " + reason; }
|
||||
}
|
||||
|
||||
record DuplicateBatchReference(String batchId, String batchType) implements StockError {
|
||||
@Override public String code() { return "DUPLICATE_BATCH_REFERENCE"; }
|
||||
@Override public String message() { return "Batch reference already exists: " + batchId + "/" + batchType; }
|
||||
}
|
||||
|
||||
record InvalidQuantity(String reason) implements StockError {
|
||||
@Override public String code() { return "INVALID_QUANTITY"; }
|
||||
@Override public String message() { return "Invalid quantity: " + reason; }
|
||||
}
|
||||
|
||||
record InvalidExpiryDate(String reason) implements StockError {
|
||||
@Override public String code() { return "INVALID_EXPIRY_DATE"; }
|
||||
@Override public String message() { return "Invalid expiry date: " + reason; }
|
||||
}
|
||||
|
||||
record BatchNotFound(String id) implements StockError {
|
||||
@Override public String code() { return "BATCH_NOT_FOUND"; }
|
||||
@Override public String message() { return "Batch not found: " + id; }
|
||||
}
|
||||
|
||||
record Unauthorized(String message) implements StockError {
|
||||
@Override public String code() { return "UNAUTHORIZED"; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package de.effigenix.infrastructure.config;
|
||||
|
||||
import de.effigenix.application.inventory.ActivateStorageLocation;
|
||||
import de.effigenix.application.inventory.AddStockBatch;
|
||||
import de.effigenix.application.inventory.CreateStock;
|
||||
import de.effigenix.application.inventory.CreateStorageLocation;
|
||||
import de.effigenix.application.inventory.DeactivateStorageLocation;
|
||||
|
|
@ -47,4 +48,9 @@ public class InventoryUseCaseConfiguration {
|
|||
public CreateStock createStock(StockRepository stockRepository) {
|
||||
return new CreateStock(stockRepository);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AddStockBatch addStockBatch(StockRepository stockRepository) {
|
||||
return new AddStockBatch(stockRepository);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
package de.effigenix.infrastructure.inventory.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Entity
|
||||
@Table(name = "stock_batches")
|
||||
public class StockBatchEntity {
|
||||
|
||||
@Id
|
||||
@Column(name = "id", nullable = false, length = 36)
|
||||
private String id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "stock_id", nullable = false)
|
||||
private StockEntity stock;
|
||||
|
||||
@Column(name = "batch_id", nullable = false, length = 100)
|
||||
private String batchId;
|
||||
|
||||
@Column(name = "batch_type", nullable = false, length = 20)
|
||||
private String batchType;
|
||||
|
||||
@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 = "expiry_date", nullable = false)
|
||||
private LocalDate expiryDate;
|
||||
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
private String status;
|
||||
|
||||
@Column(name = "received_at", nullable = false)
|
||||
private Instant receivedAt;
|
||||
|
||||
protected StockBatchEntity() {}
|
||||
|
||||
public StockBatchEntity(String id, StockEntity stock, String batchId, String batchType,
|
||||
BigDecimal quantityAmount, String quantityUnit, LocalDate expiryDate,
|
||||
String status, Instant receivedAt) {
|
||||
this.id = id;
|
||||
this.stock = stock;
|
||||
this.batchId = batchId;
|
||||
this.batchType = batchType;
|
||||
this.quantityAmount = quantityAmount;
|
||||
this.quantityUnit = quantityUnit;
|
||||
this.expiryDate = expiryDate;
|
||||
this.status = status;
|
||||
this.receivedAt = receivedAt;
|
||||
}
|
||||
|
||||
public String getId() { return id; }
|
||||
public StockEntity getStock() { return stock; }
|
||||
public String getBatchId() { return batchId; }
|
||||
public String getBatchType() { return batchType; }
|
||||
public BigDecimal getQuantityAmount() { return quantityAmount; }
|
||||
public String getQuantityUnit() { return quantityUnit; }
|
||||
public LocalDate getExpiryDate() { return expiryDate; }
|
||||
public String getStatus() { return status; }
|
||||
public Instant getReceivedAt() { return receivedAt; }
|
||||
|
||||
public void setId(String id) { this.id = id; }
|
||||
public void setStock(StockEntity stock) { this.stock = stock; }
|
||||
public void setBatchId(String batchId) { this.batchId = batchId; }
|
||||
public void setBatchType(String batchType) { this.batchType = batchType; }
|
||||
public void setQuantityAmount(BigDecimal quantityAmount) { this.quantityAmount = quantityAmount; }
|
||||
public void setQuantityUnit(String quantityUnit) { this.quantityUnit = quantityUnit; }
|
||||
public void setExpiryDate(LocalDate expiryDate) { this.expiryDate = expiryDate; }
|
||||
public void setStatus(String status) { this.status = status; }
|
||||
public void setReceivedAt(Instant receivedAt) { this.receivedAt = receivedAt; }
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ package de.effigenix.infrastructure.inventory.persistence.entity;
|
|||
import jakarta.persistence.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Entity
|
||||
@Table(name = "stocks")
|
||||
|
|
@ -27,6 +29,9 @@ public class StockEntity {
|
|||
@Column(name = "minimum_shelf_life_days")
|
||||
private Integer minimumShelfLifeDays;
|
||||
|
||||
@OneToMany(mappedBy = "stock", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
|
||||
private List<StockBatchEntity> batches = new ArrayList<>();
|
||||
|
||||
public StockEntity() {}
|
||||
|
||||
// ==================== Getters & Setters ====================
|
||||
|
|
@ -48,4 +53,7 @@ public class StockEntity {
|
|||
|
||||
public Integer getMinimumShelfLifeDays() { return minimumShelfLifeDays; }
|
||||
public void setMinimumShelfLifeDays(Integer minimumShelfLifeDays) { this.minimumShelfLifeDays = minimumShelfLifeDays; }
|
||||
|
||||
public List<StockBatchEntity> getBatches() { return batches; }
|
||||
public void setBatches(List<StockBatchEntity> batches) { this.batches = batches; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,13 @@ 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.StockBatchEntity;
|
||||
import de.effigenix.infrastructure.inventory.persistence.entity.StockEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
public class StockMapper {
|
||||
|
||||
|
|
@ -25,6 +29,11 @@ public class StockMapper {
|
|||
entity.setMinimumShelfLifeDays(stock.minimumShelfLife().days());
|
||||
}
|
||||
|
||||
List<StockBatchEntity> batchEntities = stock.batches().stream()
|
||||
.map(b -> toBatchEntity(b, entity))
|
||||
.collect(Collectors.toList());
|
||||
entity.setBatches(batchEntities);
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
|
|
@ -44,12 +53,46 @@ public class StockMapper {
|
|||
minimumShelfLife = new MinimumShelfLife(entity.getMinimumShelfLifeDays());
|
||||
}
|
||||
|
||||
List<StockBatch> batches = entity.getBatches().stream()
|
||||
.map(this::toDomainBatch)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return Stock.reconstitute(
|
||||
StockId.of(entity.getId()),
|
||||
ArticleId.of(entity.getArticleId()),
|
||||
StorageLocationId.of(entity.getStorageLocationId()),
|
||||
minimumLevel,
|
||||
minimumShelfLife
|
||||
minimumShelfLife,
|
||||
batches
|
||||
);
|
||||
}
|
||||
|
||||
private StockBatchEntity toBatchEntity(StockBatch batch, StockEntity stockEntity) {
|
||||
return new StockBatchEntity(
|
||||
batch.id().value(),
|
||||
stockEntity,
|
||||
batch.batchReference().batchId(),
|
||||
batch.batchReference().batchType().name(),
|
||||
batch.quantity().amount(),
|
||||
batch.quantity().uom().name(),
|
||||
batch.expiryDate(),
|
||||
batch.status().name(),
|
||||
batch.receivedAt()
|
||||
);
|
||||
}
|
||||
|
||||
private StockBatch toDomainBatch(StockBatchEntity entity) {
|
||||
return StockBatch.reconstitute(
|
||||
StockBatchId.of(entity.getId()),
|
||||
new BatchReference(entity.getBatchId(), BatchType.valueOf(entity.getBatchType())),
|
||||
Quantity.reconstitute(
|
||||
entity.getQuantityAmount(),
|
||||
UnitOfMeasure.valueOf(entity.getQuantityUnit()),
|
||||
null, null
|
||||
),
|
||||
entity.getExpiryDate(),
|
||||
StockBatchStatus.valueOf(entity.getStatus()),
|
||||
entity.getReceivedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
package de.effigenix.infrastructure.inventory.web.controller;
|
||||
|
||||
import de.effigenix.application.inventory.AddStockBatch;
|
||||
import de.effigenix.application.inventory.CreateStock;
|
||||
import de.effigenix.application.inventory.command.AddStockBatchCommand;
|
||||
import de.effigenix.application.inventory.command.CreateStockCommand;
|
||||
import de.effigenix.domain.inventory.StockError;
|
||||
import de.effigenix.infrastructure.inventory.web.dto.AddStockBatchRequest;
|
||||
import de.effigenix.infrastructure.inventory.web.dto.CreateStockRequest;
|
||||
import de.effigenix.infrastructure.inventory.web.dto.StockBatchResponse;
|
||||
import de.effigenix.infrastructure.inventory.web.dto.StockResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
|
@ -25,9 +29,11 @@ public class StockController {
|
|||
private static final Logger logger = LoggerFactory.getLogger(StockController.class);
|
||||
|
||||
private final CreateStock createStock;
|
||||
private final AddStockBatch addStockBatch;
|
||||
|
||||
public StockController(CreateStock createStock) {
|
||||
public StockController(CreateStock createStock, AddStockBatch addStockBatch) {
|
||||
this.createStock = createStock;
|
||||
this.addStockBatch = addStockBatch;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
|
|
@ -55,6 +61,31 @@ public class StockController {
|
|||
.body(StockResponse.from(result.unsafeGetValue()));
|
||||
}
|
||||
|
||||
@PostMapping("/{stockId}/batches")
|
||||
@PreAuthorize("hasAuthority('STOCK_WRITE')")
|
||||
public ResponseEntity<StockBatchResponse> addBatch(
|
||||
@PathVariable String stockId,
|
||||
@Valid @RequestBody AddStockBatchRequest request,
|
||||
Authentication authentication
|
||||
) {
|
||||
logger.info("Adding batch to stock: {} by actor: {}", stockId, authentication.getName());
|
||||
|
||||
var cmd = new AddStockBatchCommand(
|
||||
stockId, request.batchId(), request.batchType(),
|
||||
request.quantityAmount(), request.quantityUnit(),
|
||||
request.expiryDate()
|
||||
);
|
||||
var result = addStockBatch.execute(cmd);
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new StockDomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
|
||||
logger.info("Batch added: {}", result.unsafeGetValue().id().value());
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(StockBatchResponse.from(result.unsafeGetValue()));
|
||||
}
|
||||
|
||||
public static class StockDomainErrorException extends RuntimeException {
|
||||
private final StockError error;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package de.effigenix.infrastructure.inventory.web.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record AddStockBatchRequest(
|
||||
@NotBlank String batchId,
|
||||
@NotBlank String batchType,
|
||||
@NotBlank String quantityAmount,
|
||||
@NotBlank String quantityUnit,
|
||||
@NotBlank String expiryDate
|
||||
) {}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package de.effigenix.infrastructure.inventory.web.dto;
|
||||
|
||||
import de.effigenix.domain.inventory.StockBatch;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record StockBatchResponse(
|
||||
String id,
|
||||
String batchId,
|
||||
String batchType,
|
||||
BigDecimal quantityAmount,
|
||||
String quantityUnit,
|
||||
LocalDate expiryDate,
|
||||
String status,
|
||||
Instant receivedAt
|
||||
) {
|
||||
|
||||
public static StockBatchResponse from(StockBatch batch) {
|
||||
return new StockBatchResponse(
|
||||
batch.id().value(),
|
||||
batch.batchReference().batchId(),
|
||||
batch.batchReference().batchType().name(),
|
||||
batch.quantity().amount(),
|
||||
batch.quantity().uom().name(),
|
||||
batch.expiryDate(),
|
||||
batch.status().name(),
|
||||
batch.receivedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -25,11 +25,16 @@ public final class InventoryErrorHttpStatusMapper {
|
|||
public static int toHttpStatus(StockError error) {
|
||||
return switch (error) {
|
||||
case StockError.StockNotFound e -> 404;
|
||||
case StockError.BatchNotFound e -> 404;
|
||||
case StockError.DuplicateStock e -> 409;
|
||||
case StockError.DuplicateBatchReference e -> 409;
|
||||
case StockError.InvalidMinimumLevel e -> 400;
|
||||
case StockError.InvalidMinimumShelfLife e -> 400;
|
||||
case StockError.InvalidArticleId e -> 400;
|
||||
case StockError.InvalidStorageLocationId e -> 400;
|
||||
case StockError.InvalidBatchReference e -> 400;
|
||||
case StockError.InvalidQuantity e -> 400;
|
||||
case StockError.InvalidExpiryDate e -> 400;
|
||||
case StockError.Unauthorized e -> 403;
|
||||
case StockError.RepositoryFailure e -> 500;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
package de.effigenix.infrastructure.stub;
|
||||
|
||||
import de.effigenix.domain.inventory.Stock;
|
||||
import de.effigenix.domain.inventory.StockId;
|
||||
import de.effigenix.domain.inventory.StockRepository;
|
||||
import de.effigenix.domain.inventory.StorageLocationId;
|
||||
import de.effigenix.domain.masterdata.ArticleId;
|
||||
import de.effigenix.shared.common.RepositoryError;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
@Profile("no-db")
|
||||
public class StubStockRepository implements StockRepository {
|
||||
|
||||
private static final RepositoryError.DatabaseError STUB_ERROR =
|
||||
new RepositoryError.DatabaseError("Stub-Modus: keine Datenbankverbindung");
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, Optional<Stock>> findById(StockId id) {
|
||||
return Result.failure(STUB_ERROR);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, Optional<Stock>> findByArticleIdAndStorageLocationId(ArticleId articleId, StorageLocationId storageLocationId) {
|
||||
return Result.failure(STUB_ERROR);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, Boolean> existsByArticleIdAndStorageLocationId(ArticleId articleId, StorageLocationId storageLocationId) {
|
||||
return Result.failure(STUB_ERROR);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, Void> save(Stock stock) {
|
||||
return Result.failure(STUB_ERROR);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
<?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="014-create-stock-batches-table" author="effigenix">
|
||||
<createTable tableName="stock_batches">
|
||||
<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="batch_id" type="VARCHAR(100)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="batch_type" type="VARCHAR(20)">
|
||||
<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="expiry_date" type="DATE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="status" type="VARCHAR(20)" defaultValue="AVAILABLE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="received_at" type="TIMESTAMP WITH TIME ZONE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
|
||||
<addForeignKeyConstraint baseTableName="stock_batches" baseColumnNames="stock_id"
|
||||
referencedTableName="stocks" referencedColumnNames="id"
|
||||
constraintName="fk_stock_batches_stock"
|
||||
onDelete="CASCADE"/>
|
||||
|
||||
<addUniqueConstraint tableName="stock_batches" columnNames="stock_id, batch_id, batch_type"
|
||||
constraintName="uq_stock_batch_reference"/>
|
||||
|
||||
<createIndex tableName="stock_batches" indexName="idx_stock_batches_stock_id">
|
||||
<column name="stock_id"/>
|
||||
</createIndex>
|
||||
|
||||
<sql>
|
||||
ALTER TABLE stock_batches ADD CONSTRAINT chk_stock_batch_type
|
||||
CHECK (batch_type IN ('PRODUCED', 'PURCHASED'));
|
||||
</sql>
|
||||
|
||||
<sql>
|
||||
ALTER TABLE stock_batches ADD CONSTRAINT chk_stock_batch_status
|
||||
CHECK (status IN ('AVAILABLE', 'EXPIRING_SOON', 'BLOCKED', 'EXPIRED'));
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
@ -18,5 +18,6 @@
|
|||
<include file="db/changelog/changes/011-create-recipe-ingredients-table.xml"/>
|
||||
<include file="db/changelog/changes/012-create-recipe-production-steps-table.xml"/>
|
||||
<include file="db/changelog/changes/013-create-stock-schema.xml"/>
|
||||
<include file="db/changelog/changes/014-create-stock-batches-table.xml"/>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,180 @@
|
|||
package de.effigenix.application.inventory;
|
||||
|
||||
import de.effigenix.application.inventory.command.AddStockBatchCommand;
|
||||
import de.effigenix.domain.inventory.*;
|
||||
import de.effigenix.shared.common.Quantity;
|
||||
import de.effigenix.shared.common.RepositoryError;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.common.UnitOfMeasure;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("AddStockBatch Use Case")
|
||||
class AddStockBatchTest {
|
||||
|
||||
@Mock private StockRepository stockRepository;
|
||||
|
||||
private AddStockBatch addStockBatch;
|
||||
private AddStockBatchCommand validCommand;
|
||||
private Stock existingStock;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
addStockBatch = new AddStockBatch(stockRepository);
|
||||
|
||||
existingStock = Stock.reconstitute(
|
||||
StockId.of("stock-1"),
|
||||
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null, List.of()
|
||||
);
|
||||
|
||||
validCommand = new AddStockBatchCommand(
|
||||
"stock-1", "BATCH-001", "PRODUCED", "10.5", "KILOGRAM", "2026-12-31"
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should add batch to existing stock")
|
||||
void shouldAddBatchToExistingStock() {
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(existingStock)));
|
||||
when(stockRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
var result = addStockBatch.execute(validCommand);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
var batch = result.unsafeGetValue();
|
||||
assertThat(batch.batchReference().batchId()).isEqualTo("BATCH-001");
|
||||
assertThat(batch.batchReference().batchType()).isEqualTo(BatchType.PRODUCED);
|
||||
assertThat(batch.quantity().amount()).isEqualByComparingTo(new BigDecimal("10.5"));
|
||||
assertThat(batch.status()).isEqualTo(StockBatchStatus.AVAILABLE);
|
||||
assertThat(batch.receivedAt()).isNotNull();
|
||||
verify(stockRepository).save(existingStock);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with StockNotFound when stock does not exist")
|
||||
void shouldFailWhenStockNotFound() {
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.empty()));
|
||||
|
||||
var result = addStockBatch.execute(validCommand);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.StockNotFound.class);
|
||||
verify(stockRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with RepositoryFailure when findById fails")
|
||||
void shouldFailWhenFindByIdFails() {
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
|
||||
|
||||
var result = addStockBatch.execute(validCommand);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class);
|
||||
verify(stockRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with RepositoryFailure when save fails")
|
||||
void shouldFailWhenSaveFails() {
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(existingStock)));
|
||||
when(stockRepository.save(any()))
|
||||
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
|
||||
|
||||
var result = addStockBatch.execute(validCommand);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with DuplicateBatchReference when batch already exists")
|
||||
void shouldFailWhenDuplicateBatchReference() {
|
||||
var stockWithBatch = Stock.reconstitute(
|
||||
StockId.of("stock-1"),
|
||||
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null,
|
||||
List.of(StockBatch.reconstitute(
|
||||
StockBatchId.generate(),
|
||||
new BatchReference("BATCH-001", BatchType.PRODUCED),
|
||||
Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM, null, null),
|
||||
LocalDate.of(2026, 12, 31),
|
||||
StockBatchStatus.AVAILABLE,
|
||||
Instant.now()
|
||||
))
|
||||
);
|
||||
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(stockWithBatch)));
|
||||
|
||||
var result = addStockBatch.execute(validCommand);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.DuplicateBatchReference.class);
|
||||
verify(stockRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InvalidBatchReference when batchType is invalid")
|
||||
void shouldFailWhenInvalidBatchType() {
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(existingStock)));
|
||||
|
||||
var cmd = new AddStockBatchCommand("stock-1", "BATCH-001", "INVALID", "10", "KILOGRAM", "2026-12-31");
|
||||
var result = addStockBatch.execute(cmd);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class);
|
||||
verify(stockRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InvalidQuantity when quantity is negative")
|
||||
void shouldFailWhenInvalidQuantity() {
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(existingStock)));
|
||||
|
||||
var cmd = new AddStockBatchCommand("stock-1", "BATCH-001", "PRODUCED", "-1", "KILOGRAM", "2026-12-31");
|
||||
var result = addStockBatch.execute(cmd);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
|
||||
verify(stockRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InvalidExpiryDate when date format is wrong")
|
||||
void shouldFailWhenInvalidExpiryDate() {
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(existingStock)));
|
||||
|
||||
var cmd = new AddStockBatchCommand("stock-1", "BATCH-001", "PRODUCED", "10", "KILOGRAM", "not-a-date");
|
||||
var result = addStockBatch.execute(cmd);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidExpiryDate.class);
|
||||
verify(stockRepository, never()).save(any());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
package de.effigenix.domain.inventory;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DisplayName("BatchReference")
|
||||
class BatchReferenceTest {
|
||||
|
||||
@Nested
|
||||
@DisplayName("of()")
|
||||
class Of {
|
||||
|
||||
@Test
|
||||
@DisplayName("should create with PRODUCED type")
|
||||
void shouldCreateWithProducedType() {
|
||||
var result = BatchReference.of("BATCH-001", "PRODUCED");
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().batchId()).isEqualTo("BATCH-001");
|
||||
assertThat(result.unsafeGetValue().batchType()).isEqualTo(BatchType.PRODUCED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should create with PURCHASED type")
|
||||
void shouldCreateWithPurchasedType() {
|
||||
var result = BatchReference.of("PO-123", "PURCHASED");
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().batchId()).isEqualTo("PO-123");
|
||||
assertThat(result.unsafeGetValue().batchType()).isEqualTo(BatchType.PURCHASED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when batchId is null")
|
||||
void shouldFailWhenBatchIdNull() {
|
||||
var result = BatchReference.of(null, "PRODUCED");
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when batchId is blank")
|
||||
void shouldFailWhenBatchIdBlank() {
|
||||
var result = BatchReference.of(" ", "PRODUCED");
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when batchType is null")
|
||||
void shouldFailWhenBatchTypeNull() {
|
||||
var result = BatchReference.of("BATCH-001", null);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when batchType is invalid")
|
||||
void shouldFailWhenBatchTypeInvalid() {
|
||||
var result = BatchReference.of("BATCH-001", "INVALID");
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class);
|
||||
assertThat(result.unsafeGetError().message()).contains("INVALID");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when batchType is empty string")
|
||||
void shouldFailWhenBatchTypeEmpty() {
|
||||
var result = BatchReference.of("BATCH-001", "");
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("equals / hashCode")
|
||||
class Equality {
|
||||
|
||||
@Test
|
||||
@DisplayName("should be equal with same batchId and batchType")
|
||||
void shouldBeEqualWithSameValues() {
|
||||
var ref1 = new BatchReference("BATCH-001", BatchType.PRODUCED);
|
||||
var ref2 = new BatchReference("BATCH-001", BatchType.PRODUCED);
|
||||
|
||||
assertThat(ref1).isEqualTo(ref2);
|
||||
assertThat(ref1.hashCode()).isEqualTo(ref2.hashCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should not be equal with different batchType")
|
||||
void shouldNotBeEqualWithDifferentType() {
|
||||
var ref1 = new BatchReference("BATCH-001", BatchType.PRODUCED);
|
||||
var ref2 = new BatchReference("BATCH-001", BatchType.PURCHASED);
|
||||
|
||||
assertThat(ref1).isNotEqualTo(ref2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should not be equal with different batchId")
|
||||
void shouldNotBeEqualWithDifferentId() {
|
||||
var ref1 = new BatchReference("BATCH-001", BatchType.PRODUCED);
|
||||
var ref2 = new BatchReference("BATCH-002", BatchType.PRODUCED);
|
||||
|
||||
assertThat(ref1).isNotEqualTo(ref2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
package de.effigenix.domain.inventory;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class StockBatchTest {
|
||||
|
||||
// ==================== Create ====================
|
||||
|
||||
@Nested
|
||||
@DisplayName("create()")
|
||||
class Create {
|
||||
|
||||
@Test
|
||||
@DisplayName("should create batch with valid data")
|
||||
void shouldCreateWithValidData() {
|
||||
var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "10.5", "KILOGRAM", "2026-12-31");
|
||||
|
||||
var result = StockBatch.create(draft);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
var batch = result.unsafeGetValue();
|
||||
assertThat(batch.id()).isNotNull();
|
||||
assertThat(batch.batchReference().batchId()).isEqualTo("BATCH-001");
|
||||
assertThat(batch.batchReference().batchType()).isEqualTo(BatchType.PRODUCED);
|
||||
assertThat(batch.quantity().amount()).isEqualByComparingTo(new BigDecimal("10.5"));
|
||||
assertThat(batch.expiryDate().toString()).isEqualTo("2026-12-31");
|
||||
assertThat(batch.status()).isEqualTo(StockBatchStatus.AVAILABLE);
|
||||
assertThat(batch.receivedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should create batch with PURCHASED type")
|
||||
void shouldCreateWithPurchasedType() {
|
||||
var draft = new StockBatchDraft("PO-123", "PURCHASED", "5", "PIECE", "2027-01-15");
|
||||
|
||||
var result = StockBatch.create(draft);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().batchReference().batchType()).isEqualTo(BatchType.PURCHASED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when batchId is blank")
|
||||
void shouldFailWhenBatchIdBlank() {
|
||||
var draft = new StockBatchDraft("", "PRODUCED", "10", "KILOGRAM", "2026-12-31");
|
||||
|
||||
var result = StockBatch.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when batchType is invalid")
|
||||
void shouldFailWhenBatchTypeInvalid() {
|
||||
var draft = new StockBatchDraft("BATCH-001", "INVALID", "10", "KILOGRAM", "2026-12-31");
|
||||
|
||||
var result = StockBatch.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidBatchReference.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when quantity is negative")
|
||||
void shouldFailWhenQuantityNegative() {
|
||||
var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "-1", "KILOGRAM", "2026-12-31");
|
||||
|
||||
var result = StockBatch.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when quantity is zero")
|
||||
void shouldFailWhenQuantityZero() {
|
||||
var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "0", "KILOGRAM", "2026-12-31");
|
||||
|
||||
var result = StockBatch.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when quantity is not a number")
|
||||
void shouldFailWhenQuantityNotNumber() {
|
||||
var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "abc", "KILOGRAM", "2026-12-31");
|
||||
|
||||
var result = StockBatch.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when unit is invalid")
|
||||
void shouldFailWhenUnitInvalid() {
|
||||
var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "10", "INVALID_UNIT", "2026-12-31");
|
||||
|
||||
var result = StockBatch.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when expiryDate is blank")
|
||||
void shouldFailWhenExpiryDateBlank() {
|
||||
var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", "");
|
||||
|
||||
var result = StockBatch.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidExpiryDate.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when expiryDate is null")
|
||||
void shouldFailWhenExpiryDateNull() {
|
||||
var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", null);
|
||||
|
||||
var result = StockBatch.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidExpiryDate.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when expiryDate is not ISO format")
|
||||
void shouldFailWhenExpiryDateInvalidFormat() {
|
||||
var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", "31.12.2026");
|
||||
|
||||
var result = StockBatch.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidExpiryDate.class);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Equality ====================
|
||||
|
||||
@Nested
|
||||
@DisplayName("equals / hashCode")
|
||||
class Equality {
|
||||
|
||||
@Test
|
||||
@DisplayName("should be equal if same ID")
|
||||
void shouldBeEqualBySameId() {
|
||||
var id = StockBatchId.generate();
|
||||
var ref = new BatchReference("B1", BatchType.PRODUCED);
|
||||
var batch1 = StockBatch.reconstitute(id, ref, null, null, StockBatchStatus.AVAILABLE, null);
|
||||
var batch2 = StockBatch.reconstitute(id, ref, null, null, StockBatchStatus.BLOCKED, null);
|
||||
|
||||
assertThat(batch1).isEqualTo(batch2);
|
||||
assertThat(batch1.hashCode()).isEqualTo(batch2.hashCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should not be equal if different ID")
|
||||
void shouldNotBeEqualByDifferentId() {
|
||||
var ref = new BatchReference("B1", BatchType.PRODUCED);
|
||||
var batch1 = StockBatch.reconstitute(StockBatchId.generate(), ref, null, null, StockBatchStatus.AVAILABLE, null);
|
||||
var batch2 = StockBatch.reconstitute(StockBatchId.generate(), ref, null, null, StockBatchStatus.AVAILABLE, null);
|
||||
|
||||
assertThat(batch1).isNotEqualTo(batch2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import org.junit.jupiter.api.Nested;
|
|||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
|
@ -37,6 +38,7 @@ class StockTest {
|
|||
assertThat(stock.minimumLevel().quantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
|
||||
assertThat(stock.minimumShelfLife()).isNotNull();
|
||||
assertThat(stock.minimumShelfLife().days()).isEqualTo(30);
|
||||
assertThat(stock.batches()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -187,6 +189,68 @@ class StockTest {
|
|||
}
|
||||
}
|
||||
|
||||
// ==================== addBatch ====================
|
||||
|
||||
@Nested
|
||||
@DisplayName("addBatch()")
|
||||
class AddBatch {
|
||||
|
||||
@Test
|
||||
@DisplayName("should add batch successfully")
|
||||
void shouldAddBatch() {
|
||||
var stock = createValidStock();
|
||||
var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31");
|
||||
|
||||
var result = stock.addBatch(draft);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
var batch = result.unsafeGetValue();
|
||||
assertThat(batch.batchReference().batchId()).isEqualTo("BATCH-001");
|
||||
assertThat(batch.batchReference().batchType()).isEqualTo(BatchType.PRODUCED);
|
||||
assertThat(batch.status()).isEqualTo(StockBatchStatus.AVAILABLE);
|
||||
assertThat(stock.batches()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when duplicate batch reference")
|
||||
void shouldFailOnDuplicateBatchReference() {
|
||||
var stock = createValidStock();
|
||||
var draft1 = new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31");
|
||||
stock.addBatch(draft1);
|
||||
|
||||
var draft2 = new StockBatchDraft("BATCH-001", "PRODUCED", "5", "KILOGRAM", "2027-01-15");
|
||||
var result = stock.addBatch(draft2);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.DuplicateBatchReference.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should allow same batchId with different batchType")
|
||||
void shouldAllowSameBatchIdDifferentType() {
|
||||
var stock = createValidStock();
|
||||
stock.addBatch(new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31"));
|
||||
|
||||
var result = stock.addBatch(new StockBatchDraft("BATCH-001", "PURCHASED", "5", "KILOGRAM", "2027-01-15"));
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(stock.batches()).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should return unmodifiable batches list")
|
||||
void shouldReturnUnmodifiableBatchesList() {
|
||||
var stock = createValidStock();
|
||||
stock.addBatch(new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31"));
|
||||
|
||||
var batches = stock.batches();
|
||||
|
||||
assertThat(batches).hasSize(1);
|
||||
org.junit.jupiter.api.Assertions.assertThrows(UnsupportedOperationException.class,
|
||||
() -> batches.add(null));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Reconstitute ====================
|
||||
|
||||
@Nested
|
||||
|
|
@ -203,13 +267,14 @@ class StockTest {
|
|||
var minimumLevel = new MinimumLevel(quantity);
|
||||
var minimumShelfLife = new MinimumShelfLife(30);
|
||||
|
||||
var stock = Stock.reconstitute(id, articleId, locationId, minimumLevel, minimumShelfLife);
|
||||
var stock = Stock.reconstitute(id, articleId, locationId, minimumLevel, minimumShelfLife, List.of());
|
||||
|
||||
assertThat(stock.id()).isEqualTo(id);
|
||||
assertThat(stock.articleId()).isEqualTo(articleId);
|
||||
assertThat(stock.storageLocationId()).isEqualTo(locationId);
|
||||
assertThat(stock.minimumLevel()).isEqualTo(minimumLevel);
|
||||
assertThat(stock.minimumShelfLife()).isEqualTo(minimumShelfLife);
|
||||
assertThat(stock.batches()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -219,7 +284,7 @@ class StockTest {
|
|||
var articleId = ArticleId.of("article-1");
|
||||
var locationId = StorageLocationId.of("location-1");
|
||||
|
||||
var stock = Stock.reconstitute(id, articleId, locationId, null, null);
|
||||
var stock = Stock.reconstitute(id, articleId, locationId, null, null, List.of());
|
||||
|
||||
assertThat(stock.minimumLevel()).isNull();
|
||||
assertThat(stock.minimumShelfLife()).isNull();
|
||||
|
|
@ -236,8 +301,8 @@ class StockTest {
|
|||
@DisplayName("should be equal if same ID")
|
||||
void shouldBeEqualBySameId() {
|
||||
var id = StockId.generate();
|
||||
var stock1 = Stock.reconstitute(id, ArticleId.of("a1"), StorageLocationId.of("l1"), null, null);
|
||||
var stock2 = Stock.reconstitute(id, ArticleId.of("a2"), StorageLocationId.of("l2"), null, null);
|
||||
var stock1 = Stock.reconstitute(id, ArticleId.of("a1"), StorageLocationId.of("l1"), null, null, List.of());
|
||||
var stock2 = Stock.reconstitute(id, ArticleId.of("a2"), StorageLocationId.of("l2"), null, null, List.of());
|
||||
|
||||
assertThat(stock1).isEqualTo(stock2);
|
||||
assertThat(stock1.hashCode()).isEqualTo(stock2.hashCode());
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ package de.effigenix.infrastructure.inventory.web;
|
|||
|
||||
import de.effigenix.domain.usermanagement.RoleName;
|
||||
import de.effigenix.infrastructure.AbstractIntegrationTest;
|
||||
import de.effigenix.infrastructure.inventory.web.dto.AddStockBatchRequest;
|
||||
import de.effigenix.infrastructure.inventory.web.dto.CreateStockRequest;
|
||||
import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity;
|
||||
import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
|
|
@ -21,6 +23,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||
*
|
||||
* Abgedeckte Testfälle:
|
||||
* - Story 2.1 – Bestandsposition anlegen
|
||||
* - Story 2.2 – Charge einbuchen (addBatch)
|
||||
*/
|
||||
@DisplayName("Stock Controller Integration Tests")
|
||||
class StockControllerIntegrationTest extends AbstractIntegrationTest {
|
||||
|
|
@ -201,6 +204,147 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
|
|||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
// ==================== Charge einbuchen (addBatch) ====================
|
||||
|
||||
@Nested
|
||||
@DisplayName("POST /{stockId}/batches – Charge einbuchen")
|
||||
class AddBatch {
|
||||
|
||||
@Test
|
||||
@DisplayName("Charge einbuchen → 201")
|
||||
void addBatch_returns201() throws Exception {
|
||||
String stockId = createStock();
|
||||
var request = new AddStockBatchRequest("BATCH-001", "PRODUCED", "10.5", "KILOGRAM", "2026-12-31");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").isNotEmpty())
|
||||
.andExpect(jsonPath("$.batchId").value("BATCH-001"))
|
||||
.andExpect(jsonPath("$.batchType").value("PRODUCED"))
|
||||
.andExpect(jsonPath("$.quantityAmount").value(10.5))
|
||||
.andExpect(jsonPath("$.quantityUnit").value("KILOGRAM"))
|
||||
.andExpect(jsonPath("$.expiryDate").value("2026-12-31"))
|
||||
.andExpect(jsonPath("$.status").value("AVAILABLE"))
|
||||
.andExpect(jsonPath("$.receivedAt").isNotEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Charge mit PURCHASED Typ → 201")
|
||||
void addBatch_purchased_returns201() throws Exception {
|
||||
String stockId = createStock();
|
||||
var request = new AddStockBatchRequest("PO-123", "PURCHASED", "5", "PIECE", "2027-01-15");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.batchType").value("PURCHASED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Doppelte BatchReference → 409")
|
||||
void addBatch_duplicate_returns409() throws Exception {
|
||||
String stockId = createStock();
|
||||
var request = new AddStockBatchRequest("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("DUPLICATE_BATCH_REFERENCE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ungültiger BatchType → 400")
|
||||
void addBatch_invalidBatchType_returns400() throws Exception {
|
||||
String stockId = createStock();
|
||||
var request = new AddStockBatchRequest("BATCH-001", "INVALID", "10", "KILOGRAM", "2026-12-31");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("INVALID_BATCH_REFERENCE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ungültige Menge → 400")
|
||||
void addBatch_invalidQuantity_returns400() throws Exception {
|
||||
String stockId = createStock();
|
||||
var request = new AddStockBatchRequest("BATCH-001", "PRODUCED", "-1", "KILOGRAM", "2026-12-31");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("INVALID_QUANTITY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ungültiges Ablaufdatum → 400")
|
||||
void addBatch_invalidExpiryDate_returns400() throws Exception {
|
||||
String stockId = createStock();
|
||||
var request = new AddStockBatchRequest("BATCH-001", "PRODUCED", "10", "KILOGRAM", "31.12.2026");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("INVALID_EXPIRY_DATE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Stock nicht gefunden → 404")
|
||||
void addBatch_stockNotFound_returns404() throws Exception {
|
||||
var request = new AddStockBatchRequest("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", UUID.randomUUID().toString())
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value("STOCK_NOT_FOUND"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Charge einbuchen ohne STOCK_WRITE → 403")
|
||||
void addBatch_withViewerToken_returns403() throws Exception {
|
||||
String stockId = createStock();
|
||||
var request = new AddStockBatchRequest("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId)
|
||||
.header("Authorization", "Bearer " + viewerToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Charge einbuchen ohne Token → 401")
|
||||
void addBatch_withoutToken_returns401() throws Exception {
|
||||
var request = new AddStockBatchRequest("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", UUID.randomUUID().toString())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Hilfsmethoden ====================
|
||||
|
||||
private String createStorageLocation() throws Exception {
|
||||
|
|
@ -217,4 +361,18 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
|
|||
|
||||
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
|
||||
}
|
||||
|
||||
private String createStock() throws Exception {
|
||||
var request = new CreateStockRequest(
|
||||
UUID.randomUUID().toString(), storageLocationId, null, null, null);
|
||||
|
||||
var result = mockMvc.perform(post("/api/inventory/stocks")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
|
||||
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue