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:
parent
004d96b291
commit
b9b89e3f0e
3 changed files with 96 additions and 10 deletions
|
|
@ -15,6 +15,7 @@ import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
|
@ -446,6 +447,17 @@ public class Stock {
|
||||||
|
|
||||||
// ==================== Queries ====================
|
// ==================== 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() {
|
public BigDecimal availableQuantity() {
|
||||||
BigDecimal gross = batches.stream()
|
BigDecimal gross = batches.stream()
|
||||||
.filter(b -> b.status() == StockBatchStatus.AVAILABLE || b.status() == StockBatchStatus.EXPIRING_SOON)
|
.filter(b -> b.status() == StockBatchStatus.AVAILABLE || b.status() == StockBatchStatus.EXPIRING_SOON)
|
||||||
|
|
@ -465,15 +477,17 @@ public class Stock {
|
||||||
if (minimumLevel == null) {
|
if (minimumLevel == null) {
|
||||||
return false;
|
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();
|
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,
|
logger.log(System.Logger.Level.WARNING,
|
||||||
"Unit mismatch in stock {0}: batch units {1} vs minimum level unit {2} — skipping below-minimum check",
|
"Mixed UoMs in stock {0} — skipping below-minimum check", id);
|
||||||
id, batchUnits, minimumUnit);
|
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 false;
|
||||||
}
|
}
|
||||||
return availableQuantity().compareTo(minimumLevel.quantity().amount()) < 0;
|
return availableQuantity().compareTo(minimumLevel.quantity().amount()) < 0;
|
||||||
|
|
|
||||||
|
|
@ -44,9 +44,9 @@ public record StockResponse(
|
||||||
|
|
||||||
BigDecimal availableQuantity = stock.availableQuantity();
|
BigDecimal availableQuantity = stock.availableQuantity();
|
||||||
|
|
||||||
String quantityUnit = stock.batches().isEmpty()
|
String quantityUnit = stock.uniformUnitOfMeasure()
|
||||||
? null
|
.map(Enum::name)
|
||||||
: stock.batches().getFirst().quantity().uom().name();
|
.orElse(null);
|
||||||
|
|
||||||
List<ReservationResponse> reservationResponses = stock.reservations().stream()
|
List<ReservationResponse> reservationResponses = stock.reservations().stream()
|
||||||
.map(ReservationResponse::from)
|
.map(ReservationResponse::from)
|
||||||
|
|
|
||||||
|
|
@ -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 ====================
|
// ==================== Helpers ====================
|
||||||
|
|
||||||
private Stock createValidStock() {
|
private Stock createValidStock() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue