From aa7ac785bb8acbfa3cb38a3aab1298cb39e0bcb6 Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Thu, 19 Mar 2026 14:04:36 +0100 Subject: [PATCH] feat(production,inventory): Produktionsergebnis automatisch einbuchen (US-7.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Event-Infrastruktur (DomainEvent, DomainEventPublisher) im Shared Kernel eingeführt. CompleteBatch publiziert BatchCompleted-Event mit articleId aus Recipe. ProductionDomainEventPublisher konvertiert in IntegrationEvent, BatchCompletedInventoryListener bucht automatisch StockBatch (PRODUCED) + StockMovement (PRODUCTION_OUTPUT) am PRODUCTION_AREA-Lagerort ein. TUI RecordConsumptionScreen: Rezeptbasierte Zutatenauswahl mit skalierten Soll-Mengen, Stock-Batch-Picker und Mengen-Vorbelegung. --- .../inventory/BookProductionOutput.java | 131 ++++++ .../command/BookProductionOutputCommand.java | 9 + .../application/production/CompleteBatch.java | 40 +- .../inventory/ProductionOutputError.java | 12 + .../production/event/BatchCompleted.java | 18 +- .../config/InventoryUseCaseConfiguration.java | 9 + .../ProductionUseCaseConfiguration.java | 6 +- .../event/BatchCompletedIntegrationEvent.java | 10 + .../BatchCompletedInventoryListener.java | 40 ++ .../event/ProductionDomainEventPublisher.java | 35 ++ .../effigenix/shared/event/DomainEvent.java | 7 + .../shared/event/DomainEventPublisher.java | 5 + .../de/effigenix/shared/security/ActorId.java | 2 + .../inventory/BookProductionOutputTest.java | 165 ++++++++ .../production/CompleteBatchTest.java | 52 ++- .../production/RecordConsumptionScreen.tsx | 373 +++++++++++++----- 16 files changed, 797 insertions(+), 117 deletions(-) create mode 100644 backend/src/main/java/de/effigenix/application/inventory/BookProductionOutput.java create mode 100644 backend/src/main/java/de/effigenix/application/inventory/command/BookProductionOutputCommand.java create mode 100644 backend/src/main/java/de/effigenix/domain/inventory/ProductionOutputError.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/integration/event/BatchCompletedIntegrationEvent.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/inventory/event/BatchCompletedInventoryListener.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/event/ProductionDomainEventPublisher.java create mode 100644 backend/src/main/java/de/effigenix/shared/event/DomainEvent.java create mode 100644 backend/src/main/java/de/effigenix/shared/event/DomainEventPublisher.java create mode 100644 backend/src/test/java/de/effigenix/application/inventory/BookProductionOutputTest.java diff --git a/backend/src/main/java/de/effigenix/application/inventory/BookProductionOutput.java b/backend/src/main/java/de/effigenix/application/inventory/BookProductionOutput.java new file mode 100644 index 0000000..0d4bed1 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/BookProductionOutput.java @@ -0,0 +1,131 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.BookProductionOutputCommand; +import de.effigenix.domain.inventory.*; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.persistence.UnitOfWork; + +public class BookProductionOutput { + + private final StockRepository stockRepository; + private final StorageLocationRepository storageLocationRepository; + private final StockMovementRepository stockMovementRepository; + private final UnitOfWork unitOfWork; + + public BookProductionOutput(StockRepository stockRepository, + StorageLocationRepository storageLocationRepository, + StockMovementRepository stockMovementRepository, + UnitOfWork unitOfWork) { + this.stockRepository = stockRepository; + this.storageLocationRepository = storageLocationRepository; + this.stockMovementRepository = stockMovementRepository; + this.unitOfWork = unitOfWork; + } + + public Result execute(BookProductionOutputCommand cmd) { + // 1. Find active PRODUCTION_AREA storage location + StorageLocation productionArea; + switch (storageLocationRepository.findByStorageType(StorageType.PRODUCTION_AREA)) { + case Result.Failure(var err) -> { + return Result.failure(new ProductionOutputError.RepositoryFailure(err.message())); + } + case Result.Success(var locations) -> { + var active = locations.stream().filter(StorageLocation::active).findFirst(); + if (active.isEmpty()) { + return Result.failure(new ProductionOutputError.NoProductionArea( + "No active PRODUCTION_AREA storage location found")); + } + productionArea = active.get(); + } + } + + var articleId = de.effigenix.domain.masterdata.article.ArticleId.of(cmd.articleId()); + var storageLocationId = productionArea.id(); + + return unitOfWork.executeAtomically(() -> { + // 2. Find or create Stock for (articleId, storageLocationId) + Stock stock; + switch (stockRepository.findByArticleIdAndStorageLocationId(articleId, storageLocationId)) { + case Result.Failure(var err) -> { + return Result.failure(new ProductionOutputError.RepositoryFailure(err.message())); + } + case Result.Success(var opt) -> { + if (opt.isPresent()) { + stock = opt.get(); + } else { + // Auto-create stock with no minimumLevel/minimumShelfLife + var stockDraft = new StockDraft( + cmd.articleId(), storageLocationId.value(), null, null, null); + switch (Stock.create(stockDraft)) { + case Result.Failure(var stockErr) -> { + return Result.failure(new ProductionOutputError.StockCreationFailed( + stockErr.message())); + } + case Result.Success(var newStock) -> stock = newStock; + } + switch (stockRepository.save(stock)) { + case Result.Failure(var saveErr) -> { + return Result.failure(new ProductionOutputError.RepositoryFailure( + saveErr.message())); + } + case Result.Success(var ignored) -> { } + } + } + } + } + + // 3. Add batch to stock + StockBatch stockBatch; + var batchDraft = new StockBatchDraft( + cmd.batchNumber(), "PRODUCED", + cmd.quantityAmount(), cmd.quantityUnit(), + cmd.bestBeforeDate()); + switch (stock.addBatch(batchDraft)) { + case Result.Failure(var err) -> { + return Result.failure(new ProductionOutputError.BatchAdditionFailed(err.message())); + } + case Result.Success(var batch) -> stockBatch = batch; + } + + // 4. Save stock with new batch + switch (stockRepository.save(stock)) { + case Result.Failure(var err) -> { + return Result.failure(new ProductionOutputError.RepositoryFailure(err.message())); + } + case Result.Success(var ignored) -> { } + } + + // 5. Record stock movement + var movementDraft = new StockMovementDraft( + stock.id().value(), + cmd.articleId(), + stockBatch.id().value(), + cmd.batchNumber(), + "PRODUCED", + "PRODUCTION_OUTPUT", + null, + cmd.quantityAmount(), + cmd.quantityUnit(), + null, + null, + "SYSTEM" + ); + StockMovement movement; + switch (StockMovement.record(movementDraft)) { + case Result.Failure(var err) -> { + return Result.failure(new ProductionOutputError.MovementRecordingFailed(err.message())); + } + case Result.Success(var mv) -> movement = mv; + } + + switch (stockMovementRepository.save(movement)) { + case Result.Failure(var err) -> { + return Result.failure(new ProductionOutputError.RepositoryFailure(err.message())); + } + case Result.Success(var ignored) -> { } + } + + return Result.success(null); + }); + } +} diff --git a/backend/src/main/java/de/effigenix/application/inventory/command/BookProductionOutputCommand.java b/backend/src/main/java/de/effigenix/application/inventory/command/BookProductionOutputCommand.java new file mode 100644 index 0000000..218ffa8 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/command/BookProductionOutputCommand.java @@ -0,0 +1,9 @@ +package de.effigenix.application.inventory.command; + +public record BookProductionOutputCommand( + String batchNumber, + String articleId, + String quantityAmount, + String quantityUnit, + String bestBeforeDate +) {} diff --git a/backend/src/main/java/de/effigenix/application/production/CompleteBatch.java b/backend/src/main/java/de/effigenix/application/production/CompleteBatch.java index 9fe427e..2f66d45 100644 --- a/backend/src/main/java/de/effigenix/application/production/CompleteBatch.java +++ b/backend/src/main/java/de/effigenix/application/production/CompleteBatch.java @@ -2,20 +2,30 @@ package de.effigenix.application.production; import de.effigenix.application.production.command.CompleteBatchCommand; import de.effigenix.domain.production.*; +import de.effigenix.domain.production.event.BatchCompleted; import de.effigenix.shared.common.Result; +import de.effigenix.shared.event.DomainEventPublisher; import de.effigenix.shared.persistence.UnitOfWork; import de.effigenix.shared.security.ActorId; import de.effigenix.shared.security.AuthorizationPort; +import java.time.Instant; + public class CompleteBatch { private final BatchRepository batchRepository; + private final RecipeRepository recipeRepository; private final AuthorizationPort authorizationPort; + private final DomainEventPublisher domainEventPublisher; private final UnitOfWork unitOfWork; - public CompleteBatch(BatchRepository batchRepository, AuthorizationPort authorizationPort, UnitOfWork unitOfWork) { + public CompleteBatch(BatchRepository batchRepository, RecipeRepository recipeRepository, + AuthorizationPort authorizationPort, DomainEventPublisher domainEventPublisher, + UnitOfWork unitOfWork) { this.batchRepository = batchRepository; + this.recipeRepository = recipeRepository; this.authorizationPort = authorizationPort; + this.domainEventPublisher = domainEventPublisher; this.unitOfWork = unitOfWork; } @@ -59,6 +69,34 @@ public class CompleteBatch { } case Result.Success(var ignored) -> { } } + + // Load recipe to get articleId for the event + Recipe recipe; + switch (recipeRepository.findById(batch.recipeId())) { + case Result.Failure(var err) -> { + return Result.failure(new BatchError.RepositoryFailure(err.message())); + } + case Result.Success(var opt) -> { + if (opt.isEmpty()) { + return Result.failure(new BatchError.RepositoryFailure( + "Recipe not found: " + batch.recipeId().value())); + } + recipe = opt.get(); + } + } + + domainEventPublisher.publish(new BatchCompleted( + batch.id(), + batch.batchNumber(), + batch.recipeId(), + recipe.articleId(), + batch.actualQuantity(), + batch.waste(), + batch.bestBeforeDate(), + batch.completedAt(), + Instant.now() + )); + return Result.success(batch); }); } diff --git a/backend/src/main/java/de/effigenix/domain/inventory/ProductionOutputError.java b/backend/src/main/java/de/effigenix/domain/inventory/ProductionOutputError.java new file mode 100644 index 0000000..ae76bda --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/ProductionOutputError.java @@ -0,0 +1,12 @@ +package de.effigenix.domain.inventory; + +public sealed interface ProductionOutputError { + + String message(); + + record NoProductionArea(String message) implements ProductionOutputError {} + record StockCreationFailed(String message) implements ProductionOutputError {} + record BatchAdditionFailed(String message) implements ProductionOutputError {} + record MovementRecordingFailed(String message) implements ProductionOutputError {} + record RepositoryFailure(String message) implements ProductionOutputError {} +} diff --git a/backend/src/main/java/de/effigenix/domain/production/event/BatchCompleted.java b/backend/src/main/java/de/effigenix/domain/production/event/BatchCompleted.java index dd53b16..bee5bcc 100644 --- a/backend/src/main/java/de/effigenix/domain/production/event/BatchCompleted.java +++ b/backend/src/main/java/de/effigenix/domain/production/event/BatchCompleted.java @@ -1,17 +1,23 @@ package de.effigenix.domain.production.event; import de.effigenix.domain.production.BatchId; +import de.effigenix.domain.production.BatchNumber; +import de.effigenix.domain.production.RecipeId; import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.event.DomainEvent; +import java.time.Instant; +import java.time.LocalDate; import java.time.OffsetDateTime; -/** - * Stub – wird derzeit nicht publiziert. - * Vorgesehen für spätere Event-Infrastruktur (Inventory stock-in, Audit, Tracing). - */ public record BatchCompleted( BatchId batchId, + BatchNumber batchNumber, + RecipeId recipeId, + String articleId, Quantity actualQuantity, Quantity waste, - OffsetDateTime completedAt -) {} + LocalDate bestBeforeDate, + OffsetDateTime completedAt, + Instant occurredAt +) implements DomainEvent {} diff --git a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java index eb7ce3f..7ff0ee3 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java @@ -9,6 +9,7 @@ import de.effigenix.application.inventory.RecordCountItem; import de.effigenix.application.inventory.StartInventoryCount; import de.effigenix.application.inventory.GetStockMovement; import de.effigenix.application.inventory.ListStockMovements; +import de.effigenix.application.inventory.BookProductionOutput; import de.effigenix.application.inventory.ConfirmReservation; import de.effigenix.application.inventory.RecordStockMovement; import de.effigenix.application.inventory.ActivateStorageLocation; @@ -144,6 +145,14 @@ public class InventoryUseCaseConfiguration { return new ListStocksBelowMinimum(stockRepository, authorizationPort); } + @Bean + public BookProductionOutput bookProductionOutput(StockRepository stockRepository, + StorageLocationRepository storageLocationRepository, + StockMovementRepository stockMovementRepository, + UnitOfWork unitOfWork) { + return new BookProductionOutput(stockRepository, storageLocationRepository, stockMovementRepository, unitOfWork); + } + // ==================== StockMovement Use Cases ==================== @Bean diff --git a/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java b/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java index 1f88d58..07dc3cf 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java @@ -33,6 +33,7 @@ import de.effigenix.domain.production.BatchRepository; import de.effigenix.domain.production.BatchTraceabilityService; import de.effigenix.domain.production.ProductionOrderRepository; import de.effigenix.domain.production.RecipeRepository; +import de.effigenix.shared.event.DomainEventPublisher; import de.effigenix.shared.persistence.UnitOfWork; import de.effigenix.shared.security.AuthorizationPort; import org.springframework.context.annotation.Bean; @@ -134,9 +135,10 @@ public class ProductionUseCaseConfiguration { } @Bean - public CompleteBatch completeBatch(BatchRepository batchRepository, AuthorizationPort authorizationPort, + public CompleteBatch completeBatch(BatchRepository batchRepository, RecipeRepository recipeRepository, + AuthorizationPort authorizationPort, DomainEventPublisher domainEventPublisher, UnitOfWork unitOfWork) { - return new CompleteBatch(batchRepository, authorizationPort, unitOfWork); + return new CompleteBatch(batchRepository, recipeRepository, authorizationPort, domainEventPublisher, unitOfWork); } @Bean diff --git a/backend/src/main/java/de/effigenix/infrastructure/integration/event/BatchCompletedIntegrationEvent.java b/backend/src/main/java/de/effigenix/infrastructure/integration/event/BatchCompletedIntegrationEvent.java new file mode 100644 index 0000000..e19ec8f --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/integration/event/BatchCompletedIntegrationEvent.java @@ -0,0 +1,10 @@ +package de.effigenix.infrastructure.integration.event; + +public record BatchCompletedIntegrationEvent( + String batchId, + String batchNumber, + String articleId, + String quantityAmount, + String quantityUnit, + String bestBeforeDate +) {} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/event/BatchCompletedInventoryListener.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/event/BatchCompletedInventoryListener.java new file mode 100644 index 0000000..f0eb6be --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/event/BatchCompletedInventoryListener.java @@ -0,0 +1,40 @@ +package de.effigenix.infrastructure.inventory.event; + +import de.effigenix.application.inventory.BookProductionOutput; +import de.effigenix.application.inventory.command.BookProductionOutputCommand; +import de.effigenix.infrastructure.integration.event.BatchCompletedIntegrationEvent; +import de.effigenix.shared.common.Result; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Component +public class BatchCompletedInventoryListener { + + private static final System.Logger logger = System.getLogger(BatchCompletedInventoryListener.class.getName()); + + private final BookProductionOutput bookProductionOutput; + + public BatchCompletedInventoryListener(BookProductionOutput bookProductionOutput) { + this.bookProductionOutput = bookProductionOutput; + } + + @EventListener + public void on(BatchCompletedIntegrationEvent event) { + var cmd = new BookProductionOutputCommand( + event.batchNumber(), + event.articleId(), + event.quantityAmount(), + event.quantityUnit(), + event.bestBeforeDate() + ); + switch (bookProductionOutput.execute(cmd)) { + case Result.Failure(var err) -> + logger.log(System.Logger.Level.WARNING, + "Failed to book production output for batch {0}: {1}", + event.batchNumber(), err.message()); + case Result.Success(var ignored) -> + logger.log(System.Logger.Level.INFO, + "Booked production output for batch {0}", event.batchNumber()); + } + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/event/ProductionDomainEventPublisher.java b/backend/src/main/java/de/effigenix/infrastructure/production/event/ProductionDomainEventPublisher.java new file mode 100644 index 0000000..946ccd8 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/event/ProductionDomainEventPublisher.java @@ -0,0 +1,35 @@ +package de.effigenix.infrastructure.production.event; + +import de.effigenix.domain.production.event.BatchCompleted; +import de.effigenix.infrastructure.integration.event.BatchCompletedIntegrationEvent; +import de.effigenix.shared.event.DomainEvent; +import de.effigenix.shared.event.DomainEventPublisher; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +@Component +public class ProductionDomainEventPublisher implements DomainEventPublisher { + + private final ApplicationEventPublisher publisher; + + public ProductionDomainEventPublisher(ApplicationEventPublisher publisher) { + this.publisher = publisher; + } + + @Override + public void publish(DomainEvent event) { + switch (event) { + case BatchCompleted bc -> publisher.publishEvent( + new BatchCompletedIntegrationEvent( + bc.batchId().value(), + bc.batchNumber().value(), + bc.articleId(), + bc.actualQuantity().amount().toPlainString(), + bc.actualQuantity().uom().name(), + bc.bestBeforeDate().toString() + ) + ); + default -> { } + } + } +} diff --git a/backend/src/main/java/de/effigenix/shared/event/DomainEvent.java b/backend/src/main/java/de/effigenix/shared/event/DomainEvent.java new file mode 100644 index 0000000..2b35133 --- /dev/null +++ b/backend/src/main/java/de/effigenix/shared/event/DomainEvent.java @@ -0,0 +1,7 @@ +package de.effigenix.shared.event; + +import java.time.Instant; + +public interface DomainEvent { + Instant occurredAt(); +} diff --git a/backend/src/main/java/de/effigenix/shared/event/DomainEventPublisher.java b/backend/src/main/java/de/effigenix/shared/event/DomainEventPublisher.java new file mode 100644 index 0000000..80aec49 --- /dev/null +++ b/backend/src/main/java/de/effigenix/shared/event/DomainEventPublisher.java @@ -0,0 +1,5 @@ +package de.effigenix.shared.event; + +public interface DomainEventPublisher { + void publish(DomainEvent event); +} diff --git a/backend/src/main/java/de/effigenix/shared/security/ActorId.java b/backend/src/main/java/de/effigenix/shared/security/ActorId.java index f3aff53..0303857 100644 --- a/backend/src/main/java/de/effigenix/shared/security/ActorId.java +++ b/backend/src/main/java/de/effigenix/shared/security/ActorId.java @@ -8,6 +8,8 @@ package de.effigenix.shared.security; */ public record ActorId(String value) { + public static final ActorId SYSTEM = new ActorId("SYSTEM"); + public ActorId { if (value == null || value.isBlank()) { throw new IllegalArgumentException("ActorId cannot be null or empty"); diff --git a/backend/src/test/java/de/effigenix/application/inventory/BookProductionOutputTest.java b/backend/src/test/java/de/effigenix/application/inventory/BookProductionOutputTest.java new file mode 100644 index 0000000..0c1a3e6 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/BookProductionOutputTest.java @@ -0,0 +1,165 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.BookProductionOutputCommand; +import de.effigenix.domain.inventory.*; +import de.effigenix.domain.masterdata.article.ArticleId; +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.persistence.UnitOfWork; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; +import java.util.function.Supplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("BookProductionOutput Use Case") +class BookProductionOutputTest { + + @Mock private StockRepository stockRepository; + @Mock private StorageLocationRepository storageLocationRepository; + @Mock private StockMovementRepository stockMovementRepository; + @Mock private UnitOfWork unitOfWork; + + private BookProductionOutput bookProductionOutput; + + @BeforeEach + void setUp() { + bookProductionOutput = new BookProductionOutput(stockRepository, storageLocationRepository, + stockMovementRepository, unitOfWork); + lenient().when(unitOfWork.executeAtomically(any())).thenAnswer(inv -> ((Supplier) inv.getArgument(0)).get()); + } + + private StorageLocation activeProductionArea() { + return StorageLocation.reconstitute( + StorageLocationId.of("loc-prod"), + StorageLocationName.of("Produktion").unsafeGetValue(), + StorageType.PRODUCTION_AREA, + null, + true + ); + } + + private StorageLocation inactiveProductionArea() { + return StorageLocation.reconstitute( + StorageLocationId.of("loc-prod-inactive"), + StorageLocationName.of("Produktion Alt").unsafeGetValue(), + StorageType.PRODUCTION_AREA, + null, + false + ); + } + + private Stock existingStock(String articleId, String locationId) { + return Stock.reconstitute( + StockId.of("stock-1"), + ArticleId.of(articleId), + StorageLocationId.of(locationId), + null, null, + List.of(), List.of() + ); + } + + private BookProductionOutputCommand validCommand() { + return new BookProductionOutputCommand( + "P-2026-03-01-001", "article-42", "95", "KILOGRAM", "2026-06-01"); + } + + @Test + @DisplayName("should book production output when stock exists") + void should_BookOutput_When_StockExists() { + var location = activeProductionArea(); + var stock = existingStock("article-42", "loc-prod"); + when(storageLocationRepository.findByStorageType(StorageType.PRODUCTION_AREA)) + .thenReturn(Result.success(List.of(location))); + when(stockRepository.findByArticleIdAndStorageLocationId(ArticleId.of("article-42"), StorageLocationId.of("loc-prod"))) + .thenReturn(Result.success(Optional.of(stock))); + when(stockRepository.save(any())).thenReturn(Result.success(null)); + when(stockMovementRepository.save(any())).thenReturn(Result.success(null)); + + var result = bookProductionOutput.execute(validCommand()); + + assertThat(result.isSuccess()).isTrue(); + verify(stockRepository, times(1)).save(stock); + verify(stockMovementRepository).save(any(StockMovement.class)); + } + + @Test + @DisplayName("should auto-create stock when none exists") + void should_CreateStock_When_NoneExists() { + var location = activeProductionArea(); + when(storageLocationRepository.findByStorageType(StorageType.PRODUCTION_AREA)) + .thenReturn(Result.success(List.of(location))); + when(stockRepository.findByArticleIdAndStorageLocationId(any(), any())) + .thenReturn(Result.success(Optional.empty())); + when(stockRepository.save(any())).thenReturn(Result.success(null)); + when(stockMovementRepository.save(any())).thenReturn(Result.success(null)); + + var result = bookProductionOutput.execute(validCommand()); + + assertThat(result.isSuccess()).isTrue(); + // save called twice: once for new stock creation, once after addBatch + verify(stockRepository, times(2)).save(any(Stock.class)); + verify(stockMovementRepository).save(any(StockMovement.class)); + } + + @Test + @DisplayName("should fail when no active PRODUCTION_AREA exists") + void should_Fail_When_NoProductionArea() { + when(storageLocationRepository.findByStorageType(StorageType.PRODUCTION_AREA)) + .thenReturn(Result.success(List.of())); + + var result = bookProductionOutput.execute(validCommand()); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOutputError.NoProductionArea.class); + verify(stockRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when only inactive PRODUCTION_AREA exists") + void should_Fail_When_OnlyInactiveProductionArea() { + when(storageLocationRepository.findByStorageType(StorageType.PRODUCTION_AREA)) + .thenReturn(Result.success(List.of(inactiveProductionArea()))); + + var result = bookProductionOutput.execute(validCommand()); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOutputError.NoProductionArea.class); + } + + @Test + @DisplayName("should fail with RepositoryFailure when storage location lookup fails") + void should_Fail_When_StorageLocationLookupFails() { + when(storageLocationRepository.findByStorageType(StorageType.PRODUCTION_AREA)) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = bookProductionOutput.execute(validCommand()); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOutputError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail with RepositoryFailure when stock lookup fails") + void should_Fail_When_StockLookupFails() { + var location = activeProductionArea(); + when(storageLocationRepository.findByStorageType(StorageType.PRODUCTION_AREA)) + .thenReturn(Result.success(List.of(location))); + when(stockRepository.findByArticleIdAndStorageLocationId(any(), any())) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("read error"))); + + var result = bookProductionOutput.execute(validCommand()); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOutputError.RepositoryFailure.class); + } +} diff --git a/backend/src/test/java/de/effigenix/application/production/CompleteBatchTest.java b/backend/src/test/java/de/effigenix/application/production/CompleteBatchTest.java index be1936a..3c8b3bc 100644 --- a/backend/src/test/java/de/effigenix/application/production/CompleteBatchTest.java +++ b/backend/src/test/java/de/effigenix/application/production/CompleteBatchTest.java @@ -6,6 +6,8 @@ import de.effigenix.shared.common.Quantity; import de.effigenix.shared.common.RepositoryError; import de.effigenix.shared.common.Result; import de.effigenix.shared.common.UnitOfMeasure; +import de.effigenix.shared.event.DomainEvent; +import de.effigenix.shared.event.DomainEventPublisher; import de.effigenix.shared.security.ActorId; import de.effigenix.shared.security.AuthorizationPort; import de.effigenix.shared.persistence.UnitOfWork; @@ -13,6 +15,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -32,7 +35,9 @@ import static org.mockito.Mockito.*; class CompleteBatchTest { @Mock private BatchRepository batchRepository; + @Mock private RecipeRepository recipeRepository; @Mock private AuthorizationPort authPort; + @Mock private DomainEventPublisher domainEventPublisher; @Mock private UnitOfWork unitOfWork; private CompleteBatch completeBatch; @@ -40,11 +45,30 @@ class CompleteBatchTest { @BeforeEach void setUp() { - completeBatch = new CompleteBatch(batchRepository, authPort, unitOfWork); + completeBatch = new CompleteBatch(batchRepository, recipeRepository, authPort, domainEventPublisher, unitOfWork); performedBy = ActorId.of("admin-user"); lenient().when(unitOfWork.executeAtomically(any())).thenAnswer(inv -> ((Supplier) inv.getArgument(0)).get()); } + private Recipe activeRecipe(String recipeId) { + return Recipe.reconstitute( + RecipeId.of(recipeId), + RecipeName.of("Test Recipe").unsafeGetValue(), + 1, + RecipeType.FINISHED_PRODUCT, + "desc", + YieldPercentage.of(100).unsafeGetValue(), + 30, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + "article-42", + RecipeStatus.ACTIVE, + List.of(), + List.of(), + OffsetDateTime.now(ZoneOffset.UTC), + OffsetDateTime.now(ZoneOffset.UTC) + ); + } + private Batch inProductionBatchWithConsumption(String id) { var batch = Batch.reconstitute( BatchId.of(id), @@ -92,9 +116,11 @@ class CompleteBatchTest { void should_CompleteBatch_When_Valid() { var batchId = BatchId.of("batch-1"); var batch = inProductionBatchWithConsumption("batch-1"); + var recipe = activeRecipe("recipe-1"); when(authPort.can(performedBy, ProductionAction.BATCH_COMPLETE)).thenReturn(true); when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch))); when(batchRepository.save(any())).thenReturn(Result.success(null)); + when(recipeRepository.findById(RecipeId.of("recipe-1"))).thenReturn(Result.success(Optional.of(recipe))); var result = completeBatch.execute(validCommand("batch-1"), performedBy); @@ -106,6 +132,30 @@ class CompleteBatchTest { verify(batchRepository).save(batch); } + @Test + @DisplayName("should publish BatchCompleted event with articleId from recipe") + void should_PublishEvent_When_BatchCompleted() { + var batchId = BatchId.of("batch-1"); + var batch = inProductionBatchWithConsumption("batch-1"); + var recipe = activeRecipe("recipe-1"); + when(authPort.can(performedBy, ProductionAction.BATCH_COMPLETE)).thenReturn(true); + when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch))); + when(batchRepository.save(any())).thenReturn(Result.success(null)); + when(recipeRepository.findById(RecipeId.of("recipe-1"))).thenReturn(Result.success(Optional.of(recipe))); + + completeBatch.execute(validCommand("batch-1"), performedBy); + + var captor = ArgumentCaptor.forClass(DomainEvent.class); + verify(domainEventPublisher).publish(captor.capture()); + var event = captor.getValue(); + assertThat(event).isInstanceOf(de.effigenix.domain.production.event.BatchCompleted.class); + var batchCompleted = (de.effigenix.domain.production.event.BatchCompleted) event; + assertThat(batchCompleted.articleId()).isEqualTo("article-42"); + assertThat(batchCompleted.batchId()).isEqualTo(batchId); + assertThat(batchCompleted.actualQuantity().amount()).isEqualByComparingTo(new BigDecimal("95")); + assertThat(batchCompleted.bestBeforeDate()).isEqualTo(LocalDate.of(2026, 6, 1)); + } + @Test @DisplayName("should fail when batch not found") void should_Fail_When_BatchNotFound() { diff --git a/frontend/apps/cli/src/components/production/RecordConsumptionScreen.tsx b/frontend/apps/cli/src/components/production/RecordConsumptionScreen.tsx index 7371e58..1d0add3 100644 --- a/frontend/apps/cli/src/components/production/RecordConsumptionScreen.tsx +++ b/frontend/apps/cli/src/components/production/RecordConsumptionScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; import { Box, Text, useInput } from 'ink'; import { useNavigation } from '../../state/navigation-context.js'; import { useBatches } from '../../hooks/useBatches.js'; @@ -6,95 +6,198 @@ import { FormInput } from '../shared/FormInput.js'; import { LoadingSpinner } from '../shared/LoadingSpinner.js'; import { ErrorDisplay } from '../shared/ErrorDisplay.js'; import { UOM_VALUES, UOM_LABELS } from '@effigenix/api-client'; -import type { UoM } from '@effigenix/api-client'; +import type { UoM, RecipeDTO, ArticleDTO, StockBatchDTO, IngredientDTO } from '@effigenix/api-client'; +import { client } from '../../utils/api-client.js'; -type Field = 'inputBatchId' | 'articleId' | 'quantityUsed' | 'quantityUnit'; -const FIELDS: Field[] = ['inputBatchId', 'articleId', 'quantityUsed', 'quantityUnit']; +type Phase = 'loading' | 'pick-ingredient' | 'pick-batch' | 'edit-quantity' | 'submitting' | 'success' | 'error'; -const FIELD_LABELS: Record = { - inputBatchId: 'Input-Chargen-ID *', - articleId: 'Artikel-ID *', - quantityUsed: 'Verbrauchte Menge *', - quantityUnit: 'Mengeneinheit * (←→ wechseln)', -}; +interface AvailableBatch { + stockBatch: StockBatchDTO; + stockId: string; +} export function RecordConsumptionScreen() { const { params, back } = useNavigation(); - const { recordConsumption, loading, error, clearError } = useBatches(); + const { recordConsumption, error: submitError, clearError } = useBatches(); const batchId = params.batchId ?? ''; - const [values, setValues] = useState({ inputBatchId: '', articleId: '', quantityUsed: '' }); + + // Data state + const [recipe, setRecipe] = useState(null); + const [articles, setArticles] = useState([]); + const [consumedArticleIds, setConsumedArticleIds] = useState>(new Set()); + const [plannedQuantity, setPlannedQuantity] = useState(0); + + // Selection state + const [phase, setPhase] = useState('loading'); + const [ingredientCursor, setIngredientCursor] = useState(0); + const [selectedIngredient, setSelectedIngredient] = useState(null); + const [availableBatches, setAvailableBatches] = useState([]); + const [batchCursor, setBatchCursor] = useState(0); + const [selectedBatch, setSelectedBatch] = useState(null); + + // Quantity edit state + const [quantityValue, setQuantityValue] = useState(''); const [uomIdx, setUomIdx] = useState(0); - const [activeField, setActiveField] = useState('inputBatchId'); - const [fieldErrors, setFieldErrors] = useState>>({}); - const [success, setSuccess] = useState(false); + const [loadError, setLoadError] = useState(null); - const setField = (field: keyof typeof values) => (value: string) => { - setValues((v) => ({ ...v, [field]: value })); - }; + const articleName = useCallback((id: string) => { + const a = articles.find((art) => art.id === id); + return a ? `${a.articleNumber} – ${a.name}` : id.substring(0, 18); + }, [articles]); - const handleSubmit = async () => { - const errors: Partial> = {}; - if (!values.inputBatchId.trim()) errors.inputBatchId = 'Chargen-ID erforderlich.'; - if (!values.articleId.trim()) errors.articleId = 'Artikel-ID erforderlich.'; - if (!values.quantityUsed.trim()) errors.quantityUsed = 'Menge erforderlich.'; - setFieldErrors(errors); - if (Object.keys(errors).length > 0) return; + // Load batch, recipe, articles on mount + useEffect(() => { + async function load() { + try { + const [batchData, articleList] = await Promise.all([ + client.batches.getById(batchId), + client.articles.list(), + ]); + setArticles(articleList); + setPlannedQuantity(parseFloat(batchData.plannedQuantity ?? '0')); - const result = await recordConsumption(batchId, { - inputBatchId: values.inputBatchId.trim(), - articleId: values.articleId.trim(), - quantityUsed: values.quantityUsed.trim(), - quantityUnit: UOM_VALUES[uomIdx] as string, - }); - if (result) { - setSuccess(true); - setTimeout(() => back(), 1500); + // Track already consumed articles + const consumed = new Set((batchData.consumptions ?? []).map((c) => c.articleId ?? '')); + setConsumedArticleIds(consumed); + + if (!batchData.recipeId) { + setLoadError('Charge hat keine Rezept-ID.'); + setPhase('error'); + return; + } + + const recipeData = await client.recipes.getById(batchData.recipeId); + setRecipe(recipeData); + setPhase('pick-ingredient'); + } catch (err) { + setLoadError(err instanceof Error ? err.message : 'Fehler beim Laden'); + setPhase('error'); + } } - }; + void load(); + }, [batchId]); - const handleFieldSubmit = (field: Field) => (_value: string) => { - const idx = FIELDS.indexOf(field); - if (idx < FIELDS.length - 1) { - setActiveField(FIELDS[idx + 1] ?? field); - } else { - void handleSubmit(); + // Calculate scaled quantity for an ingredient + const scaledQuantity = useCallback((ingredient: IngredientDTO): string => { + if (!recipe) return ingredient.quantity; + const recipeOutput = parseFloat(recipe.outputQuantity); + if (!recipeOutput || recipeOutput === 0) return ingredient.quantity; + const ingredientQty = parseFloat(ingredient.quantity); + const scale = plannedQuantity / recipeOutput; + return (ingredientQty * scale).toFixed(2).replace(/\.?0+$/, ''); + }, [recipe, plannedQuantity]); + + // Ingredients sorted by position + const sortedIngredients = useMemo(() => { + if (!recipe) return []; + return [...recipe.ingredients].sort((a, b) => a.position - b.position); + }, [recipe]); + + // Load available batches for selected ingredient's article + const loadBatchesForArticle = useCallback(async (articleId: string) => { + try { + const stocks = await client.stocks.list({ articleId }); + const batches: AvailableBatch[] = []; + for (const stock of stocks) { + for (const sb of stock.batches) { + if (sb.status === 'AVAILABLE' || sb.status === 'EXPIRING_SOON') { + batches.push({ stockBatch: sb, stockId: stock.id }); + } + } + } + setAvailableBatches(batches); + setBatchCursor(0); + } catch { + setAvailableBatches([]); } - }; + }, []); + // Handle input based on current phase useInput((_input, key) => { - if (loading || success) return; + if (phase === 'loading' || phase === 'submitting' || phase === 'success') return; - if (activeField === 'quantityUnit') { + if (key.escape) { + if (phase === 'edit-quantity') { + setPhase('pick-batch'); + return; + } + if (phase === 'pick-batch') { + setPhase('pick-ingredient'); + return; + } + back(); + return; + } + + if (phase === 'pick-ingredient') { + if (key.upArrow) setIngredientCursor((c) => Math.max(0, c - 1)); + if (key.downArrow) setIngredientCursor((c) => Math.min(sortedIngredients.length - 1, c + 1)); + if (key.return && sortedIngredients[ingredientCursor]) { + const ing = sortedIngredients[ingredientCursor]!; + setSelectedIngredient(ing); + void loadBatchesForArticle(ing.articleId); + setPhase('pick-batch'); + } + return; + } + + if (phase === 'pick-batch') { + if (key.upArrow) setBatchCursor((c) => Math.max(0, c - 1)); + if (key.downArrow) setBatchCursor((c) => Math.min(availableBatches.length - 1, c + 1)); + if (key.return && availableBatches[batchCursor]) { + const batch = availableBatches[batchCursor]!; + setSelectedBatch(batch); + // Pre-fill quantity from recipe (scaled) + if (selectedIngredient) { + setQuantityValue(scaledQuantity(selectedIngredient)); + const uomIndex = UOM_VALUES.indexOf(selectedIngredient.uom as UoM); + setUomIdx(uomIndex >= 0 ? uomIndex : 0); + } + setPhase('edit-quantity'); + } + return; + } + + if (phase === 'edit-quantity') { if (key.leftArrow || key.rightArrow) { const dir = key.rightArrow ? 1 : -1; setUomIdx((i) => (i + dir + UOM_VALUES.length) % UOM_VALUES.length); - return; - } - if (key.return) { - void handleSubmit(); - return; } } - - if (key.tab || key.downArrow) { - setActiveField((f) => { - const idx = FIELDS.indexOf(f); - return FIELDS[(idx + 1) % FIELDS.length] ?? f; - }); - } - if (key.upArrow) { - setActiveField((f) => { - const idx = FIELDS.indexOf(f); - return FIELDS[(idx - 1 + FIELDS.length) % FIELDS.length] ?? f; - }); - } - if (key.escape) back(); }); - if (loading) return ; + const handleSubmit = async () => { + if (!selectedIngredient || !selectedBatch || !quantityValue.trim()) return; + setPhase('submitting'); + const result = await recordConsumption(batchId, { + inputBatchId: selectedBatch.stockBatch.batchId ?? '', + articleId: selectedIngredient.articleId, + quantityUsed: quantityValue.trim(), + quantityUnit: UOM_VALUES[uomIdx] as string, + }); + if (result) { + setPhase('success'); + setTimeout(() => back(), 1500); + } else { + setPhase('edit-quantity'); + } + }; - if (success) { + // ── Render ── + + if (phase === 'loading') return ; + if (phase === 'submitting') return ; + + if (phase === 'error') { + return ( + + back()} /> + + ); + } + + if (phase === 'success') { return ( Verbrauch erfolgreich erfasst. @@ -102,51 +205,107 @@ export function RecordConsumptionScreen() { ); } - const uomLabel = UOM_LABELS[UOM_VALUES[uomIdx] as UoM] ?? UOM_VALUES[uomIdx]; + const uomLabel = (u: string) => UOM_LABELS[u as UoM] ?? u; - return ( - - Verbrauch erfassen - Charge: {batchId} - {error && } + // ── Phase: Pick Ingredient ── + if (phase === 'pick-ingredient') { + return ( + + Verbrauch erfassen – Zutat wählen + {submitError && } - - - - - - - {FIELD_LABELS.quantityUnit}: {activeField === 'quantityUnit' ? `< ${uomLabel} >` : uomLabel} - + + {sortedIngredients.map((ing, i) => { + const consumed = consumedArticleIds.has(ing.articleId); + const scaled = scaledQuantity(ing); + const color = consumed ? 'gray' : (i === ingredientCursor ? 'cyan' : 'white'); + return ( + + + {i === ingredientCursor ? '▶ ' : ' '} + {`${ing.position}. `.padEnd(4)} + {articleName(ing.articleId).padEnd(35)} + {`${scaled} ${uomLabel(ing.uom)}`.padEnd(20)} + {consumed ? '✓ erfasst' : ''} + + + ); + })} - - - - Tab/↑↓ Feld wechseln · ←→ Einheit · Enter bestätigen/speichern · Escape Abbrechen - + ↑↓ wählen · Enter auswählen · Escape zurück - - ); + ); + } + + // ── Phase: Pick Batch ── + if (phase === 'pick-batch' && selectedIngredient) { + return ( + + Verbrauch erfassen – Input-Charge wählen + Zutat: {articleName(selectedIngredient.articleId)} · Soll: {scaledQuantity(selectedIngredient)} {uomLabel(selectedIngredient.uom)} + + {availableBatches.length === 0 ? ( + + Keine verfügbaren Chargen für diesen Artikel. + + ) : ( + + + {' Chargen-ID'.padEnd(24)}{'Menge'.padEnd(18)}{'MHD'.padEnd(14)}Status + + {availableBatches.map((ab, i) => { + const sb = ab.stockBatch; + const color = i === batchCursor ? 'cyan' : 'white'; + return ( + + + {i === batchCursor ? '▶ ' : ' '} + {(sb.batchId ?? '').padEnd(22)} + {`${sb.quantityAmount ?? ''} ${uomLabel(sb.quantityUnit ?? '')}`.padEnd(18)} + {(sb.expiryDate ?? '').padEnd(14)} + {sb.status ?? ''} + + + ); + })} + + )} + + ↑↓ wählen · Enter auswählen · Escape zurück + + ); + } + + // ── Phase: Edit Quantity ── + if (phase === 'edit-quantity' && selectedIngredient && selectedBatch) { + const currentUomLabel = UOM_LABELS[UOM_VALUES[uomIdx] as UoM] ?? UOM_VALUES[uomIdx]; + return ( + + Verbrauch erfassen – Menge bestätigen + Zutat: {articleName(selectedIngredient.articleId)} + Charge: {selectedBatch.stockBatch.batchId} ({selectedBatch.stockBatch.quantityAmount} {uomLabel(selectedBatch.stockBatch.quantityUnit ?? '')} verfügbar) + {submitError && } + + + void handleSubmit()} + focus={true} + /> + + + Mengeneinheit * (←→ wechseln): {`< ${currentUomLabel} >`} + + + + + ←→ Einheit · Enter speichern · Escape zurück + + ); + } + + return null; }