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

feat(production): Charge planen (PlanBatch) (#33)

Batch-Aggregat mit plan()-Factory, automatischer BatchNumber-Generierung
(P-YYYY-MM-DD-XXX) und Validierung (Quantity > 0, bestBeforeDate > productionDate).
Full Vertical Slice: Domain, Application, Infrastructure (JPA, REST, Liquibase).
This commit is contained in:
Sebastian Frick 2026-02-19 23:51:36 +01:00
parent d963d7fccc
commit b06157b92c
25 changed files with 1541 additions and 0 deletions

View file

@ -0,0 +1,86 @@
package de.effigenix.application.production;
import de.effigenix.application.production.command.PlanBatchCommand;
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 PlanBatch {
private final BatchRepository batchRepository;
private final RecipeRepository recipeRepository;
private final BatchNumberGenerator batchNumberGenerator;
private final AuthorizationPort authorizationPort;
public PlanBatch(
BatchRepository batchRepository,
RecipeRepository recipeRepository,
BatchNumberGenerator batchNumberGenerator,
AuthorizationPort authorizationPort
) {
this.batchRepository = batchRepository;
this.recipeRepository = recipeRepository;
this.batchNumberGenerator = batchNumberGenerator;
this.authorizationPort = authorizationPort;
}
public Result<BatchError, Batch> execute(PlanBatchCommand cmd, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.BATCH_WRITE)) {
return Result.failure(new BatchError.Unauthorized("Not authorized to plan batches"));
}
// Verify recipe exists and is ACTIVE
Recipe recipe;
switch (recipeRepository.findById(RecipeId.of(cmd.recipeId()))) {
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.ValidationFailure(
"Recipe with ID '" + cmd.recipeId() + "' not found"));
}
recipe = opt.get();
}
}
if (recipe.status() != RecipeStatus.ACTIVE) {
return Result.failure(new BatchError.RecipeNotActive(recipe.id()));
}
// Generate batch number
BatchNumber batchNumber;
switch (batchNumberGenerator.generateNext(cmd.productionDate())) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var val) -> batchNumber = val;
}
// Plan batch
var draft = new BatchDraft(
cmd.recipeId(),
cmd.plannedQuantity(),
cmd.plannedQuantityUnit(),
cmd.productionDate(),
cmd.bestBeforeDate()
);
Batch batch;
switch (Batch.plan(draft, batchNumber)) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var val) -> batch = val;
}
// Persist
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,11 @@
package de.effigenix.application.production.command;
import java.time.LocalDate;
public record PlanBatchCommand(
String recipeId,
String plannedQuantity,
String plannedQuantityUnit,
LocalDate productionDate,
LocalDate bestBeforeDate
) {}

View file

