diff --git a/backend/src/main/java/de/effigenix/domain/production/BatchNumber.java b/backend/src/main/java/de/effigenix/domain/production/BatchNumber.java new file mode 100644 index 0000000..9ea6040 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/BatchNumber.java @@ -0,0 +1,64 @@ +package de.effigenix.domain.production; + +import de.effigenix.shared.common.Result; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.regex.Pattern; + +/** + * Value Object representing a production batch number. + * + *
Format: {@code P-YYYY-MM-DD-XXX} where XXX is a 3-digit sequence number (001–999). + * + *
Invariant: must match the pattern P-YYYY-MM-DD-XXX
+ */
+public record BatchNumber(String value) {
+
+ private static final Pattern FORMAT = Pattern.compile("^P-\\d{4}-\\d{2}-\\d{2}-\\d{3}$");
+ private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+ public BatchNumber {
+ if (value == null || !FORMAT.matcher(value).matches()) {
+ throw new IllegalArgumentException(
+ "Batch number must match format P-YYYY-MM-DD-XXX, was: " + value);
+ }
+ }
+
+ /**
+ * Creates a BatchNumber, returning a Result.
+ */
+ public static Result Primary quantity is always required (amount + unit of measure).
+ * Secondary quantity (catch-weight) is optional and used when goods are counted
+ * in one unit but weighed in another (e.g., 10 pieces = 23 kg).
+ *
+ * Invariants:
+ * Used to calculate required input quantities based on expected output.
+ * A yield of 80% means that 80% of input becomes output, so to produce
+ * 100 kg output you need 125 kg input (100 / 0.80).
+ *
+ * Invariant: value must be between 1 and 200 (inclusive)
+ */
+public record YieldPercentage(int value) {
+
+ public YieldPercentage {
+ if (value < 1 || value > 200) {
+ throw new IllegalArgumentException(
+ "Yield percentage must be between 1 and 200, was: " + value);
+ }
+ }
+
+ /**
+ * Creates a YieldPercentage, returning a Result.
+ */
+ public static Result Formula: requiredInput = desiredOutput / (yieldPercentage / 100)
+ * Example: YieldPercentage(80).calculateRequiredInput(100 kg) = 125 kg
+ */
+ public Quantity calculateRequiredInput(Quantity desiredOutput) {
+ BigDecimal factor = new BigDecimal(100)
+ .divide(new BigDecimal(value), 10, RoundingMode.HALF_UP);
+ BigDecimal requiredAmount = desiredOutput.amount()
+ .multiply(factor)
+ .setScale(6, RoundingMode.HALF_UP);
+
+ BigDecimal requiredSecondary = desiredOutput.secondaryAmount() != null
+ ? desiredOutput.secondaryAmount().multiply(factor).setScale(6, RoundingMode.HALF_UP)
+ : null;
+
+ return Quantity.reconstitute(requiredAmount, desiredOutput.uom(),
+ requiredSecondary, desiredOutput.secondaryUom());
+ }
+
+ /**
+ * Returns the yield as a decimal factor (e.g., 80 → 0.80).
+ */
+ public BigDecimal asFactor() {
+ return new BigDecimal(value)
+ .divide(new BigDecimal(100), 10, RoundingMode.HALF_UP);
+ }
+}
diff --git a/backend/src/test/java/de/effigenix/domain/production/BatchNumberTest.java b/backend/src/test/java/de/effigenix/domain/production/BatchNumberTest.java
new file mode 100644
index 0000000..c1efa7d
--- /dev/null
+++ b/backend/src/test/java/de/effigenix/domain/production/BatchNumberTest.java
@@ -0,0 +1,141 @@
+package de.effigenix.domain.production;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.time.LocalDate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("BatchNumber Value Object")
+class BatchNumberTest {
+
+ @Nested
+ @DisplayName("of() factory")
+ class OfFactory {
+
+ @Test
+ @DisplayName("should create BatchNumber with valid format")
+ void should_Create_When_ValidFormat() {
+ var result = BatchNumber.of("P-2026-02-19-001");
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().value()).isEqualTo("P-2026-02-19-001");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "P-2026-01-15-999",
+ "P-2025-12-31-042",
+ "P-2024-06-01-001"
+ })
+ @DisplayName("should accept various valid batch numbers")
+ void should_Accept_When_ValidFormats(String batchNumber) {
+ var result = BatchNumber.of(batchNumber);
+ assertThat(result.isSuccess()).isTrue();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "2026-02-19-001",
+ "P-2026-02-19-01",
+ "P-2026-02-19-1000",
+ "X-2026-02-19-001",
+ "P-26-02-19-001",
+ "P2026-02-19-001",
+ "",
+ "random-string"
+ })
+ @DisplayName("should fail for invalid formats")
+ void should_Fail_When_InvalidFormat(String batchNumber) {
+ var result = BatchNumber.of(batchNumber);
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.InvalidBatchNumber.class);
+ }
+
+ @Test
+ @DisplayName("should fail for null value")
+ void should_Fail_When_Null() {
+ var result = BatchNumber.of(null);
+ assertThat(result.isFailure()).isTrue();
+ }
+ }
+
+ @Nested
+ @DisplayName("generate()")
+ class Generate {
+
+ @Test
+ @DisplayName("should generate batch number for date and sequence")
+ void should_Generate_When_ValidDateAndSequence() {
+ var bn = BatchNumber.generate(LocalDate.of(2026, 2, 19), 1);
+
+ assertThat(bn.value()).isEqualTo("P-2026-02-19-001");
+ }
+
+ @Test
+ @DisplayName("should generate with 3-digit sequence")
+ void should_PadSequence_When_SmallNumber() {
+ var bn = BatchNumber.generate(LocalDate.of(2026, 2, 19), 42);
+
+ assertThat(bn.value()).isEqualTo("P-2026-02-19-042");
+ }
+
+ @Test
+ @DisplayName("should throw when sequence out of range")
+ void should_Throw_When_SequenceOutOfRange() {
+ assertThatThrownBy(() -> BatchNumber.generate(LocalDate.of(2026, 1, 1), 0))
+ .isInstanceOf(IllegalArgumentException.class);
+
+ assertThatThrownBy(() -> BatchNumber.generate(LocalDate.of(2026, 1, 1), 1000))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+ }
+
+ @Nested
+ @DisplayName("Extraction methods")
+ class Extraction {
+
+ @Test
+ @DisplayName("should extract date from batch number")
+ void should_ExtractDate() {
+ var bn = new BatchNumber("P-2026-02-19-001");
+ assertThat(bn.date()).isEqualTo(LocalDate.of(2026, 2, 19));
+ }
+
+ @Test
+ @DisplayName("should extract sequence number from batch number")
+ void should_ExtractSequence() {
+ var bn = new BatchNumber("P-2026-02-19-042");
+ assertThat(bn.sequenceNumber()).isEqualTo(42);
+ }
+ }
+
+ @Nested
+ @DisplayName("Equality")
+ class Equality {
+
+ @Test
+ @DisplayName("should be equal when same value")
+ void should_BeEqual_When_SameValue() {
+ var bn1 = new BatchNumber("P-2026-02-19-001");
+ var bn2 = new BatchNumber("P-2026-02-19-001");
+
+ assertThat(bn1).isEqualTo(bn2);
+ assertThat(bn1.hashCode()).isEqualTo(bn2.hashCode());
+ }
+
+ @Test
+ @DisplayName("should not be equal when different value")
+ void should_NotBeEqual_When_DifferentValue() {
+ var bn1 = new BatchNumber("P-2026-02-19-001");
+ var bn2 = new BatchNumber("P-2026-02-19-002");
+
+ assertThat(bn1).isNotEqualTo(bn2);
+ }
+ }
+}
diff --git a/backend/src/test/java/de/effigenix/domain/production/QuantityTest.java b/backend/src/test/java/de/effigenix/domain/production/QuantityTest.java
new file mode 100644
index 0000000..5b3329e
--- /dev/null
+++ b/backend/src/test/java/de/effigenix/domain/production/QuantityTest.java
@@ -0,0 +1,283 @@
+package de.effigenix.domain.production;
+
+import de.effigenix.shared.common.Result;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+
+import static de.effigenix.domain.production.UnitOfMeasure.*;
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DisplayName("Quantity Value Object")
+class QuantityTest {
+
+ @Nested
+ @DisplayName("of() factory")
+ class OfFactory {
+
+ @Test
+ @DisplayName("should create Quantity with valid amount and UoM")
+ void should_CreateQuantity_When_ValidAmountAndUom() {
+ var result = Quantity.of(new BigDecimal("10"), KILOGRAM);
+
+ assertThat(result.isSuccess()).isTrue();
+ var qty = result.unsafeGetValue();
+ assertThat(qty.amount()).isEqualByComparingTo(new BigDecimal("10"));
+ assertThat(qty.uom()).isEqualTo(KILOGRAM);
+ assertThat(qty.hasDualQuantity()).isFalse();
+ assertThat(qty.secondaryAmount()).isNull();
+ assertThat(qty.secondaryUom()).isNull();
+ }
+
+ @Test
+ @DisplayName("should fail when amount is zero")
+ void should_Fail_When_AmountIsZero() {
+ var result = Quantity.of(BigDecimal.ZERO, KILOGRAM);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.AmountMustBePositive.class);
+ }
+
+ @Test
+ @DisplayName("should fail when amount is negative")
+ void should_Fail_When_AmountIsNegative() {
+ var result = Quantity.of(new BigDecimal("-5"), KILOGRAM);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.AmountMustBePositive.class);
+ }
+
+ @Test
+ @DisplayName("should fail when amount is null")
+ void should_Fail_When_AmountIsNull() {
+ var result = Quantity.of(null, KILOGRAM);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.AmountMustBePositive.class);
+ }
+ }
+
+ @Nested
+ @DisplayName("dual() factory")
+ class DualFactory {
+
+ @Test
+ @DisplayName("should create dual Quantity with primary and secondary")
+ void should_CreateDualQuantity_When_ValidInputs() {
+ var result = Quantity.dual(
+ new BigDecimal("10"), PIECE,
+ new BigDecimal("23"), KILOGRAM);
+
+ assertThat(result.isSuccess()).isTrue();
+ var qty = result.unsafeGetValue();
+ assertThat(qty.hasDualQuantity()).isTrue();
+ assertThat(qty.amount()).isEqualByComparingTo(new BigDecimal("10"));
+ assertThat(qty.uom()).isEqualTo(PIECE);
+ assertThat(qty.secondaryAmount()).isEqualByComparingTo(new BigDecimal("23"));
+ assertThat(qty.secondaryUom()).isEqualTo(KILOGRAM);
+ }
+
+ @Test
+ @DisplayName("should fail when secondary amount is zero")
+ void should_Fail_When_SecondaryAmountIsZero() {
+ var result = Quantity.dual(
+ new BigDecimal("10"), PIECE,
+ BigDecimal.ZERO, KILOGRAM);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.SecondaryAmountMustBePositive.class);
+ }
+
+ @Test
+ @DisplayName("should fail when secondary UoM is null")
+ void should_Fail_When_SecondaryUomIsNull() {
+ var result = Quantity.dual(
+ new BigDecimal("10"), PIECE,
+ new BigDecimal("23"), null);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.SecondaryUomRequired.class);
+ }
+
+ @Test
+ @DisplayName("should fail when primary amount is invalid")
+ void should_Fail_When_PrimaryAmountInvalid() {
+ var result = Quantity.dual(
+ BigDecimal.ZERO, PIECE,
+ new BigDecimal("23"), KILOGRAM);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.AmountMustBePositive.class);
+ }
+ }
+
+ @Nested
+ @DisplayName("Arithmetic operations")
+ class Arithmetic {
+
+ @Test
+ @DisplayName("should add quantities with same UoM")
+ void should_Add_When_SameUom() {
+ var q1 = Quantity.of(new BigDecimal("10"), KILOGRAM).unsafeGetValue();
+ var q2 = Quantity.of(new BigDecimal("5"), KILOGRAM).unsafeGetValue();
+
+ var result = q1.add(q2);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().amount()).isEqualByComparingTo(new BigDecimal("15"));
+ assertThat(result.unsafeGetValue().uom()).isEqualTo(KILOGRAM);
+ }
+
+ @Test
+ @DisplayName("should fail to add quantities with different UoM")
+ void should_FailAdd_When_DifferentUom() {
+ var q1 = Quantity.of(new BigDecimal("10"), KILOGRAM).unsafeGetValue();
+ var q2 = Quantity.of(new BigDecimal("5"), LITER).unsafeGetValue();
+
+ var result = q1.add(q2);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.UnitOfMeasureMismatch.class);
+ }
+
+ @Test
+ @DisplayName("should subtract quantities with same UoM")
+ void should_Subtract_When_SameUom() {
+ var q1 = Quantity.of(new BigDecimal("10"), KILOGRAM).unsafeGetValue();
+ var q2 = Quantity.of(new BigDecimal("3"), KILOGRAM).unsafeGetValue();
+
+ var result = q1.subtract(q2);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().amount()).isEqualByComparingTo(new BigDecimal("7"));
+ }
+
+ @Test
+ @DisplayName("should fail to subtract when result would not be positive")
+ void should_FailSubtract_When_ResultNotPositive() {
+ var q1 = Quantity.of(new BigDecimal("5"), KILOGRAM).unsafeGetValue();
+ var q2 = Quantity.of(new BigDecimal("5"), KILOGRAM).unsafeGetValue();
+
+ var result = q1.subtract(q2);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.AmountMustBePositive.class);
+ }
+
+ @Test
+ @DisplayName("should fail to subtract with different UoM")
+ void should_FailSubtract_When_DifferentUom() {
+ var q1 = Quantity.of(new BigDecimal("10"), KILOGRAM).unsafeGetValue();
+ var q2 = Quantity.of(new BigDecimal("3"), GRAM).unsafeGetValue();
+
+ var result = q1.subtract(q2);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.UnitOfMeasureMismatch.class);
+ }
+
+ @Test
+ @DisplayName("should multiply quantity by factor")
+ void should_Multiply_When_ValidFactor() {
+ var q = Quantity.of(new BigDecimal("10"), KILOGRAM).unsafeGetValue();
+
+ var result = q.multiply(new BigDecimal("2.5"));
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().amount()).isEqualByComparingTo(new BigDecimal("25"));
+ assertThat(result.unsafeGetValue().uom()).isEqualTo(KILOGRAM);
+ }
+
+ @Test
+ @DisplayName("should fail to multiply by zero")
+ void should_FailMultiply_When_FactorIsZero() {
+ var q = Quantity.of(new BigDecimal("10"), KILOGRAM).unsafeGetValue();
+
+ var result = q.multiply(BigDecimal.ZERO);
+
+ assertThat(result.isFailure()).isTrue();
+ }
+
+ @Test
+ @DisplayName("should add dual quantities preserving secondary")
+ void should_AddDual_When_BothHaveSecondary() {
+ var q1 = Quantity.dual(new BigDecimal("10"), PIECE, new BigDecimal("20"), KILOGRAM).unsafeGetValue();
+ var q2 = Quantity.dual(new BigDecimal("5"), PIECE, new BigDecimal("10"), KILOGRAM).unsafeGetValue();
+
+ var result = q1.add(q2);
+
+ assertThat(result.isSuccess()).isTrue();
+ var sum = result.unsafeGetValue();
+ assertThat(sum.amount()).isEqualByComparingTo(new BigDecimal("15"));
+ assertThat(sum.secondaryAmount()).isEqualByComparingTo(new BigDecimal("30"));
+ assertThat(sum.secondaryUom()).isEqualTo(KILOGRAM);
+ }
+ }
+
+ @Nested
+ @DisplayName("Query methods")
+ class QueryMethods {
+
+ @Test
+ @DisplayName("isPositive returns true for positive quantity")
+ void should_BePositive_When_AmountIsPositive() {
+ var q = Quantity.of(new BigDecimal("10"), KILOGRAM).unsafeGetValue();
+ assertThat(q.isPositive()).isTrue();
+ assertThat(q.isZero()).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("Equality")
+ class Equality {
+
+ @Test
+ @DisplayName("should be equal when same amount and UoM")
+ void should_BeEqual_When_SameValues() {
+ var q1 = Quantity.of(new BigDecimal("10"), KILOGRAM).unsafeGetValue();
+ var q2 = Quantity.of(new BigDecimal("10.000000"), KILOGRAM).unsafeGetValue();
+
+ assertThat(q1).isEqualTo(q2);
+ assertThat(q1.hashCode()).isEqualTo(q2.hashCode());
+ }
+
+ @Test
+ @DisplayName("should not be equal when different amount")
+ void should_NotBeEqual_When_DifferentAmount() {
+ var q1 = Quantity.of(new BigDecimal("10"), KILOGRAM).unsafeGetValue();
+ var q2 = Quantity.of(new BigDecimal("20"), KILOGRAM).unsafeGetValue();
+
+ assertThat(q1).isNotEqualTo(q2);
+ }
+
+ @Test
+ @DisplayName("should not be equal when different UoM")
+ void should_NotBeEqual_When_DifferentUom() {
+ var q1 = Quantity.of(new BigDecimal("10"), KILOGRAM).unsafeGetValue();
+ var q2 = Quantity.of(new BigDecimal("10"), GRAM).unsafeGetValue();
+
+ assertThat(q1).isNotEqualTo(q2);
+ }
+ }
+
+ @Nested
+ @DisplayName("toString()")
+ class ToStringTest {
+
+ @Test
+ @DisplayName("should format simple quantity")
+ void should_FormatSimple() {
+ var q = Quantity.of(new BigDecimal("10.5"), KILOGRAM).unsafeGetValue();
+ assertThat(q.toString()).contains("10.5").contains("kg");
+ }
+
+ @Test
+ @DisplayName("should format dual quantity")
+ void should_FormatDual() {
+ var q = Quantity.dual(new BigDecimal("10"), PIECE, new BigDecimal("23"), KILOGRAM).unsafeGetValue();
+ assertThat(q.toString()).contains("10").contains("pc").contains("23").contains("kg");
+ }
+ }
+}
diff --git a/backend/src/test/java/de/effigenix/domain/production/UnitOfMeasureTest.java b/backend/src/test/java/de/effigenix/domain/production/UnitOfMeasureTest.java
new file mode 100644
index 0000000..bf02346
--- /dev/null
+++ b/backend/src/test/java/de/effigenix/domain/production/UnitOfMeasureTest.java
@@ -0,0 +1,27 @@
+package de.effigenix.domain.production;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DisplayName("UnitOfMeasure Enum")
+class UnitOfMeasureTest {
+
+ @Test
+ @DisplayName("should have 6 values")
+ void should_HaveSixValues() {
+ assertThat(UnitOfMeasure.values()).hasSize(6);
+ }
+
+ @Test
+ @DisplayName("should have correct symbols")
+ void should_HaveCorrectSymbols() {
+ assertThat(UnitOfMeasure.KILOGRAM.symbol()).isEqualTo("kg");
+ assertThat(UnitOfMeasure.GRAM.symbol()).isEqualTo("g");
+ assertThat(UnitOfMeasure.LITER.symbol()).isEqualTo("L");
+ assertThat(UnitOfMeasure.MILLILITER.symbol()).isEqualTo("mL");
+ assertThat(UnitOfMeasure.PIECE.symbol()).isEqualTo("pc");
+ assertThat(UnitOfMeasure.METER.symbol()).isEqualTo("m");
+ }
+}
diff --git a/backend/src/test/java/de/effigenix/domain/production/YieldPercentageTest.java b/backend/src/test/java/de/effigenix/domain/production/YieldPercentageTest.java
new file mode 100644
index 0000000..9c967ca
--- /dev/null
+++ b/backend/src/test/java/de/effigenix/domain/production/YieldPercentageTest.java
@@ -0,0 +1,138 @@
+package de.effigenix.domain.production;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.math.BigDecimal;
+
+import static de.effigenix.domain.production.UnitOfMeasure.*;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("YieldPercentage Value Object")
+class YieldPercentageTest {
+
+ @Nested
+ @DisplayName("Creation")
+ class Creation {
+
+ @Test
+ @DisplayName("should create YieldPercentage with valid value")
+ void should_Create_When_ValidValue() {
+ var result = YieldPercentage.of(80);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().value()).isEqualTo(80);
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {1, 100, 200})
+ @DisplayName("should accept boundary values")
+ void should_Accept_When_BoundaryValues(int value) {
+ var result = YieldPercentage.of(value);
+ assertThat(result.isSuccess()).isTrue();
+ }
+
+ @Test
+ @DisplayName("should fail when value is 0")
+ void should_Fail_When_ValueIsZero() {
+ var result = YieldPercentage.of(0);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.InvalidYieldPercentage.class);
+ }
+
+ @Test
+ @DisplayName("should fail when value is 201")
+ void should_Fail_When_ValueIs201() {
+ var result = YieldPercentage.of(201);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(QuantityError.InvalidYieldPercentage.class);
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {-1, 0, 201, 300})
+ @DisplayName("should fail for out-of-range values")
+ void should_Fail_When_OutOfRange(int value) {
+ var result = YieldPercentage.of(value);
+ assertThat(result.isFailure()).isTrue();
+ }
+
+ @Test
+ @DisplayName("should throw from constructor for invalid value")
+ void should_ThrowFromConstructor_When_InvalidValue() {
+ assertThatThrownBy(() -> new YieldPercentage(0))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+ }
+
+ @Nested
+ @DisplayName("calculateRequiredInput()")
+ class CalculateRequiredInput {
+
+ @Test
+ @DisplayName("80% yield: 100kg output requires 125kg input")
+ void should_Calculate125kg_When_80PercentYieldAnd100kg() {
+ var yield80 = new YieldPercentage(80);
+ var output = Quantity.of(new BigDecimal("100"), KILOGRAM).unsafeGetValue();
+
+ var input = yield80.calculateRequiredInput(output);
+
+ assertThat(input.amount()).isEqualByComparingTo(new BigDecimal("125"));
+ assertThat(input.uom()).isEqualTo(KILOGRAM);
+ }
+
+ @Test
+ @DisplayName("100% yield: input equals output")
+ void should_ReturnSameAmount_When_100PercentYield() {
+ var yield100 = new YieldPercentage(100);
+ var output = Quantity.of(new BigDecimal("50"), LITER).unsafeGetValue();
+
+ var input = yield100.calculateRequiredInput(output);
+
+ assertThat(input.amount()).isEqualByComparingTo(new BigDecimal("50"));
+ assertThat(input.uom()).isEqualTo(LITER);
+ }
+
+ @Test
+ @DisplayName("50% yield: 100kg output requires 200kg input")
+ void should_Calculate200kg_When_50PercentYieldAnd100kg() {
+ var yield50 = new YieldPercentage(50);
+ var output = Quantity.of(new BigDecimal("100"), KILOGRAM).unsafeGetValue();
+
+ var input = yield50.calculateRequiredInput(output);
+
+ assertThat(input.amount()).isEqualByComparingTo(new BigDecimal("200"));
+ }
+
+ @Test
+ @DisplayName("should handle dual quantities")
+ void should_HandleDualQuantity() {
+ var yield80 = new YieldPercentage(80);
+ var output = Quantity.dual(
+ new BigDecimal("10"), PIECE,
+ new BigDecimal("20"), KILOGRAM).unsafeGetValue();
+
+ var input = yield80.calculateRequiredInput(output);
+
+ assertThat(input.amount()).isEqualByComparingTo(new BigDecimal("12.5"));
+ assertThat(input.secondaryAmount()).isEqualByComparingTo(new BigDecimal("25"));
+ }
+ }
+
+ @Nested
+ @DisplayName("asFactor()")
+ class AsFactor {
+
+ @Test
+ @DisplayName("should return decimal factor")
+ void should_ReturnDecimalFactor() {
+ var yield80 = new YieldPercentage(80);
+ assertThat(yield80.asFactor()).isEqualByComparingTo(new BigDecimal("0.8"));
+ }
+ }
+}
+ *
+ */
+public final class Quantity {
+
+ private static final int SCALE = 6;
+
+ private final BigDecimal amount;
+ private final UnitOfMeasure uom;
+ private final BigDecimal secondaryAmount;
+ private final UnitOfMeasure secondaryUom;
+
+ private Quantity(BigDecimal amount, UnitOfMeasure uom,
+ BigDecimal secondaryAmount, UnitOfMeasure secondaryUom) {
+ this.amount = amount;
+ this.uom = uom;
+ this.secondaryAmount = secondaryAmount;
+ this.secondaryUom = secondaryUom;
+ }
+
+ /**
+ * Creates a simple quantity with one unit of measure.
+ */
+ public static Result