diff --git a/backend/src/main/java/de/effigenix/application/inventory/BookProductionConsumption.java b/backend/src/main/java/de/effigenix/application/inventory/BookProductionConsumption.java new file mode 100644 index 0000000..6a609c5 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/BookProductionConsumption.java @@ -0,0 +1,132 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.BookProductionConsumptionCommand; +import de.effigenix.domain.inventory.*; +import de.effigenix.domain.masterdata.article.ArticleId; +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.common.UnitOfMeasure; +import de.effigenix.shared.persistence.UnitOfWork; + +import java.math.BigDecimal; + +public class BookProductionConsumption { + + private final StockRepository stockRepository; + private final StockMovementRepository stockMovementRepository; + private final UnitOfWork unitOfWork; + + public BookProductionConsumption(StockRepository stockRepository, + StockMovementRepository stockMovementRepository, + UnitOfWork unitOfWork) { + this.stockRepository = stockRepository; + this.stockMovementRepository = stockMovementRepository; + this.unitOfWork = unitOfWork; + } + + public Result execute(BookProductionConsumptionCommand cmd) { + var articleId = ArticleId.of(cmd.articleId()); + + // 1. Find all stocks for the article + java.util.List stocks; + switch (stockRepository.findAllByArticleId(articleId)) { + case Result.Failure(var err) -> { + return Result.failure(new ProductionConsumptionError.RepositoryFailure(err.message())); + } + case Result.Success(var list) -> stocks = list; + } + + if (stocks.isEmpty()) { + return Result.failure(new ProductionConsumptionError.StockNotFound( + "No stock found for article " + cmd.articleId())); + } + + // 2. Find the batch across all stocks by matching batchReference.batchId + Stock targetStock = null; + StockBatch targetBatch = null; + for (Stock stock : stocks) { + for (StockBatch batch : stock.batches()) { + if (batch.batchReference().batchId().equals(cmd.inputBatchReferenceId())) { + targetStock = stock; + targetBatch = batch; + break; + } + } + if (targetBatch != null) break; + } + + if (targetBatch == null) { + return Result.failure(new ProductionConsumptionError.BatchNotFoundInStock( + "Batch with reference " + cmd.inputBatchReferenceId() + " not found in any stock for article " + cmd.articleId())); + } + + // 3. Parse quantity + Quantity quantity; + try { + var amount = new BigDecimal(cmd.quantityAmount()); + var uom = UnitOfMeasure.valueOf(cmd.quantityUnit()); + switch (Quantity.of(amount, uom)) { + case Result.Failure(var err) -> { + return Result.failure(new ProductionConsumptionError.RemovalFailed(err.message())); + } + case Result.Success(var val) -> quantity = val; + } + } catch (IllegalArgumentException e) { + return Result.failure(new ProductionConsumptionError.RemovalFailed( + "Invalid quantity: " + cmd.quantityAmount() + " " + cmd.quantityUnit())); + } + + final Stock stock = targetStock; + final StockBatch batch = targetBatch; + + return unitOfWork.executeAtomically(() -> { + // 4. Remove quantity from batch + switch (stock.removeBatch(batch.id(), quantity)) { + case Result.Failure(var err) -> { + return Result.failure(new ProductionConsumptionError.RemovalFailed(err.message())); + } + case Result.Success(var ignored) -> { } + } + + // 5. Save stock + switch (stockRepository.save(stock)) { + case Result.Failure(var err) -> { + return Result.failure(new ProductionConsumptionError.RepositoryFailure(err.message())); + } + case Result.Success(var ignored) -> { } + } + + // 6. Record stock movement + var movementDraft = new StockMovementDraft( + stock.id().value(), + cmd.articleId(), + batch.id().value(), + batch.batchReference().batchId(), + batch.batchReference().batchType().name(), + "PRODUCTION_CONSUMPTION", + null, + cmd.quantityAmount(), + cmd.quantityUnit(), + null, + null, + "SYSTEM" + ); + StockMovement movement; + switch (StockMovement.record(movementDraft)) { + case Result.Failure(var err) -> { + return Result.failure(new ProductionConsumptionError.MovementRecordingFailed(err.message())); + } + case Result.Success(var mv) -> movement = mv; + } + + switch (stockMovementRepository.save(movement)) { + case Result.Failure(var err) -> { + return Result.failure(new ProductionConsumptionError.RepositoryFailure(err.message())); + } + case Result.Success(var ignored) -> { } + } + + return Result.success(null); + }); + } +} diff --git a/backend/src/main/java/de/effigenix/application/inventory/command/BookProductionConsumptionCommand.java b/backend/src/main/java/de/effigenix/application/inventory/command/BookProductionConsumptionCommand.java new file mode 100644 index 0000000..4aa1bd7 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/command/BookProductionConsumptionCommand.java @@ -0,0 +1,8 @@ +package de.effigenix.application.inventory.command; + +public record BookProductionConsumptionCommand( + String inputBatchReferenceId, + String articleId, + String quantityAmount, + String quantityUnit +) {} diff --git a/backend/src/main/java/de/effigenix/application/production/RecordConsumption.java b/backend/src/main/java/de/effigenix/application/production/RecordConsumption.java index 134910b..02fae47 100644 --- a/backend/src/main/java/de/effigenix/application/production/RecordConsumption.java +++ b/backend/src/main/java/de/effigenix/application/production/RecordConsumption.java @@ -2,21 +2,27 @@ package de.effigenix.application.production; import de.effigenix.application.production.command.RecordConsumptionCommand; import de.effigenix.domain.production.*; +import de.effigenix.domain.production.event.ConsumptionRecorded; 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 RecordConsumption { private final BatchRepository batchRepository; private final AuthorizationPort authorizationPort; + private final DomainEventPublisher domainEventPublisher; private final UnitOfWork unitOfWork; public RecordConsumption(BatchRepository batchRepository, AuthorizationPort authorizationPort, - UnitOfWork unitOfWork) { + DomainEventPublisher domainEventPublisher, UnitOfWork unitOfWork) { this.batchRepository = batchRepository; this.authorizationPort = authorizationPort; + this.domainEventPublisher = domainEventPublisher; this.unitOfWork = unitOfWork; } @@ -60,6 +66,16 @@ public class RecordConsumption { } case Result.Success(var ignored) -> { } } + + domainEventPublisher.publish(new ConsumptionRecorded( + batchId, + consumption.id(), + consumption.inputBatchId().value(), + consumption.articleId().value(), + consumption.quantityUsed(), + Instant.now() + )); + return Result.success(consumption); }); } diff --git a/backend/src/main/java/de/effigenix/domain/inventory/ProductionConsumptionError.java b/backend/src/main/java/de/effigenix/domain/inventory/ProductionConsumptionError.java new file mode 100644 index 0000000..c74e833 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/ProductionConsumptionError.java @@ -0,0 +1,12 @@ +package de.effigenix.domain.inventory; + +public sealed interface ProductionConsumptionError { + + String message(); + + record StockNotFound(String message) implements ProductionConsumptionError {} + record BatchNotFoundInStock(String message) implements ProductionConsumptionError {} + record RemovalFailed(String message) implements ProductionConsumptionError {} + record MovementRecordingFailed(String message) implements ProductionConsumptionError {} + record RepositoryFailure(String message) implements ProductionConsumptionError {} +} diff --git a/backend/src/main/java/de/effigenix/domain/production/event/ConsumptionRecorded.java b/backend/src/main/java/de/effigenix/domain/production/event/ConsumptionRecorded.java index 4ed4481..00f3d37 100644 --- a/backend/src/main/java/de/effigenix/domain/production/event/ConsumptionRecorded.java +++ b/backend/src/main/java/de/effigenix/domain/production/event/ConsumptionRecorded.java @@ -2,9 +2,16 @@ package de.effigenix.domain.production.event; import de.effigenix.domain.production.BatchId; import de.effigenix.domain.production.ConsumptionId; +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.event.DomainEvent; -/** - * Stub – wird derzeit nicht publiziert. - * Vorgesehen für spätere Event-Infrastruktur (Chargen-Genealogie, Bestandsabzug). - */ -public record ConsumptionRecorded(BatchId batchId, ConsumptionId consumptionId, BatchId inputBatchId) {} +import java.time.Instant; + +public record ConsumptionRecorded( + BatchId batchId, + ConsumptionId consumptionId, + String inputBatchId, + String articleId, + Quantity quantity, + 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 7ff0ee3..a8b77ec 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.BookProductionConsumption; import de.effigenix.application.inventory.BookProductionOutput; import de.effigenix.application.inventory.ConfirmReservation; import de.effigenix.application.inventory.RecordStockMovement; @@ -153,6 +154,13 @@ public class InventoryUseCaseConfiguration { return new BookProductionOutput(stockRepository, storageLocationRepository, stockMovementRepository, unitOfWork); } + @Bean + public BookProductionConsumption bookProductionConsumption(StockRepository stockRepository, + StockMovementRepository stockMovementRepository, + UnitOfWork unitOfWork) { + return new BookProductionConsumption(stockRepository, 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 07dc3cf..383536b 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java @@ -130,8 +130,8 @@ public class ProductionUseCaseConfiguration { @Bean public RecordConsumption recordConsumption(BatchRepository batchRepository, AuthorizationPort authorizationPort, - UnitOfWork unitOfWork) { - return new RecordConsumption(batchRepository, authorizationPort, unitOfWork); + DomainEventPublisher domainEventPublisher, UnitOfWork unitOfWork) { + return new RecordConsumption(batchRepository, authorizationPort, domainEventPublisher, unitOfWork); } @Bean diff --git a/backend/src/main/java/de/effigenix/infrastructure/integration/event/ConsumptionRecordedIntegrationEvent.java b/backend/src/main/java/de/effigenix/infrastructure/integration/event/ConsumptionRecordedIntegrationEvent.java new file mode 100644 index 0000000..c21a549 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/integration/event/ConsumptionRecordedIntegrationEvent.java @@ -0,0 +1,8 @@ +package de.effigenix.infrastructure.integration.event; + +public record ConsumptionRecordedIntegrationEvent( + String inputBatchReferenceId, + String articleId, + String quantityAmount, + String quantityUnit +) {} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/event/ConsumptionRecordedInventoryListener.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/event/ConsumptionRecordedInventoryListener.java new file mode 100644 index 0000000..5eafc72 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/event/ConsumptionRecordedInventoryListener.java @@ -0,0 +1,40 @@ +package de.effigenix.infrastructure.inventory.event; + +import de.effigenix.application.inventory.BookProductionConsumption; +import de.effigenix.application.inventory.command.BookProductionConsumptionCommand; +import de.effigenix.infrastructure.integration.event.ConsumptionRecordedIntegrationEvent; +import de.effigenix.shared.common.Result; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Component +public class ConsumptionRecordedInventoryListener { + + private static final System.Logger logger = System.getLogger(ConsumptionRecordedInventoryListener.class.getName()); + + private final BookProductionConsumption bookProductionConsumption; + + public ConsumptionRecordedInventoryListener(BookProductionConsumption bookProductionConsumption) { + this.bookProductionConsumption = bookProductionConsumption; + } + + @EventListener + public void on(ConsumptionRecordedIntegrationEvent event) { + var cmd = new BookProductionConsumptionCommand( + event.inputBatchReferenceId(), + event.articleId(), + event.quantityAmount(), + event.quantityUnit() + ); + switch (bookProductionConsumption.execute(cmd)) { + case Result.Failure(var err) -> + logger.log(System.Logger.Level.WARNING, + "Failed to book production consumption for batch {0}: {1}", + event.inputBatchReferenceId(), err.message()); + case Result.Success(var ignored) -> + logger.log(System.Logger.Level.INFO, + "Booked production consumption for batch {0}, article {1}", + event.inputBatchReferenceId(), event.articleId()); + } + } +} 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 index 946ccd8..0b28ef5 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/event/ProductionDomainEventPublisher.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/event/ProductionDomainEventPublisher.java @@ -1,7 +1,9 @@ package de.effigenix.infrastructure.production.event; import de.effigenix.domain.production.event.BatchCompleted; +import de.effigenix.domain.production.event.ConsumptionRecorded; import de.effigenix.infrastructure.integration.event.BatchCompletedIntegrationEvent; +import de.effigenix.infrastructure.integration.event.ConsumptionRecordedIntegrationEvent; import de.effigenix.shared.event.DomainEvent; import de.effigenix.shared.event.DomainEventPublisher; import org.springframework.context.ApplicationEventPublisher; @@ -29,6 +31,14 @@ public class ProductionDomainEventPublisher implements DomainEventPublisher { bc.bestBeforeDate().toString() ) ); + case ConsumptionRecorded cr -> publisher.publishEvent( + new ConsumptionRecordedIntegrationEvent( + cr.inputBatchId(), + cr.articleId(), + cr.quantity().amount().toPlainString(), + cr.quantity().uom().name() + ) + ); default -> { } } } diff --git a/backend/src/test/java/de/effigenix/application/inventory/BookProductionConsumptionTest.java b/backend/src/test/java/de/effigenix/application/inventory/BookProductionConsumptionTest.java new file mode 100644 index 0000000..09b5af3 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/BookProductionConsumptionTest.java @@ -0,0 +1,215 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.BookProductionConsumptionCommand; +import de.effigenix.domain.inventory.*; +import de.effigenix.domain.masterdata.article.ArticleId; +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.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.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.function.Supplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("BookProductionConsumption Use Case") +class BookProductionConsumptionTest { + + @Mock private StockRepository stockRepository; + @Mock private StockMovementRepository stockMovementRepository; + @Mock private UnitOfWork unitOfWork; + + private BookProductionConsumption bookProductionConsumption; + + @BeforeEach + void setUp() { + bookProductionConsumption = new BookProductionConsumption(stockRepository, stockMovementRepository, unitOfWork); + lenient().when(unitOfWork.executeAtomically(any())).thenAnswer(inv -> ((Supplier) inv.getArgument(0)).get()); + } + + private Stock stockWithBatch(String articleId, String batchReferenceId, String quantity) { + var stockBatch = StockBatch.reconstitute( + StockBatchId.of("sb-1"), + new BatchReference(batchReferenceId, BatchType.PRODUCED), + Quantity.of(new BigDecimal(quantity), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + LocalDate.of(2026, 6, 1), + StockBatchStatus.AVAILABLE, + Instant.now() + ); + return Stock.reconstitute( + StockId.of("stock-1"), + ArticleId.of(articleId), + StorageLocationId.of("loc-prod"), + null, null, + List.of(stockBatch), List.of() + ); + } + + private Stock stockWithoutMatchingBatch(String articleId) { + var stockBatch = StockBatch.reconstitute( + StockBatchId.of("sb-other"), + new BatchReference("OTHER-BATCH", BatchType.PRODUCED), + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + LocalDate.of(2026, 6, 1), + StockBatchStatus.AVAILABLE, + Instant.now() + ); + return Stock.reconstitute( + StockId.of("stock-2"), + ArticleId.of(articleId), + StorageLocationId.of("loc-prod"), + null, null, + List.of(stockBatch), List.of() + ); + } + + private BookProductionConsumptionCommand validCommand() { + return new BookProductionConsumptionCommand( + "P-2026-03-19-001", "article-42", "5", "KILOGRAM"); + } + + @Test + @DisplayName("should deduct stock and record movement when batch found") + void should_DeductStock_When_BatchFound() { + var stock = stockWithBatch("article-42", "P-2026-03-19-001", "50"); + when(stockRepository.findAllByArticleId(ArticleId.of("article-42"))) + .thenReturn(Result.success(List.of(stock))); + when(stockRepository.save(any())).thenReturn(Result.success(null)); + when(stockMovementRepository.save(any())).thenReturn(Result.success(null)); + + var result = bookProductionConsumption.execute(validCommand()); + + assertThat(result.isSuccess()).isTrue(); + verify(stockRepository).save(stock); + verify(stockMovementRepository).save(any(StockMovement.class)); + } + + @Test + @DisplayName("should fail when no stock exists for article") + void should_Fail_When_NoStockForArticle() { + when(stockRepository.findAllByArticleId(ArticleId.of("article-42"))) + .thenReturn(Result.success(List.of())); + + var result = bookProductionConsumption.execute(validCommand()); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionConsumptionError.StockNotFound.class); + verify(stockRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when batch not found in any stock") + void should_Fail_When_BatchNotInStock() { + var stock = stockWithoutMatchingBatch("article-42"); + when(stockRepository.findAllByArticleId(ArticleId.of("article-42"))) + .thenReturn(Result.success(List.of(stock))); + + var result = bookProductionConsumption.execute(validCommand()); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionConsumptionError.BatchNotFoundInStock.class); + verify(stockRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when removal quantity exceeds stock") + void should_Fail_When_QuantityExceedsStock() { + var stock = stockWithBatch("article-42", "P-2026-03-19-001", "3"); + when(stockRepository.findAllByArticleId(ArticleId.of("article-42"))) + .thenReturn(Result.success(List.of(stock))); + + var result = bookProductionConsumption.execute(validCommand()); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionConsumptionError.RemovalFailed.class); + verify(stockMovementRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail with RepositoryFailure when stock lookup fails") + void should_Fail_When_StockLookupFails() { + when(stockRepository.findAllByArticleId(any())) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = bookProductionConsumption.execute(validCommand()); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionConsumptionError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail when quantity amount is zero") + void should_Fail_When_ZeroQuantity() { + var stock = stockWithBatch("article-42", "P-2026-03-19-001", "50"); + when(stockRepository.findAllByArticleId(ArticleId.of("article-42"))) + .thenReturn(Result.success(List.of(stock))); + + var cmd = new BookProductionConsumptionCommand( + "P-2026-03-19-001", "article-42", "0", "KILOGRAM"); + + var result = bookProductionConsumption.execute(cmd); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionConsumptionError.RemovalFailed.class); + } + + @Test + @DisplayName("should fail when quantity unit is invalid") + void should_Fail_When_InvalidQuantityUnit() { + var stock = stockWithBatch("article-42", "P-2026-03-19-001", "50"); + when(stockRepository.findAllByArticleId(ArticleId.of("article-42"))) + .thenReturn(Result.success(List.of(stock))); + + var cmd = new BookProductionConsumptionCommand( + "P-2026-03-19-001", "article-42", "5", "INVALID_UNIT"); + + var result = bookProductionConsumption.execute(cmd); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionConsumptionError.RemovalFailed.class); + } + + @Test + @DisplayName("should fail with RepositoryFailure when movement save fails") + void should_Fail_When_MovementSaveFails() { + var stock = stockWithBatch("article-42", "P-2026-03-19-001", "50"); + when(stockRepository.findAllByArticleId(ArticleId.of("article-42"))) + .thenReturn(Result.success(List.of(stock))); + when(stockRepository.save(any())).thenReturn(Result.success(null)); + when(stockMovementRepository.save(any())) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("write error"))); + + var result = bookProductionConsumption.execute(validCommand()); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionConsumptionError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail with RepositoryFailure when stock save fails") + void should_Fail_When_StockSaveFails() { + var stock = stockWithBatch("article-42", "P-2026-03-19-001", "50"); + when(stockRepository.findAllByArticleId(ArticleId.of("article-42"))) + .thenReturn(Result.success(List.of(stock))); + when(stockRepository.save(any())) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("write error"))); + + var result = bookProductionConsumption.execute(validCommand()); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionConsumptionError.RepositoryFailure.class); + } +} diff --git a/backend/src/test/java/de/effigenix/application/production/RecordConsumptionTest.java b/backend/src/test/java/de/effigenix/application/production/RecordConsumptionTest.java index 387737a..65a5344 100644 --- a/backend/src/test/java/de/effigenix/application/production/RecordConsumptionTest.java +++ b/backend/src/test/java/de/effigenix/application/production/RecordConsumptionTest.java @@ -2,10 +2,12 @@ package de.effigenix.application.production; import de.effigenix.application.production.command.RecordConsumptionCommand; import de.effigenix.domain.production.*; +import de.effigenix.domain.production.event.ConsumptionRecorded; 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.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; @@ -33,6 +36,7 @@ class RecordConsumptionTest { @Mock private BatchRepository batchRepository; @Mock private AuthorizationPort authPort; + @Mock private DomainEventPublisher domainEventPublisher; @Mock private UnitOfWork unitOfWork; private RecordConsumption recordConsumption; @@ -40,7 +44,7 @@ class RecordConsumptionTest { @BeforeEach void setUp() { - recordConsumption = new RecordConsumption(batchRepository, authPort, unitOfWork); + recordConsumption = new RecordConsumption(batchRepository, authPort, domainEventPublisher, unitOfWork); performedBy = ActorId.of("admin-user"); lenient().when(unitOfWork.executeAtomically(any())).thenAnswer(inv -> ((Supplier) inv.getArgument(0)).get()); } @@ -191,6 +195,41 @@ class RecordConsumptionTest { assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class); } + @Test + @DisplayName("should publish ConsumptionRecorded event after successful consumption") + void should_PublishEvent_When_ConsumptionSucceeds() { + var batchId = BatchId.of("batch-1"); + var batch = inProductionBatch("batch-1"); + when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true); + when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch))); + when(batchRepository.save(any())).thenReturn(Result.success(null)); + + recordConsumption.execute(validCommand("batch-1"), performedBy); + + var captor = ArgumentCaptor.forClass(ConsumptionRecorded.class); + verify(domainEventPublisher).publish(captor.capture()); + var event = captor.getValue(); + assertThat(event.batchId()).isEqualTo(batchId); + assertThat(event.inputBatchId()).isEqualTo("input-batch-1"); + assertThat(event.articleId()).isEqualTo("article-1"); + assertThat(event.quantity().amount()).isEqualByComparingTo(new BigDecimal("5.0")); + assertThat(event.quantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM); + assertThat(event.occurredAt()).isNotNull(); + } + + @Test + @DisplayName("should not publish event when consumption fails") + void should_NotPublishEvent_When_ConsumptionFails() { + var batchId = BatchId.of("batch-1"); + var batch = plannedBatch("batch-1"); + when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true); + when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch))); + + recordConsumption.execute(validCommand("batch-1"), performedBy); + + verify(domainEventPublisher, never()).publish(any()); + } + @Test @DisplayName("should fail with RepositoryFailure on repository error during save") void should_FailWithRepositoryFailure_When_SaveFails() {