1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 18:49:59 +01:00
effigenix/backend/docs/mvp/ddd/04-production-bc.md
Sebastian Frick bfae3eff73 feat(production): Produktion über Auftrag starten (US-P15)
RELEASED ProductionOrder kann mit einer PLANNED Batch verknüpft und
in Produktion gestartet werden. Dabei wechselt der Order auf IN_PROGRESS
und die Batch auf IN_PRODUCTION. Neuer REST-Endpoint POST /{id}/start,
StartOrderProduction Use Case, BatchAlreadyAssigned Error, Liquibase-
Migration für batch_id FK auf production_orders.
2026-02-25 08:53:17 +01:00

35 KiB

Production BC - Detailliertes Domain Model

Bounded Context: Production Domain Type: CORE Verantwortung: Rezepturverwaltung, Produktionsplanung, Chargen-Erzeugung mit lückenloser Rückverfolgbarkeit


Ubiquitous Language

Begriff (DE) Begriff (EN/Code) Typ Definition
Rezept Recipe Aggregate Mehrstufige Anleitung zur Herstellung eines Produkts aus Rohstoffen und Zwischenprodukten
Zutat Ingredient Entity Rohstoff oder Zwischenprodukt in einem Rezept. Position bestimmt die Reihenfolge
Produktionsschritt ProductionStep Entity Einzelner Arbeitsschritt mit optionaler Dauer und Temperaturangabe
Rezepttyp RecipeType VO (Enum) RAW_MATERIAL, INTERMEDIATE oder FINISHED_PRODUCT
Ausbeute YieldPercentage VO Input→Output in % (1-200%). Bei 80% braucht man 125kg für 100kg Output
Haltbarkeitsdauer ShelfLifeDays VO Tage ab Produktionsdatum bis MHD. Definiert im Rezept
Unter-Rezept SubRecipe Concept Verweis auf INTERMEDIATE-Rezept als Zutat (verschachtelte Rezepturen)
Rezeptur-Version Version VO Neue Version = neues Recipe-Objekt, altes wird archiviert
Charge Batch Aggregate Eindeutig identifizierte Produktionseinheit mit Genealogie
Chargennummer BatchNumber VO Formatierte Kennung (Format: "P-YYYY-MM-DD-XXX")
Verbrauch Consumption Entity Dokumentation einer verwendeten Input-Charge. Bildet die Genealogie
Chargen-Genealogie Batch Genealogy Concept Gesamtheit aller Consumption-Beziehungen. Vorwärts-/Rückwärts-Tracing
Vorwärts-Tracing traceForward Concept Rohstoff-Charge → betroffene Endprodukt-Chargen (Rückruf)
Rückwärts-Tracing traceBackward Concept Endprodukt-Charge → verwendete Rohstoff-Chargen
Ausschuss Waste VO Verlustmenge bei Produktion (Schwund, Knochen, Fett-Trimmen)
Produktionsauftrag ProductionOrder Aggregate Geplante Produktion mit Rezept, Menge, Termin, Priorität
Priorität Priority VO (Enum) LOW, NORMAL, HIGH, URGENT
Freigabe Release Concept PLANNED → RELEASED. Material verfügbar, Produktion darf starten
Catch-Weight Dual Quantity Concept Doppelte Mengenführung: Stück + Gewicht (z.B. "10 Stk à 2,3 kg")
MHD BestBeforeDate VO Mindesthaltbarkeitsdatum = ProductionDate + ShelfLifeDays
Materialbedarf Material Requirement Concept Berechnete Rohstoffmenge aus Rezeptur inkl. Ausbeute

Aggregate-Übersicht

---
config:
  theme: neutral
  look: classic
  layout: elk
  themeVariables:
    background: "#f8fafc"
  class:
    hideEmptyMembersBox: true