@ -0,0 +1,128 @@
package de.effigenix.domain.production;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* Batch aggregate root.
*
* Invariants:
* 1. PlannedQuantity must be positive
* 2. BestBeforeDate must be after ProductionDate
* 3. BatchNumber is auto-generated (format P-YYYY-MM-DD-XXX)
* 4. New batches always start in PLANNED status
* 5. RecipeId must reference an ACTIVE recipe (enforced by Use Case)
*/
public class Batch {
private final BatchId id;
private final BatchNumber batchNumber;
private final RecipeId recipeId;
private BatchStatus status;
private final Quantity plannedQuantity;
private final LocalDate productionDate;
private final LocalDate bestBeforeDate;
private final LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Batch(
BatchId id,
BatchNumber batchNumber,
RecipeId recipeId,
BatchStatus status,
Quantity plannedQuantity,
LocalDate productionDate,
LocalDate bestBeforeDate,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
this.id = id;
this.batchNumber = batchNumber;
this.recipeId = recipeId;
this.status = status;
this.plannedQuantity = plannedQuantity;
this.productionDate = productionDate;
this.bestBeforeDate = bestBeforeDate;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
public static Result<BatchError, Batch> plan(BatchDraft draft, BatchNumber batchNumber) {
if (draft.recipeId() == null || draft.recipeId().isBlank()) {
return Result.failure(new BatchError.ValidationFailure("recipeId must not be blank"));
}
if (draft.productionDate() == null) {
return Result.failure(new BatchError.ValidationFailure("productionDate must not be null"));
}
if (draft.bestBeforeDate() == null) {
return Result.failure(new BatchError.ValidationFailure("bestBeforeDate must not be null"));
}
if (!draft.bestBeforeDate().isAfter(draft.productionDate())) {
return Result.failure(new BatchError.InvalidDates(
"bestBeforeDate (" + draft.bestBeforeDate() + ") must be after productionDate (" + draft.productionDate() + ")"));
}
Quantity plannedQuantity;
try {
var amount = new BigDecimal(draft.plannedQuantity());
var uom = UnitOfMeasure.valueOf(draft.plannedQuantityUnit());
switch (Quantity.of(amount, uom)) {
case Result.Failure(var err) -> {
return Result.failure(new BatchError.InvalidPlannedQuantity(err.toString()));
}
case Result.Success(var qty) -> plannedQuantity = qty;
}
} catch (NumberFormatException e) {
return Result.failure(new BatchError.InvalidPlannedQuantity(
"Invalid amount format: " + draft.plannedQuantity()));
} catch (IllegalArgumentException e) {
return Result.failure(new BatchError.InvalidPlannedQuantity(
"Invalid unit: " + draft.plannedQuantityUnit()));
}
var now = LocalDateTime.now();
return Result.success(new Batch(
BatchId.generate(),
batchNumber,
RecipeId.of(draft.recipeId()),
BatchStatus.PLANNED,
plannedQuantity,
draft.productionDate(),
draft.bestBeforeDate(),
now,
now
));
}
public static Batch reconstitute(
BatchId id,
BatchNumber batchNumber,
RecipeId recipeId,
BatchStatus status,
Quantity plannedQuantity,
LocalDate productionDate,
LocalDate bestBeforeDate,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
return new Batch(id, batchNumber, recipeId, status, plannedQuantity, productionDate, bestBeforeDate, createdAt, updatedAt);
}
public BatchId id() { return id; }
public BatchNumber batchNumber() { return batchNumber; }
public RecipeId recipeId() { return recipeId; }
public BatchStatus status() { return status; }
public Quantity plannedQuantity() { return plannedQuantity; }
public LocalDate productionDate() { return productionDate; }
public LocalDate bestBeforeDate() { return bestBeforeDate; }
public LocalDateTime createdAt() { return createdAt; }
public LocalDateTime updatedAt() { return updatedAt; }
}

View file

@ -0,0 +1,11 @@
package de.effigenix.domain.production;
import java.time.LocalDate;
public record BatchDraft(
String recipeId,
String plannedQuantity,
String plannedQuantityUnit,
LocalDate productionDate,
LocalDate bestBeforeDate
) {}

View file

@ -0,0 +1,39 @@
package de.effigenix.domain.production;
public sealed interface BatchError {
String code();
String message();
record BatchNotFound(BatchId id) implements BatchError {
@Override public String code() { return "BATCH_NOT_FOUND"; }
@Override public String message() { return "Batch with ID '" + id.value() + "' not found"; }
}
record InvalidPlannedQuantity(String reason) implements BatchError {
@Override public String code() { return "BATCH_INVALID_PLANNED_QUANTITY"; }
@Override public String message() { return "Invalid planned quantity: " + reason; }
}
record InvalidDates(String reason) implements BatchError {
@Override public String code() { return "BATCH_INVALID_DATES"; }
@Override public String message() { return "Invalid dates: " + reason; }
}
record RecipeNotActive(RecipeId recipeId) implements BatchError {
@Override public String code() { return "BATCH_RECIPE_NOT_ACTIVE"; }
@Override public String message() { return "Recipe '" + recipeId.value() + "' is not in ACTIVE status"; }
}
record ValidationFailure(String message) implements BatchError {
@Override public String code() { return "BATCH_VALIDATION_ERROR"; }
}
record Unauthorized(String message) implements BatchError {
@Override public String code() { return "UNAUTHORIZED"; }
}
record RepositoryFailure(String message) implements BatchError {
@Override public String code() { return "REPOSITORY_ERROR"; }
}
}

View file

@ -0,0 +1,20 @@
package de.effigenix.domain.production;
import java.util.UUID;
public record BatchId(String value) {
public BatchId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("BatchId must not be blank");
}
}
public static BatchId generate() {
return new BatchId(UUID.randomUUID().toString());
}
public static BatchId of(String value) {
return new BatchId(value);
}
}

