1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 17:49:57 +01:00

feat(production): Produktion starten und Rohstoffverbrauch dokumentieren (StartBatch, RecordConsumption)

PLANNED-Chargen können in Produktion genommen werden (IN_PRODUCTION),
anschließend wird der Rohstoff-Verbrauch pro InputBatch dokumentiert.
Bildet die Grundlage für die Chargen-Genealogie (Tracing).
This commit is contained in:
Sebastian Frick 2026-02-20 12:15:06 +01:00
parent 8c042925eb
commit a9f5956812
31 changed files with 1733 additions and 11 deletions

View file

@ -18,6 +18,7 @@ import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@ -51,7 +52,8 @@ class FindBatchByNumberTest {
LocalDate.of(2026, 3, 1),
LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC)
OffsetDateTime.now(ZoneOffset.UTC),
List.of()
);
}

View file

@ -18,6 +18,7 @@ import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@ -49,7 +50,8 @@ class GetBatchTest {
LocalDate.of(2026, 3, 1),
LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC)
OffsetDateTime.now(ZoneOffset.UTC),
List.of()
);
}

View file

@ -54,7 +54,8 @@ class ListBatchesTest {
PRODUCTION_DATE,
LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC)
OffsetDateTime.now(ZoneOffset.UTC),
List.of()
);
}

View file