---
classDiagram
    class Recipe {
        +RecipeId id
        +ArticleId articleId
        +String name
        +int version
        +RecipeType recipeType
        +YieldPercentage yieldPercentage
        +int shelfLifeDays
        +RecipeStatus status
        +List~Ingredient~ ingredients
        +List~ProductionStep~ productionSteps
        +create(RecipeDraft) Result~RecipeError, Recipe~
        +addIngredient(IngredientDraft) Result~RecipeError, Void~
        +removeIngredient(IngredientId) Result~RecipeError, Void~
        +addProductionStep(ProductionStepDraft) Result~RecipeError, Void~
        +activate() Result~RecipeError, Void~
        +archive() Result~RecipeError, Void~
        +calculateRequiredInput(Quantity) Quantity
        +calculateBestBeforeDate(LocalDate) LocalDate
    }

    class Ingredient {
        +IngredientId id
        +int position
        +ArticleId articleId
        +Quantity quantity
        +RecipeId subRecipeId
        +boolean substitutable
    }

    class ProductionStep {
        +int stepNumber
        +String description
        +Integer durationMinutes
        +Integer temperatureCelsius
    }

    class Batch {
        +BatchId id
        +BatchNumber batchNumber
        +RecipeId recipeId
        +ArticleId articleId
        +Quantity plannedQuantity
        +Quantity actualQuantity
        +Quantity waste
        +LocalDate productionDate
        +LocalDate bestBeforeDate
        +BatchStatus status
        +UserId producedBy
        +BranchId branchId
        +List~Consumption~ consumptions
        +plan(BatchDraft) Result~BatchError, Batch~
        +startProduction() Result~BatchError, Void~
        +recordConsumption(ConsumptionDraft) Result~BatchError, Void~
        +complete(Quantity, Quantity, String) Result~BatchError, Void~
        +cancel(String) Result~BatchError, Void~
        +isFullyTraceable() boolean
        +getUpstreamBatchIds() List~BatchId~
    }

    class Consumption {
        +ConsumptionId id
        +BatchId inputBatchId
        +ArticleId articleId
        +Quantity quantityUsed
        +Instant consumedAt
    }

    class ProductionOrder {
        +ProductionOrderId id
        +RecipeId recipeId
        +ArticleId articleId
        +Quantity plannedQuantity
        +LocalDate plannedDate
        +Priority priority
        +ProductionOrderStatus status
        +UserId createdBy
        +Instant createdAt
        +BranchId branchId
        +BatchId batchId
        +create(ProductionOrderDraft) Result~ProductionOrderError, ProductionOrder~
        +release() Result~ProductionOrderError, Void~
        +startProduction(BatchId) Result~ProductionOrderError, Void~
        +complete() Result~ProductionOrderError, Void~
        +cancel(String) Result~ProductionOrderError, Void~
        +reschedule(LocalDate) Result~ProductionOrderError, Void~
    }

    Recipe "1" *-- "*" Ingredient : enthält
    Recipe "1" *-- "*" ProductionStep : enthält
    Batch "1" *-- "*" Consumption : enthält
    ProductionOrder "1" --> "0..1" Batch : erzeugt

    Recipe ..> RecipeId
    Recipe ..> ArticleId
    Recipe ..> YieldPercentage
    Recipe ..> RecipeType
    Recipe ..> RecipeStatus

    Batch ..> BatchId
    Batch ..> BatchNumber
    Batch ..> BatchStatus
    Batch ..> Quantity

    ProductionOrder ..> ProductionOrderId
    ProductionOrder ..> Priority
    ProductionOrder ..> ProductionOrderStatus

Aggregates

1. Recipe (Aggregate Root)

Verantwortung: Verwaltet mehrstufige Rezepturen mit Zutaten, Produktionsschritten und Ausbeute-Berechnung.

Recipe (Aggregate Root)
├── RecipeId (VO)
├── ArticleId (VO) - Referenz auf Stammdaten-Artikel (Endprodukt)
├── Name (VO)
├── Version (VO: int) - Versionierung bei Rezepturänderungen
├── RecipeType (VO: RAW_MATERIAL | INTERMEDIATE | FINISHED_PRODUCT)
├── YieldPercentage (VO) - Ausbeute in % (1-200)
├── ShelfLifeDays (VO: int) - Haltbarkeitsdauer → MHD-Berechnung
├── Ingredients[] (Entity)
│   ├── IngredientId (VO)
│   ├── Position (VO: int) - Reihenfolge im Rezept
│   ├── ArticleId (VO) - Referenz auf Stammdaten-Artikel (Zutat)
│   ├── Quantity (VO) - Menge mit UnitOfMeasure + optionalem Catch-Weight
│   ├── SubRecipeId (VO) - Für Zwischenprodukte (verschachtelte Rezepte)
│   └── Substitutable (boolean) - Kann durch Alternativzutat ersetzt werden?
├── ProductionSteps[] (Entity)
│   ├── StepNumber (VO: int) - Sequentiell
│   ├── Description (VO: String)
│   ├── DurationMinutes (VO: Integer) - Optional, geschätzte Dauer
│   └── TemperatureCelsius (VO: Integer) - Optional, für Räuchern/Kochen
└── Status (VO: DRAFT | ACTIVE | ARCHIVED)

Invarianten:

/**
 * Recipe aggregate root.
 *
 * Invariants:
 * - ACTIVE recipe must have at least one ingredient
 * - YieldPercentage must be between 1-200% (>100% bei Pökel-/Spritzprozessen)
 * - Ingredient quantities must be positive
 * - Nested recipes (SubRecipeId) cannot create circular dependencies
 * - Position numbers in Ingredients must be unique and sequential
 * - StepNumber in ProductionSteps must be sequential (1, 2, 3, ...)
 * - ShelfLifeDays must be > 0 for FINISHED_PRODUCT and INTERMEDIATE
 * - Only DRAFT recipes can be modified (add/remove ingredients, steps)
 * - Version is immutable after creation; new version = new Recipe
 * - Status transitions: DRAFT → ACTIVE → ARCHIVED (no way back)
 */

Draft-Records:

