diff --git a/TODO.md b/TODO.md index 0f54c0d..4dc42a9 100644 --- a/TODO.md +++ b/TODO.md @@ -7,6 +7,23 @@ 3. ✅ Inventory BC implementieren (Basis: 8.1-8.3) 4. ✅ Document Archive BC (Basis: 12.1-12.2) - parallel zu Inventory + Welle 3 (Production BC – Epic 4 + 7): + 5. [ ] Production BC: Recipe Aggregate (Rezepturverwaltung, mehrstufig, Ausbeute) + 6. [ ] Production BC: Batch Aggregate (Chargen-Erzeugung, Genealogie, Rückverfolgbarkeit) + 7. [ ] Production BC: ProductionOrder Aggregate (Produktionsplanung & -steuerung) + 8. [ ] Production BC: BatchTraceabilityService (Vorwärts-/Rückwärts-Tracing) + + Welle 4 (Quality BC – Epic 3: QM & Compliance/HACCP): + 9. [ ] Quality BC: TemperatureLog Aggregate (3.1 Temperaturprotokollierung) + 10. [ ] Quality BC: CleaningPlan + CleaningRecord Aggregates (3.2 Reinigungspläne & -nachweise) + 11. [ ] Quality BC: GoodsReceiptInspection Aggregate (3.3 Wareneingangskontrolle) + 12. [ ] Quality BC: SampleRecord Aggregate (3.4 Probenentnahme & Analyseergebnisse) + 13. [ ] Quality BC: TrainingRecord Aggregate (3.5 Schulungsnachweise) + 14. [ ] Quality BC: MaintenanceRecord Aggregate (3.6 Wartungsprotokolle) + 15. [ ] Quality BC: QualityHold Aggregate (Chargen-Sperre/Freigabe, Vier-Augen-Prinzip) + 16. [ ] Quality BC: ProcessParameter Aggregate (CCP-Messwerte: Kerntemp, pH, aw) + 17. [ ] Quality BC: HaccpReportGenerator Domain Service (3.8 Audit-Reports) + - [x] Liquibase statt Flyway - [x] Package Struktur gemäß DDD-model skill, ddd-implementer fragen wegen refactor? - [ ] ActionToPermissionMapper, warum unterschiedliches Vorgehen if/else vs. switch/case diff --git a/backend/docs/mvp/ddd/03-ubiquitous-language.md b/backend/docs/mvp/ddd/03-ubiquitous-language.md index 902184f..3e37f95 100644 --- a/backend/docs/mvp/ddd/03-ubiquitous-language.md +++ b/backend/docs/mvp/ddd/03-ubiquitous-language.md @@ -11,14 +11,28 @@ Diese Datei definiert die **Ubiquitous Language** für jeden Bounded Context. Di | Begriff (DE) | Begriff (EN) | Typ | Definition | |--------------|--------------|-----|------------| | Rezept | Recipe | Aggregate | Mehrstufige Anleitung zur Herstellung eines Produkts aus Rohstoffen und Zwischenprodukten | -| Zutat | Ingredient | Entity | Rohstoff oder Zwischenprodukt, das in einem Rezept verwendet wird | -| Ausbeute | Yield | Value Object | Verhältnis zwischen Input (Rohmaterial) und Output (Endprodukt) nach Verarbeitungsverlusten, in Prozent | -| Charge | Batch | Aggregate | Eindeutig identifizierte Produktionseinheit mit Datum, Rezept, Menge und verwendeten Rohstoffchargen | -| Produktionsauftrag | Production Order | Aggregate | Auftrag zur Herstellung einer bestimmten Menge eines Produkts nach Rezept | -| Rückverfolgbarkeit | Traceability | Concept | Lückenlose Dokumentation von Rohstoff-Charge → Produktions-Charge → Verkauf | -| Zwischenprodukt | Intermediate Product | Concept | Produkt, das in Rezept A hergestellt wird und als Zutat in Rezept B verwendet wird | -| Ausschuss | Waste | Value Object | Menge an Material, die während Produktion verloren geht oder unbrauchbar ist | -| Rezeptur-Version | Recipe Version | Value Object | Versionsnummer eines Rezepts, um Änderungen nachvollziehbar zu machen | +| Zutat | Ingredient | Entity | Rohstoff oder Zwischenprodukt, das in einem Rezept verwendet wird. Position bestimmt die Reihenfolge | +| Produktionsschritt | ProductionStep | Entity | Einzelner Arbeitsschritt in einer Rezeptur mit optionaler Dauer und Temperaturangabe | +| Rezepttyp | RecipeType | Value Object | Klassifizierung: RAW_MATERIAL, INTERMEDIATE (Zwischenprodukt) oder FINISHED_PRODUCT (Endprodukt) | +| Ausbeute | YieldPercentage | Value Object | Verhältnis Input→Output in % (1-200%). >100% bei Pökel-/Spritzprozessen. Bei 80% Ausbeute braucht man 125kg Input für 100kg Output | +| Haltbarkeitsdauer | ShelfLifeDays | Value Object | Anzahl Tage ab Produktionsdatum bis MHD. Definiert im Rezept, angewandt bei Chargen-Erzeugung | +| Unter-Rezept | SubRecipe | Concept | Verweis auf ein anderes Rezept (INTERMEDIATE), das als Zutat verwendet wird. Ermöglicht verschachtelte Rezepturen | +| Rezeptur-Version | Recipe Version | Value Object | Versionsnummer eines Rezepts. Neue Version = neues Recipe-Objekt, altes wird archiviert | +| Charge | Batch | Aggregate | Eindeutig identifizierte Produktionseinheit mit Chargennummer, Datum, Rezept, Mengen und Genealogie | +| Chargennummer | BatchNumber | Value Object | Formatierte eindeutige Kennung einer Charge (Format: "P-YYYY-MM-DD-XXX"). Generiert über BatchNumberGenerator | +| Verbrauch | Consumption | Entity | Dokumentation, dass eine Input-Charge (Rohstoff/Zwischenprodukt) in einer Produktions-Charge verwendet wurde. Bildet die Chargen-Genealogie | +| Chargen-Genealogie | Batch Genealogy | Concept | Gesamtheit aller Consumption-Beziehungen einer Charge. Ermöglicht Vorwärts- und Rückwärts-Tracing | +| Vorwärts-Tracing | Forward Tracing | Concept | Ausgehend von einer Rohstoff-Charge: Welche Endprodukt-Chargen sind betroffen? Kritisch für Rückruf-Szenarien | +| Rückwärts-Tracing | Backward Tracing | Concept | Ausgehend von einer Endprodukt-Charge: Welche Rohstoff-Chargen wurden verwendet? | +| Rückverfolgbarkeit | Traceability | Concept | Lückenlose Dokumentation der Kette: Rohstoff-Charge → Produktions-Charge → Verkauf. HACCP-Pflicht | +| Zwischenprodukt | Intermediate Product | Concept | Produkt, das in Rezept A hergestellt wird und als Zutat in Rezept B verwendet wird (z.B. Brät, Gewürzmischung) | +| Ausschuss | Waste | Value Object | Menge an Material, die während Produktion verloren geht (Schwund, Knochen, Fett-Trimmen) | +| Produktionsauftrag | ProductionOrder | Aggregate | Geplante zukünftige Produktion mit Rezept, Menge, Termin und Priorität. Erzeugt bei Start eine Charge | +| Priorität | Priority | Value Object | Dringlichkeit eines Produktionsauftrags: LOW, NORMAL, HIGH, URGENT | +| Freigabe | Release | Concept | Übergang eines Produktionsauftrags von PLANNED zu RELEASED. Signalisiert, dass Material verfügbar ist | +| Catch-Weight | Dual Quantity | Concept | Doppelte Mengenführung für Artikel, die stückweise UND gewichtsweise erfasst werden (z.B. "10 Stück à 2,3 kg") | +| MHD | BestBeforeDate | Value Object | Mindesthaltbarkeitsdatum einer Charge. Berechnet: ProductionDate + Recipe.ShelfLifeDays | +| Materialbedarf | Material Requirement | Concept | Aus Rezeptur berechnete Rohstoffmenge unter Berücksichtigung der Ausbeute. Ausgelöst durch ProductionOrderReleased | --- @@ -28,16 +42,33 @@ Diese Datei definiert die **Ubiquitous Language** für jeden Bounded Context. Di |--------------|--------------|-----|------------| | HACCP | HACCP | Concept | Hazard Analysis Critical Control Points - systematische Präventivmaßnahmen für Lebensmittelsicherheit | | Kritischer Kontrollpunkt | Critical Control Point (CCP) | Concept | Stelle im Prozess, an der Kontrolle notwendig ist, um Gefahren zu vermeiden | -| Temperaturprotokoll | Temperature Log | Aggregate | Dokumentierte Temperaturmessung an kritischem Punkt (Kühlraum, Theke) mit Grenzwerten | -| Reinigungsnachweis | Cleaning Record | Aggregate | Dokumentation einer durchgeführten Reinigung mit Datum, Person, Checkliste | -| Reinigungsplan | Cleaning Plan | Aggregate | Vordefinierter Plan mit Intervallen und Checklisten für Reinigungsaufgaben | -| Wareneingangskontrolle | Goods Receipt Inspection | Aggregate | Prüfung von Temperatur, MHD, Sichtkontrolle, Dokumenten bei Warenanlieferung | -| Schulungsnachweis | Training Record | Aggregate | Zertifikat oder Nachweis einer absolvierten Schulung (HACCP, Hygiene) mit Gültigkeitsdatum | -| Wartungsprotokoll | Maintenance Record | Aggregate | Dokumentation von Gerätewartungen (planmäßig oder Störung) mit Befund | -| Messwert | Measurement | Value Object | Gemessener Wert mit Einheit (z.B. Temperatur in °C) | -| Grenzwert | Critical Limit | Value Object | Minimal-/Maximalwert für CCP (z.B. Kühlraum: 2-7°C) | +| Temperaturprotokoll | TemperatureLog | Aggregate | Standortbezogene Temperaturmessung an kritischem Punkt (Kühlraum, Theke) mit Grenzwertüberwachung | +| Messpunkt | MeasurementPoint | VO (Enum) | Physischer Ort der Messung: COLD_ROOM, FREEZER, DISPLAY_COUNTER, PRODUCTION_ROOM | +| Temperatur | Temperature | Value Object | Temperaturwert in °C mit physikalisch plausiblem Bereich (-50 bis +50) | +| Kritischer Grenzwert | CriticalLimit | Value Object | Min-/Max-Paar für einen CCP (z.B. Kühlraum 2-7°C). Mindestens ein Limit muss gesetzt sein | +| Reinigungsplan | CleaningPlan | Aggregate | Vorlage für Reinigungsaufgaben mit Bereich, Intervall und Checklisten-Template | +| Reinigungsintervall | CleaningInterval | VO (Enum) | Turnus einer Reinigung: DAILY, WEEKLY, MONTHLY | +| Reinigungsnachweis | CleaningRecord | Aggregate | Durchgeführte Reinigung gegen einen CleaningPlan mit abgehakter Checkliste | +| Checklisten-Eintrag | ChecklistItem | Entity | Einzelner Prüfpunkt in einer Reinigung oder Inspektion | +| Wareneingangskontrolle | GoodsReceiptInspection | Aggregate | Mehrteilige Prüfung bei Warenanlieferung (Temperatur, Sicht, MHD, Dokumente) | +| Temperaturprüfung | TemperatureCheck | Value Object | Temperaturmessung bei Wareneingang mit Soll-/Ist-Vergleich | +| Sichtkontrolle | VisualCheck | Value Object | Prüfung von Verpackung, Farbe, Geruch bei Wareneingang | +| MHD-Prüfung | ShelfLifeCheck | Value Object | Prüfung des Mindesthaltbarkeitsdatums gegen Mindest-Restlaufzeit | +| Dokumentenprüfung | DocumentCheck | Value Object | Prüfung von Lieferschein, Veterinärbescheinigung, Zertifikaten | +| Probenentnahme | SampleRecord | Aggregate | Probenentnahme mit Analyseergebnis, Charge und Prüfmethode | +| Probenart | SampleType | VO (Enum) | Klassifizierung: MICROBIOLOGICAL, CHEMICAL, PHYSICAL, SENSORY | +| Analyseergebnis | AnalysisResult | Value Object | Messergebnis einer Probe mit Wert, Einheit und Interpretation | +| Schulungsnachweis | TrainingRecord | Aggregate | Nachweis einer absolvierten Schulung mit Gültigkeitsdatum und Trainer | +| Schulungsart | TrainingType | VO (Enum) | Klassifizierung: HACCP, HYGIENE, FOOD_SAFETY, EQUIPMENT_OPERATION, FIRST_AID | +| Wartungsprotokoll | MaintenanceRecord | Aggregate | Dokumentation von Gerätewartungen (planmäßig, Reparatur, Kalibrierung, Inspektion) | +| Wartungsart | MaintenanceType | VO (Enum) | SCHEDULED, REPAIR, CALIBRATION, INSPECTION | +| Qualitätssperre | QualityHold | Aggregate | Sperre einer Charge bei Qualitätsproblemen. Block/Release-Pattern mit Vier-Augen-Prinzip | +| Sperrgrund | HoldReason | VO (Enum) | TEMPERATURE_DEVIATION, SAMPLE_FAILED, CONTAMINATION_SUSPECTED, PROCESS_DEVIATION, CUSTOMER_COMPLAINT, REGULATORY | +| Korrekturmaßnahme | CorrectiveAction | Entity | Maßnahme zur Behebung einer Abweichung im Rahmen eines QualityHold | +| Prozessparameter | ProcessParameter | Aggregate | Batch-bezogene CCP-Messwerte (Kerntemperatur, pH, aw-Wert, Metalldetektion) | +| CCP-Typ | CcpType | VO (Enum) | CORE_TEMPERATURE, PH_VALUE, WATER_ACTIVITY, METAL_DETECTION | | Abweichung | Deviation | Concept | Überschreitung eines Grenzwerts oder Nichteinhaltung eines Verfahrens | -| Korrekturmaßnahme | Corrective Action | Entity | Maßnahme zur Behebung einer Abweichung | +| HACCP-Report | HaccpReport | Concept | Audit-Report aggregiert aus allen Quality-Aggregates für einen Zeitraum | --- @@ -62,17 +93,29 @@ Diese Datei definiert die **Ubiquitous Language** für jeden Bounded Context. Di | Begriff (DE) | Begriff (EN) | Typ | Definition | |--------------|--------------|-----|------------| -| Bestand | Stock | Aggregate | Aktueller Bestand eines Artikels in einem Lagerort, chargengenau | -| Lagerort | Storage Location | Value Object | Physischer Ort (Kühlraum, Tiefkühler, Trockenlager, Theke) | -| Bestandsbewegung | Stock Movement | Aggregate | Veränderung des Bestands (Wareneingang, Produktion, Verkauf, Umbuchung) | -| MHD | Best-Before Date | Value Object | Mindesthaltbarkeitsdatum, chargenspezifisch | -| FEFO | First-Expired-First-Out | Concept | Verkaufspriorisierung nach MHD (ältestes MHD zuerst) | -| Charge | Batch | Concept | Eindeutig identifizierte Menge eines Artikels mit gleicher Herkunft/Produktion | -| Chargennummer | Batch Number | Value Object | Eindeutige Identifikation einer Charge (ProductionBatchId oder SupplierBatchId) | -| Verfügbarer Bestand | Available Stock | Value Object | Bestand abzüglich Reservierungen | -| Reservierung | Reservation | Entity | Vorgemerkter Bestand für Produktionsauftrag oder Kundenauftrag | -| Schwund | Shrinkage | Concept | Bestandsverlust durch Verderb, Bruch, Diebstahl | -| Inventur | Inventory Count | Aggregate | Physische Zählung des Bestands mit Soll-Ist-Abgleich | +| Bestand | Stock | Aggregate | Aktueller Bestand eines Artikels an einem Lagerort, chargengenau mit FEFO und Reservierungen | +| Bestandscharge | StockBatch | Entity | Einzelne Charge im Bestand mit Menge, MHD und Status (AVAILABLE, EXPIRING_SOON, BLOCKED, EXPIRED) | +| Chargenreferenz | BatchReference | Value Object | Verweis auf Produktions- oder Lieferantencharge mit BatchType (PRODUCED oder PURCHASED) | +| Chargentyp | BatchType | VO (Enum) | PRODUCED (Eigenproduktion via Production BC) oder PURCHASED (Zukauf via Procurement BC) | +| Chargen-Status | StockBatchStatus | VO (Enum) | AVAILABLE, EXPIRING_SOON, BLOCKED, EXPIRED – steuert Verfügbarkeit | +| Mindestbestand | MinimumLevel | Value Object | Mindestmenge, bei Unterschreitung wird StockLevelBelowMinimum Event ausgelöst | +| Mindest-Resthaltbarkeit | MinimumShelfLife | Value Object | Konfigurierbare Tage bis MHD, ab der eine Charge als EXPIRING_SOON markiert wird | +| FEFO | First-Expired-First-Out | Concept | Entnahme/Reservierung priorisiert nach nächstem MHD (ältestes zuerst) | +| Verfügbarer Bestand | AvailableStock | Concept | SUM(AVAILABLE Chargen) minus SUM(Reservierungen). Darf nicht negativ werden | +| Reservierung | Reservation | Entity | Reservierte Menge für einen Produktionsauftrag oder Kundenauftrag mit Priorität | +| Reservierungspriorität | ReservationPriority | VO (Enum) | URGENT, NORMAL, LOW – bestimmt Zuteilungsreihenfolge bei Knappheit | +| Referenztyp | ReferenceType | VO (Enum) | PRODUCTION_ORDER oder SALE_ORDER – wofür reserviert wurde | +| Chargen-Zuteilung | StockBatchAllocation | Entity | Zuordnung einer reservierten Menge zu einer konkreten StockBatch (per FEFO ausgewählt) | +| Bestandsbewegung | StockMovement | Aggregate | Immutabler Audit-Trail einer Bestandsveränderung mit Chargen-Rückverfolgbarkeit | +| Bewegungstyp | MovementType | VO (Enum) | GOODS_RECEIPT, PRODUCTION_OUTPUT, PRODUCTION_CONSUMPTION, SALE, INTER_BRANCH_TRANSFER, WASTE, ADJUSTMENT, RETURN | +| Bewegungsrichtung | MovementDirection | VO (Enum) | IN (Zugang) oder OUT (Abgang) – abgeleitet aus MovementType | +| Inventur | InventoryCount | Aggregate | Physische Bestandsaufnahme pro Lagerort mit Soll/Ist-Abgleich und automatischer Differenzbuchung | +| Zählposition | CountItem | Entity | Einzelne Position in einer Inventur: Artikel, Soll-Menge, Ist-Menge, berechnete Abweichung | +| Lagerort | StorageLocation | Aggregate | Konfigurierbarer physischer Lagerort mit Typ und optionalem Temperaturbereich | +| Lagertyp | StorageType | VO (Enum) | COLD_ROOM, FREEZER, DRY_STORAGE, DISPLAY_COUNTER, PRODUCTION_AREA | +| Temperaturbereich | TemperatureRange | Value Object | Min-/Max-Temperatur eines Lagerorts in °C (-50 bis +80) | +| Schwund | Shrinkage | Concept | Bestandsverlust durch Verderb, Bruch, Diebstahl – erfasst als WASTE-StockMovement | +| Vier-Augen-Prinzip | Four-Eyes Principle | Concept | Inventur: CompletedBy muss sich von InitiatedBy unterscheiden | --- @@ -181,7 +224,7 @@ com.effigenix.domain.masterdata // Master Data BC Recipe, Batch, ProductionOrder TemperatureLog, CleaningRecord ProductLabel, AllergenMatrix -Stock, StockMovement +Stock, StockMovement, InventoryCount, StorageLocation PurchaseOrder, GoodsReceipt Branch, InterBranchTransfer @@ -189,6 +232,8 @@ Branch, InterBranchTransfer RecipeId, BatchId, YieldPercentage Temperature, CriticalLimit AllergenType, NutritionFacts +BatchReference, MinimumLevel, MinimumShelfLife +TemperatureRange, StorageLocationName Quantity, Money, Timestamp ``` @@ -197,7 +242,9 @@ Quantity, Money, Timestamp // Domain behavior (verbs) recipe.addIngredient(...) batch.complete(...) -stock.withdraw(...) +stock.addBatch(...) +stock.reserve(...) +stock.confirmReservation(...) purchaseOrder.confirm(...) // Factory methods diff --git a/backend/docs/mvp/ddd/04-production-bc.md b/backend/docs/mvp/ddd/04-production-bc.md index bb5918f..0199585 100644 --- a/backend/docs/mvp/ddd/04-production-bc.md +++ b/backend/docs/mvp/ddd/04-production-bc.md @@ -2,7 +2,160 @@ **Bounded Context:** Production **Domain Type:** CORE -**Verantwortung:** Rezepturverwaltung, Produktionsplanung, Chargen-Erzeugung +**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 +``` --- @@ -10,26 +163,29 @@ ### 1. Recipe (Aggregate Root) -**Verantwortung:** Verwaltet mehrstufige Rezepturen mit Zutaten und Produktionsschritten. +**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) +├── Version (VO: int) - Versionierung bei Rezepturänderungen ├── RecipeType (VO: RAW_MATERIAL | INTERMEDIATE | FINISHED_PRODUCT) -├── YieldPercentage (VO) - Ausbeute in % +├── YieldPercentage (VO) - Ausbeute in % (1-200) +├── ShelfLifeDays (VO: int) - Haltbarkeitsdauer → MHD-Berechnung ├── Ingredients[] (Entity) -│ ├── Position (VO) - Reihenfolge im Rezept -│ ├── ArticleId (VO) - Reference to Master Data -│ ├── Quantity (VO) -│ ├── RecipeId (VO) - For intermediate products (nested recipes) -│ └── IsSubstitutable (VO) - Kann durch Alternativzutat ersetzt werden? +│ ├── 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) -│ ├── Description (VO) -│ ├── Duration (VO) - Geschätzte Dauer -│ └── Temperature (VO) - Optional, für Räuchern/Kochen +│ ├── 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) ``` @@ -39,248 +195,436 @@ Recipe (Aggregate Root) * Recipe aggregate root. * * Invariants: - * - Recipe must have at least one ingredient - * - Yield percentage must be between 1-100% + * - 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 cannot create circular dependencies (A → B → A) - * - Sum of ingredient quantities should roughly match expected input - * - Position numbers in Ingredients must be unique + * - 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) */ ``` -**Business Methods:** +**Draft-Records:** ```java -public Result addIngredient( - ArticleId articleId, - Quantity quantity, - Optional nestedRecipeId -); +public record RecipeDraft( + String articleId, + String name, + String recipeType, // RAW_MATERIAL | INTERMEDIATE | FINISHED_PRODUCT + Integer yieldPercentage, + Integer shelfLifeDays +) {} -public Result removeIngredient(ArticleId articleId); +public record IngredientDraft( + int position, + String articleId, + String quantityAmount, // BigDecimal als String + String quantityUnit, // UnitOfMeasure + String subRecipeId, // nullable + boolean substitutable +) {} -public Result updateYield(YieldPercentage newYield); - -public Result addProductionStep( +public record ProductionStepDraft( + int stepNumber, String description, - Optional duration -); + Integer durationMinutes, // nullable + Integer temperatureCelsius // nullable +) {} +``` +**Factory & Business Methods:** +```java +// Factory +public static Result create(RecipeDraft draft); + +// Mutations (nur im DRAFT-Status) +public Result addIngredient(IngredientDraft draft); +public Result removeIngredient(IngredientId ingredientId); +public Result addProductionStep(ProductionStepDraft draft); +public Result removeProductionStep(int stepNumber); + +// Status-Übergänge public Result activate(); - public Result archive(); -// Query methods +// Query Methods public Quantity calculateRequiredInput(Quantity desiredOutput); -public List getAllIngredients(); // Flattened, including nested -public boolean containsNestedRecipe(); +public LocalDate calculateBestBeforeDate(LocalDate productionDate); +public List getAllIngredientArticleIds(); +public boolean containsSubRecipe(); ``` **Domain Events:** ```java -RecipeCreated -RecipeActivated -RecipeArchived -IngredientAdded -IngredientRemoved -YieldPercentageChanged +RecipeCreated(RecipeId, ArticleId, RecipeType) +RecipeActivated(RecipeId) +RecipeArchived(RecipeId) ``` --- ### 2. Batch (Aggregate Root) -**Verantwortung:** Repräsentiert eine Produktions-Charge mit lückenloser Rückverfolgbarkeit. +**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) - Eindeutige Chargennummer (z.B. "BATCH-2026-02-17-001") -├── RecipeId (VO) - Reference to Recipe -├── ProductionDate (VO) -├── PlannedQuantity (VO) -├── ActualQuantity (VO) - Nach Produktionsrückmeldung +├── 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) -├── ProducedAt (VO: BranchId) - Für Mehrfilialen -├── ExpiryDate (VO) - MHD des Endprodukts -├── UsedIngredientBatches[] (Entity) - CRITICAL for traceability! -│ ├── RawMaterialBatchId (VO) - Batch of used raw material -│ ├── ArticleId (VO) -│ ├── SupplierBatchNumber (VO) - Von Wareneingang -│ ├── QuantityUsed (VO) -│ └── ExpiryDate (VO) - MHD des Rohstoffs -├── Waste (VO) - Ausschuss/Schwund -└── Remarks (VO) - Optional, für Abweichungen - -Invariants: -- Batch must reference a valid Recipe -- ActualQuantity must be <= (PlannedQuantity / Recipe.YieldPercentage) -- Status can only transition: PLANNED → IN_PRODUCTION → COMPLETED (or CANCELLED) -- Cannot complete without recording used ingredient batches -- ExpiryDate must be in the future at production time -- Waste must be >= 0 -- All UsedIngredientBatches must match Recipe.Ingredients +├── 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) ``` -**Business Methods:** +**Invarianten:** ```java -public static Result plan( - RecipeId recipeId, - Quantity plannedQuantity, - LocalDate productionDate, - LocalDate expiryDate, - UserId producedBy, - BranchId branchId -); +/** + * 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 plan(BatchDraft draft); + +// Status-Übergänge public Result startProduction(); - -public Result recordIngredientUsage( - ArticleId articleId, - BatchId rawMaterialBatchId, - SupplierBatchNumber supplierBatchNumber, - Quantity quantityUsed, - LocalDate expiryDate -); - -public Result complete( - Quantity actualQuantity, - Quantity waste, - Optional remarks -); - +public Result complete(Quantity actualQuantity, Quantity waste, String remarks); public Result cancel(String reason); -// Query methods -public boolean isTraceable(); // All ingredients have batch numbers -public List getUpstreamBatches(); // All used raw material batches -public Quantity getYieldEfficiency(); // ActualQuantity / (PlannedQuantity / YieldPercentage) +// Genealogie (nur in IN_PRODUCTION) +public Result recordConsumption(ConsumptionDraft draft); + +// Query Methods +public boolean isFullyTraceable(); +public List getUpstreamBatchIds(); +public Duration getProductionDuration(); ``` **Domain Events:** ```java -BatchPlanned -BatchStarted -IngredientUsageRecorded -BatchCompleted(BatchId, ArticleId, Quantity, ExpiryDate) → triggers Inventory stock in -BatchCancelled +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 eine zukünftige Produktion. +**Verantwortung:** Plant zukünftige Produktionen. Verbindet Produktionsplanung mit der konkreten Chargen-Erzeugung. ``` ProductionOrder (Aggregate Root) ├── ProductionOrderId (VO) ├── RecipeId (VO) -├── PlannedQuantity (VO) -├── PlannedDate (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: Timestamp) -├── TargetBranch (VO: BranchId) - For multi-branch production -├── GeneratedBatchId (VO) - Link to actual Batch when production starts -└── Remarks (VO) - -Invariants: -- Planned quantity must be positive -- Planned date cannot be in the past -- Can only release if materials available (checked in Application layer!) -- Cannot complete without generating a Batch -- Status transitions: PLANNED → RELEASED → IN_PRODUCTION → COMPLETED +├── CreatedAt (VO: Instant) +├── BranchId (VO) - Ziel-Produktionsstandort +├── BatchId (VO) - Verknüpfung zur erzeugten Charge (nach Start) +├── Remarks (VO: String) - Optional +└── CancelledReason (VO: String) - Optional ``` -**Business Methods:** +**Invarianten:** ```java -public static Result create( - RecipeId recipeId, - Quantity plannedQuantity, - LocalDate plannedDate, - Priority priority, - BranchId targetBranch, - UserId createdBy -); +/** + * 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 create(ProductionOrderDraft draft); + +// Status-Übergänge public Result release(); - public Result startProduction(BatchId batchId); - public Result complete(); - public Result cancel(String reason); +// Mutations public Result reschedule(LocalDate newDate); ``` **Domain Events:** ```java -ProductionOrderCreated -ProductionOrderReleased → triggers Demand Planning update -ProductionOrderStarted -ProductionOrderCompleted -ProductionOrderCancelled -ProductionOrderRescheduled +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) ``` --- -## Value Objects +## Shared Value Objects -### RecipeId -```java -public record RecipeId(String value) { - public RecipeId { - if (value == null || value.isBlank()) { - throw new IllegalArgumentException("RecipeId cannot be empty"); - } +```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 { + <> + KILOGRAM + GRAM + LITER + MILLILITER + PIECE + METER + } + + class YieldPercentage { + +int value + +calculateRequiredInput(Quantity) Quantity + } + + class BatchNumber { + +String value + +generate(LocalDate, int) BatchNumber + } + + class RecipeType { + <> + RAW_MATERIAL + INTERMEDIATE + FINISHED_PRODUCT + } + + class Priority { + <> + LOW + NORMAL + HIGH + URGENT + } + + class RecipeStatus { + <> + DRAFT + ACTIVE + ARCHIVED + } + + class BatchStatus { + <> + PLANNED + IN_PRODUCTION + COMPLETED + CANCELLED + } + + class ProductionOrderStatus { + <> + 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 of(BigDecimal amount, UnitOfMeasure uom); + + // Factory: Dual-Quantity (Catch-Weight) + public static Result 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(); } ``` -### BatchId +### UnitOfMeasure + ```java -public record BatchId(String value) { - // Format: "BATCH-YYYY-MM-DD-XXX" - public static BatchId generate(LocalDate productionDate, int sequenceNumber) { - String value = String.format("BATCH-%s-%03d", - productionDate, sequenceNumber); - return new BatchId(value); +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 > 100) { + if (value < 1 || value > 200) { throw new IllegalArgumentException( - "Yield percentage must be between 1-100, got: " + value + "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) { - // If yield is 80%, and we want 100kg output, we need 125kg input - return desiredOutput.multiply(100.0 / value); + return desiredOutput.multiply(new BigDecimal(100).divide( + new BigDecimal(value), 4, RoundingMode.HALF_UP)); } } ``` -### RecipeType -```java -public enum RecipeType { - RAW_MATERIAL, // Rohstoff, kein Rezept - INTERMEDIATE, // Zwischenprodukt (z.B. Gewürzmischung) - FINISHED_PRODUCT // Endprodukt -} -``` +### Priority -### ProductionOrderPriority ```java public enum Priority { LOW, @@ -294,66 +638,90 @@ public enum Priority { ## Domain Services -### RecipeValidator +### RecipeCycleDependencyChecker + ```java -public class RecipeValidator { - /** - * Validates that recipe does not create circular dependencies. - */ - public Result validateNoCyclicDependency( - Recipe recipe, - RecipeRepository recipeRepository - ); +/** + * 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 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 { /** - * Finds all upstream batches (raw materials) used in a batch. + * Vorwärts-Tracing: Welche Endprodukt-Chargen verwenden diese Rohstoff-Charge? + * KRITISCH für Rückruf-Szenarien. */ - public List findUpstreamBatches(BatchId batchId); + public List traceForward(BatchId sourceBatchId); /** - * Finds all downstream batches (finished products) that used a raw material batch. - * CRITICAL for recalls! + * Rückwärts-Tracing: Welche Rohstoff-Chargen stecken in diesem Endprodukt? */ - public List findDownstreamBatches(BatchId rawMaterialBatchId); + public List traceBackward(BatchId targetBatchId); } ``` ---- +**Chargen-Genealogie (Beispiel):** -## Repository Interfaces +```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 -package com.effigenix.domain.production; - -import com.effigenix.shared.result.Result; - -public interface RecipeRepository { - Result save(Recipe recipe); - Result findById(RecipeId id); - Result> findActive(); - Result> findByArticleId(ArticleId articleId); -} - -public interface BatchRepository { - Result save(Batch batch); - Result findById(BatchId id); - Result> findByProductionDate(LocalDate date); - Result> findByStatus(BatchStatus status); - - // For traceability - Result> findByUpstreamBatch(BatchId upstreamBatchId); -} - -public interface ProductionOrderRepository { - Result save(ProductionOrder order); - Result findById(ProductionOrderId id); - Result> findByPlannedDate(LocalDate date); - Result> findByStatus(ProductionOrderStatus status); +/** + * Generiert eindeutige Chargennummern. Infrastructure-Concern (Sequenz aus DB). + */ +public interface BatchNumberGenerator { + BatchNumber generate(LocalDate productionDate); } ``` @@ -362,157 +730,397 @@ public interface ProductionOrderRepository { ## Domain Errors ```java -public sealed interface RecipeError permits - RecipeError.InvalidYieldPercentage, - RecipeError.CyclicDependencyDetected, - RecipeError.NoIngredientsError, - RecipeError.RecipeNotFound { +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 permits - BatchError.InvalidQuantity, - BatchError.InvalidStatusTransition, - BatchError.MissingIngredientBatches, - BatchError.ExpiryDateInPast, - BatchError.RecipeNotFound { +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 permits - ProductionOrderError.PlannedDateInPast, - ProductionOrderError.InvalidQuantity, - ProductionOrderError.InvalidStatusTransition, - ProductionOrderError.RecipeNotFound { +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; } + } } ``` --- -## Integration with other BCs +## Repository Interfaces -### Upstream Dependencies -- **Master Data BC:** Recipe references ArticleId -- **User Management BC:** Batch/ProductionOrder reference UserId -- **Filiales BC:** ProductionOrder references BranchId +```java +public interface RecipeRepository { + Optional findById(RecipeId id); + List findByStatus(RecipeStatus status); + List findByArticleId(ArticleId articleId); + List findActiveByIngredientArticleId(ArticleId ingredientArticleId); + void save(Recipe recipe); + boolean existsByNameAndVersion(String name, int version); +} -### Downstream Integrations -- **Inventory BC:** `BatchCompleted` event triggers stock in -- **Labeling BC:** Labeling reads Recipe data for nutrition calculation -- **Procurement BC:** ProductionOrder triggers demand planning +public interface BatchRepository { + Optional findById(BatchId id); + Optional findByBatchNumber(BatchNumber batchNumber); + List findByProductionDate(LocalDate date); + List findByStatus(BatchStatus status); + List findByArticleId(ArticleId articleId); + // Für Rückverfolgbarkeit + List findByInputBatchId(BatchId upstreamBatchId); + void save(Batch batch); +} + +public interface ProductionOrderRepository { + Optional findById(ProductionOrderId id); + List findByPlannedDate(LocalDate date); + List findByPlannedDateRange(LocalDate from, LocalDate to); + List 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 -// application/production/CreateRecipe.java -public class CreateRecipe { - public Result execute(CreateRecipeCommand cmd); -} +// Recipe Management +CreateRecipe → Recipe.create(RecipeDraft) +AddRecipeIngredient → recipe.addIngredient(IngredientDraft) +ActivateRecipe → recipe.activate() +ArchiveRecipe → recipe.archive() +GetRecipe → Query +ListActiveRecipes → Query -// application/production/PlanProduction.java -public class PlanProduction { - public Result execute(PlanProductionCommand cmd); -} +// 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) -// application/production/StartProductionBatch.java -public class StartProductionBatch { - public Result execute(StartBatchCommand cmd); -} - -// application/production/CompleteProductionBatch.java -public class CompleteProductionBatch { - public Result execute(CompleteBatchCommand cmd); - // Triggers BatchCompleted event → Inventory stock in -} +// 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 ``` --- -## Example: Batch Creation Flow +## 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. Plan Production Order -ProductionOrder order = ProductionOrder.create( - recipeId, - Quantity.of(100, "kg"), - LocalDate.now().plusDays(1), - Priority.NORMAL, - branchId, - userId -); +// 1. Rezeptur existiert (ACTIVE) +Recipe recipe = recipeRepository.findById(recipeId).orElseThrow(); -// 2. Release Order (checks material availability in Application layer) +// 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(); -// 3. Start Production → Create Batch -Batch batch = Batch.plan( - order.recipeId(), - order.plannedQuantity(), - LocalDate.now(), - LocalDate.now().plusDays(30), // MHD - userId, - branchId +// 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()); -// 4. Record ingredient usage -batch.recordIngredientUsage( - ArticleId.of("ART-001"), - BatchId.of("BATCH-2026-02-15-042"), // Supplier batch - SupplierBatchNumber.of("SUPPLIER-12345"), - Quantity.of(50, "kg"), - LocalDate.now().plusDays(20) // MHD of raw material -); +// 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 +)); -// 5. Complete Batch +// 6. Charge abschließen batch.complete( - Quantity.of(80, "kg"), // Actual output - Quantity.of(5, "kg"), // Waste - Optional.of("Leichter Schwund beim Räuchern") + Quantity.of(new BigDecimal("80"), UnitOfMeasure.KILOGRAM).value(), + Quantity.of(new BigDecimal("5"), UnitOfMeasure.KILOGRAM).value(), + "Leichter Schwund beim Räuchern" ); order.complete(); -// 6. BatchCompleted event → Inventory creates Stock entry +// 7. Events: +// BatchCompleted → Inventory bucht 80kg Endprodukt ein +// ConsumptionRecorded → Inventory bucht 125kg Rohstoff aus ``` --- -## Testing Strategy +## DDD Validation Checklist -### Unit Tests (Domain Layer) -```java -@Test -void createBatch_withValidData_succeeds() { - var result = Batch.plan(recipeId, quantity, date, expiryDate, userId, branchId); - assertThat(result.isSuccess()).isTrue(); -} - -@Test -void completeBatch_withoutIngredients_fails() { - var batch = Batch.plan(...).unsafeGetValue(); - batch.startProduction(); - - var result = batch.complete(quantity, waste, Optional.empty()); - - assertThat(result.isFailure()).isTrue(); - // Should fail with MissingIngredientBatches error -} -``` - -### Integration Tests (Application Layer) -```java -@Test -void completeProductionBatch_updatesInventory() { - // Given: Production order and started batch - // When: Complete batch - var result = completeProductionBatch.execute(command); - - // Then: Inventory stock should increase - var stock = inventoryRepository.findByArticle(articleId); - assertThat(stock.quantity()).isEqualTo(expectedQuantity); -} -``` +- [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 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) diff --git a/backend/docs/mvp/ddd/05-qualitaets-kontext.md b/backend/docs/mvp/ddd/05-qualitaets-kontext.md deleted file mode 100644 index ea08581..0000000 --- a/backend/docs/mvp/ddd/05-qualitaets-kontext.md +++ /dev/null @@ -1,192 +0,0 @@ -# Qualitäts-Kontext (HACCP/QM) - Detailliertes Domain Model - -**Bounded Context:** Qualität -**Domain-Typ:** KERN -**Verantwortung:** HACCP-Compliance, Qualitätsmanagement, Audit-Vorbereitung - ---- - -## Aggregate - -### 1. Temperaturprotokoll (Aggregate Root) - -**Struktur:** -``` -Temperaturprotokoll -├── TemperaturprotokollId (Wertobjekt) -├── Messpunkt (Wertobjekt: KUEHLRAUM | TIEFKUEHLER | VERKAUFSTHEKE | PRODUKTIONSRAUM) -├── GeraetId (Wertobjekt) - Referenz zu Ausstattung -├── GemessenAm (Wertobjekt: Zeitstempel) -├── Temperatur (Wertobjekt: mit Einheit °C) -├── GemessenVon (Wertobjekt: BenutzerId) -├── KritischeGrenzwertMin (Wertobjekt) -├── KritischeGrenzwertMax (Wertobjekt) -└── Status (Wertobjekt: OK | WARNUNG | KRITISCH) - -Invarianten: -- Temperatur muss im physikalisch möglichen Bereich liegen (-50°C bis +50°C) -- GemessenAm darf nicht in der Zukunft liegen -- Status = KRITISCH wenn Temperatur außerhalb kritischer Grenzwerte -- Status = WARNUNG wenn Temperatur nahe an Grenzwerten (innerhalb 10%) -- KritischeGrenzwertMin < KritischeGrenzwertMax -``` - -**Domänen-Events:** -```java -TemperaturKritischeGrenzwerteUeberschritten(TemperaturprotokollId, Messpunkt, Temperatur) -``` - ---- - -### 2. Reinigungsnachweis (Aggregate Root) - -**Struktur:** -``` -Reinigungsnachweis -├── ReinigungsnachweisId (Wertobjekt) -├── Bereich (Wertobjekt: PRODUKTIONSRAUM | KUEHLRAUM | VERKAUFSTHEKE | GERAET) -├── ReinigungsplanId (Wertobjekt) - Referenz zu Reinigungsplan -├── GeplantesFuer (Wertobjekt: Datum) -├── AbgeschlossenAm (Wertobjekt: Zeitstempel) -├── AbgeschlossenVon (Wertobjekt: BenutzerId) -├── Checklisten-Eintraege[] (Entität) -│ ├── Eintrag (Wertobjekt: "Boden gewischt", "Oberflächen desinfiziert") -│ ├── Erledigt (Wertobjekt: boolean) -│ └── Bemerkungen (Wertobjekt: optional) -└── GesamtBemerkungen (Wertobjekt) - -Invarianten: -- Alle Checklisten-Einträge müssen erledigt sein zum Abschluss -- AbgeschlossenAm muss >= GeplantesFuer sein -- Kann nicht abgeschlossen werden ohne AbgeschlossenVon -- Kann nach Abschluss nicht mehr geändert werden -``` - -**Domänen-Events:** -```java -ReinigungUeberfaellig(ReinigungsplanId, Bereich, LocalDate geplantesFuer) -``` - ---- - -### 3. Wareneingangspruefung (Aggregate Root) - -**Struktur:** -``` -Wareneingangspruefung -├── PruefungId (Wertobjekt) -├── WareneingangId (Wertobjekt) - Referenz zu Beschaffungs-Kontext -├── GeprueftAm (Wertobjekt) -├── GeprueftVon (Wertobjekt: BenutzerId) -├── Temperaturpruefung (Entität) -│ ├── GemesseneTemperatur (Wertobjekt) -│ ├── ErwarteterBereich (Wertobjekt) -│ └── Status (Wertobjekt: BESTANDEN | DURCHGEFALLEN) -├── Sichtpruefung (Entität) -│ ├── VerpackungIntakt (Wertobjekt: boolean) -│ ├── Farbe (Wertobjekt: NORMAL | ABNORMAL) -│ ├── Geruchstest (Wertobjekt: NORMAL | ABNORMAL) -│ └── Bemerkungen (Wertobjekt) -├── MHD-Pruefung (Entität) -│ ├── Verfallsdatum (Wertobjekt) -│ ├── TageBeisVerfallsdatum (Wertobjekt) -│ ├── MinimalAkzeptableTage (Wertobjekt) -│ └── Status (Wertobjekt: BESTANDEN | DURCHGEFALLEN) -├── Dokumentenpruefung (Entität) -│ ├── LieferscheinErhalten (Wertobjekt: boolean) -│ ├── VeterinaerbescheinigungErhalten (Wertobjekt: boolean) -│ ├── Qualitaetszertifikate[] (Wertobjekt) -│ └── AlleDokumenteVollstaendig (Wertobjekt: boolean) -├── LieferantenChargennummer (Wertobjekt) - Für Rückverfolgbarkeit! -└── Endergebnis (Wertobjekt: ANGENOMMEN | ABGELEHNT | BEDINGT_ANGENOMMEN) - -Invarianten: -- Alle Prüfungen müssen durchgeführt sein, bevor Endergebnis gesetzt werden kann -- Bei ABGELEHNT müssen GesamtBemerkungen angegeben werden -- Temperatur muss im akzeptablen Bereich liegen für ANGENOMMEN -- MHD muss Mindesttage haben für ANGENOMMEN -- Veterinärbescheinigung erforderlich für Fleischprodukte -``` - -**Domänen-Events:** -```java -WareneingangAbgelehnt(PruefungId, WareneingangId, String grund) -``` - ---- - -### 4. Schulungsnachweis (Aggregate Root) - -**Struktur:** -``` -Schulungsnachweis -├── SchulungsnachweisId (Wertobjekt) -├── MitarbeiterId (Wertobjekt: BenutzerId) -├── SchulungsTyp (Wertobjekt: HACCP | HYGIENE | LEBENSMITTELSICHERHEIT | GERAETEBEDIENUNG) -├── Schulungsdatum (Wertobjekt) -├── GueltigBis (Wertobjekt) - Auffrischung notwendig -├── Schulender (Wertobjekt) - Intern oder extern -├── Zertifikatsnummer (Wertobjekt) -├── ZertifikatsDokumentUrl (Wertobjekt) -└── Status (Wertobjekt: GUELTIG | ABGELAUFEN | WIDERRUFEN) - -Invarianten: -- GueltigBis muss nach Schulungsdatum liegen -- Status = ABGELAUFEN wenn GueltigBis < HEUTE -- Kann nicht widerrufen werden ohne Grund -``` - -**Domänen-Events:** -```java -SchulungLaeuftDemnaechstAb(SchulungsnachweisId, BenutzerId, LocalDate ablaufdatum) -``` - ---- - -### 5. Wartungsprotokoll (Aggregate Root) - -**Struktur:** -``` -Wartungsprotokoll -├── WartungsprotokollId (Wertobjekt) -├── GeraetId (Wertobjekt) -├── WartungsTyp (Wertobjekt: GEPLANT | REPARATUR | INSPEKTION) -├── GeplantesFuer (Wertobjekt: Datum) -├── DurchgefuehrtAm (Wertobjekt: Zeitstempel) -├── DurchgefuehrtVon (Wertobjekt) - Internes Personal oder externe Firma -├── Befunde (Wertobjekt) -├── Massnahmen (Wertobjekt) -├── NaechsteWartungFaellig (Wertobjekt: Datum) -└── Status (Wertobjekt: ABGESCHLOSSEN | AUSSTEHEND | FEHLGESCHLAGEN) - -Invarianten: -- DurchgefuehrtAm muss >= GeplantesFuer sein -- Bei FEHLGESCHLAGEN müssen Befunde und Massnahmen dokumentiert sein -- NaechsteWartungFaellig muss basierend auf Wartungsintervall berechnet werden -``` - -**Domänen-Events:** -```java -WartungUeberfaellig(GeraetId, LocalDate geplantesFuer) -``` - ---- - -## Repository-Schnittstellen - -```java -public interface TemperaturprotokollRepository { - Result speichern(Temperaturprotokoll protokoll); - Result> findeNachZeitraum( - LocalDate von, LocalDate bis - ); - Result> findeKritische(); -} - -public interface WareneingangspruefungRepository { - Result speichern(Wareneingangspruefung pruefung); - Result findeNachWareneingangId( - WareneingangId id - ); -} -``` diff --git a/backend/docs/mvp/ddd/05-quality-bc.md b/backend/docs/mvp/ddd/05-quality-bc.md index 1f30f88..625ec7c 100644 --- a/backend/docs/mvp/ddd/05-quality-bc.md +++ b/backend/docs/mvp/ddd/05-quality-bc.md @@ -2,7 +2,190 @@ **Bounded Context:** Quality **Domain Type:** CORE -**Verantwortung:** HACCP-Compliance, Qualitätsmanagement, Audit-Vorbereitung +**Verantwortung:** HACCP-Compliance, Qualitätsmanagement, Audit-Vorbereitung, lückenlose Dokumentation aller qualitätsrelevanten Prozesse + +--- + +## Ubiquitous Language + +| Begriff (DE) | Begriff (EN/Code) | Typ | Definition | +|---|---|---|---| +| Temperaturprotokoll | TemperatureLog | Aggregate | Standortbezogene Temperaturmessung an kritischem Punkt (Kühlraum, Theke) mit Grenzwertüberwachung | +| Messpunkt | MeasurementPoint | VO (Enum) | Physischer Ort der Messung: COLD_ROOM, FREEZER, DISPLAY_COUNTER, PRODUCTION_ROOM | +| Temperatur | Temperature | VO | Temperaturwert in °C mit physikalisch plausiblem Bereich (-50 bis +50) | +| Kritischer Grenzwert | CriticalLimit | VO | Min-/Max-Paar für einen CCP (z.B. Kühlraum 2-7°C) | +| Reinigungsplan | CleaningPlan | Aggregate | Vorlage für Reinigungsaufgaben mit Intervall, Bereich und Checkliste | +| Reinigungsintervall | CleaningInterval | VO (Enum) | Turnus: DAILY, WEEKLY, MONTHLY | +| Reinigungsnachweis | CleaningRecord | Aggregate | Durchgeführte Reinigung gegen einen CleaningPlan mit Checkliste und Nachweis | +| Checklisten-Eintrag | ChecklistItem | Entity | Einzelner Prüfpunkt in einer Reinigung oder Inspektion | +| Wareneingangskontrolle | GoodsReceiptInspection | Aggregate | Mehrteilige Prüfung bei Warenanlieferung (Temperatur, Sicht, MHD, Dokumente) | +| Temperaturprüfung | TemperatureCheck | VO | Temperaturmessung bei Wareneingang mit Soll-/Ist-Vergleich | +| Sichtkontrolle | VisualCheck | VO | Prüfung von Verpackung, Farbe, Geruch | +| MHD-Prüfung | ShelfLifeCheck | VO | Prüfung des Mindesthaltbarkeitsdatums gegen Mindest-Restlaufzeit | +| Dokumentenprüfung | DocumentCheck | VO | Prüfung von Lieferschein, Veterinärbescheinigung, Zertifikaten | +| Probenentnahme | SampleRecord | Aggregate | Probenentnahme mit Analyseergebnis, Charge und Prüfmethode | +| Analyseergebnis | AnalysisResult | VO | Messergebnis einer Probe mit Einheit und Bewertung | +| Schulungsnachweis | TrainingRecord | Aggregate | Nachweis einer absolvierten Schulung mit Gültigkeitsdauer | +| Schulungsart | TrainingType | VO (Enum) | Klassifizierung: HACCP, HYGIENE, FOOD_SAFETY, EQUIPMENT_OPERATION, FIRST_AID | +| Wartungsprotokoll | MaintenanceRecord | Aggregate | Dokumentation einer Gerätewartung (planmäßig/Störung) mit Befund und Maßnahmen | +| Wartungsart | MaintenanceType | VO (Enum) | SCHEDULED, REPAIR, CALIBRATION, INSPECTION | +| Qualitätssperre | QualityHold | Aggregate | Sperre einer Charge mit Block/Release-Workflow und Begründung | +| Prozessparameter | ProcessParameter | Aggregate | Batch-bezogene CCP-Messwerte (Kerntemperatur, pH, aw-Wert) | +| CCP-Typ | CcpType | VO (Enum) | Typ des kritischen Kontrollpunkts: CORE_TEMPERATURE, PH_VALUE, WATER_ACTIVITY, METAL_DETECTION | +| Abweichung | Deviation | Concept | Überschreitung eines Grenzwerts oder Nichteinhaltung eines Verfahrens | +| Korrekturmaßnahme | CorrectiveAction | Entity | Maßnahme zur Behebung einer Abweichung mit Verantwortlichem und Frist | +| HACCP-Report | HaccpReport | Concept | Audit-Report aggregiert aus allen Quality-Aggregates | + +--- + +## Aggregate-Übersicht + +```mermaid +--- +config: + theme: neutral + look: classic + layout: elk + themeVariables: + background: "#f8fafc" + class: + hideEmptyMembersBox: true +--- +classDiagram + class TemperatureLog { + +TemperatureLogId id + +MeasurementPoint measurementPoint + +String deviceId + +Temperature temperature + +CriticalLimit criticalLimit + +TemperatureStatus status + +UserId measuredBy + +Instant measuredAt + +record(TemperatureLogDraft) Result~TemperatureLogError, TemperatureLog~ + } + + class CleaningPlan { + +CleaningPlanId id + +String name + +CleaningArea area + +CleaningInterval interval + +List~String~ checklistTemplate + +CleaningPlanStatus status + +create(CleaningPlanDraft) Result~CleaningPlanError, CleaningPlan~ + +update(CleaningPlanUpdateDraft) Result~CleaningPlanError, Void~ + +activate() Result~CleaningPlanError, Void~ + +deactivate() Result~CleaningPlanError, Void~ + } + + class CleaningRecord { + +CleaningRecordId id + +CleaningPlanId cleaningPlanId + +LocalDate scheduledFor + +List~ChecklistItem~ checklistItems + +CleaningRecordStatus status + +UserId completedBy + +Instant completedAt + +create(CleaningRecordDraft) Result~CleaningRecordError, CleaningRecord~ + +checkItem(int, String) Result~CleaningRecordError, Void~ + +complete(UserId) Result~CleaningRecordError, Void~ + } + + class GoodsReceiptInspection { + +InspectionId id + +String goodsReceiptId + +String supplierBatchNumber + +TemperatureCheck temperatureCheck + +VisualCheck visualCheck + +ShelfLifeCheck shelfLifeCheck + +DocumentCheck documentCheck + +InspectionResult result + +UserId inspectedBy + +Instant inspectedAt + +create(GoodsReceiptInspectionDraft) Result~InspectionError, GoodsReceiptInspection~ + +recordTemperatureCheck(TemperatureCheckDraft) Result~InspectionError, Void~ + +recordVisualCheck(VisualCheckDraft) Result~InspectionError, Void~ + +recordShelfLifeCheck(ShelfLifeCheckDraft) Result~InspectionError, Void~ + +recordDocumentCheck(DocumentCheckDraft) Result~InspectionError, Void~ + +finalize() Result~InspectionError, Void~ + } + + class SampleRecord { + +SampleRecordId id + +String batchId + +SampleType sampleType + +AnalysisResult analysisResult + +CriticalLimit criticalLimit + +SampleStatus status + +UserId sampledBy + +Instant sampledAt + +record(SampleRecordDraft) Result~SampleRecordError, SampleRecord~ + +enterResult(AnalysisResultDraft) Result~SampleRecordError, Void~ + } + + class TrainingRecord { + +TrainingRecordId id + +UserId employeeId + +TrainingType trainingType + +LocalDate trainingDate + +LocalDate validUntil + +String trainer + +String certificateNumber + +TrainingStatus status + +record(TrainingRecordDraft) Result~TrainingRecordError, TrainingRecord~ + +revoke(String) Result~TrainingRecordError, Void~ + } + + class MaintenanceRecord { + +MaintenanceRecordId id + +String equipmentId + +MaintenanceType maintenanceType + +LocalDate scheduledFor + +Instant performedAt + +String performedBy + +String findings + +String actionsTaken + +LocalDate nextMaintenanceDue + +MaintenanceStatus status + +schedule(MaintenanceRecordDraft) Result~MaintenanceRecordError, MaintenanceRecord~ + +complete(MaintenanceCompletionDraft) Result~MaintenanceRecordError, Void~ + +fail(String, String) Result~MaintenanceRecordError, Void~ + } + + class QualityHold { + +QualityHoldId id + +String batchId + +HoldReason reason + +String description + +QualityHoldStatus status + +UserId blockedBy + +Instant blockedAt + +UserId releasedBy + +Instant releasedAt + +String releaseJustification + +block(QualityHoldDraft) Result~QualityHoldError, QualityHold~ + +release(UserId, String) Result~QualityHoldError, Void~ + +reject(UserId, String) Result~QualityHoldError, Void~ + } + + class ProcessParameter { + +ProcessParameterId id + +String batchId + +CcpType ccpType + +BigDecimal measuredValue + +String unit + +CriticalLimit criticalLimit + +ParameterStatus status + +UserId measuredBy + +Instant measuredAt + +record(ProcessParameterDraft) Result~ProcessParameterError, ProcessParameter~ + } + + CleaningRecord --> CleaningPlan : referenziert + CleaningRecord "1" *-- "*" ChecklistItem : enthält + QualityHold --> Batch : sperrt + ProcessParameter --> Batch : misst + SampleRecord --> Batch : beprobt +``` --- @@ -10,153 +193,1657 @@ ### 1. TemperatureLog (Aggregate Root) +**Verantwortung:** Standortbezogene Temperaturprotokollierung für Kühlräume, Tiefkühler, Theken und Produktionsräume. Automatische Status-Ermittlung gegen kritische Grenzwerte (Epic 3.1). + ``` -TemperatureLog +TemperatureLog (Aggregate Root) ├── TemperatureLogId (VO) ├── MeasurementPoint (VO: COLD_ROOM | FREEZER | DISPLAY_COUNTER | PRODUCTION_ROOM) -├── DeviceId (VO) - Reference to Equipment -├── MeasuredAt (VO: Timestamp) -├── Temperature (VO: with unit °C) +├── DeviceId (VO: String) - Referenz auf Messgerät/Standort +├── Temperature (VO) - Gemessener Wert in °C +├── CriticalLimit (VO) - Min/Max-Grenzwerte für diesen Messpunkt +├── Status (VO: OK | WARNING | CRITICAL) - Automatisch berechnet ├── MeasuredBy (VO: UserId) -├── CriticalLimitMin (VO) -├── CriticalLimitMax (VO) -└── Status (VO: OK | WARNING | CRITICAL) - -Invariants: -- Temperature must be within physically possible range (-50°C to +50°C) -- MeasuredAt cannot be in the future -- Status = CRITICAL if temperature outside critical limits -- Status = WARNING if temperature close to limits (within 10%) -- CriticalLimitMin < CriticalLimitMax +├── MeasuredAt (VO: Instant) +└── Remarks (VO: String) - Optional, bei Abweichungen ``` -### 2. CleaningRecord (Aggregate Root) +**Invarianten:** +```java +/** + * TemperatureLog aggregate root. + * + * Invariants: + * - Temperature must be within physically possible range (-50°C to +50°C) + * - MeasuredAt cannot be in the future + * - CriticalLimit.min < CriticalLimit.max + * - Status is automatically derived: + * CRITICAL if temperature outside critical limits + * WARNING if temperature within 10% of limit boundary + * OK otherwise + * - Immutable after creation (append-only log) + */ +``` + +**Draft-Record:** +```java +public record TemperatureLogDraft( + String measurementPoint, // COLD_ROOM | FREEZER | DISPLAY_COUNTER | PRODUCTION_ROOM + String deviceId, + String temperature, // BigDecimal als String (°C) + String criticalLimitMin, // BigDecimal als String (°C) + String criticalLimitMax, // BigDecimal als String (°C) + String measuredBy, // UserId + String remarks // nullable +) {} +``` + +**Factory & Business Methods:** +```java +// Factory (einzige Erzeugung, immutable danach) +public static Result record(TemperatureLogDraft draft); + +// Query Methods +public boolean isCritical(); +public boolean isWarning(); +public BigDecimal deviationFromLimit(); // Abweichung vom nächsten Grenzwert +``` + +**Domain Events:** +```java +TemperatureLogged(TemperatureLogId, MeasurementPoint, Temperature, TemperatureStatus) +TemperatureCriticalLimitExceeded(TemperatureLogId, MeasurementPoint, Temperature, CriticalLimit) +``` + +--- + +### 2. CleaningPlan (Aggregate Root) + +**Verantwortung:** Vorlage für wiederkehrende Reinigungsaufgaben mit Bereich, Intervall und Checklisten-Template. Bildet die Grundlage für CleaningRecords (Epic 3.2). ``` -CleaningRecord +CleaningPlan (Aggregate Root) +├── CleaningPlanId (VO) +├── Name (VO: String) - Bezeichnung (z.B. "Tägliche Reinigung Kühlraum 1") +├── Area (VO: PRODUCTION_ROOM | COLD_STORAGE | SALES_COUNTER | EQUIPMENT | VEHICLE) +├── Interval (VO: DAILY | WEEKLY | MONTHLY) +├── ChecklistTemplate[] (VO: List) - Vorlagen-Checkliste +├── Status (VO: ACTIVE | INACTIVE) +├── CreatedBy (VO: UserId) +└── CreatedAt (VO: Instant) +``` + +**Invarianten:** +```java +/** + * CleaningPlan aggregate root. + * + * Invariants: + * - Name must not be blank + * - ChecklistTemplate must have at least one item + * - Only ACTIVE plans can generate CleaningRecords + * - Status transitions: ACTIVE ↔ INACTIVE (bidirectional) + * - ChecklistTemplate items must not be blank + */ +``` + +**Draft-Records:** +```java +public record CleaningPlanDraft( + String name, + String area, // PRODUCTION_ROOM | COLD_STORAGE | SALES_COUNTER | EQUIPMENT | VEHICLE + String interval, // DAILY | WEEKLY | MONTHLY + List checklistTemplate, + String createdBy // UserId +) {} + +public record CleaningPlanUpdateDraft( + String name, // nullable = keine Änderung + String interval, // nullable + List checklistTemplate // nullable +) {} +``` + +**Factory & Business Methods:** +```java +// Factory +public static Result create(CleaningPlanDraft draft); + +// Mutations +public Result update(CleaningPlanUpdateDraft draft); +public Result activate(); +public Result deactivate(); + +// Query Methods +public boolean isDueOn(LocalDate date); +``` + +**Domain Events:** +```java +CleaningPlanCreated(CleaningPlanId, CleaningArea, CleaningInterval) +CleaningPlanDeactivated(CleaningPlanId) +``` + +--- + +### 3. CleaningRecord (Aggregate Root) + +**Verantwortung:** Nachweis einer durchgeführten Reinigung gegen einen CleaningPlan. Enthält abgehakte Checkliste und optionale Anmerkungen (Epic 3.2). + +``` +CleaningRecord (Aggregate Root) ├── CleaningRecordId (VO) -├── Area (VO: PRODUCTION_ROOM | COLD_STORAGE | SALES_COUNTER | EQUIPMENT) -├── CleaningPlanId (VO) - Reference to CleaningPlan -├── ScheduledFor (VO: Date) -├── CompletedAt (VO: Timestamp) -├── CompletedBy (VO: UserId) +├── CleaningPlanId (VO) - Referenz auf zugehörigen Plan +├── ScheduledFor (VO: LocalDate) - Geplanter Termin ├── ChecklistItems[] (Entity) -│ ├── Item (VO: "Floor mopped", "Surfaces disinfected") -│ ├── Checked (VO: boolean) -│ └── Remarks (VO: optional) -└── OverallRemarks (VO) - -Invariants: -- All checklist items must be checked to complete -- CompletedAt must be >= ScheduledFor -- Cannot complete without CompletedBy -- Cannot modify after completion +│ ├── ChecklistItemId (VO: int) - Position in der Liste +│ ├── Description (VO: String) - Aus CleaningPlan-Template kopiert +│ ├── Checked (boolean) - Abgehakt? +│ └── Remarks (VO: String) - Optional, bei Besonderheiten +├── Status (VO: OPEN | IN_PROGRESS | COMPLETED | OVERDUE) +├── CompletedBy (VO: UserId) - Wer hat die Reinigung durchgeführt? +├── CompletedAt (VO: Instant) +└── OverallRemarks (VO: String) - Optional ``` -### 3. GoodsReceiptInspection (Aggregate Root) +**Invarianten:** +```java +/** + * CleaningRecord aggregate root. + * + * Invariants: + * - Must reference an existing CleaningPlanId + * - ChecklistItems are initialized from CleaningPlan template at creation + * - All ChecklistItems must be checked before completion + * - CompletedBy and CompletedAt are set at completion + * - Cannot modify after COMPLETED + * - Status transitions: OPEN → IN_PROGRESS → COMPLETED + * OPEN → OVERDUE (system-triggered if past scheduledFor) + * OVERDUE → IN_PROGRESS → COMPLETED + * - ChecklistItemId (position) must be unique within the record + */ +``` + +**Draft-Record:** +```java +public record CleaningRecordDraft( + String cleaningPlanId, + String scheduledFor, // ISO LocalDate + List checklistItems // Kopie aus CleaningPlan-Template +) {} +``` + +**Factory & Business Methods:** +```java +// Factory +public static Result create(CleaningRecordDraft draft); + +// Mutations +public Result checkItem(int position, String remarks); +public Result complete(UserId completedBy); +public Result markOverdue(); + +// Query Methods +public boolean isFullyChecked(); +public int checkedCount(); +public int totalCount(); +``` + +**Domain Events:** +```java +CleaningRecordCreated(CleaningRecordId, CleaningPlanId, LocalDate scheduledFor) +CleaningRecordCompleted(CleaningRecordId, CleaningPlanId, UserId completedBy) +CleaningOverdue(CleaningRecordId, CleaningPlanId, LocalDate scheduledFor) +``` + +--- + +### 4. GoodsReceiptInspection (Aggregate Root) + +**Verantwortung:** Mehrteilige Wareneingangsprüfung mit Temperatur-, Sicht-, MHD- und Dokumentencheck. Gesamtergebnis wird aus Einzelprüfungen abgeleitet (Epic 3.3). ``` -GoodsReceiptInspection +GoodsReceiptInspection (Aggregate Root) ├── InspectionId (VO) -├── GoodsReceiptId (VO) - Reference to Procurement BC -├── InspectedAt (VO) +├── GoodsReceiptId (VO: String) - Referenz auf Procurement BC +├── SupplierBatchNumber (VO: String) - Lieferanten-Chargennummer (Rückverfolgbarkeit) +├── TemperatureCheck (VO) +│ ├── MeasuredTemperature (BigDecimal) - Ist-Temperatur +│ ├── ExpectedMin (BigDecimal) - Soll-Min +│ ├── ExpectedMax (BigDecimal) - Soll-Max +│ └── Passed (boolean) +├── VisualCheck (VO) +│ ├── PackagingIntact (boolean) +│ ├── ColorAppearance (NORMAL | ABNORMAL) +│ ├── SmellCheck (NORMAL | ABNORMAL) +│ ├── Remarks (String) - Optional +│ └── Passed (boolean) +├── ShelfLifeCheck (VO) +│ ├── ExpiryDate (LocalDate) +│ ├── MinimumAcceptableDays (int) - Mindest-Restlaufzeit +│ ├── ActualDaysRemaining (int) +│ └── Passed (boolean) +├── DocumentCheck (VO) +│ ├── DeliveryNoteReceived (boolean) +│ ├── VeterinaryCertificateRequired (boolean) +│ ├── VeterinaryCertificateReceived (boolean) +│ ├── QualityCertificateReceived (boolean) +│ └── Passed (boolean) +├── Result (VO: PENDING | ACCEPTED | REJECTED | CONDITIONALLY_ACCEPTED) +├── RejectionReason (VO: String) - Pflicht bei REJECTED +├── Conditions (VO: String) - Pflicht bei CONDITIONALLY_ACCEPTED ├── InspectedBy (VO: UserId) -├── TemperatureCheck (Entity) -│ ├── MeasuredTemperature (VO) -│ ├── ExpectedRange (VO) -│ └── Status (VO: PASSED | FAILED) -├── VisualInspection (Entity) -│ ├── PackagingIntact (VO: boolean) -│ ├── ColorAppearance (VO: NORMAL | ABNORMAL) -│ ├── SmellTest (VO: NORMAL | ABNORMAL) -│ └── Remarks (VO) -├── MHDCheck (Entity) -│ ├── ExpiryDate (VO) -│ ├── DaysUntilExpiry (VO) -│ ├── MinimumAcceptableDays (VO) -│ └── Status (VO: PASSED | FAILED) -├── DocumentCheck (Entity) -│ ├── DeliveryNoteReceived (VO: boolean) -│ ├── VeterinaryCertificateReceived (VO: boolean) -│ ├── QualityCertificates[] (VO) -│ └── AllDocumentsComplete (VO: boolean) -├── SupplierBatchNumber (VO) - For traceability! -└── FinalResult (VO: ACCEPTED | REJECTED | CONDITIONALLY_ACCEPTED) - -Invariants: -- All checks must be performed before FinalResult can be set -- If REJECTED, OverallRemarks must be provided -- Temperature must be within acceptable range for ACCEPTED -- MHD must have minimum days for ACCEPTED -- VeterinaryCertificate required for meat products +└── InspectedAt (VO: Instant) ``` -### 4. TrainingRecord (Aggregate Root) +**Invarianten:** +```java +/** + * GoodsReceiptInspection aggregate root. + * + * Invariants: + * - All four checks must be recorded before finalize() + * - Result is PENDING until finalize() + * - If any check fails → Result cannot be ACCEPTED + * - If REJECTED → RejectionReason must not be blank + * - If CONDITIONALLY_ACCEPTED → Conditions must not be blank + * - TemperatureCheck: MeasuredTemperature must be within ExpectedMin/Max for Passed=true + * - ShelfLifeCheck: ActualDaysRemaining >= MinimumAcceptableDays for Passed=true + * - DocumentCheck: VeterinaryCertificateReceived required if VeterinaryCertificateRequired + * - Immutable after finalize() (ACCEPTED/REJECTED/CONDITIONALLY_ACCEPTED) + * - Status transitions: PENDING → ACCEPTED | REJECTED | CONDITIONALLY_ACCEPTED + */ +``` + +**Draft-Records:** +```java +public record GoodsReceiptInspectionDraft( + String goodsReceiptId, + String supplierBatchNumber, + String inspectedBy // UserId +) {} + +public record TemperatureCheckDraft( + String measuredTemperature, // BigDecimal als String (°C) + String expectedMin, // BigDecimal als String + String expectedMax // BigDecimal als String +) {} + +public record VisualCheckDraft( + boolean packagingIntact, + String colorAppearance, // NORMAL | ABNORMAL + String smellCheck, // NORMAL | ABNORMAL + String remarks // nullable +) {} + +public record ShelfLifeCheckDraft( + String expiryDate, // ISO LocalDate + int minimumAcceptableDays +) {} + +public record DocumentCheckDraft( + boolean deliveryNoteReceived, + boolean veterinaryCertificateRequired, + boolean veterinaryCertificateReceived, + boolean qualityCertificateReceived +) {} +``` + +**Factory & Business Methods:** +```java +// Factory +public static Result create( + GoodsReceiptInspectionDraft draft +); + +// Einzelne Checks aufnehmen (Reihenfolge egal) +public Result recordTemperatureCheck(TemperatureCheckDraft draft); +public Result recordVisualCheck(VisualCheckDraft draft); +public Result recordShelfLifeCheck(ShelfLifeCheckDraft draft); +public Result recordDocumentCheck(DocumentCheckDraft draft); + +// Abschluss: leitet Result aus Einzelprüfungen ab +public Result finalize(); + +// Manuelles Override bei CONDITIONALLY_ACCEPTED +public Result acceptConditionally(String conditions); +public Result reject(String reason); + +// Query Methods +public boolean allChecksRecorded(); +public List failedChecks(); +``` + +**Domain Events:** +```java +GoodsReceiptInspectionCreated(InspectionId, String goodsReceiptId) +GoodsReceiptAccepted(InspectionId, String goodsReceiptId, String supplierBatchNumber) +GoodsReceiptRejected(InspectionId, String goodsReceiptId, String reason) +GoodsReceiptConditionallyAccepted(InspectionId, String goodsReceiptId, String conditions) +``` + +--- + +### 5. SampleRecord (Aggregate Root) + +**Verantwortung:** Probenentnahme mit Analyseergebnis. Verknüpft mit einer Charge, Prüfmethode und kritischem Grenzwert. Ergebnis wird nachträglich eingetragen (Epic 3.4). ``` -TrainingRecord +SampleRecord (Aggregate Root) +├── SampleRecordId (VO) +├── BatchId (VO: String) - Referenz auf Produktions-Charge +├── SampleType (VO: MICROBIOLOGICAL | CHEMICAL | PHYSICAL | SENSORY) +├── SampleDescription (VO: String) - Was wurde beprobt +├── AnalysisMethod (VO: String) - Prüfmethode (z.B. "PCR", "pH-Messung") +├── AnalysisResult (VO) - Nullable bis Ergebnis vorliegt +│ ├── MeasuredValue (BigDecimal) +│ ├── Unit (String) - z.B. "CFU/g", "pH", "%" +│ └── Interpretation (String) - Freitext-Bewertung +├── CriticalLimit (VO) - Grenzwert für Bewertung +├── Status (VO: PENDING | PASSED | FAILED) +├── SampledBy (VO: UserId) +├── SampledAt (VO: Instant) +├── ResultEnteredAt (VO: Instant) - Nullable +└── Remarks (VO: String) - Optional +``` + +**Invarianten:** +```java +/** + * SampleRecord aggregate root. + * + * Invariants: + * - BatchId must not be blank + * - SampleDescription must not be blank + * - Status is PENDING until AnalysisResult is entered + * - Status = PASSED if measuredValue within CriticalLimit + * - Status = FAILED if measuredValue outside CriticalLimit + * - AnalysisResult can only be entered once (immutable after) + * - ResultEnteredAt must be after SampledAt + * - CriticalLimit.min < CriticalLimit.max (if both set) + */ +``` + +**Draft-Records:** +```java +public record SampleRecordDraft( + String batchId, + String sampleType, // MICROBIOLOGICAL | CHEMICAL | PHYSICAL | SENSORY + String sampleDescription, + String analysisMethod, + String criticalLimitMin, // BigDecimal als String, nullable (einseitiger Grenzwert möglich) + String criticalLimitMax, // BigDecimal als String, nullable + String sampledBy // UserId +) {} + +public record AnalysisResultDraft( + String measuredValue, // BigDecimal als String + String unit, + String interpretation // nullable +) {} +``` + +**Factory & Business Methods:** +```java +// Factory +public static Result record(SampleRecordDraft draft); + +// Ergebnis nachtragen +public Result enterResult(AnalysisResultDraft draft); + +// Query Methods +public boolean isPending(); +public boolean hasPassed(); +``` + +**Domain Events:** +```java +SampleRecorded(SampleRecordId, String batchId, SampleType) +SamplePassed(SampleRecordId, String batchId) +SampleFailed(SampleRecordId, String batchId, BigDecimal measuredValue, CriticalLimit limit) +``` + +--- + +### 6. TrainingRecord (Aggregate Root) + +**Verantwortung:** Schulungsnachweise für Mitarbeiter mit Gültigkeitsdauer. Warnt bei ablaufenden Zertifikaten (Epic 3.5). + +``` +TrainingRecord (Aggregate Root) ├── TrainingRecordId (VO) -├── EmployeeId (VO: UserId) -├── TrainingType (VO: HACCP | HYGIENE | FOOD_SAFETY | EQUIPMENT_OPERATION) -├── TrainingDate (VO) -├── ValidUntil (VO) - Auffrischung notwendig -├── Trainer (VO) - Internal or external -├── CertificateNumber (VO) -├── CertificateDocumentUrl (VO) -└── Status (VO: VALID | EXPIRED | REVOKED) - -Invariants: -- ValidUntil must be after TrainingDate -- Status = EXPIRED if ValidUntil < TODAY -- Cannot revoke without reason +├── EmployeeId (VO: UserId) - Geschulter Mitarbeiter +├── TrainingType (VO: HACCP | HYGIENE | FOOD_SAFETY | EQUIPMENT_OPERATION | FIRST_AID) +├── TrainingDate (VO: LocalDate) +├── ValidUntil (VO: LocalDate) - Auffrischung notwendig +├── Trainer (VO: String) - Name des Trainers (intern/extern) +├── CertificateNumber (VO: String) - Optional +├── Status (VO: VALID | EXPIRING_SOON | EXPIRED | REVOKED) +├── RevokedReason (VO: String) - Pflicht bei REVOKED +└── RevokedAt (VO: Instant) - Optional ``` -### 5. MaintenanceRecord (Aggregate Root) +**Invarianten:** +```java +/** + * TrainingRecord aggregate root. + * + * Invariants: + * - ValidUntil must be after TrainingDate + * - Status is derived: + * VALID if ValidUntil > today + 30 days + * EXPIRING_SOON if ValidUntil <= today + 30 days and > today + * EXPIRED if ValidUntil <= today + * REVOKED if explicitly revoked + * - Cannot revoke without reason + * - Immutable after creation (except revoke) + * - TrainingDate cannot be in the future + * - Trainer must not be blank + */ +``` + +**Draft-Record:** +```java +public record TrainingRecordDraft( + String employeeId, // UserId + String trainingType, // HACCP | HYGIENE | FOOD_SAFETY | EQUIPMENT_OPERATION | FIRST_AID + String trainingDate, // ISO LocalDate + String validUntil, // ISO LocalDate + String trainer, + String certificateNumber // nullable +) {} +``` + +**Factory & Business Methods:** +```java +// Factory +public static Result record(TrainingRecordDraft draft); + +// Mutations +public Result revoke(String reason); + +// Query Methods +public boolean isValid(); +public boolean isExpiringSoon(); +public boolean isExpired(); +public long daysUntilExpiry(); +``` + +**Domain Events:** +```java +TrainingRecorded(TrainingRecordId, UserId employeeId, TrainingType) +TrainingExpiringSoon(TrainingRecordId, UserId employeeId, LocalDate validUntil) +TrainingExpired(TrainingRecordId, UserId employeeId, TrainingType) +TrainingRevoked(TrainingRecordId, UserId employeeId, String reason) +``` + +--- + +### 7. MaintenanceRecord (Aggregate Root) + +**Verantwortung:** Wartungsprotokolle für Geräte und Anlagen. Geplante Wartungen, Störungsreparaturen und Kalibrierungen (Epic 3.6). ``` -MaintenanceRecord +MaintenanceRecord (Aggregate Root) ├── MaintenanceRecordId (VO) -├── EquipmentId (VO) -├── MaintenanceType (VO: SCHEDULED | REPAIR | INSPECTION) -├── ScheduledFor (VO: Date) -├── PerformedAt (VO: Timestamp) -├── PerformedBy (VO) - Internal staff or external company -├── Findings (VO) -├── Actions (VO) -├── NextMaintenanceDue (VO: Date) -└── Status (VO: COMPLETED | PENDING | FAILED) - -Invariants: -- PerformedAt must be >= ScheduledFor -- If FAILED, Findings and Actions must be documented -- NextMaintenanceDue must be calculated based on maintenance interval +├── EquipmentId (VO: String) - Referenz auf Gerät/Anlage +├── EquipmentName (VO: String) - Bezeichnung für Lesbarkeit +├── MaintenanceType (VO: SCHEDULED | REPAIR | CALIBRATION | INSPECTION) +├── ScheduledFor (VO: LocalDate) +├── Status (VO: SCHEDULED | COMPLETED | FAILED | OVERDUE) +├── PerformedAt (VO: Instant) - Nullable bis Durchführung +├── PerformedBy (VO: String) - Interner Mitarbeiter oder externe Firma +├── Findings (VO: String) - Befund, nullable +├── ActionsTaken (VO: String) - Durchgeführte Maßnahmen, nullable +├── NextMaintenanceDue (VO: LocalDate) - Nächster Termin, nullable +└── FailureReason (VO: String) - Pflicht bei FAILED ``` +**Invarianten:** +```java +/** + * MaintenanceRecord aggregate root. + * + * Invariants: + * - EquipmentId and EquipmentName must not be blank + * - ScheduledFor must not be in the past at creation (except REPAIR type) + * - Status transitions: SCHEDULED → COMPLETED | FAILED + * SCHEDULED → OVERDUE (system-triggered) + * - If COMPLETED: PerformedAt, PerformedBy must be set + * - If FAILED: FailureReason, Findings must not be blank + * - If COMPLETED and MaintenanceType is SCHEDULED: NextMaintenanceDue should be set + * - PerformedAt must be >= ScheduledFor (for SCHEDULED type) + * - REPAIR type can be created for past dates (Störungs-Dokumentation) + */ +``` + +**Draft-Records:** +```java +public record MaintenanceRecordDraft( + String equipmentId, + String equipmentName, + String maintenanceType, // SCHEDULED | REPAIR | CALIBRATION | INSPECTION + String scheduledFor // ISO LocalDate +) {} + +public record MaintenanceCompletionDraft( + String performedBy, + String findings, // nullable + String actionsTaken, // nullable + String nextMaintenanceDue // ISO LocalDate, nullable +) {} +``` + +**Factory & Business Methods:** +```java +// Factory +public static Result schedule( + MaintenanceRecordDraft draft +); + +// Status-Übergänge +public Result complete(MaintenanceCompletionDraft draft); +public Result fail(String failureReason, String findings); +public Result markOverdue(); + +// Query Methods +public boolean isOverdue(); +``` + +**Domain Events:** +```java +MaintenanceScheduled(MaintenanceRecordId, String equipmentId, MaintenanceType, LocalDate) +MaintenanceCompleted(MaintenanceRecordId, String equipmentId, LocalDate nextDue) +MaintenanceFailed(MaintenanceRecordId, String equipmentId, String reason) +MaintenanceOverdue(MaintenanceRecordId, String equipmentId, LocalDate scheduledFor) +``` + +--- + +### 8. QualityHold (Aggregate Root) + +**Verantwortung:** Sperre einer Charge bei Qualitätsproblemen. Block/Release-Pattern: Erst sperren, dann nach Prüfung freigeben oder endgültig ablehnen. + +``` +QualityHold (Aggregate Root) +├── QualityHoldId (VO) +├── BatchId (VO: String) - Gesperrte Produktions-Charge +├── Reason (VO: TEMPERATURE_DEVIATION | SAMPLE_FAILED | CONTAMINATION_SUSPECTED | +│ PROCESS_DEVIATION | CUSTOMER_COMPLAINT | REGULATORY) +├── Description (VO: String) - Freitext-Beschreibung des Problems +├── Status (VO: BLOCKED | RELEASED | REJECTED) +├── BlockedBy (VO: UserId) - Wer hat gesperrt +├── BlockedAt (VO: Instant) +├── ReleasedBy (VO: UserId) - Wer hat freigegeben/abgelehnt, nullable +├── ResolvedAt (VO: Instant) - Nullable +├── ReleaseJustification (VO: String) - Begründung bei RELEASED +├── RejectionJustification (VO: String) - Begründung bei REJECTED +└── CorrectiveActions[] (Entity) + ├── CorrectiveActionId (VO: int) + ├── Description (VO: String) - Was wurde getan + ├── ResponsiblePerson (VO: String) + └── CompletedAt (VO: Instant) - Nullable +``` + +**Invarianten:** +```java +/** + * QualityHold aggregate root. + * + * Invariants: + * - BatchId must not be blank + * - Description must not be blank + * - Status transitions: BLOCKED → RELEASED | REJECTED (terminal states) + * - Release requires ReleaseJustification (must not be blank) + * - Rejection requires RejectionJustification (must not be blank) + * - ReleasedBy must differ from BlockedBy (Vier-Augen-Prinzip) + * - Cannot modify after RELEASED or REJECTED + * - CorrectiveActions can only be added in BLOCKED status + * - At least one CorrectiveAction must exist before release + */ +``` + +**Draft-Records:** +```java +public record QualityHoldDraft( + String batchId, + String reason, // TEMPERATURE_DEVIATION | SAMPLE_FAILED | ... + String description, + String blockedBy // UserId +) {} + +public record CorrectiveActionDraft( + String description, + String responsiblePerson +) {} +``` + +**Factory & Business Methods:** +```java +// Factory +public static Result block(QualityHoldDraft draft); + +// Korrekturmaßnahmen (nur im BLOCKED-Status) +public Result addCorrectiveAction(CorrectiveActionDraft draft); +public Result completeCorrectiveAction(int actionId); + +// Status-Übergänge (terminal) +public Result release(UserId releasedBy, String justification); +public Result reject(UserId rejectedBy, String justification); + +// Query Methods +public boolean isBlocked(); +public boolean hasOpenCorrectiveActions(); +``` + +**Domain Events:** +```java +QualityHoldCreated(QualityHoldId, String batchId, HoldReason) +QualityHoldReleased(QualityHoldId, String batchId, UserId releasedBy, String justification) +QualityHoldRejected(QualityHoldId, String batchId, UserId rejectedBy, String justification) + → triggers batch disposal / write-off in Inventory BC +CorrectiveActionAdded(QualityHoldId, String description) +``` + +--- + +### 9. ProcessParameter (Aggregate Root) + +**Verantwortung:** Batch-bezogene CCP-Messwerte (Kerntemperatur, pH-Wert, aw-Wert, Metalldetektion). Automatische Bewertung gegen kritische Grenzwerte. + +``` +ProcessParameter (Aggregate Root) +├── ProcessParameterId (VO) +├── BatchId (VO: String) - Referenz auf Produktions-Charge +├── CcpType (VO: CORE_TEMPERATURE | PH_VALUE | WATER_ACTIVITY | METAL_DETECTION) +├── MeasuredValue (VO: BigDecimal) +├── Unit (VO: String) - z.B. "°C", "pH", "aw" +├── CriticalLimit (VO) - Min/Max-Grenzwerte +├── Status (VO: OK | DEVIATION) +├── MeasuredBy (VO: UserId) +├── MeasuredAt (VO: Instant) +├── ProductionStepReference (VO: String) - Optional, Bezug zum Produktionsschritt +└── Remarks (VO: String) - Optional, bei Abweichungen Pflicht +``` + +**Invarianten:** +```java +/** + * ProcessParameter aggregate root. + * + * Invariants: + * - BatchId must not be blank + * - MeasuredValue must not be null + * - CriticalLimit.min < CriticalLimit.max (if both set) + * - Status is automatically derived: + * OK if measuredValue within CriticalLimit + * DEVIATION if measuredValue outside CriticalLimit + * - If DEVIATION: Remarks must not be blank (Abweichung muss dokumentiert werden) + * - Unit must match CcpType convention (°C for CORE_TEMPERATURE, pH for PH_VALUE, etc.) + * - Immutable after creation (append-only measurement log) + * - MeasuredAt cannot be in the future + */ +``` + +**Draft-Record:** +```java +public record ProcessParameterDraft( + String batchId, + String ccpType, // CORE_TEMPERATURE | PH_VALUE | WATER_ACTIVITY | METAL_DETECTION + String measuredValue, // BigDecimal als String + String unit, + String criticalLimitMin, // BigDecimal als String, nullable + String criticalLimitMax, // BigDecimal als String, nullable + String measuredBy, // UserId + String productionStepReference, // nullable + String remarks // nullable, Pflicht bei Abweichung +) {} +``` + +**Factory & Business Methods:** +```java +// Factory (einzige Erzeugung, immutable danach) +public static Result record( + ProcessParameterDraft draft +); + +// Query Methods +public boolean isDeviation(); +public BigDecimal deviationFromLimit(); +``` + +**Domain Events:** +```java +ProcessParameterRecorded(ProcessParameterId, String batchId, CcpType, ParameterStatus) +ProcessParameterDeviation(ProcessParameterId, String batchId, CcpType, BigDecimal measuredValue, CriticalLimit limit) + → kann QualityHold auslösen (Application Layer Entscheidung) +``` + +--- + +## Shared Value Objects + +```mermaid +--- +config: + theme: neutral + look: classic + layout: elk + themeVariables: + background: "#f8fafc" + class: + hideEmptyMembersBox: true +--- +classDiagram + class Temperature { + +BigDecimal value + +of(BigDecimal) Result + +isWithin(CriticalLimit) boolean + +deviationFrom(CriticalLimit) BigDecimal + } + + class CriticalLimit { + +BigDecimal min + +BigDecimal max + +of(BigDecimal, BigDecimal) Result + +contains(BigDecimal) boolean + +warningZoneContains(BigDecimal) boolean + } + + class MeasurementPoint { + <> + COLD_ROOM + FREEZER + DISPLAY_COUNTER + PRODUCTION_ROOM + } + + class CleaningArea { + <> + PRODUCTION_ROOM + COLD_STORAGE + SALES_COUNTER + EQUIPMENT + VEHICLE + } + + class CleaningInterval { + <> + DAILY + WEEKLY + MONTHLY + } + + class SampleType { + <> + MICROBIOLOGICAL + CHEMICAL + PHYSICAL + SENSORY + } + + class CcpType { + <> + CORE_TEMPERATURE + PH_VALUE + WATER_ACTIVITY + METAL_DETECTION + } + + class TrainingType { + <> + HACCP + HYGIENE + FOOD_SAFETY + EQUIPMENT_OPERATION + FIRST_AID + } + + class MaintenanceType { + <> + SCHEDULED + REPAIR + CALIBRATION + INSPECTION + } + + class HoldReason { + <> + TEMPERATURE_DEVIATION + SAMPLE_FAILED + CONTAMINATION_SUSPECTED + PROCESS_DEVIATION + CUSTOMER_COMPLAINT + REGULATORY + } + + class TemperatureStatus { + <> + OK + WARNING + CRITICAL + } + + class InspectionResult { + <> + PENDING + ACCEPTED + REJECTED + CONDITIONALLY_ACCEPTED + } + + Temperature --> CriticalLimit : geprüft gegen +``` + +### Temperature + +```java +public record Temperature(BigDecimal value) { + public Temperature { + if (value.compareTo(new BigDecimal("-50")) < 0 + || value.compareTo(new BigDecimal("50")) > 0) { + throw new IllegalArgumentException( + "Temperature must be between -50°C and +50°C, got: " + value + ); + } + } + + public static Result of(String value) { + // Parse + Validierung + } + + public boolean isWithin(CriticalLimit limit) { + return limit.contains(this.value); + } + + public BigDecimal deviationFrom(CriticalLimit limit) { + // Negative = unter Min, Positive = über Max, Zero = OK + } +} +``` + +### CriticalLimit + +```java +public record CriticalLimit(BigDecimal min, BigDecimal max) { + public CriticalLimit { + if (min != null && max != null && min.compareTo(max) >= 0) { + throw new IllegalArgumentException("min must be < max"); + } + if (min == null && max == null) { + throw new IllegalArgumentException("At least one limit must be set"); + } + } + + public static Result of(String min, String max) { + // Parse + Validierung, min/max einzeln nullable + } + + public boolean contains(BigDecimal value) { + if (min != null && value.compareTo(min) < 0) return false; + if (max != null && value.compareTo(max) > 0) return false; + return true; + } + + /** Warning zone: within 10% of limit boundary */ + public boolean warningZoneContains(BigDecimal value) { + if (!contains(value)) return false; + BigDecimal range = max.subtract(min); + BigDecimal margin = range.multiply(new BigDecimal("0.10")); + return value.compareTo(min.add(margin)) < 0 + || value.compareTo(max.subtract(margin)) > 0; + } +} +``` + +### AnalysisResult + +```java +public record AnalysisResult( + BigDecimal measuredValue, + String unit, + String interpretation // nullable +) { + public AnalysisResult { + Objects.requireNonNull(measuredValue, "measuredValue must not be null"); + if (unit == null || unit.isBlank()) { + throw new IllegalArgumentException("unit must not be blank"); + } + } +} +``` + +--- + +## Domain Errors + +```java +public sealed interface TemperatureLogError { + String message(); + + record InvalidTemperature(String value) implements TemperatureLogError { + public String message() { return "Invalid temperature value: " + value; } + } + record InvalidCriticalLimit(String reason) implements TemperatureLogError { + public String message() { return "Invalid critical limit: " + reason; } + } + record FutureMeasurement() implements TemperatureLogError { + public String message() { return "Measurement timestamp cannot be in the future"; } + } + record InvalidMeasurementPoint(String value) implements TemperatureLogError { + public String message() { return "Unknown measurement point: " + value; } + } +} + +public sealed interface CleaningPlanError { + String message(); + + record BlankName() implements CleaningPlanError { + public String message() { return "Cleaning plan name must not be blank"; } + } + record EmptyChecklist() implements CleaningPlanError { + public String message() { return "Checklist template must have at least one item"; } + } + record InvalidInterval(String value) implements CleaningPlanError { + public String message() { return "Unknown cleaning interval: " + value; } + } + record InvalidArea(String value) implements CleaningPlanError { + public String message() { return "Unknown cleaning area: " + value; } + } + record AlreadyActive() implements CleaningPlanError { + public String message() { return "Cleaning plan is already active"; } + } + record AlreadyInactive() implements CleaningPlanError { + public String message() { return "Cleaning plan is already inactive"; } + } +} + +public sealed interface CleaningRecordError { + String message(); + + record NotAllItemsChecked(int checked, int total) implements CleaningRecordError { + public String message() { return "Not all items checked: " + checked + "/" + total; } + } + record AlreadyCompleted() implements CleaningRecordError { + public String message() { return "Cleaning record is already completed"; } + } + record InvalidItemPosition(int position) implements CleaningRecordError { + public String message() { return "Invalid checklist item position: " + position; } + } + record ItemAlreadyChecked(int position) implements CleaningRecordError { + public String message() { return "Checklist item already checked: " + position; } + } +} + +public sealed interface InspectionError { + String message(); + + record CheckAlreadyRecorded(String checkType) implements InspectionError { + public String message() { return "Check already recorded: " + checkType; } + } + record ChecksIncomplete(List missing) implements InspectionError { + public String message() { return "Missing checks: " + String.join(", ", missing); } + } + record AlreadyFinalized() implements InspectionError { + public String message() { return "Inspection is already finalized"; } + } + record RejectionReasonRequired() implements InspectionError { + public String message() { return "Rejection reason is required"; } + } + record ConditionsRequired() implements InspectionError { + public String message() { return "Conditions are required for conditional acceptance"; } + } + record InvalidTemperature(String reason) implements InspectionError { + public String message() { return "Invalid temperature check: " + reason; } + } +} + +public sealed interface SampleRecordError { + String message(); + + record BlankBatchId() implements SampleRecordError { + public String message() { return "Batch ID must not be blank"; } + } + record BlankDescription() implements SampleRecordError { + public String message() { return "Sample description must not be blank"; } + } + record ResultAlreadyEntered() implements SampleRecordError { + public String message() { return "Analysis result has already been entered"; } + } + record InvalidSampleType(String value) implements SampleRecordError { + public String message() { return "Unknown sample type: " + value; } + } + record InvalidCriticalLimit(String reason) implements SampleRecordError { + public String message() { return "Invalid critical limit: " + reason; } + } +} + +public sealed interface TrainingRecordError { + String message(); + + record InvalidDateRange() implements TrainingRecordError { + public String message() { return "ValidUntil must be after TrainingDate"; } + } + record FutureTrainingDate() implements TrainingRecordError { + public String message() { return "Training date cannot be in the future"; } + } + record BlankTrainer() implements TrainingRecordError { + public String message() { return "Trainer must not be blank"; } + } + record InvalidTrainingType(String value) implements TrainingRecordError { + public String message() { return "Unknown training type: " + value; } + } + record AlreadyRevoked() implements TrainingRecordError { + public String message() { return "Training record is already revoked"; } + } + record BlankRevokeReason() implements TrainingRecordError { + public String message() { return "Revoke reason must not be blank"; } + } +} + +public sealed interface MaintenanceRecordError { + String message(); + + record BlankEquipmentId() implements MaintenanceRecordError { + public String message() { return "Equipment ID must not be blank"; } + } + record ScheduledDateInPast() implements MaintenanceRecordError { + public String message() { return "Scheduled date must not be in the past"; } + } + record InvalidMaintenanceType(String value) implements MaintenanceRecordError { + public String message() { return "Unknown maintenance type: " + value; } + } + record InvalidStatusTransition(String from, String to) implements MaintenanceRecordError { + public String message() { return "Cannot transition from " + from + " to " + to; } + } + record BlankFailureReason() implements MaintenanceRecordError { + public String message() { return "Failure reason must not be blank"; } + } + record BlankFindings() implements MaintenanceRecordError { + public String message() { return "Findings must not be blank for failed maintenance"; } + } +} + +public sealed interface QualityHoldError { + String message(); + + record BlankBatchId() implements QualityHoldError { + public String message() { return "Batch ID must not be blank"; } + } + record BlankDescription() implements QualityHoldError { + public String message() { return "Hold description must not be blank"; } + } + record InvalidReason(String value) implements QualityHoldError { + public String message() { return "Unknown hold reason: " + value; } + } + record AlreadyResolved() implements QualityHoldError { + public String message() { return "Quality hold is already resolved"; } + } + record SamePersonRelease() implements QualityHoldError { + public String message() { return "Release/rejection must be by different person (Vier-Augen-Prinzip)"; } + } + record BlankJustification() implements QualityHoldError { + public String message() { return "Justification must not be blank"; } + } + record NoCorrectiveActions() implements QualityHoldError { + public String message() { return "At least one corrective action required before release"; } + } + record OpenCorrectiveActions(int count) implements QualityHoldError { + public String message() { return "Open corrective actions remaining: " + count; } + } +} + +public sealed interface ProcessParameterError { + String message(); + + record BlankBatchId() implements ProcessParameterError { + public String message() { return "Batch ID must not be blank"; } + } + record InvalidCcpType(String value) implements ProcessParameterError { + public String message() { return "Unknown CCP type: " + value; } + } + record InvalidMeasuredValue(String reason) implements ProcessParameterError { + public String message() { return "Invalid measured value: " + reason; } + } + record InvalidCriticalLimit(String reason) implements ProcessParameterError { + public String message() { return "Invalid critical limit: " + reason; } + } + record DeviationWithoutRemarks() implements ProcessParameterError { + public String message() { return "Remarks required when measurement deviates from critical limit"; } + } + record FutureMeasurement() implements ProcessParameterError { + public String message() { return "Measurement timestamp cannot be in the future"; } + } +} +``` + +--- + ## Repository Interfaces ```java public interface TemperatureLogRepository { - Result save(TemperatureLog log); - Result> findByPeriod( - LocalDate from, LocalDate to - ); - Result> findCritical(); + Optional findById(TemperatureLogId id); + List findByMeasurementPoint(MeasurementPoint point); + List findByPeriod(Instant from, Instant to); + List findCriticalByPeriod(Instant from, Instant to); + List findByDeviceId(String deviceId); + void save(TemperatureLog log); +} + +public interface CleaningPlanRepository { + Optional findById(CleaningPlanId id); + List findByStatus(CleaningPlanStatus status); + List findByArea(CleaningArea area); + List findActivePlans(); + void save(CleaningPlan plan); +} + +public interface CleaningRecordRepository { + Optional findById(CleaningRecordId id); + List findByCleaningPlanId(CleaningPlanId planId); + List findByScheduledFor(LocalDate date); + List findOverdue(); + List findByPeriod(LocalDate from, LocalDate to); + void save(CleaningRecord record); } public interface GoodsReceiptInspectionRepository { - Result save(GoodsReceiptInspection inspection); - Result findByGoodsReceiptId( - GoodsReceiptId id - ); + Optional findById(InspectionId id); + Optional findByGoodsReceiptId(String goodsReceiptId); + List findByResult(InspectionResult result); + List findByPeriod(Instant from, Instant to); + void save(GoodsReceiptInspection inspection); +} + +public interface SampleRecordRepository { + Optional findById(SampleRecordId id); + List findByBatchId(String batchId); + List findByStatus(SampleStatus status); + List findPendingResults(); + List findByPeriod(Instant from, Instant to); + void save(SampleRecord record); +} + +public interface TrainingRecordRepository { + Optional findById(TrainingRecordId id); + List findByEmployeeId(UserId employeeId); + List findByTrainingType(TrainingType type); + List findExpiringSoon(int withinDays); + List findExpired(); + void save(TrainingRecord record); +} + +public interface MaintenanceRecordRepository { + Optional findById(MaintenanceRecordId id); + List findByEquipmentId(String equipmentId); + List findByStatus(MaintenanceStatus status); + List findOverdue(); + List findByPeriod(LocalDate from, LocalDate to); + void save(MaintenanceRecord record); +} + +public interface QualityHoldRepository { + Optional findById(QualityHoldId id); + List findByBatchId(String batchId); + List findByStatus(QualityHoldStatus status); + List findActiveHolds(); + boolean existsActiveHoldForBatch(String batchId); + void save(QualityHold hold); +} + +public interface ProcessParameterRepository { + Optional findById(ProcessParameterId id); + List findByBatchId(String batchId); + List findByBatchIdAndCcpType(String batchId, CcpType ccpType); + List findDeviationsByPeriod(Instant from, Instant to); + void save(ProcessParameter parameter); } ``` -## Domain Events +--- + +## Domain Services + +### HaccpReportGenerator ```java -TemperatureCriticalLimitExceeded(TemperatureLogId, MeasurementPoint, Temperature) -CleaningOverdue(CleaningPlanId, Area, LocalDate scheduledFor) -GoodsReceiptRejected(InspectionId, GoodsReceiptId, String reason) -TrainingExpiringSoon(TrainingRecordId, UserId, LocalDate expiryDate) -MaintenanceOverdue(EquipmentId, LocalDate scheduledFor) +/** + * Generiert Audit-Reports aus allen Quality-Aggregates (Epic 3.8). + * Aggregiert Daten über alle 9 Aggregates für einen gegebenen Zeitraum. + */ +public class HaccpReportGenerator { + + /** + * Erstellt einen umfassenden HACCP-Report für Audit-Zwecke. + * Enthält: Temperaturprotokolle, Reinigungsnachweise, WE-Kontrollen, + * Proben, Schulungen, Wartungen, Sperren, Prozessparameter. + */ + public HaccpReport generate(LocalDate from, LocalDate to); + + /** + * Erstellt einen Abweichungs-Report: nur kritische Ereignisse. + */ + public DeviationReport generateDeviationReport(LocalDate from, LocalDate to); +} + +public record HaccpReport( + LocalDate from, + LocalDate to, + int temperatureLogCount, + int criticalTemperatureCount, + int cleaningRecordCount, + int overdueCleaningCount, + int inspectionCount, + int rejectedInspectionCount, + int sampleCount, + int failedSampleCount, + int activeTrainingCount, + int expiredTrainingCount, + int maintenanceCount, + int failedMaintenanceCount, + int qualityHoldCount, + int processDeviationCount +) {} ``` + +--- + +## Status-Maschinen + +### CleaningRecord Status + +```mermaid +--- +config: + theme: neutral + themeVariables: + background: "#f8fafc" +--- +stateDiagram-v2 + [*] --> OPEN : create() + OPEN --> IN_PROGRESS : checkItem() + OPEN --> OVERDUE : markOverdue() [system] + OVERDUE --> IN_PROGRESS : checkItem() + IN_PROGRESS --> COMPLETED : complete() + + OPEN : Reinigung geplant + IN_PROGRESS : Checkliste wird abgearbeitet + COMPLETED : Alle Items geprüft, completedBy gesetzt + OVERDUE : ScheduledFor überschritten +``` + +### GoodsReceiptInspection Status + +```mermaid +--- +config: + theme: neutral + themeVariables: + background: "#f8fafc" +--- +stateDiagram-v2 + [*] --> PENDING : create() + PENDING --> ACCEPTED : finalize() [alle Checks bestanden] + PENDING --> REJECTED : reject() oder finalize() [Check failed] + PENDING --> CONDITIONALLY_ACCEPTED : acceptConditionally() + + PENDING : Checks werden aufgenommen + PENDING : recordTemperatureCheck() + PENDING : recordVisualCheck() + PENDING : recordShelfLifeCheck() + PENDING : recordDocumentCheck() + ACCEPTED : Ware freigegeben + REJECTED : Ware abgelehnt, Grund dokumentiert + CONDITIONALLY_ACCEPTED : Ware mit Auflagen akzeptiert +``` + +### QualityHold Status + +```mermaid +--- +config: + theme: neutral + themeVariables: + background: "#f8fafc" +--- +stateDiagram-v2 + [*] --> BLOCKED : block() + BLOCKED --> RELEASED : release() [Vier-Augen-Prinzip] + BLOCKED --> REJECTED : reject() [Vier-Augen-Prinzip] + + BLOCKED : Charge gesperrt + BLOCKED : addCorrectiveAction() + BLOCKED : completeCorrectiveAction() + RELEASED : Charge freigegeben + RELEASED : ReleaseJustification dokumentiert + REJECTED : Charge endgültig abgelehnt + REJECTED : → Inventory write-off +``` + +### MaintenanceRecord Status + +```mermaid +--- +config: + theme: neutral + themeVariables: + background: "#f8fafc" +--- +stateDiagram-v2 + [*] --> SCHEDULED : schedule() + SCHEDULED --> COMPLETED : complete() + SCHEDULED --> FAILED : fail() + SCHEDULED --> OVERDUE : markOverdue() [system] + + SCHEDULED : Wartung geplant + COMPLETED : Durchgeführt, Befund dokumentiert + COMPLETED : nextMaintenanceDue gesetzt + FAILED : Wartung fehlgeschlagen + FAILED : failureReason + findings dokumentiert + OVERDUE : Termin überschritten +``` + +### SampleRecord Status + +```mermaid +--- +config: + theme: neutral + themeVariables: + background: "#f8fafc" +--- +stateDiagram-v2 + [*] --> PENDING : record() + PENDING --> PASSED : enterResult() [innerhalb Grenzwert] + PENDING --> FAILED : enterResult() [außerhalb Grenzwert] + + PENDING : Probe entnommen, Analyse läuft + PASSED : Ergebnis innerhalb Grenzwerte + FAILED : Ergebnis außerhalb Grenzwerte + FAILED : → kann QualityHold auslösen +``` + +--- + +## Integration mit anderen BCs + +```mermaid +--- +config: + theme: neutral + look: classic + layout: elk + themeVariables: + background: "#f8fafc" +--- +graph LR + subgraph UPSTREAM["Upstream BCs"] + PROD["Production BC\n(BatchId, BatchNumber)"] + MD["Master Data\n(ArticleId)"] + UM["User Management\n(UserId)"] + PROC["Procurement BC\n(GoodsReceiptId)"] + end + + subgraph QUALITY["Quality BC"] + TL["TemperatureLog"] + CP["CleaningPlan"] + CR["CleaningRecord"] + GRI["GoodsReceiptInspection"] + SR["SampleRecord"] + TR["TrainingRecord"] + MR["MaintenanceRecord"] + QH["QualityHold"] + PP["ProcessParameter"] + end + + subgraph DOWNSTREAM["Downstream BCs"] + INV["Inventory BC"] + end + + PROD -->|"BatchId"| SR + PROD -->|"BatchId"| QH + PROD -->|"BatchId"| PP + UM -->|"UserId"| TL + UM -->|"UserId"| CR + UM -->|"UserId"| GRI + UM -->|"UserId"| SR + UM -->|"UserId"| TR + UM -->|"UserId"| QH + UM -->|"UserId"| PP + PROC -->|"GoodsReceiptId"| GRI + + QH -->|"QualityHoldRejected\n(write-off)"| INV +``` + +### Upstream-Abhängigkeiten (Quality konsumiert) + +| BC | Referenz | Zweck | +|---|---|---| +| **Production** | BatchId | Charge in SampleRecord, QualityHold, ProcessParameter | +| **User Management** | UserId | MeasuredBy, CompletedBy, InspectedBy, BlockedBy, etc. | +| **Procurement** | GoodsReceiptId | Verknüpfung in GoodsReceiptInspection | +| **Master Data** | ArticleId | Indirekt über BatchId (Production BC) | + +### Downstream-Integrationen (Quality publiziert Events) + +| Event | Konsument | Aktion | +|---|---|---| +| `QualityHoldRejected` | **Inventory BC** | Charge als Ausschuss verbuchen (write-off) | +| `QualityHoldReleased` | **Inventory BC** | Chargen-Sperre aufheben | +| `GoodsReceiptAccepted` | **Procurement BC** | Wareneingang als geprüft markieren | +| `GoodsReceiptRejected` | **Procurement BC** | Reklamation / Rücksendung auslösen | +| `TemperatureCriticalLimitExceeded` | **Notification** | Alarm an zuständige Person | +| `SampleFailed` | **Quality BC** (intern) | Kann automatisch QualityHold erzeugen | + +### Abgrenzungen (gehören NICHT in Quality BC) + +| Konzept | Zuständiger BC | Grund | +|---|---|---| +| SOP-Dokumente (3.7) | **Document Archive BC** | Dokument mit Versionierung, kein Quality-Aggregate | +| Allergene / Nährwerte | **Labeling BC** | Eigene Domäne mit komplexen Berechnungsregeln | +| Bestandsführung | **Inventory BC** | Stock-Operationen sind Inventory-Concern | +| Lieferantenbewertung | **Procurement BC** | Rating basiert auf WE-Inspektionen, aber gehört zum Lieferantenmanagement | + +--- + +## Use Cases (Application Layer) + +```java +// TemperatureLog +RecordTemperature → TemperatureLog.record(TemperatureLogDraft) +ListTemperatureLogs → Query (by period, measurement point) +GetCriticalTemperatures → Query (critical status only) + +// CleaningPlan +CreateCleaningPlan → CleaningPlan.create(CleaningPlanDraft) +UpdateCleaningPlan → plan.update(CleaningPlanUpdateDraft) +ActivateCleaningPlan → plan.activate() +DeactivateCleaningPlan → plan.deactivate() +ListCleaningPlans → Query + +// CleaningRecord +CreateCleaningRecord → CleaningRecord.create(CleaningRecordDraft) +CheckCleaningItem → record.checkItem(position, remarks) +CompleteCleaningRecord → record.complete(completedBy) +ListCleaningRecords → Query (by plan, period) +ListOverdueCleanings → Query (OVERDUE status) + +// GoodsReceiptInspection +CreateInspection → GoodsReceiptInspection.create(draft) +RecordTemperatureCheck → inspection.recordTemperatureCheck(draft) +RecordVisualCheck → inspection.recordVisualCheck(draft) +RecordShelfLifeCheck → inspection.recordShelfLifeCheck(draft) +RecordDocumentCheck → inspection.recordDocumentCheck(draft) +FinalizeInspection → inspection.finalize() +GetInspection → Query + +// SampleRecord +RecordSample → SampleRecord.record(SampleRecordDraft) +EnterSampleResult → sample.enterResult(AnalysisResultDraft) +ListSamplesByBatch → Query (by batchId) +ListPendingSamples → Query (PENDING status) + +// TrainingRecord +RecordTraining → TrainingRecord.record(TrainingRecordDraft) +RevokeTraining → training.revoke(reason) +ListTrainingsByEmployee → Query (by employeeId) +ListExpiringTrainings → Query (expiring within N days) + +// MaintenanceRecord +ScheduleMaintenance → MaintenanceRecord.schedule(draft) +CompleteMaintenance → record.complete(completionDraft) +FailMaintenance → record.fail(reason, findings) +ListOverdueMaintenance → Query (OVERDUE status) +ListMaintenanceByEquipment → Query (by equipmentId) + +// QualityHold +BlockBatch → QualityHold.block(QualityHoldDraft) +AddCorrectiveAction → hold.addCorrectiveAction(draft) +CompleteCorrectiveAction → hold.completeCorrectiveAction(actionId) +ReleaseBatch → hold.release(releasedBy, justification) +RejectBatch → hold.reject(rejectedBy, justification) +ListActiveHolds → Query (BLOCKED status) +GetHoldsByBatch → Query (by batchId) + +// ProcessParameter +RecordProcessParameter → ProcessParameter.record(ProcessParameterDraft) +ListParametersByBatch → Query (by batchId) +ListDeviations → Query (DEVIATION status, by period) + +// HACCP Reporting (Epic 3.8) +GenerateHaccpReport → HaccpReportGenerator.generate(from, to) +GenerateDeviationReport → HaccpReportGenerator.generateDeviationReport(from, to) +``` + +--- + +## Beispiel: Wareneingangsfluss (End-to-End) + +```mermaid +--- +config: + theme: neutral + themeVariables: + background: "#f8fafc" +--- +sequenceDiagram + participant L as Lieferant + participant M as Mitarbeiter + participant GRI as GoodsReceiptInspection + participant QH as QualityHold + participant PROC as Procurement BC + participant INV as Inventory BC + + L->>M: Lieferung ankommt + M->>GRI: create(goodsReceiptId, supplierBatch, userId) + activate GRI + Note over GRI: Status: PENDING + + M->>GRI: recordTemperatureCheck(3.5°C, 2-7°C) + Note over GRI: Passed ✓ + + M->>GRI: recordVisualCheck(intact, NORMAL, NORMAL) + Note over GRI: Passed ✓ + + M->>GRI: recordShelfLifeCheck(2026-04-15, 14 days) + Note over GRI: 56 Tage Restlaufzeit → Passed ✓ + + M->>GRI: recordDocumentCheck(true, true, true, true) + Note over GRI: Passed ✓ + + M->>GRI: finalize() + GRI-->>M: ACCEPTED + GRI--)PROC: GoodsReceiptAccepted Event + Note over PROC: Wareneingang als geprüft markiert + deactivate GRI + + Note over M,INV: --- Alternativ: Temperatur zu hoch --- + + M->>GRI: recordTemperatureCheck(12.5°C, 2-7°C) + Note over GRI: Failed ✗ + M->>GRI: reject("Kühlkette unterbrochen") + GRI--)PROC: GoodsReceiptRejected Event + Note over PROC: Reklamation auslösen +``` + +### Code-Beispiel + +```java +// 1. Wareneingang kommt an → Inspektion starten +var inspectionDraft = new GoodsReceiptInspectionDraft( + "GR-2026-02-18-001", "SB-XY-42", userId +); +var inspection = GoodsReceiptInspection.create(inspectionDraft).value(); + +// 2. Temperaturprüfung +inspection.recordTemperatureCheck(new TemperatureCheckDraft("3.5", "2", "7")); + +// 3. Sichtkontrolle +inspection.recordVisualCheck(new VisualCheckDraft( + true, "NORMAL", "NORMAL", null +)); + +// 4. MHD-Prüfung +inspection.recordShelfLifeCheck(new ShelfLifeCheckDraft("2026-04-15", 14)); + +// 5. Dokumentenprüfung +inspection.recordDocumentCheck(new DocumentCheckDraft(true, true, true, true)); + +// 6. Finalisieren → ACCEPTED +inspection.finalize(); +// → GoodsReceiptAccepted Event wird publiziert + +// 7. Probe entnehmen (optional bei Fleischwaren) +var sampleDraft = new SampleRecordDraft( + "P-2026-02-18-001", // BatchId nach Einlagerung + "MICROBIOLOGICAL", + "Rohstoff-Fleischprobe", + "PCR", + null, "1000", // Max 1000 CFU/g + userId +); +var sample = SampleRecord.record(sampleDraft).value(); + +// 8. Analyseergebnis nachtragen (Laborbefund nach 2 Tagen) +sample.enterResult(new AnalysisResultDraft("250", "CFU/g", "Unauffällig")); +// → SamplePassed Event + +// 9. Bei Abweichung: Charge sperren +var holdDraft = new QualityHoldDraft( + "P-2026-02-18-001", + "SAMPLE_FAILED", + "Mikrobiologische Grenzwertüberschreitung bei Probe SR-123", + userId +); +var hold = QualityHold.block(holdDraft).value(); +// → QualityHoldCreated Event + +// 10. Korrekturmaßnahme dokumentieren +hold.addCorrectiveAction(new CorrectiveActionDraft( + "Nachbeprobung und sensorische Prüfung", + "Max Mustermann" +)); + +// 11. Nach Klärung: Freigabe (durch andere Person!) +hold.release(otherUserId, "Nachprobe unauffällig, sensorisch einwandfrei"); +// → QualityHoldReleased Event +``` + +--- + +## DDD Validation Checklist + +- [x] Aggregate Root ist einziger Einstiegspunkt (alle 9 Aggregates) +- [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: BatchId, UserId, GoodsReceiptId, CleaningPlanId) +- [x] Ein Aggregate = eine Transaktionsgrenze +- [x] EntityDraft-Pattern für VO-Konstruktion im Domain Layer +- [x] Result für erwartbare Fehler, keine Exceptions +- [x] Sealed interfaces für Domain Errors (9 Error-Interfaces) +- [x] Status-Maschinen explizit dokumentiert (CleaningRecord, GoodsReceiptInspection, QualityHold, MaintenanceRecord, SampleRecord) +- [x] BC-Grenzen klar definiert (SOP → Document Archive, Allergene → Labeling) +- [x] Alle Epic-3-Features abgedeckt: 3.1 Temperatur, 3.2 Reinigung, 3.3 Wareneingang, 3.4 Proben, 3.5 Schulungen, 3.6 Wartung, 3.7 SOP→Document Archive, 3.8 Reports +- [x] Domain Events für BC-Integration definiert +- [x] Vier-Augen-Prinzip bei QualityHold (BlockedBy ≠ ReleasedBy) diff --git a/backend/docs/mvp/ddd/07-inventory-bc.md b/backend/docs/mvp/ddd/07-inventory-bc.md index 1029e44..9674b78 100644 --- a/backend/docs/mvp/ddd/07-inventory-bc.md +++ b/backend/docs/mvp/ddd/07-inventory-bc.md @@ -1,68 +1,1308 @@ # Inventory BC - Detailliertes Domain Model **Bounded Context:** Inventory -**Domain Type:** CORE -**Verantwortung:** Chargen-basierte Bestandsführung, Rückverfolgbarkeit, MHD-Tracking +**Domain Type:** SUPPORTING +**Verantwortung:** Chargen-basierte Bestandsführung mit FEFO, Reservierungen, Bestandsbewegungen und Inventur + +--- + +## Ubiquitous Language + +| Begriff (DE) | Begriff (EN/Code) | Typ | Definition | +|---|---|---|---| +| Bestand | Stock | Aggregate | Aktueller Bestand eines Artikels an einem Lagerort, chargengenau geführt | +| Bestandscharge | StockBatch | Entity | Einzelne Charge im Bestand mit Menge, MHD und Status | +| Chargenreferenz | BatchReference | VO | Verweis auf Produktions- oder Lieferantencharge (ProductionBatchId oder SupplierBatchId) | +| Chargentyp | BatchType | VO (Enum) | PRODUCED (Eigenproduktion) oder PURCHASED (Zukauf) | +| Chargen-Status | StockBatchStatus | VO (Enum) | AVAILABLE, EXPIRING_SOON, BLOCKED, EXPIRED | +| Mindestbestand | MinimumLevel | VO | Mindestmenge, bei Unterschreitung wird Event ausgelöst | +| Mindest-Resthaltbarkeit | MinimumShelfLife | VO | Konfigurierbare Tage bis MHD, ab der Charge als EXPIRING_SOON gilt | +| FEFO | FEFO | Concept | First-Expired-First-Out – Entnahme priorisiert nach nächstem MHD | +| Verfügbarer Bestand | AvailableStock | Concept | Summe aller AVAILABLE-Chargen minus reservierte Mengen | +| Reservierung | Reservation | Entity | Reservierte Menge für einen Produktionsauftrag oder Kundenauftrag | +| Reservierungspriorität | ReservationPriority | VO (Enum) | URGENT, NORMAL, LOW – bestimmt Zuteilungsreihenfolge bei Knappheit | +| Chargen-Zuteilung | StockBatchAllocation | Entity | Zuordnung einer reservierten Menge zu einer konkreten StockBatch (FEFO) | +| Bestandsbewegung | StockMovement | Aggregate | Dokumentierte Bestandsveränderung (Ein-/Ausgang/Umbuchung) | +| Bewegungstyp | MovementType | VO (Enum) | GOODS_RECEIPT, PRODUCTION_OUTPUT, PRODUCTION_CONSUMPTION, SALE, INTER_BRANCH_TRANSFER, WASTE, ADJUSTMENT, RETURN | +| Bewegungsrichtung | MovementDirection | VO (Enum) | IN (Zugang) oder OUT (Abgang) – abgeleitet aus MovementType | +| Inventur | InventoryCount | Aggregate | Physische Bestandsaufnahme mit Soll/Ist-Abgleich und automatischer Differenzbuchung | +| Zählposition | CountItem | Entity | Einzelne Position in einer Inventur: Artikel, Soll-Menge, Ist-Menge, Abweichung | +| Lagerort | StorageLocation | Aggregate | Konfigurierbarer physischer Lagerort mit Typ und Temperaturbereich | +| Lagertyp | StorageType | VO (Enum) | COLD_ROOM, FREEZER, DRY_STORAGE, DISPLAY_COUNTER, PRODUCTION_AREA | +| Temperaturbereich | TemperatureRange | VO | Min-/Max-Temperatur eines Lagerorts in °C | +| Schwund | Shrinkage | Concept | Bestandsverlust durch Verderb, Bruch, Diebstahl – erfasst als WASTE-Bewegung | + +--- + +## Aggregate-Übersicht + +```mermaid +--- +config: + theme: neutral + look: classic + layout: elk + themeVariables: + background: "#f8fafc" + class: + hideEmptyMembersBox: true +--- +classDiagram + class Stock { + +StockId id + +ArticleId articleId + +StorageLocationId storageLocationId + +MinimumLevel minimumLevel + +MinimumShelfLife minimumShelfLife + +List~StockBatch~ batches + +List~Reservation~ reservations + +create(StockDraft) Result~StockError, Stock~ + +update(StockUpdateDraft) Result~StockError, Void~ + +addBatch(StockBatchDraft) Result~StockError, Void~ + +removeBatch(StockBatchId, Quantity) Result~StockError, Void~ + +blockBatch(StockBatchId, String) Result~StockError, Void~ + +unblockBatch(StockBatchId) Result~StockError, Void~ + +reserve(ReservationDraft) Result~StockError, Void~ + +releaseReservation(ReservationId) Result~StockError, Void~ + +confirmReservation(ReservationId) Result~StockError, Void~ + +markExpiredBatches(LocalDate) Result~StockError, Void~ + +markExpiringSoonBatches(LocalDate) Result~StockError, Void~ + +getAvailableQuantity() Quantity + +getTotalQuantity() Quantity + } + + class StockBatch { + +StockBatchId id + +BatchReference batchReference + +Quantity quantity + +LocalDate expiryDate + +StockBatchStatus status + +Instant receivedAt + } + + class Reservation { + +ReservationId id + +ReferenceType referenceType + +String referenceId + +Quantity quantity + +ReservationPriority priority + +Instant reservedAt + +List~StockBatchAllocation~ allocations + } + + class StockBatchAllocation { + +StockBatchId stockBatchId + +Quantity allocatedQuantity + } + + class StockMovement { + +StockMovementId id + +StockId stockId + +ArticleId articleId + +StockBatchId stockBatchId + +BatchReference batchReference + +MovementType movementType + +MovementDirection direction + +Quantity quantity + +String reason + +String referenceDocumentId + +UserId performedBy + +Instant performedAt + +record(StockMovementDraft) Result~StockMovementError, StockMovement~ + } + + class InventoryCount { + +InventoryCountId id + +StorageLocationId storageLocationId + +LocalDate countDate + +InventoryCountStatus status + +UserId initiatedBy + +UserId completedBy + +Instant completedAt + +List~CountItem~ countItems + +create(InventoryCountDraft) Result~InventoryCountError, InventoryCount~ + +addCountItem(CountItemDraft) Result~InventoryCountError, Void~ + +updateCountItem(CountItemId, Quantity) Result~InventoryCountError, Void~ + +complete(UserId) Result~InventoryCountError, Void~ + +cancel(String) Result~InventoryCountError, Void~ + } + + class CountItem { + +CountItemId id + +ArticleId articleId + +Quantity expectedQuantity + +Quantity actualQuantity + +Quantity deviation + } + + class StorageLocation { + +StorageLocationId id + +StorageLocationName name + +StorageType storageType + +TemperatureRange temperatureRange + +boolean active + +create(StorageLocationDraft) Result~StorageLocationError, StorageLocation~ + +update(StorageLocationUpdateDraft) Result~StorageLocationError, Void~ + +deactivate() Result~StorageLocationError, Void~ + +activate() Result~StorageLocationError, Void~ + } + + Stock "1" *-- "*" StockBatch : enthält + Stock "1" *-- "*" Reservation : enthält + Reservation "1" *-- "*" StockBatchAllocation : enthält + InventoryCount "1" *-- "*" CountItem : enthält + + Stock ..> StockId + Stock ..> ArticleId + Stock ..> StorageLocationId + Stock ..> MinimumLevel + Stock ..> MinimumShelfLife + + StockBatch ..> StockBatchId + StockBatch ..> BatchReference + StockBatch ..> StockBatchStatus + StockBatch ..> Quantity + + StockMovement ..> StockMovementId + StockMovement ..> MovementType + StockMovement ..> MovementDirection + + InventoryCount ..> InventoryCountId + InventoryCount ..> InventoryCountStatus + + StorageLocation ..> StorageLocationId + StorageLocation ..> StorageLocationName + StorageLocation ..> StorageType + StorageLocation ..> TemperatureRange +``` + +--- ## Aggregates -### Stock (Aggregate Root) +### 1. Stock (Aggregate Root) + +**Verantwortung:** Verwaltet den chargen-genauen Bestand eines Artikels an einem Lagerort. Erzwingt FEFO-Prinzip bei Reservierungen und prüft Mindestbestände. ``` -Stock -├── StockId -├── ArticleId - Reference to Master Data -├── StorageLocationId -├── BranchId -├── StockLevel (Quantity) - Current total -├── Batches[] (Entity) - Batch-level inventory (CRITICAL!) -│ ├── BatchId - ProductionBatchId OR SupplierBatchId -│ ├── BatchType (PRODUCED | PURCHASED) -│ ├── Quantity -│ ├── ExpiryDate (MHD) -│ ├── ReceivedAt -│ └── Status (AVAILABLE | RESERVED | EXPIRED | SOLD) -├── MinimumStockLevel -└── ReorderPoint - -Invariants: -- StockLevel = SUM(Batches.Quantity where Status = AVAILABLE) -- Cannot withdraw more than available -- FEFO enforced: oldest expiry first -- Negative stock not allowed -- Expired batches must have Status = EXPIRED +Stock (Aggregate Root) +├── StockId (VO) +├── ArticleId (VO) - Referenz auf Stammdaten-Artikel +├── StorageLocationId (VO) - Referenz auf Lagerort +├── MinimumLevel (VO) - Mindestbestand für Nachbestellwarnung +├── MinimumShelfLife (VO, optional) - Tage bis MHD für EXPIRING_SOON +├── StockBatches[] (Entity) +│ ├── StockBatchId (VO) +│ ├── BatchReference (VO) +│ │ ├── batchId (String) - ProductionBatchId oder SupplierBatchId +│ │ └── batchType (BatchType: PRODUCED | PURCHASED) +│ ├── Quantity (VO) - Aktuelle Menge (mit Catch-Weight) +│ ├── ExpiryDate (LocalDate) - MHD +│ ├── Status (StockBatchStatus: AVAILABLE | EXPIRING_SOON | BLOCKED | EXPIRED) +│ └── ReceivedAt (Instant) - Einbuchungszeitpunkt +└── Reservations[] (Entity) + ├── ReservationId (VO) + ├── ReferenceType (VO: PRODUCTION_ORDER | SALE_ORDER) + ├── ReferenceId (String) - ID des referenzierten Auftrags + ├── Quantity (VO) - Reservierte Gesamtmenge + ├── Priority (ReservationPriority: URGENT | NORMAL | LOW) + ├── ReservedAt (Instant) + └── StockBatchAllocations[] (Entity) + ├── StockBatchId (VO) - Zugeordnete Charge + └── AllocatedQuantity (VO) - Reservierte Menge aus dieser Charge ``` -### StockMovement (Aggregate Root) - Event Sourcing candidate! - -``` -StockMovement -├── StockMovementId -├── ArticleId -├── BatchId - CRITICAL for traceability -├── MovementType (GOODS_RECEIPT | PRODUCTION_OUTPUT | SALE | -│ INTER_BRANCH_TRANSFER | WASTE | ADJUSTMENT) -├── FromLocation, ToLocation -├── FromBranch, ToBranch -├── Quantity -├── MovementDate -├── PerformedBy (UserId) -├── Reason (for WASTE/ADJUSTMENT) -├── ReferenceDocument (GoodsReceiptId, ProductionOrderId, InvoiceId) -└── TraceabilityChain - Link to upstream/downstream - -Invariants: -- Quantity must be positive -- GOODS_RECEIPT: FromLocation = null -- SALE/WASTE: ToLocation = null -- INTER_BRANCH_TRANSFER: FromBranch != ToBranch -- All movements must reference a Batch +**Invarianten:** +```java +/** + * Stock aggregate root. + * + * Invariants: + * - ArticleId + StorageLocationId bilden ein logisches Unique-Constraint (Application Layer) + * - StockBatch.Quantity must be positive (> 0) + * - Negative Gesamtbestände sind nicht erlaubt + * - FEFO: Reservierungen allokieren immer die Chargen mit dem nächsten MHD zuerst + * - AvailableQuantity = SUM(AVAILABLE batches) - SUM(allocated reservations) + * - AvailableQuantity must be >= 0 (cannot over-reserve) + * - Expired batches (ExpiryDate < today) must have Status = EXPIRED + * - EXPIRING_SOON batches: ExpiryDate < today + MinimumShelfLife.days + * - BLOCKED batches cannot be reserved or withdrawn + * - StockBatchAllocations.SUM(allocatedQuantity) must equal Reservation.quantity + * - Each StockBatchAllocation must reference an AVAILABLE or EXPIRING_SOON batch + * - MinimumLevel.amount must be >= 0 + * - BatchReference must be unique within a Stock (no duplicate batch entries) + */ ``` -## Domain Events +**Draft-Records:** +```java +public record StockDraft( + String articleId, + String storageLocationId, + String minimumLevelAmount, // BigDecimal als String, nullable + String minimumLevelUnit, // UnitOfMeasure, nullable + Integer minimumShelfLifeDays // nullable +) {} + +public record StockUpdateDraft( + String minimumLevelAmount, // BigDecimal als String, nullable + String minimumLevelUnit, // UnitOfMeasure, nullable + Integer minimumShelfLifeDays // nullable +) {} + +public record StockBatchDraft( + String batchId, // ProductionBatchId oder SupplierBatchId + String batchType, // PRODUCED | PURCHASED + String quantityAmount, // BigDecimal als String + String quantityUnit, // UnitOfMeasure + String expiryDate // ISO LocalDate +) {} + +public record ReservationDraft( + String referenceType, // PRODUCTION_ORDER | SALE_ORDER + String referenceId, + String quantityAmount, // BigDecimal als String + String quantityUnit, // UnitOfMeasure + String priority // URGENT | NORMAL | LOW +) {} +``` + +**Factory & Business Methods:** +```java +// Factory +public static Result create(StockDraft draft); + +// Bestandsänderungen +public Result update(StockUpdateDraft draft); +public Result addBatch(StockBatchDraft draft); +public Result removeBatch(StockBatchId batchId, Quantity quantity); +public Result blockBatch(StockBatchId batchId, String reason); +public Result unblockBatch(StockBatchId batchId); + +// Reservierungen (FEFO-basiert) +public Result reserve(ReservationDraft draft); +public Result releaseReservation(ReservationId reservationId); +public Result confirmReservation(ReservationId reservationId); + +// MHD-Prüfung (typischerweise per Scheduler aufgerufen) +public Result markExpiredBatches(LocalDate today); +public Result markExpiringSoonBatches(LocalDate today); + +// Query Methods +public Quantity getAvailableQuantity(); +public Quantity getTotalQuantity(); +public List getAvailableBatchesByFefo(); +public boolean isBelowMinimumLevel(); +``` + +**Domain Events:** +```java +StockBatchAdded(StockId, StockBatchId, ArticleId, BatchReference, Quantity, LocalDate expiryDate) +StockBatchRemoved(StockId, StockBatchId, Quantity) +StockBatchBlocked(StockId, StockBatchId, String reason) +StockBatchUnblocked(StockId, StockBatchId) +StockReserved(StockId, ReservationId, Quantity, String referenceId) +StockReservationReleased(StockId, ReservationId) +StockReservationConfirmed(StockId, ReservationId, Quantity) +StockLevelBelowMinimum(StockId, ArticleId, StorageLocationId, Quantity currentLevel, Quantity minimumLevel) + → triggers Procurement demand / Notification +BatchExpiringSoon(StockId, StockBatchId, ArticleId, LocalDate expiryDate, int daysRemaining) + → triggers Notification +BatchExpired(StockId, StockBatchId, ArticleId, LocalDate expiryDate) +``` + +--- + +### 2. StockMovement (Aggregate Root) + +**Verantwortung:** Dokumentiert jede Bestandsveränderung als unveränderlichen Audit-Trail. Ermöglicht lückenlose Rückverfolgbarkeit aller Ein- und Ausgänge. + +``` +StockMovement (Aggregate Root) +├── StockMovementId (VO) +├── StockId (VO) - Referenz auf betroffenen Stock +├── ArticleId (VO) - Denormalisiert für einfache Abfragen +├── StockBatchId (VO) - Betroffene Charge +├── BatchReference (VO) - Chargenreferenz (denormalisiert) +├── MovementType (VO: GOODS_RECEIPT | PRODUCTION_OUTPUT | PRODUCTION_CONSUMPTION | +│ SALE | INTER_BRANCH_TRANSFER | WASTE | ADJUSTMENT | RETURN) +├── Direction (VO: IN | OUT) - Abgeleitet aus MovementType +├── Quantity (VO) - Bewegte Menge (immer positiv) +├── Reason (String, optional) - Pflicht bei WASTE und ADJUSTMENT +├── ReferenceDocumentId (String, optional) - GoodsReceiptId, ProductionOrderId, InvoiceId etc. +├── PerformedBy (VO: UserId) +└── PerformedAt (VO: Instant) +``` + +**Invarianten:** +```java +/** + * StockMovement aggregate root (immutable after creation). + * + * Invariants: + * - Quantity must be positive + * - Direction is derived: IN for GOODS_RECEIPT, PRODUCTION_OUTPUT, RETURN, ADJUSTMENT(+) + * OUT for PRODUCTION_CONSUMPTION, SALE, WASTE, ADJUSTMENT(-), INTER_BRANCH_TRANSFER(source) + * - WASTE and ADJUSTMENT require a non-empty Reason + * - INTER_BRANCH_TRANSFER must have a ReferenceDocumentId (Transfer-Dokument) + * - PerformedBy must not be null + * - StockMovement is immutable after creation (append-only) + * - BatchReference must not be null (all movements are batch-traceable) + */ +``` + +**Draft-Record:** +```java +public record StockMovementDraft( + String stockId, + String articleId, + String stockBatchId, + String batchId, // BatchReference.batchId + String batchType, // BatchReference.batchType + String movementType, // MovementType enum + String quantityAmount, // BigDecimal als String + String quantityUnit, // UnitOfMeasure + String reason, // nullable, Pflicht bei WASTE/ADJUSTMENT + String referenceDocumentId, // nullable + String performedBy // UserId +) {} +``` + +**Factory & Business Methods:** +```java +// Factory (einzige Mutation – StockMovement ist immutable) +public static Result record(StockMovementDraft draft); + +// Query Methods +public boolean isIncoming(); +public boolean isOutgoing(); +``` + +**Domain Events:** +```java +StockMovementRecorded(StockMovementId, StockId, ArticleId, BatchReference, MovementType, Quantity) +``` + +--- + +### 3. InventoryCount (Aggregate Root) + +**Verantwortung:** Physische Bestandsaufnahme pro Lagerort mit Soll/Ist-Abgleich. Abweichungen werden nach Abschluss als ADJUSTMENT-StockMovements verbucht. + +``` +InventoryCount (Aggregate Root) +├── InventoryCountId (VO) +├── StorageLocationId (VO) - Gezählter Lagerort +├── CountDate (LocalDate) - Stichtag der Inventur +├── Status (InventoryCountStatus: OPEN | COUNTING | COMPLETED | CANCELLED) +├── InitiatedBy (VO: UserId) +├── CompletedBy (VO: UserId, optional) +├── CompletedAt (Instant, optional) +├── CancelledReason (String, optional) +└── CountItems[] (Entity) + ├── CountItemId (VO) + ├── ArticleId (VO) - Gezählter Artikel + ├── ExpectedQuantity (VO: Quantity) - Soll-Bestand aus System + ├── ActualQuantity (VO: Quantity, optional) - Ist-Menge nach Zählung + └── Deviation (VO: Quantity, optional) - Berechnet: ActualQuantity - ExpectedQuantity +``` + +**Invarianten:** +```java +/** + * InventoryCount aggregate root. + * + * Invariants: + * - Status transitions: OPEN → COUNTING → COMPLETED + * OPEN → CANCELLED + * COUNTING → CANCELLED + * - Cannot complete without at least one CountItem + * - All CountItems must have ActualQuantity set before completion + * - Deviation is auto-calculated: ActualQuantity - ExpectedQuantity + * - CountDate cannot be in the future at creation + * - ArticleId must be unique within CountItems (no duplicate articles) + * - Only one active (OPEN/COUNTING) InventoryCount per StorageLocation (Application Layer) + * - CompletedBy must differ from InitiatedBy (Vier-Augen-Prinzip) + */ +``` + +**Draft-Records:** +```java +public record InventoryCountDraft( + String storageLocationId, + String countDate, // ISO LocalDate + String initiatedBy // UserId +) {} + +public record CountItemDraft( + String articleId, + String expectedQuantityAmount, // BigDecimal als String + String expectedQuantityUnit // UnitOfMeasure +) {} +``` + +**Factory & Business Methods:** +```java +// Factory +public static Result create(InventoryCountDraft draft); + +// Zählung +public Result startCounting(); +public Result addCountItem(CountItemDraft draft); +public Result updateCountItem(CountItemId itemId, Quantity actualQuantity); + +// Abschluss +public Result complete(UserId completedBy); +public Result cancel(String reason); + +// Query Methods +public List getDeviations(); +public boolean hasDeviations(); +public Quantity getTotalDeviation(); +``` + +**Domain Events:** +```java +InventoryCountCreated(InventoryCountId, StorageLocationId, LocalDate countDate) +InventoryCountStarted(InventoryCountId) +InventoryCountCompleted(InventoryCountId, StorageLocationId, List deviations) + → triggers ADJUSTMENT StockMovements for each deviation +InventoryCountCancelled(InventoryCountId, String reason) +``` + +--- + +### 4. StorageLocation (Aggregate Root) + +**Verantwortung:** Verwaltet konfigurierbare physische Lagerorte mit Typ-Klassifizierung und optionalem Temperaturbereich. + +``` +StorageLocation (Aggregate Root) +├── StorageLocationId (VO) +├── Name (VO: StorageLocationName) - Eindeutiger Name +├── StorageType (VO: COLD_ROOM | FREEZER | DRY_STORAGE | DISPLAY_COUNTER | PRODUCTION_AREA) +├── TemperatureRange (VO, optional) +│ ├── MinTemperature (BigDecimal) - in °C +│ └── MaxTemperature (BigDecimal) - in °C +└── Active (boolean) - Soft-Delete +``` + +**Invarianten:** +```java +/** + * StorageLocation aggregate root. + * + * Invariants: + * - Name must be unique (Application Layer, Repository-Concern) + * - Name must not be blank, max 100 chars + * - TemperatureRange: minTemperature < maxTemperature + * - TemperatureRange: values must be in range -50°C to +80°C + * - Cannot deactivate if Stock exists at this location (Application Layer) + * - StorageType is immutable after creation (changing type = new location) + */ +``` + +**Draft-Records:** +```java +public record StorageLocationDraft( + String name, + String storageType, // StorageType enum + String minTemperature, // BigDecimal als String, nullable + String maxTemperature // BigDecimal als String, nullable +) {} + +public record StorageLocationUpdateDraft( + String name, // nullable = no change + String minTemperature, // BigDecimal als String, nullable + String maxTemperature // BigDecimal als String, nullable +) {} +``` + +**Factory & Business Methods:** +```java +// Factory +public static Result create(StorageLocationDraft draft); + +// Mutations +public Result update(StorageLocationUpdateDraft draft); +public Result deactivate(); +public Result activate(); + +// Query Methods +public boolean isTemperatureControlled(); +``` + +**Domain Events:** +```java +StorageLocationCreated(StorageLocationId, String name, StorageType) +StorageLocationDeactivated(StorageLocationId) +``` + +--- + +## Shared Value Objects + +```mermaid +--- +config: + theme: neutral + look: classic + layout: elk + themeVariables: + background: "#f8fafc" + class: + hideEmptyMembersBox: true +--- +classDiagram + class BatchReference { + +String batchId + +BatchType batchType + +of(String, BatchType) Result + } + + class BatchType { + <> + PRODUCED + PURCHASED + } + + class MinimumLevel { + +Quantity quantity + +of(Quantity) Result + } + + class MinimumShelfLife { + +int days + +of(int) Result + +isExpiringSoon(LocalDate, LocalDate) boolean + } + + class StockBatchStatus { + <> + AVAILABLE + EXPIRING_SOON + BLOCKED + EXPIRED + } + + class ReservationPriority { + <> + URGENT + NORMAL + LOW + } + + class ReferenceType { + <> + PRODUCTION_ORDER + SALE_ORDER + } + + class MovementType { + <> + GOODS_RECEIPT + PRODUCTION_OUTPUT + PRODUCTION_CONSUMPTION + SALE + INTER_BRANCH_TRANSFER + WASTE + ADJUSTMENT + RETURN + } + + class MovementDirection { + <> + IN + OUT + } + + class InventoryCountStatus { + <> + OPEN + COUNTING + COMPLETED + CANCELLED + } + + class StorageType { + <> + COLD_ROOM + FREEZER + DRY_STORAGE + DISPLAY_COUNTER + PRODUCTION_AREA + } + + class StorageLocationName { + +String value + +of(String) Result + } + + class TemperatureRange { + +BigDecimal minTemperature + +BigDecimal maxTemperature + +of(BigDecimal, BigDecimal) Result + +contains(BigDecimal) boolean + } + + BatchReference --> BatchType + MovementType --> MovementDirection : derives +``` + +### BatchReference ```java -StockLevelBelowMinimum(ArticleId, BranchId, Quantity) -BatchExpiringSoon(BatchId, ArticleId, ExpiryDate) -StockMovementRecorded(StockMovementId, BatchId, MovementType) +public record BatchReference(String batchId, BatchType batchType) { + public static Result of(String batchId, String batchType) { + // batchId must not be blank + // batchType must be valid enum value + } +} ``` + +### MinimumLevel + +```java +public record MinimumLevel(Quantity quantity) { + public static Result of(Quantity quantity) { + // quantity.amount must be >= 0 + } + + public boolean isBelow(Quantity currentLevel) { + return currentLevel.amount().compareTo(quantity.amount()) < 0; + } +} +``` + +### MinimumShelfLife + +```java +public record MinimumShelfLife(int days) { + public static Result of(int days) { + // days must be > 0 + } + + public boolean isExpiringSoon(LocalDate expiryDate, LocalDate today) { + return expiryDate.isBefore(today.plusDays(days)); + } +} +``` + +### TemperatureRange + +```java +public record TemperatureRange(BigDecimal minTemperature, BigDecimal maxTemperature) { + public static Result of( + BigDecimal minTemperature, BigDecimal maxTemperature) { + // minTemperature < maxTemperature + // both in range -50 to +80 + } + + public boolean contains(BigDecimal temperature) { + return temperature.compareTo(minTemperature) >= 0 + && temperature.compareTo(maxTemperature) <= 0; + } +} +``` + +### StorageLocationName + +```java +public record StorageLocationName(String value) { + public static Result of(String value) { + // must not be blank + // max 100 chars + } +} +``` + +### MovementType + +```java +public enum MovementType { + GOODS_RECEIPT(MovementDirection.IN), + PRODUCTION_OUTPUT(MovementDirection.IN), + PRODUCTION_CONSUMPTION(MovementDirection.OUT), + SALE(MovementDirection.OUT), + INTER_BRANCH_TRANSFER(MovementDirection.OUT), // Source-Seite + WASTE(MovementDirection.OUT), + ADJUSTMENT(null), // Richtung wird durch Vorzeichen der Menge bestimmt + RETURN(MovementDirection.IN); + + private final MovementDirection defaultDirection; +} +``` + +### ReservationPriority + +```java +public enum ReservationPriority { + URGENT(1), + NORMAL(2), + LOW(3); + + private final int sortOrder; +} +``` + +--- + +## Domain Services + +### StockExpiryChecker + +```java +/** + * Prüft alle Stocks auf ablaufende und abgelaufene Chargen. + * Wird typischerweise per Scheduler (z.B. täglich um 06:00) aufgerufen. + */ +public class StockExpiryChecker { + /** + * Markiert abgelaufene Chargen als EXPIRED und + * bald ablaufende als EXPIRING_SOON. + * Löst BatchExpiringSoon und BatchExpired Events aus. + */ + public void checkAll(LocalDate today, StockRepository stockRepository); +} +``` + +### InventoryCountReconciliationService + +```java +/** + * Erstellt ADJUSTMENT-StockMovements für Inventur-Abweichungen + * nach Abschluss einer InventoryCount. + */ +public class InventoryCountReconciliationService { + /** + * Für jede CountItem-Abweichung wird ein StockMovement (ADJUSTMENT) erzeugt. + * Positive Abweichung → IN, negative Abweichung → OUT. + */ + public List reconcile(InventoryCount completedCount, UserId performedBy); +} +``` + +### StockAvailabilityService + +```java +/** + * Prüft Materialverfügbarkeit für Produktionsaufträge. + * Wird vom Production BC aufgerufen (via Application Layer). + */ +public class StockAvailabilityService { + /** + * Prüft ob ausreichend Material an einem Lagerort verfügbar ist. + */ + public boolean isAvailable(ArticleId articleId, StorageLocationId locationId, Quantity requiredQuantity); + + /** + * Gibt verfügbare Menge über alle Lagerorte zurück. + */ + public Quantity getAvailableQuantityAcrossLocations(ArticleId articleId); +} +``` + +--- + +## Domain Errors + +```java +public sealed interface StockError { + String message(); + + record StockNotFound(String stockId) implements StockError { + public String message() { return "Stock not found: " + stockId; } + } + record DuplicateBatchReference(String batchId) implements StockError { + public String message() { return "Batch already exists in stock: " + batchId; } + } + record InsufficientStock(String available, String requested) implements StockError { + public String message() { return "Insufficient stock: available=" + available + ", requested=" + requested; } + } + record BatchNotFound(String stockBatchId) implements StockError { + public String message() { return "Stock batch not found: " + stockBatchId; } + } + record BatchBlocked(String stockBatchId) implements StockError { + public String message() { return "Batch is blocked: " + stockBatchId; } + } + record BatchExpired(String stockBatchId) implements StockError { + public String message() { return "Batch is expired: " + stockBatchId; } + } + record ReservationNotFound(String reservationId) implements StockError { + public String message() { return "Reservation not found: " + reservationId; } + } + record InvalidQuantity(String reason) implements StockError { + public String message() { return "Invalid quantity: " + reason; } + } + record InvalidMinimumLevel(String reason) implements StockError { + public String message() { return "Invalid minimum level: " + reason; } + } + record InvalidMinimumShelfLife(int days) implements StockError { + public String message() { return "Minimum shelf life must be > 0, got: " + days; } + } + record InvalidBatchReference(String reason) implements StockError { + public String message() { return "Invalid batch reference: " + reason; } + } + record NegativeStockNotAllowed() implements StockError { + public String message() { return "Stock cannot go negative"; } + } +} + +public sealed interface StockMovementError { + String message(); + + record InvalidQuantity(String reason) implements StockMovementError { + public String message() { return "Invalid quantity: " + reason; } + } + record ReasonRequired(String movementType) implements StockMovementError { + public String message() { return "Reason is required for movement type: " + movementType; } + } + record ReferenceDocumentRequired(String movementType) implements StockMovementError { + public String message() { return "Reference document required for: " + movementType; } + } + record InvalidMovementType(String value) implements StockMovementError { + public String message() { return "Invalid movement type: " + value; } + } + record MissingBatchReference() implements StockMovementError { + public String message() { return "All stock movements must reference a batch"; } + } +} + +public sealed interface InventoryCountError { + String message(); + + record InvalidStatusTransition(String from, String to) implements InventoryCountError { + public String message() { return "Cannot transition from " + from + " to " + to; } + } + record CountDateInFuture(String date) implements InventoryCountError { + public String message() { return "Count date cannot be in the future: " + date; } + } + record NoCountItems() implements InventoryCountError { + public String message() { return "Cannot complete inventory count without count items"; } + } + record IncompleteCountItems(int uncounted) implements InventoryCountError { + public String message() { return "Cannot complete: " + uncounted + " items not yet counted"; } + } + record DuplicateArticle(String articleId) implements InventoryCountError { + public String message() { return "Article already in count: " + articleId; } + } + record CountItemNotFound(String countItemId) implements InventoryCountError { + public String message() { return "Count item not found: " + countItemId; } + } + record SamePersonViolation() implements InventoryCountError { + public String message() { return "Initiator and completer must be different persons (Vier-Augen-Prinzip)"; } + } +} + +public sealed interface StorageLocationError { + String message(); + + record NameAlreadyExists(String name) implements StorageLocationError { + public String message() { return "Storage location name already exists: " + name; } + } + record InvalidName(String reason) implements StorageLocationError { + public String message() { return "Invalid storage location name: " + reason; } + } + record InvalidTemperatureRange(String reason) implements StorageLocationError { + public String message() { return "Invalid temperature range: " + reason; } + } + record StockExistsAtLocation(String locationId) implements StorageLocationError { + public String message() { return "Cannot deactivate: stock exists at location " + locationId; } + } + record AlreadyActive() implements StorageLocationError { + public String message() { return "Storage location is already active"; } + } + record AlreadyInactive() implements StorageLocationError { + public String message() { return "Storage location is already inactive"; } + } +} +``` + +--- + +## Repository Interfaces + +```java +public interface StockRepository { + Optional findById(StockId id); + Optional findByArticleIdAndStorageLocationId(ArticleId articleId, StorageLocationId locationId); + List findByArticleId(ArticleId articleId); + List findByStorageLocationId(StorageLocationId locationId); + List findBelowMinimumLevel(); + List findWithExpiringBatches(LocalDate before); + List findByBatchReference(BatchReference batchReference); + void save(Stock stock); +} + +public interface StockMovementRepository { + Optional findById(StockMovementId id); + List findByStockId(StockId stockId); + List findByArticleId(ArticleId articleId); + List findByBatchReference(BatchReference batchReference); + List findByMovementType(MovementType type); + List findByPerformedAtBetween(Instant from, Instant to); + void save(StockMovement movement); +} + +public interface InventoryCountRepository { + Optional findById(InventoryCountId id); + List findByStorageLocationId(StorageLocationId locationId); + List findByStatus(InventoryCountStatus status); + List findByCountDateBetween(LocalDate from, LocalDate to); + boolean existsActiveByStorageLocationId(StorageLocationId locationId); + void save(InventoryCount count); +} + +public interface StorageLocationRepository { + Optional findById(StorageLocationId id); + List findAll(); + List findByStorageType(StorageType type); + List findActive(); + boolean existsByName(StorageLocationName name); + void save(StorageLocation location); +} +``` + +--- + +## Status-Maschinen + +### StockBatch Status + +```mermaid +--- +config: + theme: neutral + themeVariables: + background: "#f8fafc" +--- +stateDiagram-v2 + [*] --> AVAILABLE : addBatch() + AVAILABLE --> EXPIRING_SOON : markExpiringSoonBatches() + AVAILABLE --> BLOCKED : blockBatch() + AVAILABLE --> EXPIRED : markExpiredBatches() + EXPIRING_SOON --> BLOCKED : blockBatch() + EXPIRING_SOON --> EXPIRED : markExpiredBatches() + BLOCKED --> AVAILABLE : unblockBatch() + BLOCKED --> EXPIRING_SOON : unblockBatch() + MHD-Check + + AVAILABLE : Frei verfügbar, reservierbar + EXPIRING_SOON : MHD < heute + MinimumShelfLife + EXPIRING_SOON : Noch verfügbar, aber Warnung + BLOCKED : Gesperrt (QualityHold etc.) + BLOCKED : Nicht reservierbar/entnehmbar + EXPIRED : MHD überschritten + EXPIRED : Muss als WASTE verbucht werden +``` + +### InventoryCount Status + +```mermaid +--- +config: + theme: neutral + themeVariables: + background: "#f8fafc" +--- +stateDiagram-v2 + [*] --> OPEN : create() + OPEN --> COUNTING : startCounting() + OPEN --> CANCELLED : cancel() + COUNTING --> COMPLETED : complete() + COUNTING --> CANCELLED : cancel() + + OPEN : Inventur angelegt + OPEN : addCountItem() möglich + COUNTING : Zählung läuft + COUNTING : updateCountItem() möglich + COMPLETED : Alle Positionen gezählt + COMPLETED : Abweichungen als ADJUSTMENT gebucht + CANCELLED : Inventur abgebrochen +``` + +--- + +## 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)"] + PROD["Production BC"] + QUAL["Quality BC"] + PROC["Procurement BC"] + end + + subgraph INVENTORY["Inventory BC"] + S["Stock"] + SM["StockMovement"] + IC["InventoryCount"] + SL["StorageLocation"] + end + + subgraph DOWNSTREAM["Downstream BCs"] + SALES["Sales BC"] + PROCDOWN["Procurement BC\n(Nachbestellung)"] + end + + MD -->|ArticleId| S + UM -->|UserId| SM + UM -->|UserId| IC + + PROD -->|"BatchCompleted\n(stock-in)"| S + PROD -->|"ConsumptionRecorded\n(stock-out)"| S + QUAL -->|"QualityHoldCreated\n(block batch)"| S + QUAL -->|"QualityHoldReleased\n(unblock batch)"| S + QUAL -->|"QualityHoldRejected\n(write-off)"| S + PROC -->|"GoodsReceived\n(stock-in)"| S + + S -->|"StockLevelBelowMinimum\n(demand)"| PROCDOWN + S -->|"BatchExpiringSoon\n(notification)"| SALES +``` + +### Upstream-Abhängigkeiten (Inventory konsumiert) +| BC | Referenz / Event | Zweck | +|---|---|---| +| **Master Data** | ArticleId | Artikel-Referenz in Stock, StockMovement, CountItem | +| **User Management** | UserId | PerformedBy in StockMovement, InitiatedBy/CompletedBy in InventoryCount | +| **Production** | `BatchCompleted` Event | Stock-In: Neue StockBatch mit BatchType=PRODUCED | +| **Production** | `ConsumptionRecorded` Event | Stock-Out: removeBatch() für verbrauchte Rohstoffe | +| **Quality** | `QualityHoldCreated` Event | blockBatch() – Charge sperren | +| **Quality** | `QualityHoldReleased` Event | unblockBatch() – Charge freigeben | +| **Quality** | `QualityHoldRejected` Event | removeBatch() + WASTE-StockMovement – Charge entsorgen | +| **Procurement** | `GoodsReceived` Event | Stock-In: Neue StockBatch mit BatchType=PURCHASED | + +### Downstream-Integrationen (Inventory publiziert Events) +| Event | Konsument | Aktion | +|---|---|---| +| `StockLevelBelowMinimum` | **Procurement BC** | Automatische Nachbestellung / Bestellvorschlag | +| `BatchExpiringSoon` | **Sales BC** | Rabattaktion / Prioritäts-Verkauf | +| `StockMovementRecorded` | **Audit** | Lückenloser Audit-Trail | +| `InventoryCountCompleted` | **Audit** | Inventur-Dokumentation | + +### Abgrenzungen (gehören NICHT in Inventory BC) +| Konzept | Zuständiger BC | Grund | +|---|---|---| +| Qualitätssperre (QualityHold) | **Quality BC** | Eigene Aggregate-Logik mit Vier-Augen-Prinzip | +| Rezeptur / Produktion | **Production BC** | Inventory kennt keine Rezepte, nur Mengen | +| Lieferant / Wareneingang | **Procurement BC** | Inventory reagiert nur auf GoodsReceived Event | +| Artikelstamm | **Master Data BC** | Inventory referenziert nur ArticleId | +| Preisgestaltung | **Sales BC** | Inventory verwaltet keine Preise | + +--- + +## Use Cases (Application Layer) + +```java +// Stock Management +CreateStock → Stock.create(StockDraft) +UpdateStock → stock.update(StockUpdateDraft) +AddStockBatch → stock.addBatch(StockBatchDraft) + StockMovement.record() +RemoveStockBatch → stock.removeBatch(id, qty) + StockMovement.record() +BlockStockBatch → stock.blockBatch(id, reason) +UnblockStockBatch → stock.unblockBatch(id) +GetStock → Query +ListStockByLocation → Query +ListStockBelowMinimum → Query + +// Reservations +ReserveStock → stock.reserve(ReservationDraft) +ReleaseReservation → stock.releaseReservation(reservationId) +ConfirmReservation → stock.confirmReservation(reservationId) + StockMovement.record() + +// Stock Movements +RecordStockMovement → StockMovement.record(StockMovementDraft) +GetStockMovement → Query +ListMovementsByArticle → Query +ListMovementsByPeriod → Query + +// Inventory Count +CreateInventoryCount → InventoryCount.create(InventoryCountDraft) + auto-populate CountItems +StartInventoryCount → count.startCounting() +RecordCountItem → count.updateCountItem(id, actualQuantity) +CompleteInventoryCount → count.complete(userId) + reconciliation → ADJUSTMENT movements +CancelInventoryCount → count.cancel(reason) +GetInventoryCount → Query + +// Storage Location Management +CreateStorageLocation → StorageLocation.create(StorageLocationDraft) +UpdateStorageLocation → location.update(StorageLocationUpdateDraft) +DeactivateStorageLocation → location.deactivate() +ListStorageLocations → Query + +// Event Handlers (reagieren auf Upstream-Events) +HandleBatchCompleted → AddStockBatch (PRODUCED) +HandleConsumptionRecorded → RemoveStockBatch +HandleGoodsReceived → AddStockBatch (PURCHASED) +HandleQualityHoldCreated → BlockStockBatch +HandleQualityHoldReleased → UnblockStockBatch +HandleQualityHoldRejected → RemoveStockBatch + WASTE movement +``` + +--- + +## Beispiel: Bestandsfluss (End-to-End) + +```mermaid +--- +config: + theme: neutral + themeVariables: + background: "#f8fafc" +--- +sequenceDiagram + participant PROC as Procurement BC + participant INV as Inventory (Stock) + participant SM as StockMovement + participant PROD as Production BC + participant QUAL as Quality BC + participant SALES as Sales BC + + Note over PROC,INV: 1. Wareneingang + PROC--)INV: GoodsReceived Event + activate INV + INV->>INV: addBatch(PURCHASED, 100kg Schweineschulter, MHD 2026-03-15) + INV->>SM: record(GOODS_RECEIPT, 100kg) + deactivate INV + + Note over PROD,INV: 2. Produktions-Verbrauch + PROD--)INV: ConsumptionRecorded Event + activate INV + INV->>INV: removeBatch(Schweineschulter, 45kg) + INV->>SM: record(PRODUCTION_CONSUMPTION, 45kg) + deactivate INV + + Note over PROD,INV: 3. Produktions-Zugang + PROD--)INV: BatchCompleted Event + activate INV + INV->>INV: addBatch(PRODUCED, 35kg Fleischwurst, MHD 2026-03-20) + INV->>SM: record(PRODUCTION_OUTPUT, 35kg) + INV-->>INV: isBelowMinimumLevel()? + deactivate INV + + Note over QUAL,INV: 4. Qualitätssperre + QUAL--)INV: QualityHoldCreated Event + activate INV + INV->>INV: blockBatch(Fleischwurst-Charge) + Note over INV: Status: BLOCKED + deactivate INV + + QUAL--)INV: QualityHoldReleased Event + activate INV + INV->>INV: unblockBatch(Fleischwurst-Charge) + Note over INV: Status: AVAILABLE + deactivate INV + + Note over INV,SALES: 5. Verkauf (mit FEFO) + SALES->>INV: reserve(SALE_ORDER, 10kg Fleischwurst, NORMAL) + activate INV + INV->>INV: FEFO: älteste Charge zuerst + INV-->>SALES: ReservationId + deactivate INV + + SALES->>INV: confirmReservation(reservationId) + activate INV + INV->>INV: removeBatch(allocated batches) + INV->>SM: record(SALE, 10kg) + deactivate INV +``` + +### Code-Beispiel + +```java +// 1. Wareneingang verarbeiten (Event Handler) +Stock stock = stockRepository + .findByArticleIdAndStorageLocationId(articleId, locationId) + .orElseGet(() -> Stock.create(new StockDraft( + articleId, locationId, "10", "KILOGRAM", 7 + )).value()); + +var batchDraft = new StockBatchDraft( + "SUPP-2026-02-19-042", // Lieferanten-Chargennummer + "PURCHASED", + "100", "KILOGRAM", + "2026-03-15" // MHD +); +stock.addBatch(batchDraft); +stockRepository.save(stock); + +// StockMovement dokumentieren +StockMovement.record(new StockMovementDraft( + stock.id().value(), articleId, stockBatchId, + "SUPP-2026-02-19-042", "PURCHASED", + "GOODS_RECEIPT", "100", "KILOGRAM", + null, "GR-2026-02-19-001", userId +)); + +// 2. Reservierung für Produktionsauftrag +var reserveDraft = new ReservationDraft( + "PRODUCTION_ORDER", "PO-2026-02-20-001", + "45", "KILOGRAM", "URGENT" +); +switch (stock.reserve(reserveDraft)) { + case Result.Success s -> { + stockRepository.save(stock); + // FEFO hat automatisch die Charge mit MHD 2026-03-15 allokiert + } + case Result.Failure f -> { + switch (f.error()) { + case StockError.InsufficientStock e -> + log.warn("Nicht genug Material: {}", e.message()); + // ... + } + } +} + +// 3. Inventur durchführen +var countDraft = new InventoryCountDraft(locationId, "2026-02-19", userId); +InventoryCount count = InventoryCount.create(countDraft).value(); + +// Soll-Bestände aus Stock laden und als CountItems hinzufügen +for (Stock s : stockRepository.findByStorageLocationId(locationId)) { + count.addCountItem(new CountItemDraft( + s.articleId().value(), + s.getTotalQuantity().amount().toString(), + s.getTotalQuantity().uom().name() + )); +} + +count.startCounting(); + +// Zähler erfasst Ist-Menge +count.updateCountItem(countItemId, Quantity.of(new BigDecimal("97"), UnitOfMeasure.KILOGRAM).value()); + +// Vier-Augen-Prinzip: anderer User schließt ab +count.complete(otherUserId); +inventoryCountRepository.save(count); + +// Reconciliation: ADJUSTMENT-Bewegungen für Abweichungen +List adjustments = reconciliationService.reconcile(count, otherUserId); +adjustments.forEach(stockMovementRepository::save); +``` + +--- + +## DDD Validation Checklist + +- [x] Aggregate Root ist einziger Einstiegspunkt (Stock, StockMovement, InventoryCount, StorageLocation) +- [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, StorageLocationId, BatchReference) +- [x] Ein Aggregate = eine Transaktionsgrenze +- [x] EntityDraft-Pattern für VO-Konstruktion im Domain Layer +- [x] Result für erwartbare Fehler, keine Exceptions +- [x] Sealed interfaces für Domain Errors +- [x] Status-Maschinen explizit dokumentiert (StockBatchStatus, InventoryCountStatus) +- [x] BC-Grenzen klar definiert (QualityHold → Quality BC, Rezeptur → Production BC) +- [x] FEFO-Prinzip im Stock-Aggregate verankert +- [x] Vier-Augen-Prinzip bei Inventur-Abschluss +- [x] Alle Bestandsbewegungen sind chargen-traceable (BatchReference pflicht)