@ -0,0 +1,199 @@
package de.effigenix.application.production;
import de.effigenix.application.production.command.RecordConsumptionCommand;
import de.effigenix.domain.production.*;
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.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
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.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("RecordConsumption Use Case")
class RecordConsumptionTest {
@Mock private BatchRepository batchRepository;
@Mock private AuthorizationPort authPort;
private RecordConsumption recordConsumption;
private ActorId performedBy;
@BeforeEach
void setUp() {
recordConsumption = new RecordConsumption(batchRepository, authPort);
performedBy = ActorId.of("admin-user");
}
private Batch inProductionBatch(String id) {
return Batch.reconstitute(
BatchId.of(id),
BatchNumber.generate(LocalDate.of(2026, 3, 1), 1),
RecipeId.of("recipe-1"),
BatchStatus.IN_PRODUCTION,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
LocalDate.of(2026, 3, 1),
LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC),
List.of()
);
}
private Batch plannedBatch(String id) {
return Batch.reconstitute(
BatchId.of(id),
BatchNumber.generate(LocalDate.of(2026, 3, 1), 1),
RecipeId.of("recipe-1"),
BatchStatus.PLANNED,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
LocalDate.of(2026, 3, 1),
LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC),
List.of()
);
}
private RecordConsumptionCommand validCommand(String batchId) {
return new RecordConsumptionCommand(batchId, "input-batch-1", "article-1", "5.0", "KILOGRAM");
}
@Test
@DisplayName("should record consumption when batch is IN_PRODUCTION")
void should_RecordConsumption_When_InProduction() {
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));
var result = recordConsumption.execute(validCommand("batch-1"), performedBy);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().inputBatchId().value()).isEqualTo("input-batch-1");
assertThat(result.unsafeGetValue().articleId().value()).isEqualTo("article-1");
verify(batchRepository).save(batch);
}
@Test
@DisplayName("should fail when batch not found")
void should_Fail_When_BatchNotFound() {
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
when(batchRepository.findById(any())).thenReturn(Result.success(Optional.empty()));
var result = recordConsumption.execute(validCommand("nonexistent"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.BatchNotFound.class);
verify(batchRepository, never()).save(any());
}
@Test
@DisplayName("should fail when batch is not IN_PRODUCTION")
void should_Fail_When_NotInProduction() {
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)));
var result = recordConsumption.execute(validCommand("batch-1"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.NotInProduction.class);
verify(batchRepository, never()).save(any());
}
@Test
@DisplayName("should fail when duplicate inputBatchId")
void should_Fail_When_DuplicateInputBatch() {
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));
// First consumption succeeds
recordConsumption.execute(validCommand("batch-1"), performedBy);
// Second with same inputBatchId fails
var result = recordConsumption.execute(validCommand("batch-1"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.DuplicateInputBatch.class);
}
@Test
@DisplayName("should fail when consumption quantity is invalid")
void should_Fail_When_InvalidQuantity() {
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)));
var cmd = new RecordConsumptionCommand("batch-1", "input-1", "article-1", "0", "KILOGRAM");
var result = recordConsumption.execute(cmd, performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidConsumptionQuantity.class);
verify(batchRepository, never()).save(any());
}
@Test
@DisplayName("should fail with Unauthorized when actor lacks permission")
void should_FailWithUnauthorized_When_ActorLacksPermission() {
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(false);
var result = recordConsumption.execute(validCommand("batch-1"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.Unauthorized.class);
verify(batchRepository, never()).findById(any());
}
@Test
@DisplayName("should fail with RepositoryFailure on repository error during find")
void should_FailWithRepositoryFailure_When_FindFails() {
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
when(batchRepository.findById(any()))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = recordConsumption.execute(validCommand("batch-1"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail with RepositoryFailure on repository error during save")
void should_FailWithRepositoryFailure_When_SaveFails() {
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.failure(new RepositoryError.DatabaseError("write error")));
var result = recordConsumption.execute(validCommand("batch-1"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
}
}

View file

@ -0,0 +1,159 @@
package de.effigenix.application.production;
import de.effigenix.application.production.command.StartBatchCommand;
import de.effigenix.domain.production.*;
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.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
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.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("StartBatch Use Case")
class StartBatchTest {
@Mock private BatchRepository batchRepository;
@Mock private AuthorizationPort authPort;
private StartBatch startBatch;
private ActorId performedBy;
@BeforeEach
void setUp() {
startBatch = new StartBatch(batchRepository, authPort);
performedBy = ActorId.of("admin-user");
}
private Batch plannedBatch(String id) {
return Batch.reconstitute(
BatchId.of(id),
BatchNumber.generate(LocalDate.of(2026, 3, 1), 1),
RecipeId.of("recipe-1"),
BatchStatus.PLANNED,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
LocalDate.of(2026, 3, 1),
LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC),
List.of()
);
}
private Batch inProductionBatch(String id) {
return Batch.reconstitute(
BatchId.of(id),
BatchNumber.generate(LocalDate.of(2026, 3, 1), 1),
RecipeId.of("recipe-1"),
BatchStatus.IN_PRODUCTION,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
LocalDate.of(2026, 3, 1),
LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC),
List.of()
);
}
@Test
@DisplayName("should start batch when PLANNED")
void should_StartBatch_When_Planned() {
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)));
when(batchRepository.save(any())).thenReturn(Result.success(null));
var result = startBatch.execute(new StartBatchCommand("batch-1"), performedBy);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().status()).isEqualTo(BatchStatus.IN_PRODUCTION);
verify(batchRepository).save(batch);
}
@Test
@DisplayName("should fail when batch not found")
void should_Fail_When_BatchNotFound() {
var batchId = BatchId.of("nonexistent");
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.empty()));
var result = startBatch.execute(new StartBatchCommand("nonexistent"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.BatchNotFound.class);
verify(batchRepository, never()).save(any());
}
@Test
@DisplayName("should fail when batch already IN_PRODUCTION")
void should_Fail_When_AlreadyInProduction() {
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)));
var result = startBatch.execute(new StartBatchCommand("batch-1"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class);
verify(batchRepository, never()).save(any());
}
@Test
@DisplayName("should fail with Unauthorized when actor lacks permission")
void should_FailWithUnauthorized_When_ActorLacksPermission() {
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(false);
var result = startBatch.execute(new StartBatchCommand("batch-1"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.Unauthorized.class);
verify(batchRepository, never()).findById(any());
}
@Test
@DisplayName("should fail with RepositoryFailure on repository error during find")
void should_FailWithRepositoryFailure_When_FindFails() {
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
when(batchRepository.findById(any()))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = startBatch.execute(new StartBatchCommand("batch-1"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail with RepositoryFailure on repository error during save")
void should_FailWithRepositoryFailure_When_SaveFails() {
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)));
when(batchRepository.save(any()))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("write error")));
var result = startBatch.execute(new StartBatchCommand("batch-1"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
}
}

View file