public record RecipeDraft(
    String articleId,
    String name,
    String recipeType,       // RAW_MATERIAL | INTERMEDIATE | FINISHED_PRODUCT
    Integer yieldPercentage,
    Integer shelfLifeDays
) {}

public record IngredientDraft(
    int position,
    String articleId,
    String quantityAmount,   // BigDecimal als String
    String quantityUnit,     // UnitOfMeasure
    String subRecipeId,      // nullable
    boolean substitutable
) {}

public record ProductionStepDraft(
    int stepNumber,
    String description,
    Integer durationMinutes,     // nullable
    Integer temperatureCelsius   // nullable
) {}

Factory & Business Methods:

// Factory
public static Result<RecipeError, Recipe> create(RecipeDraft draft);

// Mutations (nur im DRAFT-Status)
public Result<RecipeError, Void> addIngredient(IngredientDraft draft);
public Result<RecipeError, Void> removeIngredient(IngredientId ingredientId);
public Result<RecipeError, Void> addProductionStep(ProductionStepDraft draft);
public Result<RecipeError, Void> removeProductionStep(int stepNumber);

// Status-Übergänge
public Result<RecipeError, Void> activate();
public Result<RecipeError, Void> archive();

// Query Methods
public Quantity calculateRequiredInput(Quantity desiredOutput);
public LocalDate calculateBestBeforeDate(LocalDate productionDate);
public List<ArticleId> getAllIngredientArticleIds();
public boolean containsSubRecipe();

Domain Events:

RecipeCreated(RecipeId, ArticleId, RecipeType)
RecipeActivated(RecipeId)
RecipeArchived(RecipeId)

2. Batch (Aggregate Root)

Verantwortung: Repräsentiert eine Produktions-Charge mit lückenloser Rückverfolgbarkeit (Chargen-Genealogie). Zentral für HACCP-Compliance und Rückruf-Szenarien.

Batch (Aggregate Root)
├── BatchId (VO)
├── BatchNumber (VO) - Formatierte Chargennummer (z.B. "P-2026-02-17-001")
├── RecipeId (VO) - Referenz auf verwendete Rezeptur
├── ArticleId (VO) - Referenz auf produziertes Endprodukt
├── PlannedQuantity (VO: Quantity) - Soll-Menge
├── ActualQuantity (VO: Quantity) - Ist-Menge nach Rückmeldung
├── Waste (VO: Quantity) - Ausschuss/Schwund
├── ProductionDate (VO: LocalDate)
├── BestBeforeDate (VO: LocalDate) - MHD, berechnet aus Recipe.ShelfLifeDays
├── Status (VO: PLANNED | IN_PRODUCTION | COMPLETED | CANCELLED)
├── ProducedBy (VO: UserId)
├── BranchId (VO) - Produktionsstandort
├── Consumptions[] (Entity) - Chargen-Genealogie (Input-Tracking)
│   ├── ConsumptionId (VO)
│   ├── InputBatchId (VO) - Verbrauchte Rohstoff-/Zwischen-Charge
│   ├── ArticleId (VO) - Zutat-Artikel
│   ├── QuantityUsed (VO: Quantity) - Verbrauchte Menge (mit Catch-Weight)
│   └── ConsumedAt (VO: Instant)
├── Remarks (VO: String) - Optional, für Abweichungen
├── CancelledReason (VO: String) - Optional, Grund bei Stornierung
├── StartedAt (VO: Instant)
└── CompletedAt (VO: Instant)

Invarianten:

/**
 * Batch aggregate root (ProductionBatch).
 *
 * Invariants:
 * - PlannedQuantity must be positive
 * - BestBeforeDate must be after ProductionDate
 * - Status transitions: PLANNED → IN_PRODUCTION → COMPLETED
 *                       PLANNED → CANCELLED
 *                       IN_PRODUCTION → CANCELLED
 * - Cannot complete without at least one Consumption recorded
 * - ActualQuantity must be positive at completion
 * - Waste must be >= 0
 * - All Consumptions must reference distinct InputBatchIds
 * - Consumptions can only be added in IN_PRODUCTION status
 * - CompletedAt must be after StartedAt
 * - BatchNumber is immutable after creation
 */

Draft-Record:

public record BatchDraft(
    String recipeId,
    String articleId,
    String plannedQuantityAmount,   // BigDecimal als String
    String plannedQuantityUnit,     // UnitOfMeasure
    String productionDate,          // ISO LocalDate
    String bestBeforeDate,          // ISO LocalDate
    String producedBy,              // UserId
    String branchId
) {}

public record ConsumptionDraft(
    String inputBatchId,
    String articleId,
    String quantityAmount,          // BigDecimal als String
    String quantityUnit             // UnitOfMeasure
) {}

Factory & Business Methods:

// Factory
public static Result<BatchError, Batch> plan(BatchDraft draft);

// Status-Übergänge
public Result<BatchError, Void> startProduction();
public Result<BatchError, Void> complete(Quantity actualQuantity, Quantity waste, String remarks);
public Result<BatchError, Void> cancel(String reason);