View file

@ -0,0 +1,10 @@
package de.effigenix.domain.production;
import de.effigenix.shared.common.Result;
import java.time.LocalDate;
public interface BatchNumberGenerator {
Result<BatchError, BatchNumber> generateNext(LocalDate date);
}

View file

@ -0,0 +1,16 @@
package de.effigenix.domain.production;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import java.util.List;
import java.util.Optional;
public interface BatchRepository {
Result<RepositoryError, Optional<Batch>> findById(BatchId id);
Result<RepositoryError, List<Batch>> findAll();
Result<RepositoryError, Void> save(Batch batch);
}

View file

@ -0,0 +1,8 @@
package de.effigenix.domain.production;
public enum BatchStatus {
PLANNED,
IN_PRODUCTION,
COMPLETED,
CANCELLED
}

View file

@ -5,11 +5,14 @@ import de.effigenix.application.production.ArchiveRecipe;
import de.effigenix.application.production.AddProductionStep;
import de.effigenix.application.production.AddRecipeIngredient;
import de.effigenix.application.production.CreateRecipe;
import de.effigenix.application.production.PlanBatch;
import de.effigenix.application.production.RecipeCycleChecker;
import de.effigenix.application.production.GetRecipe;
import de.effigenix.application.production.ListRecipes;
import de.effigenix.application.production.RemoveProductionStep;
import de.effigenix.application.production.RemoveRecipeIngredient;
import de.effigenix.domain.production.BatchNumberGenerator;
import de.effigenix.domain.production.BatchRepository;
import de.effigenix.domain.production.RecipeRepository;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.context.annotation.Bean;
@ -68,4 +71,10 @@ public class ProductionUseCaseConfiguration {
public ArchiveRecipe archiveRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
return new ArchiveRecipe(recipeRepository, authorizationPort);
}
@Bean
public PlanBatch planBatch(BatchRepository batchRepository, RecipeRepository recipeRepository,
BatchNumberGenerator batchNumberGenerator, AuthorizationPort authorizationPort) {
return new PlanBatch(batchRepository, recipeRepository, batchNumberGenerator, authorizationPort);
}
}

View file

@ -0,0 +1,37 @@
package de.effigenix.infrastructure.production.persistence;
import de.effigenix.domain.production.BatchError;
import de.effigenix.domain.production.BatchNumber;
import de.effigenix.domain.production.BatchNumberGenerator;
import de.effigenix.infrastructure.production.persistence.repository.BatchJpaRepository;
import de.effigenix.shared.common.Result;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
@Component
@Profile("!no-db")
public class JpaBatchNumberGenerator implements BatchNumberGenerator {
private final BatchJpaRepository batchJpaRepository;
public JpaBatchNumberGenerator(BatchJpaRepository batchJpaRepository) {
this.batchJpaRepository = batchJpaRepository;
}
@Override
public Result<BatchError, BatchNumber> generateNext(LocalDate date) {
try {
int count = batchJpaRepository.countByProductionDate(date);
int nextSequence = count + 1;
if (nextSequence > 999) {
return Result.failure(new BatchError.ValidationFailure(
"Maximum batch number sequence (999) reached for date " + date));
}
return Result.success(BatchNumber.generate(date, nextSequence));
} catch (Exception e) {
return Result.failure(new BatchError.RepositoryFailure(e.getMessage()));
}
}
}

View file

