From b9b89e3f0e1b27e0b2ab326f5de3df14f1dd8698 Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Thu, 19 Mar 2026 19:31:39 +0100 Subject: [PATCH] fix(inventory): quantityUnit-Ermittlung bei heterogenen UoMs absichern (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stock.uniformUnitOfMeasure() gibt die UoM nur zurück wenn alle Chargen dieselbe Einheit haben, sonst Optional.empty(). StockResponse nutzt diese Methode statt blind die erste Charge zu nehmen. --- .../de/effigenix/domain/inventory/Stock.java | 28 ++++++-- .../inventory/web/dto/StockResponse.java | 6 +- .../effigenix/domain/inventory/StockTest.java | 72 +++++++++++++++++++ 3 files changed, 96 insertions(+), 10 deletions(-) diff --git a/backend/src/main/java/de/effigenix/domain/inventory/Stock.java b/backend/src/main/java/de/effigenix/domain/inventory/Stock.java index e92ec06..10076f6 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/Stock.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/Stock.java @@ -15,6 +15,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -446,6 +447,17 @@ public class Stock { // ==================== Queries ==================== + /** + * Returns the uniform UoM if all batches share the same unit, empty otherwise. + * Empty batches list also returns empty. + */ + public Optional uniformUnitOfMeasure() { + Set units = batches.stream() + .map(b -> b.quantity().uom()) + .collect(Collectors.toSet()); + return units.size() == 1 ? Optional.of(units.iterator().next()) : Optional.empty(); + } + public BigDecimal availableQuantity() { BigDecimal gross = batches.stream() .filter(b -> b.status() == StockBatchStatus.AVAILABLE || b.status() == StockBatchStatus.EXPIRING_SOON) @@ -465,15 +477,17 @@ public class Stock { if (minimumLevel == null) { return false; } - Set batchUnits = batches.stream() - .filter(b -> b.status() == StockBatchStatus.AVAILABLE || b.status() == StockBatchStatus.EXPIRING_SOON) - .map(b -> b.quantity().uom()) - .collect(Collectors.toSet()); UnitOfMeasure minimumUnit = minimumLevel.quantity().uom(); - if (!batchUnits.isEmpty() && !(batchUnits.size() == 1 && batchUnits.contains(minimumUnit))) { + Optional uniform = uniformUnitOfMeasure(); + if (uniform.isEmpty() && !batches.isEmpty()) { logger.log(System.Logger.Level.WARNING, - "Unit mismatch in stock {0}: batch units {1} vs minimum level unit {2} — skipping below-minimum check", - id, batchUnits, minimumUnit); + "Mixed UoMs in stock {0} — skipping below-minimum check", id); + return false; + } + if (uniform.isPresent() && uniform.get() != minimumUnit) { + logger.log(System.Logger.Level.WARNING, + "Unit mismatch in stock {0}: batch unit {1} vs minimum level unit {2} — skipping below-minimum check", + id, uniform.get(), minimumUnit); return false; } return availableQuantity().compareTo(minimumLevel.quantity().amount()) < 0; diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockResponse.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockResponse.java index dc9733d..434e5ea 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockResponse.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockResponse.java @@ -44,9 +44,9 @@ public record StockResponse( BigDecimal availableQuantity = stock.availableQuantity(); - String quantityUnit = stock.batches().isEmpty() - ? null - : stock.batches().getFirst().quantity().uom().name(); + String quantityUnit = stock.uniformUnitOfMeasure() + .map(Enum::name) + .orElse(null); List reservationResponses = stock.reservations().stream() .map(ReservationResponse::from) diff --git a/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java b/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java index ac6bd36..49aac56 100644 --- a/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java +++ b/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java @@ -2068,6 +2068,78 @@ class StockTest { } } + // ==================== uniformUnitOfMeasure ==================== + + @Nested + @DisplayName("uniformUnitOfMeasure()") + class UniformUnitOfMeasure { + + @Test + @DisplayName("should return empty when no batches") + void should_returnEmpty_when_noBatches() { + var stock = Stock.reconstitute( + StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), + null, null, List.of(), List.of() + ); + + assertThat(stock.uniformUnitOfMeasure()).isEmpty(); + } + + @Test + @DisplayName("should return UoM when all batches have same unit") + void should_returnUoM_when_allBatchesSameUnit() { + var batch1 = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now() + ); + var batch2 = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-002", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 12, 31), StockBatchStatus.BLOCKED, Instant.now() + ); + var stock = Stock.reconstitute( + StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), + null, null, new ArrayList<>(List.of(batch1, batch2)), List.of() + ); + + assertThat(stock.uniformUnitOfMeasure()).contains(UnitOfMeasure.KILOGRAM); + } + + @Test + @DisplayName("should return empty when batches have different units") + void should_returnEmpty_when_heterogeneousUnits() { + var kgBatch = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now() + ); + var literBatch = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-002", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.LITER), + LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now() + ); + var stock = Stock.reconstitute( + StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), + null, null, new ArrayList<>(List.of(kgBatch, literBatch)), List.of() + ); + + assertThat(stock.uniformUnitOfMeasure()).isEmpty(); + } + + @Test + @DisplayName("should return UoM when single batch present") + void should_returnUoM_when_singleBatch() { + var stock = createStockWithBatch("10", UnitOfMeasure.PIECE, StockBatchStatus.AVAILABLE); + + assertThat(stock.uniformUnitOfMeasure()).contains(UnitOfMeasure.PIECE); + } + } + // ==================== Helpers ==================== private Stock createValidStock() {