// Genealogie (nur in IN_PRODUCTION)
public Result<BatchError, Void> recordConsumption(ConsumptionDraft draft);

// Query Methods
public boolean isFullyTraceable();
public List<BatchId> getUpstreamBatchIds();
public Duration getProductionDuration();

Domain Events:

BatchPlanned(BatchId, BatchNumber, RecipeId, ArticleId, Quantity)
BatchStarted(BatchId)
BatchCompleted(BatchId, ArticleId, Quantity, LocalDate bestBeforeDate, BranchId)
   triggers Inventory stock-in
BatchCancelled(BatchId, String reason)
ConsumptionRecorded(BatchId, BatchId inputBatchId, Quantity)
   triggers Inventory stock-out for consumed materials

3. ProductionOrder (Aggregate Root)

Verantwortung: Plant zukünftige Produktionen. Verbindet Produktionsplanung mit der konkreten Chargen-Erzeugung.

ProductionOrder (Aggregate Root)
├── ProductionOrderId (VO)
├── RecipeId (VO)
├── ArticleId (VO) - Zu produzierender Artikel
├── PlannedQuantity (VO: Quantity)
├── PlannedDate (VO: LocalDate)
├── Priority (VO: LOW | NORMAL | HIGH | URGENT)
├── Status (VO: PLANNED | RELEASED | IN_PRODUCTION | COMPLETED | CANCELLED)
├── CreatedBy (VO: UserId)
├── CreatedAt (VO: Instant)
├── BranchId (VO) - Ziel-Produktionsstandort
├── BatchId (VO) - Verknüpfung zur erzeugten Charge (nach Start)
├── Remarks (VO: String) - Optional
└── CancelledReason (VO: String) - Optional

Invarianten:

/**
 * ProductionOrder aggregate root.
 *
 * Invariants:
 * - PlannedQuantity must be positive
 * - PlannedDate cannot be in the past at creation
 * - Status transitions: PLANNED → RELEASED → IN_PRODUCTION → COMPLETED
 *                       PLANNED → CANCELLED
 *                       RELEASED → CANCELLED
 * - Can only release if RecipeId references an ACTIVE recipe (checked in Application Layer)
 * - Cannot start production without setting BatchId
 * - Cannot complete without linked Batch being COMPLETED (checked in Application Layer)
 * - BatchId is set exactly once (at startProduction)
 * - Reschedule only allowed in PLANNED or RELEASED status
 */

Draft-Record:

public record ProductionOrderDraft(
    String recipeId,
    String articleId,
    String plannedQuantityAmount,   // BigDecimal als String
    String plannedQuantityUnit,     // UnitOfMeasure
    String plannedDate,             // ISO LocalDate
    String priority,                // LOW | NORMAL | HIGH | URGENT
    String branchId,
    String createdBy,
    String remarks                  // nullable
) {}

Factory & Business Methods:

// Factory
public static Result<ProductionOrderError, ProductionOrder> create(ProductionOrderDraft draft);

// Status-Übergänge
public Result<ProductionOrderError, Void> release();
public Result<ProductionOrderError, Void> startProduction(BatchId batchId);
public Result<ProductionOrderError, Void> complete();
public Result<ProductionOrderError, Void> cancel(String reason);

// Mutations
public Result<ProductionOrderError, Void> reschedule(LocalDate newDate);

Domain Events:

ProductionOrderCreated(ProductionOrderId, RecipeId, Quantity, LocalDate)
ProductionOrderReleased(ProductionOrderId)
   triggers material availability check / Procurement demand
ProductionOrderStarted(ProductionOrderId, BatchId)
ProductionOrderCompleted(ProductionOrderId, BatchId)
ProductionOrderCancelled(ProductionOrderId, String reason)
ProductionOrderRescheduled(ProductionOrderId, LocalDate oldDate, LocalDate newDate)

Shared Value Objects

---
config:
  theme: neutral
  look: classic
  layout: elk
  themeVariables:
    background: "#f8fafc"
  class:
    hideEmptyMembersBox: true
---
classDiagram
    class Quantity {
        +BigDecimal amount
        +UnitOfMeasure uom
        +BigDecimal secondaryAmount
        +UnitOfMeasure secondaryUom
        +of(BigDecimal, UnitOfMeasure) Result
        +dual(BigDecimal, UnitOfMeasure, BigDecimal, UnitOfMeasure) Result
        +hasDualQuantity() boolean
        +add(Quantity) Quantity
        +subtract(Quantity) Quantity
        +multiply(BigDecimal) Quantity
        +isZero() boolean
        +isPositive() boolean
    }

    class UnitOfMeasure {
        <<enumeration>>
        KILOGRAM
        GRAM
        LITER
        MILLILITER
        PIECE
        METER
    }

    class YieldPercentage {
        +int value
        +calculateRequiredInput(Quantity) Quantity
    }

    class BatchNumber {
        +String value
        +generate(LocalDate, int) BatchNumber
    }

    class RecipeType {
        <<enumeration>>
        RAW_MATERIAL
        INTERMEDIATE
        FINISHED_PRODUCT
    }

    class Priority {
        <<enumeration>>
        LOW
        NORMAL
        HIGH
        URGENT
    }

    class RecipeStatus {
        <<enumeration>>
        DRAFT
        ACTIVE
        ARCHIVED
    }

    class BatchStatus {
        <<enumeration>>
        PLANNED
        IN_PRODUCTION
        COMPLETED
        CANCELLED
    }

    class ProductionOrderStatus {
        <<enumeration>>
        PLANNED
        RELEASED
        IN_PRODUCTION
        COMPLETED
        CANCELLED
    }

    Quantity --> UnitOfMeasure

