1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 19:30:16 +01:00

feat(production): Quantity VO mit Catch-Weight, YieldPercentage und BatchNumber

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
This commit is contained in:
Sebastian Frick 2026-02-19 09:48:11 +01:00
parent c474388f32
commit a1161cfbad
9 changed files with 968 additions and 0 deletions

View file

@ -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.
*
* <p>Format: {@code P-YYYY-MM-DD-XXX} where XXX is a 3-digit sequence number (001999).
*
* <p>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<QuantityError, BatchNumber> 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));
}
}

View file

@ -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).
*
* <p>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).
*
* <p>Invariants:
* <ul>
* <li>Primary amount must be positive</li>
* <li>Secondary amount, if present, must be positive</li>
* <li>Secondary UoM is required when secondary amount is provided (and vice versa)</li>
* <li>Arithmetic operations require matching primary UoM</li>
* </ul>
*/
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<QuantityError, Quantity> 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<QuantityError, Quantity> 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<QuantityError, Quantity> 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<QuantityError, Quantity> 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<QuantityError, Quantity> 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();
}
}

View file

@ -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; }
}
}

View file

@ -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;
}
}

View file

@ -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 (1200).
*
* <p>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).
*
* <p>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<QuantityError, YieldPercentage> 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.
*
* <p>Formula: requiredInput = desiredOutput / (yieldPercentage / 100)
* <p>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);
}
}