# 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 create(StockDraft draft); // Bestandsänderungen public Result update(StockUpdateDraft draft); public Result addBatch(StockBatchDraft draft); public Result removeBatch(StockBatchId batchId, Quantity quantity); public Result blockBatch(StockBatchId batchId, String reason); public Result unblockBatch(StockBatchId batchId); // Reservierungen (FEFO-basiert) public Result reserve(ReservationDraft draft); public Result releaseReservation(ReservationId reservationId); public Result confirmReservation(ReservationId reservationId); // MHD-Prüfung (typischerweise per Scheduler aufgerufen) public Result markExpiredBatches(LocalDate today); public Result markExpiringSoonBatches(LocalDate today); // Query Methods public Quantity getAvailableQuantity(); public Quantity getTotalQuantity(); public List getAvailableBatchesByFefo(); public boolean isBelowMinimumLevel(); ``` **Domain Events:** ```java StockBatchAdded(StockId, StockBatchId, ArticleId, BatchReference, Quantity, LocalDate expiryDate) StockBatchRemoved(StockId, StockBatchId, Quantity) StockBatchBlocked(StockId, StockBatchId, String reason) StockBatchUnblocked(StockId, StockBatchId) StockReserved(StockId, ReservationId, Quantity, String referenceId) StockReservationReleased(StockId, ReservationId) StockReservationConfirmed(StockId, ReservationId, Quantity) StockLevelBelowMinimum(StockId, ArticleId, StorageLocationId, Quantity currentLevel, Quantity minimumLevel) → triggers Procurement demand / Notification BatchExpiringSoon(StockId, StockBatchId, ArticleId, LocalDate expiryDate, int daysRemaining) → triggers Notification BatchExpired(StockId, StockBatchId, ArticleId, LocalDate expiryDate) ``` --- ### 2. StockMovement (Aggregate Root) **Verantwortung:** Dokumentiert jede Bestandsveränderung als unveränderlichen Audit-Trail. Ermöglicht lückenlose Rückverfolgbarkeit aller Ein- und Ausgänge. ``` StockMovement (Aggregate Root) ├── StockMovementId (VO) ├── StockId (VO) - Referenz auf betroffenen Stock ├── ArticleId (VO) - Denormalisiert für einfache Abfragen ├── StockBatchId (VO) - Betroffene Charge ├── BatchReference (VO) - Chargenreferenz (denormalisiert) ├── MovementType (VO: GOODS_RECEIPT | PRODUCTION_OUTPUT | PRODUCTION_CONSUMPTION | │ SALE | INTER_BRANCH_TRANSFER | WASTE | ADJUSTMENT | RETURN) ├── Direction (VO: IN | OUT) - Abgeleitet aus MovementType ├── Quantity (VO) - Bewegte Menge (immer positiv) ├── Reason (String, optional) - Pflicht bei WASTE und ADJUSTMENT ├── ReferenceDocumentId (String, optional) - GoodsReceiptId, ProductionOrderId, InvoiceId etc. ├── PerformedBy (VO: UserId) └── PerformedAt (VO: Instant) ``` **Invarianten:** ```java /** * StockMovement aggregate root (immutable after creation). * * Invariants: * - Quantity must be positive * - Direction is derived: IN for GOODS_RECEIPT, PRODUCTION_OUTPUT, RETURN, ADJUSTMENT(+) * OUT for PRODUCTION_CONSUMPTION, SALE, WASTE, ADJUSTMENT(-), INTER_BRANCH_TRANSFER(source) * - WASTE and ADJUSTMENT require a non-empty Reason * - INTER_BRANCH_TRANSFER must have a ReferenceDocumentId (Transfer-Dokument) * - PerformedBy must not be null * - StockMovement is immutable after creation (append-only) * - BatchReference must not be null (all movements are batch-traceable) */ ``` **Draft-Record:** ```java public record StockMovementDraft( String stockId, String articleId, String stockBatchId, String batchId, // BatchReference.batchId String batchType, // BatchReference.batchType String movementType, // MovementType enum String quantityAmount, // BigDecimal als String String quantityUnit, // UnitOfMeasure String reason, // nullable, Pflicht bei WASTE/ADJUSTMENT String referenceDocumentId, // nullable String performedBy // UserId ) {} ``` **Factory & Business Methods:** ```java // Factory (einzige Mutation – StockMovement ist immutable) public static Result record(StockMovementDraft draft); // Query Methods public boolean isIncoming(); public boolean isOutgoing(); ``` **Domain Events:** ```java StockMovementRecorded(StockMovementId, StockId, ArticleId, BatchReference, MovementType, Quantity) ``` --- ### 3. InventoryCount (Aggregate Root) **Verantwortung:** Physische Bestandsaufnahme pro Lagerort mit Soll/Ist-Abgleich. Abweichungen werden nach Abschluss als ADJUSTMENT-StockMovements verbucht. ``` InventoryCount (Aggregate Root) ├── InventoryCountId (VO) ├── StorageLocationId (VO) - Gezählter Lagerort ├── CountDate (LocalDate) - Stichtag der Inventur ├── Status (InventoryCountStatus: OPEN | COUNTING | COMPLETED | CANCELLED) ├── InitiatedBy (VO: UserId) ├── CompletedBy (VO: UserId, optional) ├── CompletedAt (Instant, optional) ├── CancelledReason (String, optional) └── CountItems[] (Entity) ├── CountItemId (VO) ├── ArticleId (VO) - Gezählter Artikel ├── ExpectedQuantity (VO: Quantity) - Soll-Bestand aus System ├── ActualQuantity (VO: Quantity, optional) - Ist-Menge nach Zählung └── Deviation (VO: Quantity, optional) - Berechnet: ActualQuantity - ExpectedQuantity ``` **Invarianten:** ```java /** * InventoryCount aggregate root. * * Invariants: * - Status transitions: OPEN → COUNTING → COMPLETED * OPEN → CANCELLED * COUNTING → CANCELLED * - Cannot complete without at least one CountItem * - All CountItems must have ActualQuantity set before completion * - Deviation is auto-calculated: ActualQuantity - ExpectedQuantity * - CountDate cannot be in the future at creation * - ArticleId must be unique within CountItems (no duplicate articles) * - Only one active (OPEN/COUNTING) InventoryCount per StorageLocation (Application Layer) * - CompletedBy must differ from InitiatedBy (Vier-Augen-Prinzip) */ ``` **Draft-Records:** ```java public record InventoryCountDraft( String storageLocationId, String countDate, // ISO LocalDate String initiatedBy // UserId ) {} public record CountItemDraft( String articleId, String expectedQuantityAmount, // BigDecimal als String String expectedQuantityUnit // UnitOfMeasure ) {} ``` **Factory & Business Methods:** ```java // Factory public static Result create(InventoryCountDraft draft); // Zählung public Result startCounting(); public Result addCountItem(CountItemDraft draft); public Result updateCountItem(CountItemId itemId, Quantity actualQuantity); // Abschluss public Result complete(UserId completedBy); public Result cancel(String reason); // Query Methods public List getDeviations(); public boolean hasDeviations(); public Quantity getTotalDeviation(); ``` **Domain Events:** ```java InventoryCountCreated(InventoryCountId, StorageLocationId, LocalDate countDate) InventoryCountStarted(InventoryCountId) InventoryCountCompleted(InventoryCountId, StorageLocationId, List deviations) → triggers ADJUSTMENT StockMovements for each deviation InventoryCountCancelled(InventoryCountId, String reason) ``` --- ### 4. StorageLocation (Aggregate Root) **Verantwortung:** Verwaltet konfigurierbare physische Lagerorte mit Typ-Klassifizierung und optionalem Temperaturbereich. ``` StorageLocation (Aggregate Root) ├── StorageLocationId (VO) ├── Name (VO: StorageLocationName) - Eindeutiger Name ├── StorageType (VO: COLD_ROOM | FREEZER | DRY_STORAGE | DISPLAY_COUNTER | PRODUCTION_AREA) ├── TemperatureRange (VO, optional) │ ├── MinTemperature (BigDecimal) - in °C │ └── MaxTemperature (BigDecimal) - in °C └── Active (boolean) - Soft-Delete ``` **Invarianten:** ```java /** * StorageLocation aggregate root. * * Invariants: * - Name must be unique (Application Layer, Repository-Concern) * - Name must not be blank, max 100 chars * - TemperatureRange: minTemperature < maxTemperature * - TemperatureRange: values must be in range -50°C to +80°C * - Cannot deactivate if Stock exists at this location (Application Layer) * - StorageType is immutable after creation (changing type = new location) */ ``` **Draft-Records:** ```java public record StorageLocationDraft( String name, String storageType, // StorageType enum String minTemperature, // BigDecimal als String, nullable String maxTemperature // BigDecimal als String, nullable ) {} public record StorageLocationUpdateDraft( String name, // nullable = no change String minTemperature, // BigDecimal als String, nullable String maxTemperature // BigDecimal als String, nullable ) {} ``` **Factory & Business Methods:** ```java // Factory public static Result create(StorageLocationDraft draft); // Mutations public Result update(StorageLocationUpdateDraft draft); public Result deactivate(); public Result activate(); // Query Methods public boolean isTemperatureControlled(); ``` **Domain Events:** ```java StorageLocationCreated(StorageLocationId, String name, StorageType) StorageLocationDeactivated(StorageLocationId) ``` --- ## Shared Value Objects ```mermaid --- config: theme: neutral look: classic layout: elk themeVariables: background: "#f8fafc" class: hideEmptyMembersBox: true --- classDiagram class BatchReference { +String batchId +BatchType batchType +of(String, BatchType) Result } class BatchType { <> PRODUCED PURCHASED } class MinimumLevel { +Quantity quantity +of(Quantity) Result } class MinimumShelfLife { +int days +of(int) Result +isExpiringSoon(LocalDate, LocalDate) boolean } class StockBatchStatus { <> AVAILABLE EXPIRING_SOON BLOCKED EXPIRED } class ReservationPriority { <> URGENT NORMAL LOW } class ReferenceType { <> PRODUCTION_ORDER SALE_ORDER } class MovementType { <> GOODS_RECEIPT PRODUCTION_OUTPUT PRODUCTION_CONSUMPTION SALE INTER_BRANCH_TRANSFER WASTE ADJUSTMENT RETURN } class MovementDirection { <> IN OUT } class InventoryCountStatus { <> OPEN COUNTING COMPLETED CANCELLED } class StorageType { <> COLD_ROOM FREEZER DRY_STORAGE DISPLAY_COUNTER PRODUCTION_AREA } class StorageLocationName { +String value +of(String) Result } class TemperatureRange { +BigDecimal minTemperature +BigDecimal maxTemperature +of(BigDecimal, BigDecimal) Result +contains(BigDecimal) boolean } BatchReference --> BatchType MovementType --> MovementDirection : derives ``` ### BatchReference ```java public record BatchReference(String batchId, BatchType batchType) { public static Result of(String batchId, String batchType) { // batchId must not be blank // batchType must be valid enum value } } ``` ### MinimumLevel ```java public record MinimumLevel(Quantity quantity) { public static Result of(Quantity quantity) { // quantity.amount must be >= 0 } public boolean isBelow(Quantity currentLevel) { return currentLevel.amount().compareTo(quantity.amount()) < 0; } } ``` ### MinimumShelfLife ```java public record MinimumShelfLife(int days) { public static Result of(int days) { // days must be > 0 } public boolean isExpiringSoon(LocalDate expiryDate, LocalDate today) { return expiryDate.isBefore(today.plusDays(days)); } } ``` ### TemperatureRange ```java public record TemperatureRange(BigDecimal minTemperature, BigDecimal maxTemperature) { public static Result of( BigDecimal minTemperature, BigDecimal maxTemperature) { // minTemperature < maxTemperature // both in range -50 to +80 } public boolean contains(BigDecimal temperature) { return temperature.compareTo(minTemperature) >= 0 && temperature.compareTo(maxTemperature) <= 0; } } ``` ### StorageLocationName ```java public record StorageLocationName(String value) { public static Result of(String value) { // must not be blank // max 100 chars } } ``` ### MovementType ```java public enum MovementType { GOODS_RECEIPT(MovementDirection.IN), PRODUCTION_OUTPUT(MovementDirection.IN), PRODUCTION_CONSUMPTION(MovementDirection.OUT), SALE(MovementDirection.OUT), INTER_BRANCH_TRANSFER(MovementDirection.OUT), // Source-Seite WASTE(MovementDirection.OUT), ADJUSTMENT(null), // Richtung wird durch Vorzeichen der Menge bestimmt RETURN(MovementDirection.IN); private final MovementDirection defaultDirection; } ``` ### ReservationPriority ```java public enum ReservationPriority { URGENT(1), NORMAL(2), LOW(3); private final int sortOrder; } ``` --- ## Domain Services ### StockExpiryChecker ```java /** * Prüft alle Stocks auf ablaufende und abgelaufene Chargen. * Wird typischerweise per Scheduler (z.B. täglich um 06:00) aufgerufen. */ public class StockExpiryChecker { /** * Markiert abgelaufene Chargen als EXPIRED und * bald ablaufende als EXPIRING_SOON. * Löst BatchExpiringSoon und BatchExpired Events aus. */ public void checkAll(LocalDate today, StockRepository stockRepository); } ``` ### InventoryCountReconciliationService ```java /** * Erstellt ADJUSTMENT-StockMovements für Inventur-Abweichungen * nach Abschluss einer InventoryCount. */ public class InventoryCountReconciliationService { /** * Für jede CountItem-Abweichung wird ein StockMovement (ADJUSTMENT) erzeugt. * Positive Abweichung → IN, negative Abweichung → OUT. */ public List reconcile(InventoryCount completedCount, UserId performedBy); } ``` ### StockAvailabilityService ```java /** * Prüft Materialverfügbarkeit für Produktionsaufträge. * Wird vom Production BC aufgerufen (via Application Layer). */ public class StockAvailabilityService { /** * Prüft ob ausreichend Material an einem Lagerort verfügbar ist. */ public boolean isAvailable(ArticleId articleId, StorageLocationId locationId, Quantity requiredQuantity); /** * Gibt verfügbare Menge über alle Lagerorte zurück. */ public Quantity getAvailableQuantityAcrossLocations(ArticleId articleId); } ``` --- ## Domain Errors ```java public sealed interface StockError { String message(); record StockNotFound(String stockId) implements StockError { public String message() { return "Stock not found: " + stockId; } } record DuplicateBatchReference(String batchId) implements StockError { public String message() { return "Batch already exists in stock: " + batchId; } } record InsufficientStock(String available, String requested) implements StockError { public String message() { return "Insufficient stock: available=" + available + ", requested=" + requested; } } record BatchNotFound(String stockBatchId) implements StockError { public String message() { return "Stock batch not found: " + stockBatchId; } } record BatchBlocked(String stockBatchId) implements StockError { public String message() { return "Batch is blocked: " + stockBatchId; } } record BatchExpired(String stockBatchId) implements StockError { public String message() { return "Batch is expired: " + stockBatchId; } } record ReservationNotFound(String reservationId) implements StockError { public String message() { return "Reservation not found: " + reservationId; } } record InvalidQuantity(String reason) implements StockError { public String message() { return "Invalid quantity: " + reason; } } record InvalidMinimumLevel(String reason) implements StockError { public String message() { return "Invalid minimum level: " + reason; } } record InvalidMinimumShelfLife(int days) implements StockError { public String message() { return "Minimum shelf life must be > 0, got: " + days; } } record InvalidBatchReference(String reason) implements StockError { public String message() { return "Invalid batch reference: " + reason; } } record NegativeStockNotAllowed() implements StockError { public String message() { return "Stock cannot go negative"; } } 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 findById(StockId id); Optional findByArticleIdAndStorageLocationId(ArticleId articleId, StorageLocationId locationId); List findByArticleId(ArticleId articleId); List findByStorageLocationId(StorageLocationId locationId); List findBelowMinimumLevel(); List findWithExpiringBatches(LocalDate before); List findByBatchReference(BatchReference batchReference); void save(Stock stock); } public interface StockMovementRepository { Optional findById(StockMovementId id); List findByStockId(StockId stockId); List findByArticleId(ArticleId articleId); List findByBatchReference(BatchReference batchReference); List findByMovementType(MovementType type); List findByPerformedAtBetween(Instant from, Instant to); void save(StockMovement movement); } public interface InventoryCountRepository { Optional findById(InventoryCountId id); List findByStorageLocationId(StorageLocationId locationId); List findByStatus(InventoryCountStatus status); List findByCountDateBetween(LocalDate from, LocalDate to); boolean existsActiveByStorageLocationId(StorageLocationId locationId); void save(InventoryCount count); } public interface StorageLocationRepository { Optional findById(StorageLocationId id); List findAll(); List findByStorageType(StorageType type); List findActive(); boolean existsByName(StorageLocationName name); void save(StorageLocation location); } ``` --- ## Status-Maschinen ### StockBatch Status ```mermaid --- config: theme: neutral themeVariables: background: "#f8fafc" --- stateDiagram-v2 [*] --> AVAILABLE : addBatch() AVAILABLE --> EXPIRING_SOON : markExpiringSoonBatches() AVAILABLE --> BLOCKED : blockBatch() AVAILABLE --> EXPIRED : markExpiredBatches() EXPIRING_SOON --> BLOCKED : blockBatch() EXPIRING_SOON --> EXPIRED : markExpiredBatches() BLOCKED --> AVAILABLE : unblockBatch() BLOCKED --> EXPIRING_SOON : unblockBatch() + MHD-Check AVAILABLE : Frei verfügbar, reservierbar EXPIRING_SOON : MHD < heute + MinimumShelfLife EXPIRING_SOON : Noch verfügbar, aber Warnung BLOCKED : Gesperrt (QualityHold etc.) BLOCKED : Nicht reservierbar/entnehmbar EXPIRED : MHD überschritten EXPIRED : Muss als WASTE verbucht werden ``` ### InventoryCount Status ```mermaid --- config: theme: neutral themeVariables: background: "#f8fafc" --- stateDiagram-v2 [*] --> OPEN : create() OPEN --> COUNTING : startCounting() OPEN --> CANCELLED : cancel() COUNTING --> COMPLETED : complete() COUNTING --> CANCELLED : cancel() OPEN : Inventur angelegt OPEN : addCountItem() möglich COUNTING : Zählung läuft COUNTING : updateCountItem() möglich COMPLETED : Alle Positionen gezählt COMPLETED : Abweichungen als ADJUSTMENT gebucht CANCELLED : Inventur abgebrochen ``` --- ## Integration mit anderen BCs ```mermaid --- config: theme: neutral look: classic layout: elk themeVariables: background: "#f8fafc" --- graph LR subgraph UPSTREAM["Upstream BCs"] MD["Master Data\n(ArticleId)"] UM["User Management\n(UserId)"] PROD["Production BC"] QUAL["Quality BC"] PROC["Procurement BC"] end subgraph INVENTORY["Inventory BC"] S["Stock"] SM["StockMovement"] IC["InventoryCount"] SL["StorageLocation"] end subgraph DOWNSTREAM["Downstream BCs"] SALES["Sales BC"] PROCDOWN["Procurement BC\n(Nachbestellung)"] end MD -->|ArticleId| S UM -->|UserId| SM UM -->|UserId| IC PROD -->|"BatchCompleted\n(stock-in)"| S PROD -->|"ConsumptionRecorded\n(stock-out)"| S QUAL -->|"QualityHoldCreated\n(block batch)"| S QUAL -->|"QualityHoldReleased\n(unblock batch)"| S QUAL -->|"QualityHoldRejected\n(write-off)"| S PROC -->|"GoodsReceived\n(stock-in)"| S S -->|"StockLevelBelowMinimum\n(demand)"| PROCDOWN S -->|"BatchExpiringSoon\n(notification)"| SALES ``` ### Upstream-Abhängigkeiten (Inventory konsumiert) | BC | Referenz / Event | Zweck | |---|---|---| | **Master Data** | ArticleId | Artikel-Referenz in Stock, StockMovement, CountItem | | **User Management** | UserId | PerformedBy in StockMovement, InitiatedBy/CompletedBy in InventoryCount | | **Production** | `BatchCompleted` Event | Stock-In: Neue StockBatch mit BatchType=PRODUCED | | **Production** | `ConsumptionRecorded` Event | Stock-Out: removeBatch() für verbrauchte Rohstoffe | | **Quality** | `QualityHoldCreated` Event | blockBatch() – Charge sperren | | **Quality** | `QualityHoldReleased` Event | unblockBatch() – Charge freigeben | | **Quality** | `QualityHoldRejected` Event | removeBatch() + WASTE-StockMovement – Charge entsorgen | | **Procurement** | `GoodsReceived` Event | Stock-In: Neue StockBatch mit BatchType=PURCHASED | ### Downstream-Integrationen (Inventory publiziert Events) | Event | Konsument | Aktion | |---|---|---| | `StockLevelBelowMinimum` | **Procurement BC** | Automatische Nachbestellung / Bestellvorschlag | | `BatchExpiringSoon` | **Sales BC** | Rabattaktion / Prioritäts-Verkauf | | `StockMovementRecorded` | **Audit** | Lückenloser Audit-Trail | | `InventoryCountCompleted` | **Audit** | Inventur-Dokumentation | ### Abgrenzungen (gehören NICHT in Inventory BC) | Konzept | Zuständiger BC | Grund | |---|---|---| | Qualitätssperre (QualityHold) | **Quality BC** | Eigene Aggregate-Logik mit Vier-Augen-Prinzip | | Rezeptur / Produktion | **Production BC** | Inventory kennt keine Rezepte, nur Mengen | | Lieferant / Wareneingang | **Procurement BC** | Inventory reagiert nur auf GoodsReceived Event | | Artikelstamm | **Master Data BC** | Inventory referenziert nur ArticleId | | Preisgestaltung | **Sales BC** | Inventory verwaltet keine Preise | --- ## Use Cases (Application Layer) ```java // Stock Management CreateStock → Stock.create(StockDraft) UpdateStock → stock.update(StockUpdateDraft) AddStockBatch → stock.addBatch(StockBatchDraft) + StockMovement.record() RemoveStockBatch → stock.removeBatch(id, qty) + StockMovement.record() BlockStockBatch → stock.blockBatch(id, reason) UnblockStockBatch → stock.unblockBatch(id) GetStock → Query ListStockByLocation → Query ListStockBelowMinimum → Query // Reservations ReserveStock → stock.reserve(ReservationDraft) ReleaseReservation → stock.releaseReservation(reservationId) ConfirmReservation → stock.confirmReservation(reservationId) + StockMovement.record() // Stock Movements RecordStockMovement → StockMovement.record(StockMovementDraft) GetStockMovement → Query ListMovementsByArticle → Query ListMovementsByPeriod → Query // Inventory Count CreateInventoryCount → InventoryCount.create(InventoryCountDraft) + auto-populate CountItems StartInventoryCount → count.startCounting() RecordCountItem → count.updateCountItem(id, actualQuantity) CompleteInventoryCount → count.complete(userId) + reconciliation → ADJUSTMENT movements CancelInventoryCount → count.cancel(reason) GetInventoryCount → Query // Storage Location Management CreateStorageLocation → StorageLocation.create(StorageLocationDraft) UpdateStorageLocation → location.update(StorageLocationUpdateDraft) DeactivateStorageLocation → location.deactivate() ListStorageLocations → Query // Event Handlers (reagieren auf Upstream-Events) HandleBatchCompleted → AddStockBatch (PRODUCED) HandleConsumptionRecorded → RemoveStockBatch HandleGoodsReceived → AddStockBatch (PURCHASED) HandleQualityHoldCreated → BlockStockBatch HandleQualityHoldReleased → UnblockStockBatch HandleQualityHoldRejected → RemoveStockBatch + WASTE movement ``` --- ## Beispiel: Bestandsfluss (End-to-End) ```mermaid --- config: theme: neutral themeVariables: background: "#f8fafc" --- sequenceDiagram participant PROC as Procurement BC participant INV as Inventory (Stock) participant SM as StockMovement participant PROD as Production BC participant QUAL as Quality BC participant SALES as Sales BC Note over PROC,INV: 1. Wareneingang PROC--)INV: GoodsReceived Event activate INV INV->>INV: addBatch(PURCHASED, 100kg Schweineschulter, MHD 2026-03-15) INV->>SM: record(GOODS_RECEIPT, 100kg) deactivate INV Note over PROD,INV: 2. Produktions-Verbrauch PROD--)INV: ConsumptionRecorded Event activate INV INV->>INV: removeBatch(Schweineschulter, 45kg) INV->>SM: record(PRODUCTION_CONSUMPTION, 45kg) deactivate INV Note over PROD,INV: 3. Produktions-Zugang PROD--)INV: BatchCompleted Event activate INV INV->>INV: addBatch(PRODUCED, 35kg Fleischwurst, MHD 2026-03-20) INV->>SM: record(PRODUCTION_OUTPUT, 35kg) INV-->>INV: isBelowMinimumLevel()? deactivate INV Note over QUAL,INV: 4. Qualitätssperre QUAL--)INV: QualityHoldCreated Event activate INV INV->>INV: blockBatch(Fleischwurst-Charge) Note over INV: Status: BLOCKED deactivate INV QUAL--)INV: QualityHoldReleased Event activate INV INV->>INV: unblockBatch(Fleischwurst-Charge) Note over INV: Status: AVAILABLE deactivate INV Note over INV,SALES: 5. Verkauf (mit FEFO) SALES->>INV: reserve(SALE_ORDER, 10kg Fleischwurst, NORMAL) activate INV INV->>INV: FEFO: älteste Charge zuerst INV-->>SALES: ReservationId deactivate INV SALES->>INV: confirmReservation(reservationId) activate INV INV->>INV: removeBatch(allocated batches) INV->>SM: record(SALE, 10kg) deactivate INV ``` ### Code-Beispiel ```java // 1. Wareneingang verarbeiten (Event Handler) Stock stock = stockRepository .findByArticleIdAndStorageLocationId(articleId, locationId) .orElseGet(() -> Stock.create(new StockDraft( articleId, locationId, "10", "KILOGRAM", 7 )).value()); var batchDraft = new StockBatchDraft( "SUPP-2026-02-19-042", // Lieferanten-Chargennummer "PURCHASED", "100", "KILOGRAM", "2026-03-15" // MHD ); stock.addBatch(batchDraft); stockRepository.save(stock); // StockMovement dokumentieren StockMovement.record(new StockMovementDraft( stock.id().value(), articleId, stockBatchId, "SUPP-2026-02-19-042", "PURCHASED", "GOODS_RECEIPT", "100", "KILOGRAM", null, "GR-2026-02-19-001", userId )); // 2. Reservierung für Produktionsauftrag var reserveDraft = new ReservationDraft( "PRODUCTION_ORDER", "PO-2026-02-20-001", "45", "KILOGRAM", "URGENT" ); switch (stock.reserve(reserveDraft)) { case Result.Success s -> { stockRepository.save(stock); // FEFO hat automatisch die Charge mit MHD 2026-03-15 allokiert } case Result.Failure f -> { switch (f.error()) { case StockError.InsufficientStock e -> log.warn("Nicht genug Material: {}", e.message()); // ... } } } // 3. Inventur durchführen var countDraft = new InventoryCountDraft(locationId, "2026-02-19", userId); InventoryCount count = InventoryCount.create(countDraft).value(); // Soll-Bestände aus Stock laden und als CountItems hinzufügen for (Stock s : stockRepository.findByStorageLocationId(locationId)) { count.addCountItem(new CountItemDraft( s.articleId().value(), s.getTotalQuantity().amount().toString(), s.getTotalQuantity().uom().name() )); } count.startCounting(); // Zähler erfasst Ist-Menge count.updateCountItem(countItemId, Quantity.of(new BigDecimal("97"), UnitOfMeasure.KILOGRAM).value()); // Vier-Augen-Prinzip: anderer User schließt ab count.complete(otherUserId); inventoryCountRepository.save(count); // Reconciliation: ADJUSTMENT-Bewegungen für Abweichungen List adjustments = reconciliationService.reconcile(count, otherUserId); adjustments.forEach(stockMovementRepository::save); ``` --- ## DDD Validation Checklist - [x] Aggregate Root ist einziger Einstiegspunkt (Stock, StockMovement, InventoryCount, StorageLocation) - [x] Alle Änderungen gehen durch Aggregate-Root-Methoden - [x] Invarianten werden in Factory und Methoden geprüft - [x] Keine direkten Referenzen auf andere Aggregates (nur IDs: ArticleId, UserId, StorageLocationId, BatchReference) - [x] Ein Aggregate = eine Transaktionsgrenze - [x] EntityDraft-Pattern für VO-Konstruktion im Domain Layer - [x] Result für erwartbare Fehler, keine Exceptions - [x] Sealed interfaces für Domain Errors - [x] Status-Maschinen explizit dokumentiert (StockBatchStatus, InventoryCountStatus) - [x] BC-Grenzen klar definiert (QualityHold → Quality BC, Rezeptur → Production BC) - [x] FEFO-Prinzip im Stock-Aggregate verankert - [x] Vier-Augen-Prinzip bei Inventur-Abschluss - [x] Alle Bestandsbewegungen sind chargen-traceable (BatchReference pflicht)