Quantity (mit Catch-Weight / Dual-Quantity)

Zentral für die gesamte Produktion. Unterstützt Dual-Quantity für Catch-Weight-Artikel (z.B. "10 Stück à 2,3 kg").

public record Quantity(
    BigDecimal amount,
    UnitOfMeasure uom,
    BigDecimal secondaryAmount,     // Catch-Weight, nullable
    UnitOfMeasure secondaryUom      // nullable
) {
    // Factory: einfache Menge
    public static Result<QuantityError, Quantity> of(BigDecimal amount, UnitOfMeasure uom);

    // Factory: Dual-Quantity (Catch-Weight)
    public static Result<QuantityError, Quantity> dual(
        BigDecimal primaryAmount, UnitOfMeasure primaryUom,
        BigDecimal secondaryAmount, UnitOfMeasure secondaryUom
    );

    public boolean hasDualQuantity();
    public Quantity add(Quantity other);
    public Quantity subtract(Quantity other);
    public Quantity multiply(BigDecimal factor);
    public boolean isZero();
    public boolean isPositive();
}

UnitOfMeasure

public enum UnitOfMeasure {
    KILOGRAM("kg"),
    GRAM("g"),
    LITER("l"),
    MILLILITER("ml"),
    PIECE("Stk"),
    METER("m");

    private final String abbreviation;
}

BatchNumber

public record BatchNumber(String value) {
    // Format: "P-YYYY-MM-DD-XXX" (P = Production)
    public static BatchNumber generate(LocalDate productionDate, int sequenceNumber) {
        String value = String.format("P-%s-%03d", productionDate, sequenceNumber);
        return new BatchNumber(value);
    }
}

YieldPercentage

public record YieldPercentage(int value) {
    public YieldPercentage {
        if (value < 1 || value > 200) {
            throw new IllegalArgumentException(
                "Yield percentage must be between 1-200, got: " + value
            );
        }
    }

    /**
     * Berechnet benötigten Input für gewünschten Output.
     * Bei 80% Ausbeute und 100kg gewünschtem Output → 125kg Input nötig.
     */
    public Quantity calculateRequiredInput(Quantity desiredOutput) {
        return desiredOutput.multiply(new BigDecimal(100).divide(
            new BigDecimal(value), 4, RoundingMode.HALF_UP));
    }
}

Priority

public enum Priority {
    LOW,
    NORMAL,
    HIGH,
    URGENT
}

Domain Services

RecipeCycleDependencyChecker

/**
 * Prüft ob verschachtelte Rezepturen zirkuläre Abhängigkeiten erzeugen.
 * Wird im Application Layer aufgerufen nach Recipe.addIngredient() mit SubRecipeId.
 */
public class RecipeCycleDependencyChecker {
    public Result<RecipeError, Void> check(RecipeId recipeId, RecipeRepository repository);
}

BatchTraceabilityService

/**
 * Rückverfolgbarkeits-Service für HACCP-Compliance und Rückruf-Szenarien.
 * Traversiert Chargen-Genealogie über BatchConsumptions.
 */
public class BatchTraceabilityService {
    /**
     * Vorwärts-Tracing: Welche Endprodukt-Chargen verwenden diese Rohstoff-Charge?
     * KRITISCH für Rückruf-Szenarien.
     */
    public List<BatchId> traceForward(BatchId sourceBatchId);

    /**
     * Rückwärts-Tracing: Welche Rohstoff-Chargen stecken in diesem Endprodukt?
     */
    public List<BatchId> traceBackward(BatchId targetBatchId);
}

Chargen-Genealogie (Beispiel):

---
config:
  theme: neutral
  look: classic
  layout: elk
  themeVariables:
    background: "#f8fafc"
