From 6504d3a54e3fa4e9cfbcf6af099e0e0294a9ae70 Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Wed, 25 Feb 2026 22:27:51 +0100 Subject: [PATCH] fix(production): cancelledReason im ProductionOrder-Aggregat persistieren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cancel() nimmt jetzt einen reason-Parameter entgegen und speichert ihn im Aggregat, wie im DDD-Modell (04-production-bc.md) spezifiziert. Liquibase-Migration für cancelled_reason-Spalte ergänzt. --- .../production/CancelProductionOrder.java | 2 +- .../domain/production/ProductionOrder.java | 17 +++++-- .../JdbcProductionOrderRepository.java | 12 +++-- .../web/dto/ProductionOrderResponse.java | 2 + ...-cancelled-reason-to-production-orders.xml | 16 +++++++ .../db/changelog/db.changelog-master.xml | 1 + .../production/CancelProductionOrderTest.java | 3 ++ .../CompleteProductionOrderTest.java | 6 ++- .../ReleaseProductionOrderTest.java | 2 + .../production/StartProductionOrderTest.java | 2 + .../production/ProductionOrderTest.java | 44 ++++++++++++++++--- ...ductionOrderControllerIntegrationTest.java | 6 ++- 12 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 backend/src/main/resources/db/changelog/changes/033-add-cancelled-reason-to-production-orders.xml diff --git a/backend/src/main/java/de/effigenix/application/production/CancelProductionOrder.java b/backend/src/main/java/de/effigenix/application/production/CancelProductionOrder.java index fa5b019..4c12213 100644 --- a/backend/src/main/java/de/effigenix/application/production/CancelProductionOrder.java +++ b/backend/src/main/java/de/effigenix/application/production/CancelProductionOrder.java @@ -42,7 +42,7 @@ public class CancelProductionOrder { } } - switch (order.cancel()) { + switch (order.cancel(cmd.reason())) { case Result.Failure(var err) -> { return Result.failure(err); } case Result.Success(var ignored) -> { } } diff --git a/backend/src/main/java/de/effigenix/domain/production/ProductionOrder.java b/backend/src/main/java/de/effigenix/domain/production/ProductionOrder.java index bca54ac..fe6bf44 100644 --- a/backend/src/main/java/de/effigenix/domain/production/ProductionOrder.java +++ b/backend/src/main/java/de/effigenix/domain/production/ProductionOrder.java @@ -23,7 +23,8 @@ import java.time.ZoneOffset; * 8. BatchId is set exactly once (null → non-null) during startProduction() * 9. BatchId must not already be assigned (BatchAlreadyAssigned) * 10. Only IN_PROGRESS → COMPLETED transition allowed via complete() (Batch must be COMPLETED – enforced by Use Case) - * 11. Only PLANNED or RELEASED → CANCELLED transition allowed via cancel() + * 11. Only PLANNED or RELEASED → CANCELLED transition allowed via cancel(reason) + * 12. CancelledReason is set exactly once during cancel() and must not be blank */ public class ProductionOrder { @@ -35,6 +36,7 @@ public class ProductionOrder { private final LocalDate plannedDate; private final Priority priority; private final String notes; + private String cancelledReason; private final OffsetDateTime createdAt; private OffsetDateTime updatedAt; private final long version; @@ -48,6 +50,7 @@ public class ProductionOrder { LocalDate plannedDate, Priority priority, String notes, + String cancelledReason, OffsetDateTime createdAt, OffsetDateTime updatedAt, long version @@ -60,6 +63,7 @@ public class ProductionOrder { this.plannedDate = plannedDate; this.priority = priority; this.notes = notes; + this.cancelledReason = cancelledReason; this.createdAt = createdAt; this.updatedAt = updatedAt; this.version = version; @@ -120,6 +124,7 @@ public class ProductionOrder { draft.plannedDate(), priority, draft.notes(), + null, now, now, 0L @@ -135,12 +140,13 @@ public class ProductionOrder { LocalDate plannedDate, Priority priority, String notes, + String cancelledReason, OffsetDateTime createdAt, OffsetDateTime updatedAt, long version ) { return new ProductionOrder(id, recipeId, status, batchId, plannedQuantity, plannedDate, - priority, notes, createdAt, updatedAt, version); + priority, notes, cancelledReason, createdAt, updatedAt, version); } public ProductionOrderId id() { return id; } @@ -151,6 +157,7 @@ public class ProductionOrder { public LocalDate plannedDate() { return plannedDate; } public Priority priority() { return priority; } public String notes() { return notes; } + public String cancelledReason() { return cancelledReason; } public OffsetDateTime createdAt() { return createdAt; } public OffsetDateTime updatedAt() { return updatedAt; } public long version() { return version; } @@ -186,11 +193,15 @@ public class ProductionOrder { return Result.success(null); } - public Result cancel() { + public Result cancel(String reason) { + if (reason == null || reason.isBlank()) { + return Result.failure(new ProductionOrderError.ValidationFailure("Cancellation reason must not be blank")); + } if (status != ProductionOrderStatus.PLANNED && status != ProductionOrderStatus.RELEASED) { return Result.failure(new ProductionOrderError.InvalidStatusTransition(status, ProductionOrderStatus.CANCELLED)); } this.status = ProductionOrderStatus.CANCELLED; + this.cancelledReason = reason; this.updatedAt = OffsetDateTime.now(ZoneOffset.UTC); return Result.success(null); } diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/JdbcProductionOrderRepository.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/JdbcProductionOrderRepository.java index b988080..512005f 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/JdbcProductionOrderRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/JdbcProductionOrderRepository.java @@ -62,7 +62,8 @@ public class JdbcProductionOrderRepository implements ProductionOrderRepository int rows = jdbc.sql(""" UPDATE production_orders SET status = :status, batch_id = :batchId, priority = :priority, - notes = :notes, updated_at = :updatedAt, version = version + 1 + notes = :notes, cancelled_reason = :cancelledReason, + updated_at = :updatedAt, version = version + 1 WHERE id = :id AND version = :version """) .param("id", order.id().value()) @@ -70,6 +71,7 @@ public class JdbcProductionOrderRepository implements ProductionOrderRepository .param("batchId", order.batchId() != null ? order.batchId().value() : null) .param("priority", order.priority().name()) .param("notes", order.notes()) + .param("cancelledReason", order.cancelledReason()) .param("updatedAt", order.updatedAt()) .param("version", order.version()) .update(); @@ -88,9 +90,11 @@ public class JdbcProductionOrderRepository implements ProductionOrderRepository jdbc.sql(""" INSERT INTO production_orders (id, recipe_id, status, planned_quantity_amount, planned_quantity_unit, - planned_date, priority, batch_id, notes, created_at, updated_at, version) + planned_date, priority, batch_id, notes, cancelled_reason, + created_at, updated_at, version) VALUES (:id, :recipeId, :status, :plannedQuantityAmount, :plannedQuantityUnit, - :plannedDate, :priority, :batchId, :notes, :createdAt, :updatedAt, 0) + :plannedDate, :priority, :batchId, :notes, :cancelledReason, + :createdAt, :updatedAt, 0) """) .param("id", order.id().value()) .param("recipeId", order.recipeId().value()) @@ -101,6 +105,7 @@ public class JdbcProductionOrderRepository implements ProductionOrderRepository .param("priority", order.priority().name()) .param("batchId", order.batchId() != null ? order.batchId().value() : null) .param("notes", order.notes()) + .param("cancelledReason", order.cancelledReason()) .param("createdAt", order.createdAt()) .param("updatedAt", order.updatedAt()) .update(); @@ -126,6 +131,7 @@ public class JdbcProductionOrderRepository implements ProductionOrderRepository rs.getObject("planned_date", java.time.LocalDate.class), Priority.valueOf(rs.getString("priority")), rs.getString("notes"), + rs.getString("cancelled_reason"), rs.getObject("created_at", OffsetDateTime.class), rs.getObject("updated_at", OffsetDateTime.class), rs.getLong("version") diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/ProductionOrderResponse.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/ProductionOrderResponse.java index 86cf609..42297a8 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/ProductionOrderResponse.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/ProductionOrderResponse.java @@ -15,6 +15,7 @@ public record ProductionOrderResponse( LocalDate plannedDate, String priority, String notes, + String cancelledReason, OffsetDateTime createdAt, OffsetDateTime updatedAt ) { @@ -29,6 +30,7 @@ public record ProductionOrderResponse( order.plannedDate(), order.priority().name(), order.notes(), + order.cancelledReason(), order.createdAt(), order.updatedAt() ); diff --git a/backend/src/main/resources/db/changelog/changes/033-add-cancelled-reason-to-production-orders.xml b/backend/src/main/resources/db/changelog/changes/033-add-cancelled-reason-to-production-orders.xml new file mode 100644 index 0000000..e9ac829 --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/033-add-cancelled-reason-to-production-orders.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/backend/src/main/resources/db/changelog/db.changelog-master.xml b/backend/src/main/resources/db/changelog/db.changelog-master.xml index 328975b..61150cc 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -37,5 +37,6 @@ + diff --git a/backend/src/test/java/de/effigenix/application/production/CancelProductionOrderTest.java b/backend/src/test/java/de/effigenix/application/production/CancelProductionOrderTest.java index 29de4ee..9fcbadc 100644 --- a/backend/src/test/java/de/effigenix/application/production/CancelProductionOrderTest.java +++ b/backend/src/test/java/de/effigenix/application/production/CancelProductionOrderTest.java @@ -61,6 +61,7 @@ class CancelProductionOrderTest { PLANNED_DATE, Priority.NORMAL, null, + null, OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), 1L @@ -79,6 +80,7 @@ class CancelProductionOrderTest { assertThat(result.isSuccess()).isTrue(); assertThat(result.unsafeGetValue().status()).isEqualTo(ProductionOrderStatus.CANCELLED); + assertThat(result.unsafeGetValue().cancelledReason()).isEqualTo("Kunde hat storniert"); verify(productionOrderRepository).save(any(ProductionOrder.class)); } @@ -94,6 +96,7 @@ class CancelProductionOrderTest { assertThat(result.isSuccess()).isTrue(); assertThat(result.unsafeGetValue().status()).isEqualTo(ProductionOrderStatus.CANCELLED); + assertThat(result.unsafeGetValue().cancelledReason()).isEqualTo("Kunde hat storniert"); verify(productionOrderRepository).save(any(ProductionOrder.class)); } diff --git a/backend/src/test/java/de/effigenix/application/production/CompleteProductionOrderTest.java b/backend/src/test/java/de/effigenix/application/production/CompleteProductionOrderTest.java index f0fb5ca..921b0c2 100644 --- a/backend/src/test/java/de/effigenix/application/production/CompleteProductionOrderTest.java +++ b/backend/src/test/java/de/effigenix/application/production/CompleteProductionOrderTest.java @@ -63,6 +63,7 @@ class CompleteProductionOrderTest { PLANNED_DATE, Priority.NORMAL, null, + null, OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), 1L @@ -79,6 +80,7 @@ class CompleteProductionOrderTest { PLANNED_DATE, Priority.NORMAL, null, + null, OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), 1L @@ -176,7 +178,7 @@ class CompleteProductionOrderTest { ProductionOrderId.of("order-1"), RecipeId.of("recipe-1"), ProductionOrderStatus.COMPLETED, BatchId.of("batch-1"), Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), - PLANNED_DATE, Priority.NORMAL, null, + PLANNED_DATE, Priority.NORMAL, null, null, OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), 1L); when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); @@ -199,7 +201,7 @@ class CompleteProductionOrderTest { ProductionOrderId.of("order-1"), RecipeId.of("recipe-1"), ProductionOrderStatus.CANCELLED, null, Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), - PLANNED_DATE, Priority.NORMAL, null, + PLANNED_DATE, Priority.NORMAL, null, "Storniert", OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), 1L); when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); diff --git a/backend/src/test/java/de/effigenix/application/production/ReleaseProductionOrderTest.java b/backend/src/test/java/de/effigenix/application/production/ReleaseProductionOrderTest.java index 0aa709b..c65766d 100644 --- a/backend/src/test/java/de/effigenix/application/production/ReleaseProductionOrderTest.java +++ b/backend/src/test/java/de/effigenix/application/production/ReleaseProductionOrderTest.java @@ -63,6 +63,7 @@ class ReleaseProductionOrderTest { PLANNED_DATE, Priority.NORMAL, null, + null, OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), 1L @@ -79,6 +80,7 @@ class ReleaseProductionOrderTest { PLANNED_DATE, Priority.NORMAL, null, + null, OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), 1L diff --git a/backend/src/test/java/de/effigenix/application/production/StartProductionOrderTest.java b/backend/src/test/java/de/effigenix/application/production/StartProductionOrderTest.java index f518b3e..ebb6935 100644 --- a/backend/src/test/java/de/effigenix/application/production/StartProductionOrderTest.java +++ b/backend/src/test/java/de/effigenix/application/production/StartProductionOrderTest.java @@ -63,6 +63,7 @@ class StartProductionOrderTest { PLANNED_DATE, Priority.NORMAL, null, + null, OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), 1L @@ -79,6 +80,7 @@ class StartProductionOrderTest { PLANNED_DATE, Priority.NORMAL, null, + null, OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), 1L diff --git a/backend/src/test/java/de/effigenix/domain/production/ProductionOrderTest.java b/backend/src/test/java/de/effigenix/domain/production/ProductionOrderTest.java index 0095071..0d55e14 100644 --- a/backend/src/test/java/de/effigenix/domain/production/ProductionOrderTest.java +++ b/backend/src/test/java/de/effigenix/domain/production/ProductionOrderTest.java @@ -310,6 +310,7 @@ class ProductionOrderTest { FUTURE_DATE, Priority.NORMAL, null, + null, OffsetDateTime.now(ZoneOffset.UTC).minusHours(1), OffsetDateTime.now(ZoneOffset.UTC).minusHours(1), 1L @@ -394,6 +395,7 @@ class ProductionOrderTest { FUTURE_DATE, Priority.NORMAL, null, + null, OffsetDateTime.now(ZoneOffset.UTC).minusHours(1), OffsetDateTime.now(ZoneOffset.UTC).minusHours(1), 1L @@ -481,6 +483,7 @@ class ProductionOrderTest { FUTURE_DATE, Priority.NORMAL, null, + null, OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), 1L @@ -518,6 +521,7 @@ class ProductionOrderTest { FUTURE_DATE, Priority.NORMAL, null, + null, OffsetDateTime.now(ZoneOffset.UTC).minusHours(1), OffsetDateTime.now(ZoneOffset.UTC).minusHours(1), 1L @@ -605,6 +609,7 @@ class ProductionOrderTest { FUTURE_DATE, Priority.NORMAL, null, + null, OffsetDateTime.now(ZoneOffset.UTC).minusHours(1), OffsetDateTime.now(ZoneOffset.UTC).minusHours(1), 1L @@ -612,37 +617,61 @@ class ProductionOrderTest { } @Test - @DisplayName("should cancel PLANNED order") + @DisplayName("should cancel PLANNED order with reason") void should_Cancel_When_Planned() { var order = orderWithStatus(ProductionOrderStatus.PLANNED); var beforeUpdate = order.updatedAt(); - var result = order.cancel(); + var result = order.cancel("Kunde hat storniert"); assertThat(result.isSuccess()).isTrue(); assertThat(order.status()).isEqualTo(ProductionOrderStatus.CANCELLED); + assertThat(order.cancelledReason()).isEqualTo("Kunde hat storniert"); assertThat(order.updatedAt()).isAfter(beforeUpdate); } @Test - @DisplayName("should cancel RELEASED order") + @DisplayName("should cancel RELEASED order with reason") void should_Cancel_When_Released() { var order = orderWithStatus(ProductionOrderStatus.RELEASED); var beforeUpdate = order.updatedAt(); - var result = order.cancel(); + var result = order.cancel("Material nicht verfügbar"); assertThat(result.isSuccess()).isTrue(); assertThat(order.status()).isEqualTo(ProductionOrderStatus.CANCELLED); + assertThat(order.cancelledReason()).isEqualTo("Material nicht verfügbar"); assertThat(order.updatedAt()).isAfter(beforeUpdate); } + @Test + @DisplayName("should fail when reason is blank") + void should_Fail_When_ReasonBlank() { + var order = orderWithStatus(ProductionOrderStatus.PLANNED); + + var result = order.cancel(""); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.ValidationFailure.class); + } + + @Test + @DisplayName("should fail when reason is null") + void should_Fail_When_ReasonNull() { + var order = orderWithStatus(ProductionOrderStatus.PLANNED); + + var result = order.cancel(null); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.ValidationFailure.class); + } + @Test @DisplayName("should fail when cancelling IN_PROGRESS order") void should_Fail_When_InProgress() { var order = orderWithStatus(ProductionOrderStatus.IN_PROGRESS); - var result = order.cancel(); + var result = order.cancel("Storniergrund"); assertThat(result.isFailure()).isTrue(); var err = (ProductionOrderError.InvalidStatusTransition) result.unsafeGetError(); @@ -655,7 +684,7 @@ class ProductionOrderTest { void should_Fail_When_Completed() { var order = orderWithStatus(ProductionOrderStatus.COMPLETED); - var result = order.cancel(); + var result = order.cancel("Storniergrund"); assertThat(result.isFailure()).isTrue(); var err = (ProductionOrderError.InvalidStatusTransition) result.unsafeGetError(); @@ -668,7 +697,7 @@ class ProductionOrderTest { void should_Fail_When_AlreadyCancelled() { var order = orderWithStatus(ProductionOrderStatus.CANCELLED); - var result = order.cancel(); + var result = order.cancel("Storniergrund"); assertThat(result.isFailure()).isTrue(); var err = (ProductionOrderError.InvalidStatusTransition) result.unsafeGetError(); @@ -693,6 +722,7 @@ class ProductionOrderTest { FUTURE_DATE, Priority.HIGH, "Wichtiger Auftrag", + null, OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), 5L diff --git a/backend/src/test/java/de/effigenix/infrastructure/production/web/ProductionOrderControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/production/web/ProductionOrderControllerIntegrationTest.java index 92099f4..1fe373f 100644 --- a/backend/src/test/java/de/effigenix/infrastructure/production/web/ProductionOrderControllerIntegrationTest.java +++ b/backend/src/test/java/de/effigenix/infrastructure/production/web/ProductionOrderControllerIntegrationTest.java @@ -684,7 +684,8 @@ class ProductionOrderControllerIntegrationTest extends AbstractIntegrationTest { .content(json)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(orderId)) - .andExpect(jsonPath("$.status").value("CANCELLED")); + .andExpect(jsonPath("$.status").value("CANCELLED")) + .andExpect(jsonPath("$.cancelledReason").value("Kunde hat storniert")); } @Test @@ -702,7 +703,8 @@ class ProductionOrderControllerIntegrationTest extends AbstractIntegrationTest { .content(json)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(orderId)) - .andExpect(jsonPath("$.status").value("CANCELLED")); + .andExpect(jsonPath("$.status").value("CANCELLED")) + .andExpect(jsonPath("$.cancelledReason").value("Material nicht verfügbar")); } @Test