mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 19:30:16 +01:00
- Production BC: Aggregates (Recipe, Batch, ProductionOrder) mit Invarianten, Drafts, Status-Maschinen, Domain Events und Chargen-Genealogie - Quality BC: 9 Aggregates (TemperatureLog, CleaningPlan/Record, GoodsReceiptInspection, SampleRecord, TrainingRecord, MaintenanceRecord, QualityHold, ProcessParameter) mit HACCP-Compliance - Inventory BC: 4 Aggregates (Stock, StockMovement, InventoryCount, StorageLocation) mit FEFO, Reservierungen mit Priorität, Vier-Augen-Prinzip bei Inventur - Ubiquitous Language: Inventory-Sektion von 11 auf 27 Begriffe erweitert - Alte deutsche Datei 05-qualitaets-kontext.md entfernt (ersetzt durch 05-quality-bc.md)
1126 lines
35 KiB
Markdown
1126 lines
35 KiB
Markdown
# 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
|
|
|
|
```mermaid
|
|
---
|
|
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:**
|
|
```java
|
|
/**
|
|
* 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:**
|
|
```java
|
|
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:**
|
|
```java
|
|
// 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:**
|
|
```java
|
|
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:**
|
|
```java
|
|
/**
|
|
* 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:**
|
|
```java
|
|
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:**
|
|
```java
|
|
// 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:**
|
|
```java
|
|
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:**
|
|
```java
|
|
/**
|
|
* 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:**
|
|
```java
|
|
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:**
|
|
```java
|
|
// 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:**
|
|
```java
|
|
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
|
|
|
|
```mermaid
|
|
---
|
|
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").
|
|
|
|
```java
|
|
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
|
|
|
|
```java
|
|
public enum UnitOfMeasure {
|
|
KILOGRAM("kg"),
|
|
GRAM("g"),
|
|
LITER("l"),
|
|
MILLILITER("ml"),
|
|
PIECE("Stk"),
|
|
METER("m");
|
|
|
|
private final String abbreviation;
|
|
}
|
|
```
|
|
|
|
### BatchNumber
|
|
|
|
```java
|
|
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
|
|
|
|
```java
|
|
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
|
|
|
|
```java
|
|
public enum Priority {
|
|
LOW,
|
|
NORMAL,
|
|
HIGH,
|
|
URGENT
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Domain Services
|
|
|
|
### RecipeCycleDependencyChecker
|
|
|
|
```java
|
|
/**
|
|
* 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
|
|
|
|
```java
|
|
/**
|
|
* 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):**
|
|
|
|
```mermaid
|
|
---
|
|
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
|
|
|
|
```java
|
|
/**
|
|
* Generiert eindeutige Chargennummern. Infrastructure-Concern (Sequenz aus DB).
|
|
*/
|
|
public interface BatchNumberGenerator {
|
|
BatchNumber generate(LocalDate productionDate);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Domain Errors
|
|
|
|
```java
|
|
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
|
|
|
|
```java
|
|
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
|
|
|
|
```mermaid
|
|
---
|
|
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
|
|
|
|
```mermaid
|
|
---
|
|
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
|
|
|
|
```mermaid
|
|
---
|
|
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
|
|
|
|
```mermaid
|
|
---
|
|
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)
|
|
|
|
```java
|
|
// 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()
|
|
StartOrderProduction → order.startProduction(batchId) + Batch.plan()
|
|
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?
|
|
```mermaid
|
|
---
|
|
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
|
|
|
|
```java
|
|
// 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
|
|
|
|
- [x] Aggregate Root ist einziger Einstiegspunkt (Recipe, Batch, ProductionOrder)
|
|
- [x] Alle Änderungen gehen durch Aggregate-Root-Methoden
|
|
- [x] Invarianten werden in Factory und Methoden geprüft
|
|
- [x] Keine direkten Referenzen auf andere Aggregates (nur IDs: ArticleId, UserId, BranchId, BatchId)
|
|
- [x] Ein Aggregate = eine Transaktionsgrenze
|
|
- [x] EntityDraft-Pattern für VO-Konstruktion im Domain Layer
|
|
- [x] Result<E,T> für erwartbare Fehler, keine Exceptions
|
|
- [x] Sealed interfaces für Domain Errors
|
|
- [x] Status-Maschinen explizit dokumentiert
|
|
- [x] BC-Grenzen klar definiert (QualityHold → Quality BC, Stock → Inventory BC)
|