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

feat(production): Charge abschließen (CompleteBatch)

Batch.complete() mit Ist-Menge, Ausschuss und Bemerkungen.
Invarianten: nur IN_PRODUCTION→COMPLETED, mind. eine Consumption,
ActualQuantity > 0, Waste >= 0. Full Vertical Slice mit Domain Event
Stub (BatchCompleted), REST POST /api/batches/{id}/complete und
Liquibase-Migration für die neuen Spalten.
This commit is contained in:
Sebastian Frick 2026-02-23 13:35:30 +01:00
parent f63790c058
commit a08e4194ab
23 changed files with 1138 additions and 9 deletions

View file

@ -0,0 +1,63 @@
package de.effigenix.application.production;
import de.effigenix.application.production.command.CompleteBatchCommand;
import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public class CompleteBatch {
private final BatchRepository batchRepository;
private final AuthorizationPort authorizationPort;
public CompleteBatch(BatchRepository batchRepository, AuthorizationPort authorizationPort) {
this.batchRepository = batchRepository;
this.authorizationPort = authorizationPort;
}
public Result<BatchError, Batch> execute(CompleteBatchCommand cmd, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.BATCH_COMPLETE)) {
return Result.failure(new BatchError.Unauthorized("Not authorized to complete batches"));
}
var batchId = BatchId.of(cmd.batchId());
Batch batch;
switch (batchRepository.findById(batchId)) {
case Result.Failure(var err) -> {
return Result.failure(new BatchError.RepositoryFailure(err.message()));
}
case Result.Success(var opt) -> {
if (opt.isEmpty()) {
return Result.failure(new BatchError.BatchNotFound(batchId));
}
batch = opt.get();
}
}
var draft = new CompleteBatchDraft(
cmd.actualQuantity(),
cmd.actualQuantityUnit(),
cmd.waste(),
cmd.wasteUnit(),
cmd.remarks()
);
switch (batch.complete(draft)) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var ignored) -> { }
}
switch (batchRepository.save(batch)) {
case Result.Failure(var err) -> {
return Result.failure(new BatchError.RepositoryFailure(err.message()));
}
case Result.Success(var ignored) -> { }
}
return Result.success(batch);
}
}

View file

@ -0,0 +1,10 @@
package de.effigenix.application.production.command;
public record CompleteBatchCommand(
String batchId,
String actualQuantity,
String actualQuantityUnit,
String waste,
String wasteUnit,
String remarks
) {}

View file