@ -0,0 +1,80 @@
package de.effigenix.infrastructure.production.persistence.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Entity
@Table(name = "batches")
public class BatchEntity {
@Id
@Column(name = "id", nullable = false, length = 36)
private String id;
@Column(name = "batch_number", nullable = false, unique = true, length = 20)
private String batchNumber;
@Column(name = "recipe_id", nullable = false, length = 36)
private String recipeId;
@Column(name = "status", nullable = false, length = 20)
private String status;
@Column(name = "planned_quantity_amount", nullable = false, precision = 19, scale = 6)
private BigDecimal plannedQuantityAmount;
@Column(name = "planned_quantity_unit", nullable = false, length = 10)
private String plannedQuantityUnit;
@Column(name = "production_date", nullable = false)
private LocalDate productionDate;
@Column(name = "best_before_date", nullable = false)
private LocalDate bestBeforeDate;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
protected BatchEntity() {}
public BatchEntity(
String id,
String batchNumber,
String recipeId,
String status,
BigDecimal plannedQuantityAmount,
String plannedQuantityUnit,
LocalDate productionDate,
LocalDate bestBeforeDate,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
this.id = id;
this.batchNumber = batchNumber;
this.recipeId = recipeId;
this.status = status;
this.plannedQuantityAmount = plannedQuantityAmount;
this.plannedQuantityUnit = plannedQuantityUnit;
this.productionDate = productionDate;
this.bestBeforeDate = bestBeforeDate;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
public String getId() { return id; }
public String getBatchNumber() { return batchNumber; }
public String getRecipeId() { return recipeId; }
public String getStatus() { return status; }
public BigDecimal getPlannedQuantityAmount() { return plannedQuantityAmount; }
public String getPlannedQuantityUnit() { return plannedQuantityUnit; }
public LocalDate getProductionDate() { return productionDate; }
public LocalDate getBestBeforeDate() { return bestBeforeDate; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
}

View file

@ -0,0 +1,44 @@
package de.effigenix.infrastructure.production.persistence.mapper;
import de.effigenix.domain.production.*;
import de.effigenix.infrastructure.production.persistence.entity.BatchEntity;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.UnitOfMeasure;
import org.springframework.stereotype.Component;
@Component
public class BatchMapper {
public BatchEntity toEntity(Batch batch) {
return new BatchEntity(
batch.id().value(),
batch.batchNumber().value(),
batch.recipeId().value(),
batch.status().name(),
batch.plannedQuantity().amount(),
batch.plannedQuantity().uom().name(),
batch.productionDate(),
batch.bestBeforeDate(),
batch.createdAt(),
batch.updatedAt()
);
}
public Batch toDomain(BatchEntity entity) {
return Batch.reconstitute(
BatchId.of(entity.getId()),
new BatchNumber(entity.getBatchNumber()),
RecipeId.of(entity.getRecipeId()),
BatchStatus.valueOf(entity.getStatus()),
Quantity.reconstitute(
entity.getPlannedQuantityAmount(),
UnitOfMeasure.valueOf(entity.getPlannedQuantityUnit()),
null, null
),
entity.getProductionDate(),
entity.getBestBeforeDate(),
entity.getCreatedAt(),
entity.getUpdatedAt()
);
}
}

View file

@ -0,0 +1,13 @@
package de.effigenix.infrastructure.production.persistence.repository;
import de.effigenix.infrastructure.production.persistence.entity.BatchEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.time.LocalDate;
public interface BatchJpaRepository extends JpaRepository<BatchEntity, String> {
@Query("SELECT COUNT(b) FROM BatchEntity b WHERE b.productionDate = :date")
int countByProductionDate(LocalDate date);
}

View file

@ -0,0 +1,68 @@
package de.effigenix.infrastructure.production.persistence.repository;
import de.effigenix.domain.production.*;
import de.effigenix.infrastructure.production.persistence.mapper.BatchMapper;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
@Profile("!no-db")
@Transactional(readOnly = true)
public class JpaBatchRepository implements BatchRepository {
private static final Logger logger = LoggerFactory.getLogger(JpaBatchRepository.class);
private final BatchJpaRepository jpaRepository;
private final BatchMapper mapper;
public JpaBatchRepository(BatchJpaRepository jpaRepository, BatchMapper mapper) {
this.jpaRepository = jpaRepository;
this.mapper = mapper;
}
@Override
public Result<RepositoryError, Optional<Batch>> findById(BatchId id) {
try {
Optional<Batch> result = jpaRepository.findById(id.value())
.map(mapper::toDomain);
return Result.success(result);
} catch (Exception e) {
logger.trace("Database error in findById", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, List<Batch>> findAll() {
try {
List<Batch> result = jpaRepository.findAll().stream()
.map(mapper::toDomain)
.collect(Collectors.toList());
return Result.success(result);
} catch (Exception e) {
logger.trace("Database error in findAll", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
@Transactional
public Result<RepositoryError, Void> save(Batch batch) {
try {
jpaRepository.save(mapper.toEntity(batch));
return Result.success(null);
} catch (Exception e) {
logger.trace("Database error in save", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
}

View file

@ -0,0 +1,72 @@
package de.effigenix.infrastructure.production.web.controller;
import de.effigenix.application.production.PlanBatch;
import de.effigenix.application.production.command.PlanBatchCommand;
import de.effigenix.domain.production.BatchError;
import de.effigenix.infrastructure.production.web.dto.BatchResponse;
import de.effigenix.infrastructure.production.web.dto.PlanBatchRequest;
import de.effigenix.shared.security.ActorId;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/production/batches")
@SecurityRequirement(name = "Bearer Authentication")
@Tag(name = "Batches", description = "Production batch management endpoints")
public class BatchController {
private static final Logger logger = LoggerFactory.getLogger(BatchController.class);
private final PlanBatch planBatch;
public BatchController(PlanBatch planBatch) {
this.planBatch = planBatch;
}
@PostMapping
@PreAuthorize("hasAuthority('BATCH_WRITE')")
public ResponseEntity<BatchResponse> planBatch(
@Valid @RequestBody PlanBatchRequest request,
Authentication authentication
) {
logger.info("Planning batch for recipe: {} by actor: {}", request.recipeId(), authentication.getName());
var cmd = new PlanBatchCommand(
request.recipeId(),
request.plannedQuantity(),
request.plannedQuantityUnit(),
request.productionDate(),
request.bestBeforeDate()
);
var result = planBatch.execute(cmd, ActorId.of(authentication.getName()));
if (result.isFailure()) {
throw new BatchDomainErrorException(result.unsafeGetError());
}
return ResponseEntity.status(HttpStatus.CREATED)
.body(BatchResponse.from(result.unsafeGetValue()));
}
public static class BatchDomainErrorException extends RuntimeException {
private final BatchError error;
public BatchDomainErrorException(BatchError error) {
super(error.message());
this.error = error;
}
public BatchError getError() {
return error;
}
}
}

View file

@ -0,0 +1,34 @@
package de.effigenix.infrastructure.production.web.dto;
import de.effigenix.domain.production.Batch;
import java.time.LocalDate;
import java.time.LocalDateTime;
public record BatchResponse(
String id,
String batchNumber,
String recipeId,
String status,
String plannedQuantity,
String plannedQuantityUnit,
LocalDate productionDate,
LocalDate bestBeforeDate,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
public static BatchResponse from(Batch batch) {
return new BatchResponse(
batch.id().value(),
batch.batchNumber().value(),
batch.recipeId().value(),
batch.status().name(),
batch.plannedQuantity().amount().toPlainString(),
batch.plannedQuantity().uom().name(),
batch.productionDate(),
batch.bestBeforeDate(),
batch.createdAt(),
batch.updatedAt()
);
}
}

View file

@ -0,0 +1,14 @@
package de.effigenix.infrastructure.production.web.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
public record PlanBatchRequest(
@NotBlank String recipeId,
@NotBlank String plannedQuantity,
@NotBlank String plannedQuantityUnit,
@NotNull LocalDate productionDate,
@NotNull LocalDate bestBeforeDate
) {}

View file

@ -1,5 +1,6 @@
package de.effigenix.infrastructure.production.web.exception;
import de.effigenix.domain.production.BatchError;
import de.effigenix.domain.production.RecipeError;
public final class ProductionErrorHttpStatusMapper {
@ -24,4 +25,16 @@ public final class ProductionErrorHttpStatusMapper {
case RecipeError.RepositoryFailure e -> 500;
};
}
public static int toHttpStatus(BatchError error) {
return switch (error) {
case BatchError.BatchNotFound e -> 404;
case BatchError.InvalidPlannedQuantity e -> 400;
case BatchError.InvalidDates e -> 400;
case BatchError.RecipeNotActive e -> 409;
case BatchError.ValidationFailure e -> 400;
case BatchError.Unauthorized e -> 403;
case BatchError.RepositoryFailure e -> 500;
};
}
}

View file

@ -6,6 +6,7 @@ import de.effigenix.domain.masterdata.ArticleError;
import de.effigenix.domain.masterdata.ProductCategoryError;
import de.effigenix.domain.masterdata.CustomerError;
import de.effigenix.domain.masterdata.SupplierError;
import de.effigenix.domain.production.BatchError;
import de.effigenix.domain.production.RecipeError;
import de.effigenix.domain.usermanagement.UserError;
import de.effigenix.infrastructure.inventory.web.controller.StorageLocationController;
@ -15,6 +16,7 @@ import de.effigenix.infrastructure.masterdata.web.controller.ArticleController;
import de.effigenix.infrastructure.masterdata.web.controller.CustomerController;
import de.effigenix.infrastructure.masterdata.web.controller.ProductCategoryController;
import de.effigenix.infrastructure.masterdata.web.controller.SupplierController;
import de.effigenix.infrastructure.production.web.controller.BatchController;
import de.effigenix.infrastructure.production.web.controller.RecipeController;
import de.effigenix.infrastructure.production.web.exception.ProductionErrorHttpStatusMapper;
import de.effigenix.shared.common.RepositoryError;
@ -248,6 +250,29 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(status).body(errorResponse);
}
@ExceptionHandler(BatchController.BatchDomainErrorException.class)
public ResponseEntity<ErrorResponse> handleBatchDomainError(
BatchController.BatchDomainErrorException ex,
HttpServletRequest request
) {
BatchError error = ex.getError();
int status = ProductionErrorHttpStatusMapper.toHttpStatus(error);
logDomainError("Batch", error.code(), error.message(), status);
String clientMessage = status >= 500
? "An internal error occurred"
: error.message();
ErrorResponse errorResponse = ErrorResponse.from(
error.code(),
clientMessage,
status,
request.getRequestURI()
);
return ResponseEntity.status(status).body(errorResponse);
}
@ExceptionHandler(RoleController.RoleDomainErrorException.class)
public ResponseEntity<ErrorResponse> handleRoleDomainError(
RoleController.RoleDomainErrorException ex,

View file

@ -0,0 +1,60 @@
<?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="015-create-batches-table" author="effigenix">
<createTable tableName="batches">
<column name="id" type="VARCHAR(36)">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="batch_number" type="VARCHAR(20)">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_batch_number"/>
</column>
<column name="recipe_id" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="status" type="VARCHAR(20)" defaultValue="PLANNED">
<constraints nullable="false"/>
</column>
<column name="planned_quantity_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="planned_quantity_unit" type="VARCHAR(10)">
<constraints nullable="false"/>
</column>
<column name="production_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="best_before_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="created_at" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
<column name="updated_at" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint
baseTableName="batches"
baseColumnNames="recipe_id"
constraintName="fk_batch_recipe"
referencedTableName="recipes"
referencedColumnNames="id"/>
<createIndex tableName="batches" indexName="idx_batch_recipe_id">
<column name="recipe_id"/>
</createIndex>
<createIndex tableName="batches" indexName="idx_batch_production_date">
<column name="production_date"/>
</createIndex>
<sql>ALTER TABLE batches ADD CONSTRAINT chk_batch_status CHECK (status IN ('PLANNED', 'IN_PRODUCTION', 'COMPLETED', 'CANCELLED'));</sql>
</changeSet>
</databaseChangeLog>

View file

@ -19,5 +19,6 @@
<include file="db/changelog/changes/012-create-recipe-production-steps-table.xml"/>
<include file="db/changelog/changes/013-create-stock-schema.xml"/>
<include file="db/changelog/changes/014-create-stock-batches-table.xml"/>
<include file="db/changelog/changes/015-create-batches-table.xml"/>
</databaseChangeLog>