1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 08:29:36 +01:00

fix(inventory): quantityUnit-Ermittlung bei heterogenen UoMs absichern (#60)

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.
This commit is contained in:
Sebastian Frick 2026-03-19 19:31:39 +01:00
parent 004d96b291
commit b9b89e3f0e
3 changed files with 96 additions and 10 deletions

View file

@ -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<UnitOfMeasure> uniformUnitOfMeasure() {
Set<UnitOfMeasure> 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<UnitOfMeasure> 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<UnitOfMeasure> 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;

View file

@ -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<ReservationResponse> reservationResponses = stock.reservations().stream()
.map(ReservationResponse::from)

View file

@ -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() {