1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 22:39:58 +01:00
effigenix/backend/docs/mvp/ddd/07-inventory-bc.md
Sebastian Frick fb735702cf docs(ddd): detaillierte Domain Models für Production, Quality und Inventory BC
- Production BC: Aggregates (Recipe, Batch, ProductionOrder) mit Invarianten, Drafts, Status-Maschinen, Domain Events und Chargen-Genealogie
- Quality BC: 9 Aggregates (TemperatureLog, CleaningPlan/Record, GoodsReceiptInspection, SampleRecord, TrainingRecord, MaintenanceRecord, QualityHold, ProcessParameter) mit HACCP-Compliance
- Inventory BC: 4 Aggregates (Stock, StockMovement, InventoryCount, StorageLocation) mit FEFO, Reservierungen mit Priorität, Vier-Augen-Prinzip bei Inventur
- Ubiquitous Language: Inventory-Sektion von 11 auf 27 Begriffe erweitert
- Alte deutsche Datei 05-qualitaets-kontext.md entfernt (ersetzt durch 05-quality-bc.md)
2026-02-19 01:13:12 +01:00

1308 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Inventory BC - Detailliertes Domain Model
**Bounded Context:** Inventory
**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
### 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 (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
```
**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)
*/
```
**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<StockError, Stock> create(StockDraft draft);
// Bestandsänderungen
public Result<StockError, Void> update(StockUpdateDraft draft);
public Result<StockError, Void> addBatch(StockBatchDraft draft);
public Result<StockError, Void> removeBatch(StockBatchId batchId, Quantity quantity);
public Result<StockError, Void> blockBatch(StockBatchId batchId, String reason);
public Result<StockError, Void> unblockBatch(StockBatchId batchId);
// Reservierungen (FEFO-basiert)
public Result<StockError, Void> reserve(ReservationDraft draft);
public Result<StockError, Void> releaseReservation(ReservationId reservationId);
public Result<StockError, Void> confirmReservation(ReservationId reservationId);
// MHD-Prüfung (typischerweise per Scheduler aufgerufen)
public Result<StockError, Void> markExpiredBatches(LocalDate today);
public Result<StockError, Void> markExpiringSoonBatches(LocalDate today);
// Query Methods
public Quantity getAvailableQuantity();
public Quantity getTotalQuantity();
public List<StockBatch> 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<StockMovementError, StockMovement> 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<InventoryCountError, InventoryCount> create(InventoryCountDraft draft);
// Zählung
public Result<InventoryCountError, Void> startCounting();
public Result<InventoryCountError, Void> addCountItem(CountItemDraft draft);
public Result<InventoryCountError, Void> updateCountItem(CountItemId itemId, Quantity actualQuantity);
// Abschluss
public Result<InventoryCountError, Void> complete(UserId completedBy);
public Result<InventoryCountError, Void> cancel(String reason);
// Query Methods
public List<CountItem> getDeviations();
public boolean hasDeviations();
public Quantity getTotalDeviation();
```
**Domain Events:**
```java
InventoryCountCreated(InventoryCountId, StorageLocationId, LocalDate countDate)
InventoryCountStarted(InventoryCountId)
InventoryCountCompleted(InventoryCountId, StorageLocationId, List<CountItem> 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<StorageLocationError, StorageLocation> create(StorageLocationDraft draft);
// Mutations
public Result<StorageLocationError, Void> update(StorageLocationUpdateDraft draft);
public Result<StorageLocationError, Void> deactivate();
public Result<StorageLocationError, Void> 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 {
<<enumeration>>
PRODUCED
PURCHASED
}
class MinimumLevel {
+Quantity quantity
+of(Quantity) Result
}
class MinimumShelfLife {
+int days
+of(int) Result
+isExpiringSoon(LocalDate, LocalDate) boolean
}
class StockBatchStatus {
<<enumeration>>
AVAILABLE
EXPIRING_SOON
BLOCKED
EXPIRED
}
class ReservationPriority {
<<enumeration>>
URGENT
NORMAL
LOW
}
class ReferenceType {
<<enumeration>>
PRODUCTION_ORDER
SALE_ORDER
}
class MovementType {
<<enumeration>>
GOODS_RECEIPT
PRODUCTION_OUTPUT
PRODUCTION_CONSUMPTION
SALE
INTER_BRANCH_TRANSFER
WASTE
ADJUSTMENT
RETURN
}
class MovementDirection {
<<enumeration>>
IN
OUT
}
class InventoryCountStatus {
<<enumeration>>
OPEN
COUNTING
COMPLETED
CANCELLED
}
class StorageType {
<<enumeration>>
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
public record BatchReference(String batchId, BatchType batchType) {
public static Result<StockError, BatchReference> 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<StockError, MinimumLevel> 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<StockError, MinimumShelfLife> 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<StorageLocationError, TemperatureRange> 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<StorageLocationError, StorageLocationName> 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<StockMovement> 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<Stock> findById(StockId id);
Optional<Stock> findByArticleIdAndStorageLocationId(ArticleId articleId, StorageLocationId locationId);
List<Stock> findByArticleId(ArticleId articleId);
List<Stock> findByStorageLocationId(StorageLocationId locationId);
List<Stock> findBelowMinimumLevel();
List<Stock> findWithExpiringBatches(LocalDate before);
List<Stock> findByBatchReference(BatchReference batchReference);
void save(Stock stock);
}
public interface StockMovementRepository {
Optional<StockMovement> findById(StockMovementId id);
List<StockMovement> findByStockId(StockId stockId);
List<StockMovement> findByArticleId(ArticleId articleId);
List<StockMovement> findByBatchReference(BatchReference batchReference);
List<StockMovement> findByMovementType(MovementType type);
List<StockMovement> findByPerformedAtBetween(Instant from, Instant to);
void save(StockMovement movement);
}
public interface InventoryCountRepository {
Optional<InventoryCount> findById(InventoryCountId id);
List<InventoryCount> findByStorageLocationId(StorageLocationId locationId);
List<InventoryCount> findByStatus(InventoryCountStatus status);
List<InventoryCount> findByCountDateBetween(LocalDate from, LocalDate to);
boolean existsActiveByStorageLocationId(StorageLocationId locationId);
void save(InventoryCount count);
}
public interface StorageLocationRepository {
Optional<StorageLocation> findById(StorageLocationId id);
List<StorageLocation> findAll();
List<StorageLocation> findByStorageType(StorageType type);
List<StorageLocation> 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<StockError, Void> s -> {
stockRepository.save(stock);
// FEFO hat automatisch die Charge mit MHD 2026-03-15 allokiert
}
case Result.Failure<StockError, Void> 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<StockMovement> 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<E,T> 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)