---
graph TB
    subgraph ROHSTOFFE["Rohstoff-Chargen (Wareneingang)"]
        RB1["P-2026-02-10-001\nSchweineschulter\n50kg, MHD 2026-03-01"]
        RB2["P-2026-02-11-003\nGewürzmischung A\n5kg, MHD 2026-08-01"]
        RB3["P-2026-02-10-007\nNitritpökelsalz\n2kg, MHD 2027-01-01"]
    end

    subgraph ZWISCHEN["Zwischen-Charge"]
        ZB1["P-2026-02-15-001\nBrät (Intermediate)\n40kg, MHD 2026-02-18"]
    end

    subgraph ENDPRODUKT["Endprodukt-Chargen"]
        EB1["P-2026-02-15-002\nFleischwurst\n35kg, MHD 2026-03-15"]
    end

    RB1 -->|"Consumption\n45kg"| ZB1
    RB2 -->|"Consumption\n3kg"| ZB1
    RB3 -->|"Consumption\n1.5kg"| ZB1
    ZB1 -->|"Consumption\n38kg"| EB1

    style RB1 fill:#e3f2fd
    style RB2 fill:#e3f2fd
    style RB3 fill:#e3f2fd
    style ZB1 fill:#fff3e0
    style EB1 fill:#e8f5e9

    EB1 -. "Rückwärts-Tracing\n(traceBackward)" .-> ZB1
    ZB1 -. "Rückwärts-Tracing" .-> RB1
    RB1 -. "Vorwärts-Tracing\n(traceForward)" .-> ZB1
    ZB1 -. "Vorwärts-Tracing" .-> EB1

BatchNumberGenerator

/**
 * Generiert eindeutige Chargennummern. Infrastructure-Concern (Sequenz aus DB).
 */
public interface BatchNumberGenerator {
    BatchNumber generate(LocalDate productionDate);
}

Domain Errors

public sealed interface RecipeError {
    String message();

    record RecipeNotFound(String recipeId) implements RecipeError {
        public String message() { return "Recipe not found: " + recipeId; }
    }
    record InvalidYieldPercentage(int value) implements RecipeError {
        public String message() { return "Yield must be 1-200%, got: " + value; }
    }
    record CyclicDependencyDetected(String path) implements RecipeError {
        public String message() { return "Cyclic recipe dependency: " + path; }
    }
    record NoIngredients() implements RecipeError {
        public String message() { return "Recipe must have at least one ingredient"; }
    }
    record DuplicatePosition(int position) implements RecipeError {
        public String message() { return "Duplicate ingredient position: " + position; }
    }
    record NotInDraftStatus() implements RecipeError {
        public String message() { return "Recipe can only be modified in DRAFT status"; }
    }
    record InvalidShelfLife(int days) implements RecipeError {
        public String message() { return "Shelf life must be > 0, got: " + days; }
    }
    record InvalidQuantity(String reason) implements RecipeError {
        public String message() { return "Invalid quantity: " + reason; }
    }
}

public sealed interface BatchError {
    String message();

    record InvalidQuantity(String reason) implements BatchError {
        public String message() { return "Invalid quantity: " + reason; }
    }
    record InvalidStatusTransition(String from, String to) implements BatchError {
        public String message() { return "Cannot transition from " + from + " to " + to; }
    }
    record MissingConsumptions() implements BatchError {
        public String message() { return "Cannot complete batch without recorded consumptions"; }
    }
    record InvalidBestBeforeDate() implements BatchError {
        public String message() { return "Best-before date must be after production date"; }
    }
    record DuplicateInputBatch(String batchId) implements BatchError {
        public String message() { return "Input batch already recorded: " + batchId; }
    }
    record NegativeWaste() implements BatchError {
        public String message() { return "Waste cannot be negative"; }
    }
}

public sealed interface ProductionOrderError {
    String message();

    record PlannedDateInPast(String date) implements ProductionOrderError {
        public String message() { return "Planned date is in the past: " + date; }
    }
    record InvalidQuantity(String reason) implements ProductionOrderError {
        public String message() { return "Invalid quantity: " + reason; }
    }
    record InvalidStatusTransition(String from, String to) implements ProductionOrderError {
        public String message() { return "Cannot transition from " + from + " to " + to; }
    }
    record BatchAlreadyAssigned(String batchId) implements ProductionOrderError {
        public String message() { return "Batch already assigned: " + batchId; }
    }
}

Repository Interfaces

public interface RecipeRepository {
    Optional<Recipe> findById(RecipeId id);
    List<Recipe> findByStatus(RecipeStatus status);
    List<Recipe> findByArticleId(ArticleId articleId);
    List<Recipe> findActiveByIngredientArticleId(ArticleId ingredientArticleId);
    void save(Recipe recipe);
    boolean existsByNameAndVersion(String name, int version);
}

public interface BatchRepository {
    Optional<Batch> findById(BatchId id);
    Optional<Batch> findByBatchNumber(BatchNumber batchNumber);
    List<Batch> findByProductionDate(LocalDate date);
    List<Batch> findByStatus(BatchStatus status);
    List<Batch> findByArticleId(ArticleId articleId);
    // Für Rückverfolgbarkeit
    List<Batch> findByInputBatchId(BatchId upstreamBatchId);
    void save(Batch batch);
}