@ -10,6 +10,7 @@ import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@ -173,6 +174,194 @@ class BatchTest {
}
}
@Nested
@DisplayName("startProduction()")
class StartProduction {
@Test
@DisplayName("should transition PLANNED to IN_PRODUCTION")
void should_TransitionToInProduction_When_Planned() {
var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
assertThat(batch.status()).isEqualTo(BatchStatus.PLANNED);
var result = batch.startProduction();
assertThat(result.isSuccess()).isTrue();
assertThat(batch.status()).isEqualTo(BatchStatus.IN_PRODUCTION);
}
@Test
@DisplayName("should update updatedAt on transition")
void should_UpdateTimestamp_When_StartingProduction() {
var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
var beforeUpdate = batch.updatedAt();
batch.startProduction();
assertThat(batch.updatedAt()).isAfterOrEqualTo(beforeUpdate);
}
@Test
@DisplayName("should fail when already IN_PRODUCTION")
void should_Fail_When_AlreadyInProduction() {
var batch = Batch.reconstitute(
BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"),
BatchStatus.IN_PRODUCTION,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
List.of()
);
var result = batch.startProduction();
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class);
var err = (BatchError.InvalidStatusTransition) result.unsafeGetError();
assertThat(err.current()).isEqualTo(BatchStatus.IN_PRODUCTION);
assertThat(err.target()).isEqualTo(BatchStatus.IN_PRODUCTION);
}
@Test
@DisplayName("should fail when COMPLETED")
void should_Fail_When_Completed() {
var batch = Batch.reconstitute(
BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"),
BatchStatus.COMPLETED,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
List.of()
);
var result = batch.startProduction();
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class);
}
@Test
@DisplayName("should fail when CANCELLED")
void should_Fail_When_Cancelled() {
var batch = Batch.reconstitute(
BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"),
BatchStatus.CANCELLED,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
List.of()
);
var result = batch.startProduction();
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class);
}
}
@Nested
@DisplayName("recordConsumption()")
class RecordConsumption {
private Batch inProductionBatch() {
var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
batch.startProduction();
return batch;
}
@Test
@DisplayName("should record consumption when IN_PRODUCTION")
void should_RecordConsumption_When_InProduction() {
var batch = inProductionBatch();
var draft = new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM");
var result = batch.recordConsumption(draft);
assertThat(result.isSuccess()).isTrue();
assertThat(batch.consumptions()).hasSize(1);
assertThat(result.unsafeGetValue().inputBatchId().value()).isEqualTo("input-1");
}
@Test
@DisplayName("should record multiple different consumptions")
void should_RecordMultiple_When_DifferentInputBatches() {
var batch = inProductionBatch();
batch.recordConsumption(new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM"));
batch.recordConsumption(new ConsumptionDraft("input-2", "article-2", "3.0", "LITER"));
assertThat(batch.consumptions()).hasSize(2);
}
@Test
@DisplayName("should fail when not IN_PRODUCTION (PLANNED)")
void should_Fail_When_Planned() {
var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
var draft = new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM");
var result = batch.recordConsumption(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.NotInProduction.class);
}
@Test
@DisplayName("should fail when not IN_PRODUCTION (COMPLETED)")
void should_Fail_When_Completed() {
var batch = Batch.reconstitute(
BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"),
BatchStatus.COMPLETED,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
List.of()
);
var draft = new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM");
var result = batch.recordConsumption(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.NotInProduction.class);
}
@Test
@DisplayName("should fail when duplicate inputBatchId")
void should_Fail_When_DuplicateInputBatch() {
var batch = inProductionBatch();
batch.recordConsumption(new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM"));
var result = batch.recordConsumption(
new ConsumptionDraft("input-1", "article-2", "3.0", "LITER"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.DuplicateInputBatch.class);
assertThat(batch.consumptions()).hasSize(1);
}
@Test
@DisplayName("should fail when consumption quantity is invalid")
void should_Fail_When_InvalidQuantity() {
var batch = inProductionBatch();
var draft = new ConsumptionDraft("input-1", "article-1", "0", "KILOGRAM");
var result = batch.recordConsumption(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidConsumptionQuantity.class);
assertThat(batch.consumptions()).isEmpty();
}
@Test
@DisplayName("should update updatedAt when recording consumption")
void should_UpdateTimestamp_When_RecordingConsumption() {
var batch = inProductionBatch();
var beforeRecord = batch.updatedAt();
batch.recordConsumption(new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM"));
assertThat(batch.updatedAt()).isAfterOrEqualTo(beforeRecord);
}
}
@Nested
@DisplayName("reconstitute()")
class Reconstitute {
@ -189,7 +378,8 @@ class BatchTest {
PRODUCTION_DATE,
BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC)
OffsetDateTime.now(ZoneOffset.UTC),
List.of()
);
assertThat(batch.id().value()).isEqualTo("batch-1");

View file

@ -0,0 +1,125 @@
package de.effigenix.domain.production;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Consumption Entity")
class ConsumptionTest {
private ConsumptionDraft validDraft() {
return new ConsumptionDraft("input-batch-1", "article-1", "10.5", "KILOGRAM");
}
@Nested
@DisplayName("create()")
class Create {
@Test
@DisplayName("should create consumption with valid draft")
void should_CreateConsumption_When_ValidDraft() {
var result = Consumption.create(validDraft());
assertThat(result.isSuccess()).isTrue();
var consumption = result.unsafeGetValue();
assertThat(consumption.id()).isNotNull();
assertThat(consumption.inputBatchId().value()).isEqualTo("input-batch-1");
assertThat(consumption.articleId().value()).isEqualTo("article-1");
assertThat(consumption.quantityUsed().amount()).isEqualByComparingTo(new BigDecimal("10.5"));
assertThat(consumption.quantityUsed().uom().name()).isEqualTo("KILOGRAM");
assertThat(consumption.consumedAt()).isNotNull();
}
@Test
@DisplayName("should fail when inputBatchId is blank")
void should_Fail_When_InputBatchIdBlank() {
var draft = new ConsumptionDraft("", "article-1", "10", "KILOGRAM");
var result = Consumption.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when inputBatchId is null")
void should_Fail_When_InputBatchIdNull() {
var draft = new ConsumptionDraft(null, "article-1", "10", "KILOGRAM");
var result = Consumption.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when articleId is blank")
void should_Fail_When_ArticleIdBlank() {
var draft = new ConsumptionDraft("input-batch-1", "", "10", "KILOGRAM");
var result = Consumption.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when articleId is null")
void should_Fail_When_ArticleIdNull() {
var draft = new ConsumptionDraft("input-batch-1", null, "10", "KILOGRAM");
var result = Consumption.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when quantity is zero")
void should_Fail_When_QuantityZero() {
var draft = new ConsumptionDraft("input-batch-1", "article-1", "0", "KILOGRAM");
var result = Consumption.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidConsumptionQuantity.class);
}
@Test
@DisplayName("should fail when quantity is negative")
void should_Fail_When_QuantityNegative() {
var draft = new ConsumptionDraft("input-batch-1", "article-1", "-5", "KILOGRAM");
var result = Consumption.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidConsumptionQuantity.class);
}
@Test
@DisplayName("should fail when quantity is not a number")
void should_Fail_When_QuantityNotANumber() {
var draft = new ConsumptionDraft("input-batch-1", "article-1", "abc", "KILOGRAM");
var result = Consumption.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidConsumptionQuantity.class);
}
@Test
@DisplayName("should fail when unit is invalid")
void should_Fail_When_UnitInvalid() {
var draft = new ConsumptionDraft("input-batch-1", "article-1", "10", "INVALID_UNIT");
var result = Consumption.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidConsumptionQuantity.class);
}
}
}

View file

@ -0,0 +1,306 @@
package de.effigenix.infrastructure.production.web;
import de.effigenix.domain.usermanagement.RoleName;
import de.effigenix.infrastructure.AbstractIntegrationTest;
import de.effigenix.infrastructure.production.web.dto.PlanBatchRequest;
import de.effigenix.infrastructure.production.web.dto.RecordConsumptionRequest;
import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity;
import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import java.time.LocalDate;
import java.util.Set;
import java.util.UUID;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@DisplayName("RecordConsumption Integration Tests")
class RecordConsumptionIntegrationTest extends AbstractIntegrationTest {
private String adminToken;
private String viewerToken;
private static final LocalDate PRODUCTION_DATE = LocalDate.of(2026, 3, 1);
private static final LocalDate BEST_BEFORE_DATE = LocalDate.of(2026, 6, 1);
@BeforeEach
void setUp() throws Exception {
RoleEntity adminRole = createRole(RoleName.ADMIN, "Admin");
RoleEntity viewerRole = createRole(RoleName.PRODUCTION_WORKER, "Viewer");
UserEntity admin = createUser("cons.admin", "cons.admin@test.com", Set.of(adminRole), "BRANCH-01");
UserEntity viewer = createUser("cons.viewer", "cons.viewer@test.com", Set.of(viewerRole), "BRANCH-01");
adminToken = generateToken(admin.getId(), "cons.admin", "BATCH_WRITE,BATCH_READ,RECIPE_WRITE,RECIPE_READ");
viewerToken = generateToken(viewer.getId(), "cons.viewer", "USER_READ");
}
@Nested
@DisplayName("POST /api/production/batches/{id}/consumptions Verbrauch dokumentieren")
class RecordConsumptionEndpoint {
@Test
@DisplayName("Verbrauch dokumentieren bei IN_PRODUCTION → 201")
void recordConsumption_withInProductionBatch_returns201() throws Exception {
String batchId = createInProductionBatch();
var request = new RecordConsumptionRequest(
UUID.randomUUID().toString(), UUID.randomUUID().toString(), "10.5", "KILOGRAM");
mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isNotEmpty())
.andExpect(jsonPath("$.inputBatchId").value(request.inputBatchId()))
.andExpect(jsonPath("$.articleId").value(request.articleId()))
.andExpect(jsonPath("$.quantityUsed").value("10.500000"))
.andExpect(jsonPath("$.quantityUsedUnit").value("KILOGRAM"))
.andExpect(jsonPath("$.consumedAt").isNotEmpty());
}
@Test
@DisplayName("Verbrauch wird in BatchResponse angezeigt")
void recordConsumption_visibleInBatchResponse() throws Exception {
String batchId = createInProductionBatch();
String inputBatchId = UUID.randomUUID().toString();
var request = new RecordConsumptionRequest(inputBatchId, UUID.randomUUID().toString(), "5.0", "LITER");
mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
mockMvc.perform(get("/api/production/batches/{id}", batchId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.consumptions").isArray())
.andExpect(jsonPath("$.consumptions.length()").value(1))
.andExpect(jsonPath("$.consumptions[0].inputBatchId").value(inputBatchId));
}
@Test
@DisplayName("Mehrere Verbrauchseinträge möglich")
void recordConsumption_multipleConsumptions() throws Exception {
String batchId = createInProductionBatch();
var request1 = new RecordConsumptionRequest(
UUID.randomUUID().toString(), UUID.randomUUID().toString(), "5.0", "KILOGRAM");
var request2 = new RecordConsumptionRequest(
UUID.randomUUID().toString(), UUID.randomUUID().toString(), "3.0", "LITER");
mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request1)))
.andExpect(status().isCreated());
mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request2)))
.andExpect(status().isCreated());
mockMvc.perform(get("/api/production/batches/{id}", batchId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(jsonPath("$.consumptions.length()").value(2));
}
@Test
@DisplayName("Doppelte InputBatchId → 409")
void recordConsumption_duplicateInputBatch_returns409() throws Exception {
String batchId = createInProductionBatch();
String inputBatchId = UUID.randomUUID().toString();
var request = new RecordConsumptionRequest(inputBatchId, UUID.randomUUID().toString(), "5.0", "KILOGRAM");
mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("BATCH_DUPLICATE_INPUT_BATCH"));
}
@Test
@DisplayName("Charge nicht IN_PRODUCTION (PLANNED) → 409")
void recordConsumption_plannedBatch_returns409() throws Exception {
String batchId = createPlannedBatch();
var request = new RecordConsumptionRequest(
UUID.randomUUID().toString(), UUID.randomUUID().toString(), "5.0", "KILOGRAM");
mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("BATCH_NOT_IN_PRODUCTION"));
}
@Test
@DisplayName("Ungültige Menge (0) → 400")
void recordConsumption_zeroQuantity_returns400() throws Exception {
String batchId = createInProductionBatch();
var request = new RecordConsumptionRequest(
UUID.randomUUID().toString(), UUID.randomUUID().toString(), "0", "KILOGRAM");
mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BATCH_INVALID_CONSUMPTION_QUANTITY"));
}
@Test
@DisplayName("Ungültige Unit → 400")
void recordConsumption_invalidUnit_returns400() throws Exception {
String batchId = createInProductionBatch();
var request = new RecordConsumptionRequest(
UUID.randomUUID().toString(), UUID.randomUUID().toString(), "5.0", "INVALID_UNIT");
mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BATCH_INVALID_CONSUMPTION_QUANTITY"));
}
@Test
@DisplayName("Charge nicht gefunden → 404")
void recordConsumption_batchNotFound_returns404() throws Exception {
var request = new RecordConsumptionRequest(
UUID.randomUUID().toString(), UUID.randomUUID().toString(), "5.0", "KILOGRAM");
mockMvc.perform(post("/api/production/batches/{id}/consumptions", UUID.randomUUID().toString())
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("BATCH_NOT_FOUND"));
}
@Test
@DisplayName("Leere Pflichtfelder → 400 (Bean Validation)")
void recordConsumption_blankFields_returns400() throws Exception {
var request = new RecordConsumptionRequest("", "", "", "");
mockMvc.perform(post("/api/production/batches/{id}/consumptions", UUID.randomUUID().toString())
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
}
@Nested
@DisplayName("Authorization")
class AuthTests {
@Test
@DisplayName("Ohne BATCH_WRITE → 403")
void recordConsumption_withViewerToken_returns403() throws Exception {
var request = new RecordConsumptionRequest(
UUID.randomUUID().toString(), UUID.randomUUID().toString(), "5.0", "KILOGRAM");
mockMvc.perform(post("/api/production/batches/{id}/consumptions", UUID.randomUUID().toString())
.header("Authorization", "Bearer " + viewerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("Ohne Token → 401")
void recordConsumption_withoutToken_returns401() throws Exception {
var request = new RecordConsumptionRequest(
UUID.randomUUID().toString(), UUID.randomUUID().toString(), "5.0", "KILOGRAM");
mockMvc.perform(post("/api/production/batches/{id}/consumptions", UUID.randomUUID().toString())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isUnauthorized());
}
}
// ==================== Hilfsmethoden ====================
private String createPlannedBatch() throws Exception {
String recipeId = createActiveRecipe();
var request = new PlanBatchRequest(recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
var result = mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andReturn();
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
}
private String createInProductionBatch() throws Exception {
String batchId = createPlannedBatch();
mockMvc.perform(post("/api/production/batches/{id}/start", batchId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk());
return batchId;
}
private String createActiveRecipe() throws Exception {
String json = """
{
"name": "Test-Rezept-%s",
"version": 1,
"type": "FINISHED_PRODUCT",
"description": "Testrezept",
"yieldPercentage": 85,
"shelfLifeDays": 14,
"outputQuantity": "100",
"outputUom": "KILOGRAM",
"articleId": "article-123"
}
""".formatted(UUID.randomUUID().toString().substring(0, 8));
var result = mockMvc.perform(post("/api/recipes")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isCreated())
.andReturn();
String recipeId = objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
String ingredientJson = """
{"position": 1, "articleId": "%s", "quantity": "5.5", "uom": "KILOGRAM", "substitutable": false}
""".formatted(UUID.randomUUID().toString());
mockMvc.perform(post("/api/recipes/{id}/ingredients", recipeId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(ingredientJson))
.andExpect(status().isCreated());
mockMvc.perform(post("/api/recipes/{id}/activate", recipeId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk());
return recipeId;
}
}

View file

@ -0,0 +1,165 @@
package de.effigenix.infrastructure.production.web;
import de.effigenix.domain.usermanagement.RoleName;
import de.effigenix.infrastructure.AbstractIntegrationTest;
import de.effigenix.infrastructure.production.web.dto.PlanBatchRequest;
import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity;
import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import java.time.LocalDate;
import java.util.Set;
import java.util.UUID;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@DisplayName("StartBatch Integration Tests")
class StartBatchIntegrationTest extends AbstractIntegrationTest {
private String adminToken;
private String viewerToken;
private static final LocalDate PRODUCTION_DATE = LocalDate.of(2026, 3, 1);
private static final LocalDate BEST_BEFORE_DATE = LocalDate.of(2026, 6, 1);
@BeforeEach
void setUp() throws Exception {
RoleEntity adminRole = createRole(RoleName.ADMIN, "Admin");
RoleEntity viewerRole = createRole(RoleName.PRODUCTION_WORKER, "Viewer");
UserEntity admin = createUser("start.admin", "start.admin@test.com", Set.of(adminRole), "BRANCH-01");
UserEntity viewer = createUser("start.viewer", "start.viewer@test.com", Set.of(viewerRole), "BRANCH-01");
adminToken = generateToken(admin.getId(), "start.admin", "BATCH_WRITE,BATCH_READ,RECIPE_WRITE,RECIPE_READ");
viewerToken = generateToken(viewer.getId(), "start.viewer", "USER_READ");
}
@Nested
@DisplayName("POST /api/production/batches/{id}/start Produktion starten")
class StartBatchEndpoint {
@Test
@DisplayName("PLANNED Charge starten → 200 mit IN_PRODUCTION Status")
void startBatch_withPlannedBatch_returns200() throws Exception {
String batchId = createPlannedBatch();
mockMvc.perform(post("/api/production/batches/{id}/start", batchId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(batchId))
.andExpect(jsonPath("$.status").value("IN_PRODUCTION"))
.andExpect(jsonPath("$.consumptions").isArray())
.andExpect(jsonPath("$.consumptions").isEmpty());
}
@Test
@DisplayName("Bereits gestartete Charge → 409")
void startBatch_alreadyInProduction_returns409() throws Exception {
String batchId = createPlannedBatch();
// Start once
mockMvc.perform(post("/api/production/batches/{id}/start", batchId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk());
// Start again
mockMvc.perform(post("/api/production/batches/{id}/start", batchId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("BATCH_INVALID_STATUS_TRANSITION"));
}
@Test
@DisplayName("Charge nicht gefunden → 404")
void startBatch_notFound_returns404() throws Exception {
mockMvc.perform(post("/api/production/batches/{id}/start", UUID.randomUUID().toString())
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("BATCH_NOT_FOUND"));
}
}
@Nested
@DisplayName("Authorization")
class AuthTests {
@Test
@DisplayName("Ohne BATCH_WRITE → 403")
void startBatch_withViewerToken_returns403() throws Exception {
mockMvc.perform(post("/api/production/batches/{id}/start", UUID.randomUUID().toString())
.header("Authorization", "Bearer " + viewerToken))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("Ohne Token → 401")
void startBatch_withoutToken_returns401() throws Exception {
mockMvc.perform(post("/api/production/batches/{id}/start", UUID.randomUUID().toString()))
.andExpect(status().isUnauthorized());
}
}
// ==================== Hilfsmethoden ====================
private String createPlannedBatch() throws Exception {
String recipeId = createActiveRecipe();
var request = new PlanBatchRequest(
recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
var result = mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andReturn();
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
}
private String createActiveRecipe() throws Exception {
String json = """
{
"name": "Test-Rezept-%s",
"version": 1,
"type": "FINISHED_PRODUCT",
"description": "Testrezept",
"yieldPercentage": 85,
"shelfLifeDays": 14,
"outputQuantity": "100",
"outputUom": "KILOGRAM",
"articleId": "article-123"
}
""".formatted(UUID.randomUUID().toString().substring(0, 8));
var result = mockMvc.perform(post("/api/recipes")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isCreated())
.andReturn();
String recipeId = objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
String ingredientJson = """
{"position": 1, "articleId": "%s", "quantity": "5.5", "uom": "KILOGRAM", "substitutable": false}
""".formatted(UUID.randomUUID().toString());
mockMvc.perform(post("/api/recipes/{id}/ingredients", recipeId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(ingredientJson))
.andExpect(status().isCreated());
mockMvc.perform(post("/api/recipes/{id}/activate", recipeId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk());
return recipeId;
}
}