@ -25,6 +25,10 @@ import java.util.List;
* 7. recordConsumption() only allowed in IN_PRODUCTION status
* 8. No duplicate inputBatchId within consumptions
* 9. Consumption quantity must be positive
* 10. complete() only allowed from IN_PRODUCTION status
* 11. At least one Consumption required before completion
* 12. ActualQuantity must be positive
* 13. Waste must be zero or positive
*/
public class Batch {
@ -33,10 +37,14 @@ public class Batch {
private final RecipeId recipeId;
private BatchStatus status;
private final Quantity plannedQuantity;
private Quantity actualQuantity;
private Quantity waste;
private String remarks;
private final LocalDate productionDate;
private final LocalDate bestBeforeDate;
private final OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
private OffsetDateTime completedAt;
private final long version;
private final List<Consumption> consumptions;
@ -46,10 +54,14 @@ public class Batch {
RecipeId recipeId,
BatchStatus status,
Quantity plannedQuantity,
Quantity actualQuantity,
Quantity waste,
String remarks,
LocalDate productionDate,
LocalDate bestBeforeDate,
OffsetDateTime createdAt,
OffsetDateTime updatedAt,
OffsetDateTime completedAt,
long version,
List<Consumption> consumptions
) {
@ -58,10 +70,14 @@ public class Batch {
this.recipeId = recipeId;
this.status = status;
this.plannedQuantity = plannedQuantity;
this.actualQuantity = actualQuantity;
this.waste = waste;
this.remarks = remarks;
this.productionDate = productionDate;
this.bestBeforeDate = bestBeforeDate;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
this.completedAt = completedAt;
this.version = version;
this.consumptions = consumptions;
}
@ -109,10 +125,14 @@ public class Batch {
RecipeId.of(draft.recipeId()),
BatchStatus.PLANNED,
plannedQuantity,
null,
null,
null,
draft.productionDate(),
draft.bestBeforeDate(),
now,
now,
null,
0L,
new ArrayList<>()
));
@ -127,6 +147,74 @@ public class Batch {
return Result.success(null);
}
public Result<BatchError, Void> complete(CompleteBatchDraft draft) {
if (status != BatchStatus.IN_PRODUCTION) {
return Result.failure(new BatchError.InvalidStatusTransition(status, BatchStatus.COMPLETED));
}
if (consumptions.isEmpty()) {
return Result.failure(new BatchError.MissingConsumptions(id));
}
Quantity actualQty;
switch (parseQuantity(draft.actualQuantity(), draft.actualQuantityUnit(), BatchError.InvalidActualQuantity::new)) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var qty) -> actualQty = qty;
}
Quantity wasteQty;
switch (parseWasteQuantity(draft.waste(), draft.wasteUnit())) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var qty) -> wasteQty = qty;
}
this.actualQuantity = actualQty;
this.waste = wasteQty;
this.remarks = draft.remarks();
this.status = BatchStatus.COMPLETED;
var now = OffsetDateTime.now(ZoneOffset.UTC);
this.completedAt = now;
this.updatedAt = now;
return Result.success(null);
}
private Result<BatchError, Quantity> parseQuantity(String amountStr, String unitStr,
java.util.function.Function<String, BatchError> errorFactory) {
try {
var amount = new BigDecimal(amountStr);
var uom = UnitOfMeasure.valueOf(unitStr);
switch (Quantity.of(amount, uom)) {
case Result.Failure(var err) -> { return Result.failure(errorFactory.apply(err.toString())); }
case Result.Success(var qty) -> { return Result.success(qty); }
}
} catch (NumberFormatException e) {
return Result.failure(errorFactory.apply("Invalid amount format: " + amountStr));
} catch (IllegalArgumentException e) {
return Result.failure(errorFactory.apply("Invalid unit: " + unitStr));
}
}
private Result<BatchError, Quantity> parseWasteQuantity(String amountStr, String unitStr) {
try {
var amount = new BigDecimal(amountStr);
if (amount.compareTo(BigDecimal.ZERO) < 0) {
return Result.failure(new BatchError.InvalidWaste("Waste must be zero or positive"));
}
var uom = UnitOfMeasure.valueOf(unitStr);
if (amount.compareTo(BigDecimal.ZERO) == 0) {
return Result.success(Quantity.reconstitute(amount, uom));
}
switch (Quantity.of(amount, uom)) {
case Result.Failure(var err) -> { return Result.failure(new BatchError.InvalidWaste(err.toString())); }
case Result.Success(var qty) -> { return Result.success(qty); }
}
} catch (NumberFormatException e) {
return Result.failure(new BatchError.InvalidWaste("Invalid amount format: " + amountStr));
} catch (IllegalArgumentException e) {
return Result.failure(new BatchError.InvalidWaste("Invalid unit: " + unitStr));
}
}
public Result<BatchError, Consumption> recordConsumption(ConsumptionDraft draft) {
if (status != BatchStatus.IN_PRODUCTION) {
return Result.failure(new BatchError.NotInProduction(id));
@ -155,15 +243,20 @@ public class Batch {
RecipeId recipeId,
BatchStatus status,
Quantity plannedQuantity,
Quantity actualQuantity,
Quantity waste,
String remarks,
LocalDate productionDate,
LocalDate bestBeforeDate,
OffsetDateTime createdAt,
OffsetDateTime updatedAt,
OffsetDateTime completedAt,
long version,
List<Consumption> consumptions
) {
return new Batch(id, batchNumber, recipeId, status, plannedQuantity, productionDate,
bestBeforeDate, createdAt, updatedAt, version, new ArrayList<>(consumptions));
return new Batch(id, batchNumber, recipeId, status, plannedQuantity, actualQuantity,
waste, remarks, productionDate, bestBeforeDate, createdAt, updatedAt,
completedAt, version, new ArrayList<>(consumptions));
}
public BatchId id() { return id; }
@ -171,10 +264,14 @@ public class Batch {
public RecipeId recipeId() { return recipeId; }
public BatchStatus status() { return status; }
public Quantity plannedQuantity() { return plannedQuantity; }
public Quantity actualQuantity() { return actualQuantity; }
public Quantity waste() { return waste; }
public String remarks() { return remarks; }
public LocalDate productionDate() { return productionDate; }
public LocalDate bestBeforeDate() { return bestBeforeDate; }
public OffsetDateTime createdAt() { return createdAt; }
public OffsetDateTime updatedAt() { return updatedAt; }
public OffsetDateTime completedAt() { return completedAt; }
public long version() { return version; }
public List<Consumption> consumptions() { return Collections.unmodifiableList(consumptions); }
}

View file

@ -54,6 +54,21 @@ public sealed interface BatchError {
@Override public String message() { return "Invalid consumption quantity: " + reason; }
}
record MissingConsumptions(BatchId id) implements BatchError {
@Override public String code() { return "BATCH_MISSING_CONSUMPTIONS"; }
@Override public String message() { return "Batch '" + id.value() + "' requires at least one consumption before completion"; }
}
record InvalidActualQuantity(String reason) implements BatchError {
@Override public String code() { return "BATCH_INVALID_ACTUAL_QUANTITY"; }
@Override public String message() { return "Invalid actual quantity: " + reason; }
}
record InvalidWaste(String reason) implements BatchError {
@Override public String code() { return "BATCH_INVALID_WASTE"; }
@Override public String message() { return "Invalid waste: " + reason; }
}
record Unauthorized(String message) implements BatchError {
@Override public String code() { return "UNAUTHORIZED"; }
}

View file

@ -0,0 +1,9 @@
package de.effigenix.domain.production;
public record CompleteBatchDraft(
String actualQuantity,
String actualQuantityUnit,
String waste,
String wasteUnit,
String remarks
) {}

View file

@ -0,0 +1,17 @@
package de.effigenix.domain.production.event;
import de.effigenix.domain.production.BatchId;
import de.effigenix.shared.common.Quantity;
import java.time.OffsetDateTime;
/**
* Stub wird derzeit nicht publiziert.
* Vorgesehen für spätere Event-Infrastruktur (Inventory stock-in, Audit, Tracing).
*/
public record BatchCompleted(
BatchId batchId,
Quantity actualQuantity,
Quantity waste,
OffsetDateTime completedAt
) {}

View file

@ -4,6 +4,7 @@ import de.effigenix.application.production.ActivateRecipe;
import de.effigenix.application.production.ArchiveRecipe;
import de.effigenix.application.production.AddProductionStep;
import de.effigenix.application.production.AddRecipeIngredient;
import de.effigenix.application.production.CompleteBatch;
import de.effigenix.application.production.CreateRecipe;
import de.effigenix.application.production.FindBatchByNumber;
import de.effigenix.application.production.GetBatch;
@ -108,4 +109,9 @@ public class ProductionUseCaseConfiguration {
public RecordConsumption recordConsumption(BatchRepository batchRepository, AuthorizationPort authorizationPort) {
return new RecordConsumption(batchRepository, authorizationPort);
}
@Bean
public CompleteBatch completeBatch(BatchRepository batchRepository, AuthorizationPort authorizationPort) {
return new CompleteBatch(batchRepository, authorizationPort);
}
}

View file

@ -41,12 +41,30 @@ public class BatchEntity {
@Column(name = "best_before_date", nullable = false)
private LocalDate bestBeforeDate;
@Column(name = "actual_quantity_amount", precision = 19, scale = 6)
private BigDecimal actualQuantityAmount;
@Column(name = "actual_quantity_unit", length = 10)
private String actualQuantityUnit;
@Column(name = "waste_amount", precision = 19, scale = 6)
private BigDecimal wasteAmount;
@Column(name = "waste_unit", length = 10)
private String wasteUnit;
@Column(name = "remarks", length = 500)
private String remarks;
@Column(name = "created_at", nullable = false, updatable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@Column(name = "completed_at")
private OffsetDateTime completedAt;
@OneToMany(mappedBy = "batch", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<ConsumptionEntity> consumptions = new ArrayList<>();
@ -89,6 +107,19 @@ public class BatchEntity {
public OffsetDateTime getUpdatedAt() { return updatedAt; }
public List<ConsumptionEntity> getConsumptions() { return consumptions; }
public BigDecimal getActualQuantityAmount() { return actualQuantityAmount; }
public String getActualQuantityUnit() { return actualQuantityUnit; }
public BigDecimal getWasteAmount() { return wasteAmount; }
public String getWasteUnit() { return wasteUnit; }
public String getRemarks() { return remarks; }
public OffsetDateTime getCompletedAt() { return completedAt; }
public void setStatus(String status) { this.status = status; }
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
public void setActualQuantityAmount(BigDecimal actualQuantityAmount) { this.actualQuantityAmount = actualQuantityAmount; }
public void setActualQuantityUnit(String actualQuantityUnit) { this.actualQuantityUnit = actualQuantityUnit; }
public void setWasteAmount(BigDecimal wasteAmount) { this.wasteAmount = wasteAmount; }
public void setWasteUnit(String wasteUnit) { this.wasteUnit = wasteUnit; }
public void setRemarks(String remarks) { this.remarks = remarks; }
public void setCompletedAt(OffsetDateTime completedAt) { this.completedAt = completedAt; }
}

View file

@ -29,6 +29,17 @@ public class BatchMapper {
batch.updatedAt()
);
if (batch.actualQuantity() != null) {
entity.setActualQuantityAmount(batch.actualQuantity().amount());
entity.setActualQuantityUnit(batch.actualQuantity().uom().name());
}
if (batch.waste() != null) {
entity.setWasteAmount(batch.waste().amount());
entity.setWasteUnit(batch.waste().uom().name());
}
entity.setRemarks(batch.remarks());
entity.setCompletedAt(batch.completedAt());
for (Consumption c : batch.consumptions()) {
entity.getConsumptions().add(toConsumptionEntity(c, entity));
}
@ -40,6 +51,23 @@ public class BatchMapper {
entity.setStatus(batch.status().name());
entity.setUpdatedAt(batch.updatedAt());
if (batch.actualQuantity() != null) {
entity.setActualQuantityAmount(batch.actualQuantity().amount());
entity.setActualQuantityUnit(batch.actualQuantity().uom().name());
} else {
entity.setActualQuantityAmount(null);
entity.setActualQuantityUnit(null);
}
if (batch.waste() != null) {
entity.setWasteAmount(batch.waste().amount());
entity.setWasteUnit(batch.waste().uom().name());
} else {
entity.setWasteAmount(null);
entity.setWasteUnit(null);
}
entity.setRemarks(batch.remarks());
entity.setCompletedAt(batch.completedAt());
Set<String> existingIds = entity.getConsumptions().stream()
.map(ConsumptionEntity::getId)
.collect(Collectors.toSet());
@ -65,6 +93,13 @@ public class BatchMapper {
))
.toList();
Quantity actualQuantity = entity.getActualQuantityAmount() != null
? Quantity.reconstitute(entity.getActualQuantityAmount(), UnitOfMeasure.valueOf(entity.getActualQuantityUnit()))
: null;
Quantity waste = entity.getWasteAmount() != null
? Quantity.reconstitute(entity.getWasteAmount(), UnitOfMeasure.valueOf(entity.getWasteUnit()))
: null;
return Batch.reconstitute(
BatchId.of(entity.getId()),
new BatchNumber(entity.getBatchNumber()),
@ -74,10 +109,14 @@ public class BatchMapper {
entity.getPlannedQuantityAmount(),
UnitOfMeasure.valueOf(entity.getPlannedQuantityUnit())
),
actualQuantity,
waste,
entity.getRemarks(),
entity.getProductionDate(),
entity.getBestBeforeDate(),
entity.getCreatedAt(),
entity.getUpdatedAt(),
entity.getCompletedAt(),
entity.getVersion(),
consumptions
);

View file

@ -1,11 +1,13 @@
package de.effigenix.infrastructure.production.web.controller;
import de.effigenix.application.production.CompleteBatch;
import de.effigenix.application.production.FindBatchByNumber;
import de.effigenix.application.production.GetBatch;
import de.effigenix.application.production.ListBatches;
import de.effigenix.application.production.PlanBatch;
import de.effigenix.application.production.RecordConsumption;
import de.effigenix.application.production.StartBatch;
import de.effigenix.application.production.command.CompleteBatchCommand;
import de.effigenix.application.production.command.PlanBatchCommand;
import de.effigenix.application.production.command.RecordConsumptionCommand;
import de.effigenix.application.production.command.StartBatchCommand;
@ -16,6 +18,7 @@ import de.effigenix.domain.production.BatchStatus;
import de.effigenix.infrastructure.production.web.dto.BatchResponse;
import de.effigenix.infrastructure.production.web.dto.BatchSummaryResponse;
import de.effigenix.infrastructure.production.web.dto.ConsumptionResponse;
import de.effigenix.infrastructure.production.web.dto.CompleteBatchRequest;
import de.effigenix.infrastructure.production.web.dto.PlanBatchRequest;
import de.effigenix.infrastructure.production.web.dto.RecordConsumptionRequest;
import de.effigenix.shared.security.ActorId;
@ -48,16 +51,18 @@ public class BatchController {
private final FindBatchByNumber findBatchByNumber;
private final StartBatch startBatch;
private final RecordConsumption recordConsumption;
private final CompleteBatch completeBatch;
public BatchController(PlanBatch planBatch, GetBatch getBatch, ListBatches listBatches,
FindBatchByNumber findBatchByNumber, StartBatch startBatch,
RecordConsumption recordConsumption) {
RecordConsumption recordConsumption, CompleteBatch completeBatch) {
this.planBatch = planBatch;
this.getBatch = getBatch;
this.listBatches = listBatches;
this.findBatchByNumber = findBatchByNumber;
this.startBatch = startBatch;
this.recordConsumption = recordConsumption;
this.completeBatch = completeBatch;
}
@GetMapping("/{id}")
@ -206,6 +211,33 @@ public class BatchController {
.body(ConsumptionResponse.from(result.unsafeGetValue()));
}
@PostMapping("/{id}/complete")
@PreAuthorize("hasAuthority('BATCH_COMPLETE')")
public ResponseEntity<BatchResponse> completeBatch(
@PathVariable("id") String id,
@Valid @RequestBody CompleteBatchRequest request,
Authentication authentication
) {
logger.info("Completing batch: {} by actor: {}", id, authentication.getName());
var cmd = new CompleteBatchCommand(
id,
request.actualQuantity(),
request.actualQuantityUnit(),
request.waste(),
request.wasteUnit(),
request.remarks()
);
var result = completeBatch.execute(cmd, ActorId.of(authentication.getName()));
if (result.isFailure()) {
throw new BatchDomainErrorException(result.unsafeGetError());
}
return ResponseEntity.ok(BatchResponse.from(result.unsafeGetValue()));
}
private static String filterType(String status, LocalDate productionDate, String articleId) {
int count = (status != null ? 1 : 0) + (productionDate != null ? 1 : 0) + (articleId != null ? 1 : 0);
if (count > 1) return "ambiguous";

View file

@ -13,11 +13,17 @@ public record BatchResponse(
String status,
String plannedQuantity,
String plannedQuantityUnit,
String actualQuantity,
String actualQuantityUnit,
String waste,
String wasteUnit,
String remarks,
LocalDate productionDate,
LocalDate bestBeforeDate,
List<ConsumptionResponse> consumptions,
OffsetDateTime createdAt,
OffsetDateTime updatedAt
OffsetDateTime updatedAt,
OffsetDateTime completedAt
) {
public static BatchResponse from(Batch batch) {
var consumptions = batch.consumptions().stream()
@ -31,11 +37,17 @@ public record BatchResponse(
batch.status().name(),
batch.plannedQuantity().amount().toPlainString(),
batch.plannedQuantity().uom().name(),
batch.actualQuantity() != null ? batch.actualQuantity().amount().toPlainString() : null,
batch.actualQuantity() != null ? batch.actualQuantity().uom().name() : null,
batch.waste() != null ? batch.waste().amount().toPlainString() : null,
batch.waste() != null ? batch.waste().uom().name() : null,
batch.remarks(),
batch.productionDate(),
batch.bestBeforeDate(),
consumptions,
batch.createdAt(),
batch.updatedAt()
batch.updatedAt(),
batch.completedAt()
);
}
}

View file

@ -0,0 +1,19 @@
package de.effigenix.infrastructure.production.web.dto;
import jakarta.validation.constraints.NotBlank;
public record CompleteBatchRequest(
@NotBlank(message = "actualQuantity is required")
String actualQuantity,
@NotBlank(message = "actualQuantityUnit is required")
String actualQuantityUnit,
@NotBlank(message = "waste is required")
String waste,
@NotBlank(message = "wasteUnit is required")
String wasteUnit,
String remarks
) {}

View file

@ -37,6 +37,9 @@ public final class ProductionErrorHttpStatusMapper {
case BatchError.NotInProduction e -> 409;
case BatchError.DuplicateInputBatch e -> 409;
case BatchError.InvalidConsumptionQuantity e -> 400;
case BatchError.MissingConsumptions e -> 409;
case BatchError.InvalidActualQuantity e -> 400;
case BatchError.InvalidWaste e -> 400;
case BatchError.ValidationFailure e -> 400;
case BatchError.Unauthorized e -> 403;
case BatchError.RepositoryFailure e -> 500;

View file

@ -0,0 +1,19 @@
<?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="021-add-completion-fields-to-batches" author="effigenix">
<addColumn tableName="batches">
<column name="actual_quantity_amount" type="DECIMAL(19,6)"/>
<column name="actual_quantity_unit" type="VARCHAR(10)"/>
<column name="waste_amount" type="DECIMAL(19,6)"/>
<column name="waste_unit" type="VARCHAR(10)"/>
<column name="remarks" type="VARCHAR(500)"/>
<column name="completed_at" type="TIMESTAMPTZ"/>
</addColumn>
</changeSet>
</databaseChangeLog>

View file

@ -25,5 +25,6 @@
<include file="db/changelog/changes/018-add-article-id-to-recipes.xml"/>
<include file="db/changelog/changes/019-create-batch-consumptions-table.xml"/>
<include file="db/changelog/changes/020-add-version-to-batches.xml"/>
<include file="db/changelog/changes/021-add-completion-fields-to-batches.xml"/>
</databaseChangeLog>