public interface ProductionOrderRepository {
    Optional<ProductionOrder> findById(ProductionOrderId id);
    List<ProductionOrder> findByPlannedDate(LocalDate date);
    List<ProductionOrder> findByPlannedDateRange(LocalDate from, LocalDate to);
    List<ProductionOrder> findByStatus(ProductionOrderStatus status);
    void save(ProductionOrder order);
}

Status-Maschinen

Recipe Status

---
config:
  theme: neutral
  themeVariables:
    background: "#f8fafc"
---
stateDiagram-v2
    [*] --> DRAFT : create()
    DRAFT --> ACTIVE : activate()
    ACTIVE --> ARCHIVED : archive()

    DRAFT : Editierbar
    DRAFT : addIngredient(), removeIngredient()
    DRAFT : addProductionStep(), removeProductionStep()
    ACTIVE : Nur lesen, in Produktion verwendbar
    ARCHIVED : Nur lesen, nicht mehr verwendbar

Batch Status

---
config:
  theme: neutral
  themeVariables:
    background: "#f8fafc"
---
stateDiagram-v2
    [*] --> PLANNED : plan()
    PLANNED --> IN_PRODUCTION : startProduction()
    PLANNED --> CANCELLED : cancel()
    IN_PRODUCTION --> COMPLETED : complete()
    IN_PRODUCTION --> CANCELLED : cancel()

    PLANNED : Charge geplant
    IN_PRODUCTION : recordConsumption()
    IN_PRODUCTION : Genealogie dokumentieren
    COMPLETED : actualQuantity + waste gesetzt
    COMPLETED : → BatchCompleted Event
    CANCELLED : cancelledReason gesetzt

ProductionOrder Status

---
config:
  theme: neutral
  themeVariables:
    background: "#f8fafc"
---
stateDiagram-v2
    [*] --> PLANNED : create()
    PLANNED --> RELEASED : release()
    PLANNED --> CANCELLED : cancel()
    RELEASED --> IN_PRODUCTION : startProduction(batchId)
    RELEASED --> CANCELLED : cancel()
    IN_PRODUCTION --> COMPLETED : complete()

    PLANNED : reschedule() möglich
    RELEASED : Material-Check bestanden
    RELEASED : reschedule() möglich
    RELEASED : → ProductionOrderReleased Event
    IN_PRODUCTION : BatchId zugewiesen
    COMPLETED : Batch ist COMPLETED

Integration mit anderen BCs

---
config:
  theme: neutral
  look: classic
  layout: elk
  themeVariables:
    background: "#f8fafc"
---
graph LR
    subgraph UPSTREAM["Upstream BCs"]
        MD["Master Data\n(ArticleId)"]
        UM["User Management\n(UserId)"]
        FI["Filiales\n(BranchId)"]
    end

    subgraph PRODUCTION["Production BC"]
        R["Recipe"]
        B["Batch"]
        PO["ProductionOrder"]
    end

    subgraph DOWNSTREAM["Downstream BCs"]
        INV["Inventory BC"]
        PROC["Procurement BC"]
        LAB["Labeling BC"]
    end

    MD -->|ArticleId| R
    MD -->|ArticleId| B
    MD -->|ArticleId| PO
    UM -->|UserId| B
    UM -->|UserId| PO
    FI -->|BranchId| B
    FI -->|BranchId| PO

    PO -->|erzeugt| B
    R -->|RecipeId| B
    R -->|RecipeId| PO

    B -->|"BatchCompleted\n(stock-in)"| INV
    B -->|"ConsumptionRecorded\n(stock-out)"| INV
    PO -->|"OrderReleased\n(demand)"| PROC
    R -->|"RecipeActivated\n(Nährwerte)"| LAB

Upstream-Abhängigkeiten (Production konsumiert)

BC Referenz Zweck
Master Data ArticleId Artikel-Referenz in Recipe, Batch, ProductionOrder
User Management UserId ProducedBy in Batch, CreatedBy in ProductionOrder
Filiales BranchId Produktionsstandort

Downstream-Integrationen (Production publiziert Events)

Event Konsument Aktion
BatchCompleted Inventory BC Stock-In für produzierte Ware
ConsumptionRecorded Inventory BC Stock-Out für verbrauchte Rohstoffe
ProductionOrderReleased Procurement BC Materialbedarf / Demand Planning
RecipeActivated Labeling BC Nährwert-/Allergen-Berechnung

Abgrenzungen (gehören NICHT in Production BC)

Konzept Zuständiger BC Grund
QualityHold / Freigabe Quality BC Eigene Aggregate-Logik mit QualityStatus
StockReservation Inventory BC Bestandsführung ist Inventory-Concern
Labeling / Deklaration Labeling BC Nährwert- und Allergen-Deklaration
Lieferanten-Chargen Master Data BC SupplierBatchNumber ist Stammdaten-Concern

Use Cases (Application Layer)

// Recipe Management
CreateRecipe         Recipe.create(RecipeDraft)
AddRecipeIngredient  recipe.addIngredient(IngredientDraft)
ActivateRecipe       recipe.activate()
ArchiveRecipe        recipe.archive()
GetRecipe            Query
ListActiveRecipes    Query

