1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 10:19:35 +01:00

fix(production): cancelledReason im ProductionOrder-Aggregat persistieren

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.
This commit is contained in:
Sebastian Frick 2026-02-25 22:27:51 +01:00
parent 0e58cbfacf
commit 6504d3a54e
12 changed files with 95 additions and 18 deletions

View file

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

View file

@ -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<ProductionOrderError, Void> cancel() {
public Result<ProductionOrderError, Void> 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);
}

View file

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

View file

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

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="033-add-cancelled-reason-to-production-orders" author="effigenix">
<addColumn tableName="production_orders">
<column name="cancelled_reason" type="VARCHAR(1000)">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>

View file

@ -37,5 +37,6 @@
<include file="db/changelog/changes/030-add-batch-id-index-to-stock-movements.xml"/>
<include file="db/changelog/changes/032-add-batch-id-to-production-orders.xml"/>
<include file="db/changelog/changes/033-add-cancelled-reason-to-production-orders.xml"/>
</databaseChangeLog>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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