mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 19:10:22 +01:00
- Move Java backend to backend/ directory - Create frontend/ directory for TypeScript TUI and future WebUI - Update .gitignore for Node.js and worktrees - Update README.md with new repository structure - Copy documentation to backend/
518 lines
14 KiB
Markdown
518 lines
14 KiB
Markdown
# Production BC - Detailliertes Domain Model
|
|
|
|
**Bounded Context:** Production
|
|
**Domain Type:** CORE
|
|
**Verantwortung:** Rezepturverwaltung, Produktionsplanung, Chargen-Erzeugung
|
|
|
|
---
|
|
|
|
## Aggregates
|
|
|
|
### 1. Recipe (Aggregate Root)
|
|
|
|
**Verantwortung:** Verwaltet mehrstufige Rezepturen mit Zutaten und Produktionsschritten.
|
|
|
|
```
|
|
Recipe (Aggregate Root)
|
|
├── RecipeId (VO)
|
|
├── Name (VO)
|
|
├── Version (VO)
|
|
├── RecipeType (VO: RAW_MATERIAL | INTERMEDIATE | FINISHED_PRODUCT)
|
|
├── YieldPercentage (VO) - Ausbeute in %
|
|
├── Ingredients[] (Entity)
|
|
│ ├── Position (VO) - Reihenfolge im Rezept
|
|
│ ├── ArticleId (VO) - Reference to Master Data
|
|
│ ├── Quantity (VO)
|
|
│ ├── RecipeId (VO) - For intermediate products (nested recipes)
|
|
│ └── IsSubstitutable (VO) - Kann durch Alternativzutat ersetzt werden?
|
|
├── ProductionSteps[] (Entity)
|
|
│ ├── StepNumber (VO)
|
|
│ ├── Description (VO)
|
|
│ ├── Duration (VO) - Geschätzte Dauer
|
|
│ └── Temperature (VO) - Optional, für Räuchern/Kochen
|
|
└── Status (VO: DRAFT | ACTIVE | ARCHIVED)
|
|
```
|
|
|
|
**Invarianten:**
|
|
```java
|
|
/**
|
|
* Recipe aggregate root.
|
|
*
|
|
* Invariants:
|
|
* - Recipe must have at least one ingredient
|
|
* - Yield percentage must be between 1-100%
|
|
* - Ingredient quantities must be positive
|
|
* - Nested recipes cannot create circular dependencies (A → B → A)
|
|
* - Sum of ingredient quantities should roughly match expected input
|
|
* - Position numbers in Ingredients must be unique
|
|
* - StepNumber in ProductionSteps must be sequential (1, 2, 3, ...)
|
|
*/
|
|
```
|
|
|
|
**Business Methods:**
|
|
```java
|
|
public Result<RecipeError, Void> addIngredient(
|
|
ArticleId articleId,
|
|
Quantity quantity,
|
|
Optional<RecipeId> nestedRecipeId
|
|
);
|
|
|
|
public Result<RecipeError, Void> removeIngredient(ArticleId articleId);
|
|
|
|
public Result<RecipeError, Void> updateYield(YieldPercentage newYield);
|
|
|
|
public Result<RecipeError, Void> addProductionStep(
|
|
String description,
|
|
Optional<Duration> duration
|
|
);
|
|
|
|
public Result<RecipeError, Void> activate();
|
|
|
|
public Result<RecipeError, Void> archive();
|
|
|
|
// Query methods
|
|
public Quantity calculateRequiredInput(Quantity desiredOutput);
|
|
public List<ArticleId> getAllIngredients(); // Flattened, including nested
|
|
public boolean containsNestedRecipe();
|
|
```
|
|
|
|
**Domain Events:**
|
|
```java
|
|
RecipeCreated
|
|
RecipeActivated
|
|
RecipeArchived
|
|
IngredientAdded
|
|
IngredientRemoved
|
|
YieldPercentageChanged
|
|
```
|
|
|
|
---
|
|
|
|
### 2. Batch (Aggregate Root)
|
|
|
|
**Verantwortung:** Repräsentiert eine Produktions-Charge mit lückenloser Rückverfolgbarkeit.
|
|
|
|
```
|
|
Batch (Aggregate Root)
|
|
├── BatchId (VO) - Eindeutige Chargennummer (z.B. "BATCH-2026-02-17-001")
|
|
├── RecipeId (VO) - Reference to Recipe
|
|
├── ProductionDate (VO)
|
|
├── PlannedQuantity (VO)
|
|
├── ActualQuantity (VO) - Nach Produktionsrückmeldung
|
|
├── Status (VO: PLANNED | IN_PRODUCTION | COMPLETED | CANCELLED)
|
|
├── ProducedBy (VO: UserId)
|
|
├── ProducedAt (VO: BranchId) - Für Mehrfilialen
|
|
├── ExpiryDate (VO) - MHD des Endprodukts
|
|
├── UsedIngredientBatches[] (Entity) - CRITICAL for traceability!
|
|
│ ├── RawMaterialBatchId (VO) - Batch of used raw material
|
|
│ ├── ArticleId (VO)
|
|
│ ├── SupplierBatchNumber (VO) - Von Wareneingang
|
|
│ ├── QuantityUsed (VO)
|
|
│ └── ExpiryDate (VO) - MHD des Rohstoffs
|
|
├── Waste (VO) - Ausschuss/Schwund
|
|
└── Remarks (VO) - Optional, für Abweichungen
|
|
|
|
Invariants:
|
|
- Batch must reference a valid Recipe
|
|
- ActualQuantity must be <= (PlannedQuantity / Recipe.YieldPercentage)
|
|
- Status can only transition: PLANNED → IN_PRODUCTION → COMPLETED (or CANCELLED)
|
|
- Cannot complete without recording used ingredient batches
|
|
- ExpiryDate must be in the future at production time
|
|
- Waste must be >= 0
|
|
- All UsedIngredientBatches must match Recipe.Ingredients
|
|
```
|
|
|
|
**Business Methods:**
|
|
```java
|
|
public static Result<BatchError, Batch> plan(
|
|
RecipeId recipeId,
|
|
Quantity plannedQuantity,
|
|
LocalDate productionDate,
|
|
LocalDate expiryDate,
|
|
UserId producedBy,
|
|
BranchId branchId
|
|
);
|
|
|
|
public Result<BatchError, Void> startProduction();
|
|
|
|
public Result<BatchError, Void> recordIngredientUsage(
|
|
ArticleId articleId,
|
|
BatchId rawMaterialBatchId,
|
|
SupplierBatchNumber supplierBatchNumber,
|
|
Quantity quantityUsed,
|
|
LocalDate expiryDate
|
|
);
|
|
|
|
public Result<BatchError, Void> complete(
|
|
Quantity actualQuantity,
|
|
Quantity waste,
|
|
Optional<String> remarks
|
|
);
|
|
|
|
public Result<BatchError, Void> cancel(String reason);
|
|
|
|
// Query methods
|
|
public boolean isTraceable(); // All ingredients have batch numbers
|
|
public List<BatchId> getUpstreamBatches(); // All used raw material batches
|
|
public Quantity getYieldEfficiency(); // ActualQuantity / (PlannedQuantity / YieldPercentage)
|
|
```
|
|
|
|
**Domain Events:**
|
|
```java
|
|
BatchPlanned
|
|
BatchStarted
|
|
IngredientUsageRecorded
|
|
BatchCompleted(BatchId, ArticleId, Quantity, ExpiryDate) → triggers Inventory stock in
|
|
BatchCancelled
|
|
```
|
|
|
|
---
|
|
|
|
### 3. ProductionOrder (Aggregate Root)
|
|
|
|
**Verantwortung:** Plant eine zukünftige Produktion.
|
|
|
|
```
|
|
ProductionOrder (Aggregate Root)
|
|
├── ProductionOrderId (VO)
|
|
├── RecipeId (VO)
|
|
├── PlannedQuantity (VO)
|
|
├── PlannedDate (VO)
|
|
├── Priority (VO: LOW | NORMAL | HIGH | URGENT)
|
|
├── Status (VO: PLANNED | RELEASED | IN_PRODUCTION | COMPLETED | CANCELLED)
|
|
├── CreatedBy (VO: UserId)
|
|
├── CreatedAt (VO: Timestamp)
|
|
├── TargetBranch (VO: BranchId) - For multi-branch production
|
|
├── GeneratedBatchId (VO) - Link to actual Batch when production starts
|
|
└── Remarks (VO)
|
|
|
|
Invariants:
|
|
- Planned quantity must be positive
|
|
- Planned date cannot be in the past
|
|
- Can only release if materials available (checked in Application layer!)
|
|
- Cannot complete without generating a Batch
|
|
- Status transitions: PLANNED → RELEASED → IN_PRODUCTION → COMPLETED
|
|
```
|
|
|
|
**Business Methods:**
|
|
```java
|
|
public static Result<ProductionOrderError, ProductionOrder> create(
|
|
RecipeId recipeId,
|
|
Quantity plannedQuantity,
|
|
LocalDate plannedDate,
|
|
Priority priority,
|
|
BranchId targetBranch,
|
|
UserId createdBy
|
|
);
|
|
|
|
public Result<ProductionOrderError, Void> release();
|
|
|
|
public Result<ProductionOrderError, Void> startProduction(BatchId batchId);
|
|
|
|
public Result<ProductionOrderError, Void> complete();
|
|
|
|
public Result<ProductionOrderError, Void> cancel(String reason);
|
|
|
|
public Result<ProductionOrderError, Void> reschedule(LocalDate newDate);
|
|
```
|
|
|
|
**Domain Events:**
|
|
```java
|
|
ProductionOrderCreated
|
|
ProductionOrderReleased → triggers Demand Planning update
|
|
ProductionOrderStarted
|
|
ProductionOrderCompleted
|
|
ProductionOrderCancelled
|
|
ProductionOrderRescheduled
|
|
```
|
|
|
|
---
|
|
|
|
## Value Objects
|
|
|
|
### RecipeId
|
|
```java
|
|
public record RecipeId(String value) {
|
|
public RecipeId {
|
|
if (value == null || value.isBlank()) {
|
|
throw new IllegalArgumentException("RecipeId cannot be empty");
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### BatchId
|
|
```java
|
|
public record BatchId(String value) {
|
|
// Format: "BATCH-YYYY-MM-DD-XXX"
|
|
public static BatchId generate(LocalDate productionDate, int sequenceNumber) {
|
|
String value = String.format("BATCH-%s-%03d",
|
|
productionDate, sequenceNumber);
|
|
return new BatchId(value);
|
|
}
|
|
}
|
|
```
|
|
|
|
### YieldPercentage
|
|
```java
|
|
public record YieldPercentage(int value) {
|
|
public YieldPercentage {
|
|
if (value < 1 || value > 100) {
|
|
throw new IllegalArgumentException(
|
|
"Yield percentage must be between 1-100, got: " + value
|
|
);
|
|
}
|
|
}
|
|
|
|
public Quantity calculateRequiredInput(Quantity desiredOutput) {
|
|
// If yield is 80%, and we want 100kg output, we need 125kg input
|
|
return desiredOutput.multiply(100.0 / value);
|
|
}
|
|
}
|
|
```
|
|
|
|
### RecipeType
|
|
```java
|
|
public enum RecipeType {
|
|
RAW_MATERIAL, // Rohstoff, kein Rezept
|
|
INTERMEDIATE, // Zwischenprodukt (z.B. Gewürzmischung)
|
|
FINISHED_PRODUCT // Endprodukt
|
|
}
|
|
```
|
|
|
|
### ProductionOrderPriority
|
|
```java
|
|
public enum Priority {
|
|
LOW,
|
|
NORMAL,
|
|
HIGH,
|
|
URGENT
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Domain Services
|
|
|
|
### RecipeValidator
|
|
```java
|
|
public class RecipeValidator {
|
|
/**
|
|
* Validates that recipe does not create circular dependencies.
|
|
*/
|
|
public Result<RecipeError, Void> validateNoCyclicDependency(
|
|
Recipe recipe,
|
|
RecipeRepository recipeRepository
|
|
);
|
|
}
|
|
```
|
|
|
|
### BatchTraceabilityService
|
|
```java
|
|
public class BatchTraceabilityService {
|
|
/**
|
|
* Finds all upstream batches (raw materials) used in a batch.
|
|
*/
|
|
public List<BatchId> findUpstreamBatches(BatchId batchId);
|
|
|
|
/**
|
|
* Finds all downstream batches (finished products) that used a raw material batch.
|
|
* CRITICAL for recalls!
|
|
*/
|
|
public List<BatchId> findDownstreamBatches(BatchId rawMaterialBatchId);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Repository Interfaces
|
|
|
|
```java
|
|
package com.effigenix.domain.production;
|
|
|
|
import com.effigenix.shared.result.Result;
|
|
|
|
public interface RecipeRepository {
|
|
Result<RepositoryError, Void> save(Recipe recipe);
|
|
Result<RepositoryError, Recipe> findById(RecipeId id);
|
|
Result<RepositoryError, List<Recipe>> findActive();
|
|
Result<RepositoryError, List<Recipe>> findByArticleId(ArticleId articleId);
|
|
}
|
|
|
|
public interface BatchRepository {
|
|
Result<RepositoryError, Void> save(Batch batch);
|
|
Result<RepositoryError, Batch> findById(BatchId id);
|
|
Result<RepositoryError, List<Batch>> findByProductionDate(LocalDate date);
|
|
Result<RepositoryError, List<Batch>> findByStatus(BatchStatus status);
|
|
|
|
// For traceability
|
|
Result<RepositoryError, List<Batch>> findByUpstreamBatch(BatchId upstreamBatchId);
|
|
}
|
|
|
|
public interface ProductionOrderRepository {
|
|
Result<RepositoryError, Void> save(ProductionOrder order);
|
|
Result<RepositoryError, ProductionOrder> findById(ProductionOrderId id);
|
|
Result<RepositoryError, List<ProductionOrder>> findByPlannedDate(LocalDate date);
|
|
Result<RepositoryError, List<ProductionOrder>> findByStatus(ProductionOrderStatus status);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Domain Errors
|
|
|
|
```java
|
|
public sealed interface RecipeError permits
|
|
RecipeError.InvalidYieldPercentage,
|
|
RecipeError.CyclicDependencyDetected,
|
|
RecipeError.NoIngredientsError,
|
|
RecipeError.RecipeNotFound {
|
|
String message();
|
|
}
|
|
|
|
public sealed interface BatchError permits
|
|
BatchError.InvalidQuantity,
|
|
BatchError.InvalidStatusTransition,
|
|
BatchError.MissingIngredientBatches,
|
|
BatchError.ExpiryDateInPast,
|
|
BatchError.RecipeNotFound {
|
|
String message();
|
|
}
|
|
|
|
public sealed interface ProductionOrderError permits
|
|
ProductionOrderError.PlannedDateInPast,
|
|
ProductionOrderError.InvalidQuantity,
|
|
ProductionOrderError.InvalidStatusTransition,
|
|
ProductionOrderError.RecipeNotFound {
|
|
String message();
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Integration with other BCs
|
|
|
|
### Upstream Dependencies
|
|
- **Master Data BC:** Recipe references ArticleId
|
|
- **User Management BC:** Batch/ProductionOrder reference UserId
|
|
- **Filiales BC:** ProductionOrder references BranchId
|
|
|
|
### Downstream Integrations
|
|
- **Inventory BC:** `BatchCompleted` event triggers stock in
|
|
- **Labeling BC:** Labeling reads Recipe data for nutrition calculation
|
|
- **Procurement BC:** ProductionOrder triggers demand planning
|
|
|
|
---
|
|
|
|
## Use Cases (Application Layer)
|
|
|
|
```java
|
|
// application/production/CreateRecipe.java
|
|
public class CreateRecipe {
|
|
public Result<ApplicationError, RecipeDTO> execute(CreateRecipeCommand cmd);
|
|
}
|
|
|
|
// application/production/PlanProduction.java
|
|
public class PlanProduction {
|
|
public Result<ApplicationError, ProductionOrderDTO> execute(PlanProductionCommand cmd);
|
|
}
|
|
|
|
// application/production/StartProductionBatch.java
|
|
public class StartProductionBatch {
|
|
public Result<ApplicationError, BatchDTO> execute(StartBatchCommand cmd);
|
|
}
|
|
|
|
// application/production/CompleteProductionBatch.java
|
|
public class CompleteProductionBatch {
|
|
public Result<ApplicationError, BatchDTO> execute(CompleteBatchCommand cmd);
|
|
// Triggers BatchCompleted event → Inventory stock in
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Example: Batch Creation Flow
|
|
|
|
```java
|
|
// 1. Plan Production Order
|
|
ProductionOrder order = ProductionOrder.create(
|
|
recipeId,
|
|
Quantity.of(100, "kg"),
|
|
LocalDate.now().plusDays(1),
|
|
Priority.NORMAL,
|
|
branchId,
|
|
userId
|
|
);
|
|
|
|
// 2. Release Order (checks material availability in Application layer)
|
|
order.release();
|
|
|
|
// 3. Start Production → Create Batch
|
|
Batch batch = Batch.plan(
|
|
order.recipeId(),
|
|
order.plannedQuantity(),
|
|
LocalDate.now(),
|
|
LocalDate.now().plusDays(30), // MHD
|
|
userId,
|
|
branchId
|
|
);
|
|
batch.startProduction();
|
|
order.startProduction(batch.id());
|
|
|
|
// 4. Record ingredient usage
|
|
batch.recordIngredientUsage(
|
|
ArticleId.of("ART-001"),
|
|
BatchId.of("BATCH-2026-02-15-042"), // Supplier batch
|
|
SupplierBatchNumber.of("SUPPLIER-12345"),
|
|
Quantity.of(50, "kg"),
|
|
LocalDate.now().plusDays(20) // MHD of raw material
|
|
);
|
|
|
|
// 5. Complete Batch
|
|
batch.complete(
|
|
Quantity.of(80, "kg"), // Actual output
|
|
Quantity.of(5, "kg"), // Waste
|
|
Optional.of("Leichter Schwund beim Räuchern")
|
|
);
|
|
order.complete();
|
|
|
|
// 6. BatchCompleted event → Inventory creates Stock entry
|
|
```
|
|
|
|
---
|
|
|
|
## Testing Strategy
|
|
|
|
### Unit Tests (Domain Layer)
|
|
```java
|
|
@Test
|
|
void createBatch_withValidData_succeeds() {
|
|
var result = Batch.plan(recipeId, quantity, date, expiryDate, userId, branchId);
|
|
assertThat(result.isSuccess()).isTrue();
|
|
}
|
|
|
|
@Test
|
|
void completeBatch_withoutIngredients_fails() {
|
|
var batch = Batch.plan(...).unsafeGetValue();
|
|
batch.startProduction();
|
|
|
|
var result = batch.complete(quantity, waste, Optional.empty());
|
|
|
|
assertThat(result.isFailure()).isTrue();
|
|
// Should fail with MissingIngredientBatches error
|
|
}
|
|
```
|
|
|
|
### Integration Tests (Application Layer)
|
|
```java
|
|
@Test
|
|
void completeProductionBatch_updatesInventory() {
|
|
// Given: Production order and started batch
|
|
// When: Complete batch
|
|
var result = completeProductionBatch.execute(command);
|
|
|
|
// Then: Inventory stock should increase
|
|
var stock = inventoryRepository.findByArticle(articleId);
|
|
assertThat(stock.quantity()).isEqualTo(expectedQuantity);
|
|
}
|
|
```
|