1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 17:04:49 +01:00
effigenix/backend/docs/mvp/ddd/04-production-bc.md
Sebastian Frick c2c48a03e8 refactor: restructure repository with separate backend and frontend directories
- 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/
2026-02-17 22:08:51 +01:00

14 KiB

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:

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

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:

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:

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:

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:

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:

ProductionOrderCreated
ProductionOrderReleased  triggers Demand Planning update
ProductionOrderStarted
ProductionOrderCompleted
ProductionOrderCancelled
ProductionOrderRescheduled

Value Objects

RecipeId

public record RecipeId(String value) {
    public RecipeId {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("RecipeId cannot be empty");
        }
    }
}

BatchId

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

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

public enum RecipeType {
    RAW_MATERIAL,        // Rohstoff, kein Rezept
    INTERMEDIATE,        // Zwischenprodukt (z.B. Gewürzmischung)
    FINISHED_PRODUCT     // Endprodukt
}

ProductionOrderPriority

public enum Priority {
    LOW,
    NORMAL,
    HIGH,
    URGENT
}

Domain Services

RecipeValidator

public class RecipeValidator {
    /**
     * Validates that recipe does not create circular dependencies.
     */
    public Result<RecipeError, Void> validateNoCyclicDependency(
        Recipe recipe,
        RecipeRepository recipeRepository
    );
}

BatchTraceabilityService

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

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

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)

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

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

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

@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);
}