// Batch Management
PlanBatch            Batch.plan(BatchDraft)
StartBatch           batch.startProduction()
RecordConsumption    batch.recordConsumption(ConsumptionDraft)
CompleteBatch        batch.complete(actualQty, waste, remarks)
CancelBatch          batch.cancel(reason)
TraceBatchForward    BatchTraceabilityService.traceForward(batchId)
TraceBatchBackward   BatchTraceabilityService.traceBackward(batchId)

// ProductionOrder Management
CreateProductionOrder  ProductionOrder.create(ProductionOrderDraft)
ReleaseOrder           order.release()
StartProductionOrder   order.startProduction(batchId) + batch.startProduction()
CompleteOrder          order.complete()
CancelOrder            order.cancel(reason)
RescheduleOrder        order.reschedule(newDate)
ListOrdersByDate       Query

Beispiel: Produktionsfluss (End-to-End)

TODO: Was passiert wenn Material da/nicht da? Woher ist klar dass Produktion gestartet werden kann?

---
config:
  theme: neutral
  themeVariables:
    background: "#f8fafc"
---
sequenceDiagram
    participant M as Metzgermeister
    participant PO as ProductionOrder
    participant R as Recipe
    participant B as Batch
    participant INV as Inventory BC
    participant PROC as Procurement BC

    M->>PO: create(ProductionOrderDraft)
    activate PO
    PO-->>M: ProductionOrder (PLANNED)

    M->>PO: release()
    PO--)PROC: ProductionOrderReleased Event
    Note over PROC: Materialbedarf prüfen

    M->>PO: startProduction(batchId)
    PO->>B: plan(BatchDraft)
    activate B
    B-->>PO: Batch (PLANNED)
    B->>B: startProduction()
    Note over B: Status: IN_PRODUCTION

    M->>B: recordConsumption(Schweineschulter, 125kg)
    B--)INV: ConsumptionRecorded Event
    Note over INV: Stock-Out: 125kg Schweineschulter

    M->>B: recordConsumption(Gewürz, 3kg)
    B--)INV: ConsumptionRecorded Event

    M->>B: complete(80kg, 5kg waste)
    B--)INV: BatchCompleted Event
    Note over INV: Stock-In: 80kg Endprodukt
    deactivate B

    M->>PO: complete()
    deactivate PO
    Note over PO: Status: COMPLETED

Code-Beispiel

// 1. Rezeptur existiert (ACTIVE)
Recipe recipe = recipeRepository.findById(recipeId).orElseThrow();

// 2. Produktionsauftrag erstellen
var orderDraft = new ProductionOrderDraft(
    recipeId, articleId, "100", "KILOGRAM",
    "2026-02-20", "NORMAL", branchId, userId, null
);
ProductionOrder order = ProductionOrder.create(orderDraft).value();

// 3. Auftrag freigeben (Material-Check im Application Layer)
order.release();

// 4. Produktion starten → Charge erzeugen
var batchDraft = new BatchDraft(
    recipeId, articleId, "100", "KILOGRAM",
    "2026-02-20", "2026-03-22",  // MHD = ProductionDate + ShelfLifeDays
    userId, branchId
);
Batch batch = Batch.plan(batchDraft).value();
batch.startProduction();
order.startProduction(batch.id());

// 5. Rohstoff-Verbrauch dokumentieren (Genealogie)
batch.recordConsumption(new ConsumptionDraft(
    "P-2026-02-15-042",  // Rohstoff-Charge
    "ART-001",           // Schweineschulter
    "125", "KILOGRAM"   // 125kg Input für 100kg Output bei 80% Ausbeute
));

// 6. Charge abschließen
batch.complete(
    Quantity.of(new BigDecimal("80"), UnitOfMeasure.KILOGRAM).value(),
    Quantity.of(new BigDecimal("5"), UnitOfMeasure.KILOGRAM).value(),
    "Leichter Schwund beim Räuchern"
);
order.complete();

// 7. Events:
//    BatchCompleted → Inventory bucht 80kg Endprodukt ein
//    ConsumptionRecorded → Inventory bucht 125kg Rohstoff aus

DDD Validation Checklist

  • Aggregate Root ist einziger Einstiegspunkt (Recipe, Batch, ProductionOrder)
  • Alle Änderungen gehen durch Aggregate-Root-Methoden
  • Invarianten werden in Factory und Methoden geprüft
  • Keine direkten Referenzen auf andere Aggregates (nur IDs: ArticleId, UserId, BranchId, BatchId)
  • Ein Aggregate = eine Transaktionsgrenze
  • EntityDraft-Pattern für VO-Konstruktion im Domain Layer
  • Result<E,T> für erwartbare Fehler, keine Exceptions
  • Sealed interfaces für Domain Errors
  • Status-Maschinen explizit dokumentiert
  • BC-Grenzen klar definiert (QualityHold → Quality BC, Stock → Inventory BC)