mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 23:03:40 +01:00
Implementiert Story 4.1: Reservierung von Beständen mit automatischer
FEFO-Allokation (First-Expired-First-Out) über verfügbare Chargen.
Domain: Reservation-Entity, StockBatchAllocation, ReservationDraft,
FEFO-Logik in Stock.reserve(), availableQuantity() berücksichtigt
bestehende Allokationen. Neue Error-Varianten für InsufficientStock,
InvalidReferenceType, InvalidReservationPriority, ReservationNotFound.
API: POST /api/inventory/stocks/{stockId}/reservations → 201 Created.
Liquibase: reservations + stock_batch_allocations Tabellen mit FK- und
CHECK-Constraints.
Tests: 43 neue Tests (22 Domain, 10 UseCase, 11 Integration) für
FEFO-Logik, Validierung, Mengenprüfung, Auth und Edge Cases.
1321 lines
45 KiB
Markdown
1321 lines
45 KiB
Markdown
# 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 {
|
||
+AllocationId id
|
||
+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
|
||
|
||
StockBatchAllocation ..> AllocationId
|
||
|
||
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)
|
||
├── AllocationId (VO)
|
||
├── 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"; }
|
||
}
|
||
record InvalidReferenceType(String value) implements StockError {
|
||
public String message() { return "Invalid reference type: " + value; }
|
||
}
|
||
record InvalidReferenceId(String reason) implements StockError {
|
||
public String message() { return "Invalid reference ID: " + reason; }
|
||
}
|
||
record InvalidReservationPriority(String value) implements StockError {
|
||
public String message() { return "Invalid reservation priority: " + value; }
|
||
}
|
||
}
|
||
|
||
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)
|