1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 16:09:35 +01:00
effigenix/backend/docs/mvp/ddd/07-inventory-bc.md
Sebastian Frick 0b49bb2977 feat(inventory): Bestand reservieren mit FEFO-Allokation (#12)
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.
2026-02-24 00:18:51 +01:00

45 KiB
Raw Blame History

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

---
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:

/**
 * 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:

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:

// 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:

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:

/**
 * 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:

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:

// Factory (einzige Mutation  StockMovement ist immutable)
public static Result<StockMovementError, StockMovement> record(StockMovementDraft draft);

// Query Methods
public boolean isIncoming();
public boolean isOutgoing();

Domain Events:

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:

/**
 * 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:

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:

// 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:

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:

/**
 * 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:

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:

// 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:

StorageLocationCreated(StorageLocationId, String name, StorageType)
StorageLocationDeactivated(StorageLocationId)

Shared Value Objects

---
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

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

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

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

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

public record StorageLocationName(String value) {
    public static Result<StorageLocationError, StorageLocationName> of(String value) {
        // must not be blank
        // max 100 chars
    }
}

MovementType

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

public enum ReservationPriority {
    URGENT(1),
    NORMAL(2),
    LOW(3);

    private final int sortOrder;
}

Domain Services

StockExpiryChecker

/**
 * 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

/**
 * 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

/**
 * 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

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

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

---
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

---
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

---
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)

// 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)

---
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

// 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

  • Aggregate Root ist einziger Einstiegspunkt (Stock, StockMovement, InventoryCount, StorageLocation)
  • Alle Änderungen gehen durch Aggregate-Root-Methoden
  • Invarianten werden in Factory und Methoden geprüft
  • Keine direkten Referenzen auf andere Aggregates (nur IDs: ArticleId, UserId, StorageLocationId, BatchReference)
  • Ein Aggregate = eine Transaktionsgrenze
  • EntityDraft-Pattern für VO-Konstruktion im Domain Layer
  • Result<E,T> für erwartbare Fehler, keine Exceptions
  • Sealed interfaces für Domain Errors
  • Status-Maschinen explizit dokumentiert (StockBatchStatus, InventoryCountStatus)
  • BC-Grenzen klar definiert (QualityHold → Quality BC, Rezeptur → Production BC)
  • FEFO-Prinzip im Stock-Aggregate verankert
  • Vier-Augen-Prinzip bei Inventur-Abschluss
  • Alle Bestandsbewegungen sind chargen-traceable (BatchReference pflicht)