From a1161cfbade3371bf54cf19d2f91fc091e51093f Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Thu, 19 Feb 2026 09:48:11 +0100 Subject: [PATCH] feat(production): Quantity VO mit Catch-Weight, YieldPercentage und BatchNumber MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared Value Objects für den Production BC implementiert (#25): - Quantity mit Dual-Quantity/Catch-Weight Support und Arithmetik - UnitOfMeasure Enum (kg, g, L, mL, pc, m) - YieldPercentage (1-200) mit calculateRequiredInput() - BatchNumber mit Format P-YYYY-MM-DD-XXX - QuantityError sealed interface für funktionales Error Handling - 60 Unit Tests für alle VOs --- .../domain/production/BatchNumber.java | 64 ++++ .../effigenix/domain/production/Quantity.java | 186 ++++++++++++ .../domain/production/QuantityError.java | 40 +++ .../domain/production/UnitOfMeasure.java | 24 ++ .../domain/production/YieldPercentage.java | 65 ++++ .../domain/production/BatchNumberTest.java | 141 +++++++++ .../domain/production/QuantityTest.java | 283 ++++++++++++++++++ .../domain/production/UnitOfMeasureTest.java | 27 ++ .../production/YieldPercentageTest.java | 138 +++++++++ 9 files changed, 968 insertions(+) create mode 100644 backend/src/main/java/de/effigenix/domain/production/BatchNumber.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/Quantity.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/QuantityError.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/UnitOfMeasure.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/YieldPercentage.java create mode 100644 backend/src/test/java/de/effigenix/domain/production/BatchNumberTest.java create mode 100644 backend/src/test/java/de/effigenix/domain/production/QuantityTest.java create mode 100644 backend/src/test/java/de/effigenix/domain/production/UnitOfMeasureTest.java create mode 100644 backend/src/test/java/de/effigenix/domain/production/YieldPercentageTest.java 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 of(String value) { + if (value == null || !FORMAT.matcher(value).matches()) { + return Result.failure(new QuantityError.InvalidBatchNumber(value)); + } + return Result.success(new BatchNumber(value)); + } + + /** + * Generates a new BatchNumber for the given date and sequence number. + */ + public static BatchNumber generate(LocalDate date, int sequenceNumber) { + if (sequenceNumber < 1 || sequenceNumber > 999) { + throw new IllegalArgumentException( + "Sequence number must be between 1 and 999, was: " + sequenceNumber); + } + String formatted = String.format("P-%s-%03d", date.format(DATE_FORMAT), sequenceNumber); + return new BatchNumber(formatted); + } + + /** + * Extracts the date portion from the batch number. + */ + public LocalDate date() { + String datePart = value.substring(2, 12); + return LocalDate.parse(datePart, DATE_FORMAT); + } + + /** + * Extracts the sequence number from the batch number. + */ + public int sequenceNumber() { + return Integer.parseInt(value.substring(13)); + } +} diff --git a/backend/src/main/java/de/effigenix/domain/production/Quantity.java b/backend/src/main/java/de/effigenix/domain/production/Quantity.java new file mode 100644 index 0000000..48622fc --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/Quantity.java @@ -0,0 +1,186 @@ +package de.effigenix.domain.production; + +import de.effigenix.shared.common.Result; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Objects; + +/** + * Value Object representing a production quantity with optional Catch-Weight (dual quantity). + * + *

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: + *

+ */ +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 of(BigDecimal amount, UnitOfMeasure uom) { + Objects.requireNonNull(uom, "Unit of measure must not be null"); + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + return Result.failure(new QuantityError.AmountMustBePositive( + amount == null ? "null" : amount.toPlainString())); + } + return Result.success(new Quantity(scale(amount), uom, null, null)); + } + + /** + * Creates a dual quantity (catch-weight) with primary and secondary units. + */ + public static Result dual(BigDecimal amount, UnitOfMeasure uom, + BigDecimal secondaryAmount, UnitOfMeasure secondaryUom) { + Objects.requireNonNull(uom, "Unit of measure must not be null"); + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + return Result.failure(new QuantityError.AmountMustBePositive( + amount == null ? "null" : amount.toPlainString())); + } + if (secondaryAmount == null || secondaryAmount.compareTo(BigDecimal.ZERO) <= 0) { + return Result.failure(new QuantityError.SecondaryAmountMustBePositive( + secondaryAmount == null ? "null" : secondaryAmount.toPlainString())); + } + if (secondaryUom == null) { + return Result.failure(new QuantityError.SecondaryUomRequired()); + } + return Result.success(new Quantity(scale(amount), uom, scale(secondaryAmount), secondaryUom)); + } + + /** + * Reconstitutes a Quantity from persistence. No validation. + */ + public static Quantity reconstitute(BigDecimal amount, UnitOfMeasure uom, + BigDecimal secondaryAmount, UnitOfMeasure secondaryUom) { + return new Quantity(amount, uom, secondaryAmount, secondaryUom); + } + + /** + * Adds two quantities. Both must have the same primary UoM. + * Secondary quantities are added if both are present. + */ + public Result add(Quantity other) { + if (this.uom != other.uom) { + return Result.failure(new QuantityError.UnitOfMeasureMismatch(this.uom, other.uom)); + } + BigDecimal newSecondary = addSecondary(this.secondaryAmount, other.secondaryAmount); + return Result.success(new Quantity( + scale(this.amount.add(other.amount)), this.uom, + newSecondary, this.secondaryAmount != null ? this.secondaryUom : other.secondaryUom)); + } + + /** + * Subtracts another quantity. Both must have the same primary UoM. + * Result amount must remain positive. + */ + public Result subtract(Quantity other) { + if (this.uom != other.uom) { + return Result.failure(new QuantityError.UnitOfMeasureMismatch(this.uom, other.uom)); + } + BigDecimal newAmount = this.amount.subtract(other.amount); + if (newAmount.compareTo(BigDecimal.ZERO) <= 0) { + return Result.failure(new QuantityError.AmountMustBePositive(newAmount.toPlainString())); + } + BigDecimal newSecondary = subtractSecondary(this.secondaryAmount, other.secondaryAmount); + return Result.success(new Quantity( + scale(newAmount), this.uom, + newSecondary, this.secondaryUom)); + } + + /** + * Multiplies this quantity by a factor. + */ + public Result multiply(BigDecimal factor) { + if (factor == null || factor.compareTo(BigDecimal.ZERO) <= 0) { + return Result.failure(new QuantityError.AmountMustBePositive( + factor == null ? "null" : factor.toPlainString())); + } + BigDecimal newAmount = scale(this.amount.multiply(factor)); + BigDecimal newSecondary = this.secondaryAmount != null + ? scale(this.secondaryAmount.multiply(factor)) + : null; + return Result.success(new Quantity(newAmount, this.uom, newSecondary, this.secondaryUom)); + } + + public boolean isZero() { + return amount.compareTo(BigDecimal.ZERO) == 0; + } + + public boolean isPositive() { + return amount.compareTo(BigDecimal.ZERO) > 0; + } + + public boolean hasDualQuantity() { + return secondaryAmount != null && secondaryUom != null; + } + + public BigDecimal amount() { return amount; } + public UnitOfMeasure uom() { return uom; } + public BigDecimal secondaryAmount() { return secondaryAmount; } + public UnitOfMeasure secondaryUom() { return secondaryUom; } + + private static BigDecimal scale(BigDecimal value) { + return value.setScale(SCALE, RoundingMode.HALF_UP); + } + + private static BigDecimal addSecondary(BigDecimal a, BigDecimal b) { + if (a != null && b != null) return scale(a.add(b)); + return a != null ? a : b; + } + + private static BigDecimal subtractSecondary(BigDecimal a, BigDecimal b) { + if (a != null && b != null) return scale(a.subtract(b)); + return a; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Quantity that)) return false; + return amount.compareTo(that.amount) == 0 + && uom == that.uom + && (Objects.equals(secondaryAmount, that.secondaryAmount) + || (secondaryAmount != null && that.secondaryAmount != null + && secondaryAmount.compareTo(that.secondaryAmount) == 0)) + && secondaryUom == that.secondaryUom; + } + + @Override + public int hashCode() { + return Objects.hash(amount.stripTrailingZeros(), uom, + secondaryAmount != null ? secondaryAmount.stripTrailingZeros() : null, + secondaryUom); + } + + @Override + public String toString() { + if (hasDualQuantity()) { + return amount.toPlainString() + " " + uom.symbol() + + " (" + secondaryAmount.toPlainString() + " " + secondaryUom.symbol() + ")"; + } + return amount.toPlainString() + " " + uom.symbol(); + } +} diff --git a/backend/src/main/java/de/effigenix/domain/production/QuantityError.java b/backend/src/main/java/de/effigenix/domain/production/QuantityError.java new file mode 100644 index 0000000..13b496b --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/QuantityError.java @@ -0,0 +1,40 @@ +package de.effigenix.domain.production; + +/** + * Domain errors for Quantity-related Value Objects. + */ +public sealed interface QuantityError { + + String code(); + String message(); + + record AmountMustBePositive(String amount) implements QuantityError { + @Override public String code() { return "QUANTITY_AMOUNT_NOT_POSITIVE"; } + @Override public String message() { return "Quantity amount must be positive, was: " + amount; } + } + + record UnitOfMeasureMismatch(UnitOfMeasure expected, UnitOfMeasure actual) implements QuantityError { + @Override public String code() { return "QUANTITY_UOM_MISMATCH"; } + @Override public String message() { return "Unit of measure mismatch: expected " + expected.symbol() + ", got " + actual.symbol(); } + } + + record SecondaryAmountMustBePositive(String amount) implements QuantityError { + @Override public String code() { return "QUANTITY_SECONDARY_AMOUNT_NOT_POSITIVE"; } + @Override public String message() { return "Secondary quantity amount must be positive, was: " + amount; } + } + + record SecondaryUomRequired() implements QuantityError { + @Override public String code() { return "QUANTITY_SECONDARY_UOM_REQUIRED"; } + @Override public String message() { return "Secondary unit of measure is required when secondary amount is provided"; } + } + + record InvalidYieldPercentage(int value) implements QuantityError { + @Override public String code() { return "INVALID_YIELD_PERCENTAGE"; } + @Override public String message() { return "Yield percentage must be between 1 and 200, was: " + value; } + } + + record InvalidBatchNumber(String value) implements QuantityError { + @Override public String code() { return "INVALID_BATCH_NUMBER"; } + @Override public String message() { return "Batch number must match format P-YYYY-MM-DD-XXX, was: " + value; } + } +} diff --git a/backend/src/main/java/de/effigenix/domain/production/UnitOfMeasure.java b/backend/src/main/java/de/effigenix/domain/production/UnitOfMeasure.java new file mode 100644 index 0000000..0d8e5c0 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/UnitOfMeasure.java @@ -0,0 +1,24 @@ +package de.effigenix.domain.production; + +/** + * Unit of Measure for production quantities. + * Supports mass, volume, length, and piece-based units. + */ +public enum UnitOfMeasure { + KILOGRAM("kg"), + GRAM("g"), + LITER("L"), + MILLILITER("mL"), + PIECE("pc"), + METER("m"); + + private final String symbol; + + UnitOfMeasure(String symbol) { + this.symbol = symbol; + } + + public String symbol() { + return symbol; + } +} diff --git a/backend/src/main/java/de/effigenix/domain/production/YieldPercentage.java b/backend/src/main/java/de/effigenix/domain/production/YieldPercentage.java new file mode 100644 index 0000000..7210d6a --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/YieldPercentage.java @@ -0,0 +1,65 @@ +package de.effigenix.domain.production; + +import de.effigenix.shared.common.Result; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * Value Object representing production yield as a percentage (1–200). + * + *

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 of(int value) { + if (value < 1 || value > 200) { + return Result.failure(new QuantityError.InvalidYieldPercentage(value)); + } + return Result.success(new YieldPercentage(value)); + } + + /** + * Calculates the required input quantity to achieve the desired output quantity + * at this yield percentage. + * + *

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")); + } + } +}