diff --git a/backend/pom.xml b/backend/pom.xml
index 1449c3a..b5ed8df 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -128,6 +128,7 @@
org.springframework.boot
spring-boot-maven-plugin
+ exec
org.projectlombok
diff --git a/backend/src/main/java/de/effigenix/application/production/ListBatches.java b/backend/src/main/java/de/effigenix/application/production/ListBatches.java
index d27e55d..27799b0 100644
--- a/backend/src/main/java/de/effigenix/application/production/ListBatches.java
+++ b/backend/src/main/java/de/effigenix/application/production/ListBatches.java
@@ -28,7 +28,7 @@ public class ListBatches {
return Result.failure(new BatchError.Unauthorized("Not authorized to read batches"));
}
- switch (batchRepository.findAll()) {
+ switch (batchRepository.findAllSummary()) {
case Result.Failure(var err) ->
{ return Result.failure(new BatchError.RepositoryFailure(err.message())); }
case Result.Success(var batches) ->
@@ -41,7 +41,7 @@ public class ListBatches {
return Result.failure(new BatchError.Unauthorized("Not authorized to read batches"));
}
- switch (batchRepository.findByStatus(status)) {
+ switch (batchRepository.findByStatusSummary(status)) {
case Result.Failure(var err) ->
{ return Result.failure(new BatchError.RepositoryFailure(err.message())); }
case Result.Success(var batches) ->
@@ -54,7 +54,7 @@ public class ListBatches {
return Result.failure(new BatchError.Unauthorized("Not authorized to read batches"));
}
- switch (batchRepository.findByProductionDate(date)) {
+ switch (batchRepository.findByProductionDateSummary(date)) {
case Result.Failure(var err) ->
{ return Result.failure(new BatchError.RepositoryFailure(err.message())); }
case Result.Success(var batches) ->
@@ -75,7 +75,7 @@ public class ListBatches {
return Result.success(List.of());
}
List recipeIds = recipes.stream().map(Recipe::id).toList();
- switch (batchRepository.findByRecipeIds(recipeIds)) {
+ switch (batchRepository.findByRecipeIdsSummary(recipeIds)) {
case Result.Failure(var batchErr) ->
{ return Result.failure(new BatchError.RepositoryFailure(batchErr.message())); }
case Result.Success(var batches) ->
diff --git a/backend/src/main/java/de/effigenix/domain/production/BatchRepository.java b/backend/src/main/java/de/effigenix/domain/production/BatchRepository.java
index 65a769a..084d772 100644
--- a/backend/src/main/java/de/effigenix/domain/production/BatchRepository.java
+++ b/backend/src/main/java/de/effigenix/domain/production/BatchRepository.java
@@ -21,5 +21,13 @@ public interface BatchRepository {
Result> findByRecipeIds(List recipeIds);
+ Result> findAllSummary();
+
+ Result> findByStatusSummary(BatchStatus status);
+
+ Result> findByProductionDateSummary(LocalDate date);
+
+ Result> findByRecipeIdsSummary(List recipeIds);
+
Result save(Batch batch);
}
diff --git a/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/FrameContractJpaRepository.java b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/FrameContractJpaRepository.java
index 16d906a..4f93e19 100644
--- a/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/FrameContractJpaRepository.java
+++ b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/FrameContractJpaRepository.java
@@ -3,11 +3,14 @@ package de.effigenix.infrastructure.masterdata.persistence.repository;
import de.effigenix.infrastructure.masterdata.persistence.entity.FrameContractEntity;
import org.springframework.data.jpa.repository.JpaRepository;
+import java.util.List;
import java.util.Optional;
public interface FrameContractJpaRepository extends JpaRepository {
Optional findByCustomerId(String customerId);
+ List findByCustomerIdIn(List customerIds);
+
void deleteByCustomerId(String customerId);
}
diff --git a/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/JpaCustomerRepository.java b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/JpaCustomerRepository.java
index e64a1fe..701dbe3 100644
--- a/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/JpaCustomerRepository.java
+++ b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/JpaCustomerRepository.java
@@ -1,6 +1,7 @@
package de.effigenix.infrastructure.masterdata.persistence.repository;
import de.effigenix.domain.masterdata.*;
+import de.effigenix.infrastructure.masterdata.persistence.entity.CustomerEntity;
import de.effigenix.infrastructure.masterdata.persistence.entity.FrameContractEntity;
import de.effigenix.infrastructure.masterdata.persistence.mapper.CustomerMapper;
import de.effigenix.shared.common.RepositoryError;
@@ -12,7 +13,9 @@ import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
+import java.util.function.Function;
import java.util.stream.Collectors;
@Repository
@@ -52,13 +55,7 @@ public class JpaCustomerRepository implements CustomerRepository {
@Override
public Result> findAll() {
try {
- List result = jpaRepository.findAll().stream()
- .map(entity -> {
- var fc = frameContractJpaRepository.findByCustomerId(entity.getId()).orElse(null);
- return mapper.toDomain(entity, fc);
- })
- .collect(Collectors.toList());
- return Result.success(result);
+ return Result.success(mapWithFrameContracts(jpaRepository.findAll()));
} catch (Exception e) {
logger.trace("Database error in findAll", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
@@ -68,13 +65,7 @@ public class JpaCustomerRepository implements CustomerRepository {
@Override
public Result> findByType(CustomerType type) {
try {
- List result = jpaRepository.findByType(type.name()).stream()
- .map(entity -> {
- var fc = frameContractJpaRepository.findByCustomerId(entity.getId()).orElse(null);
- return mapper.toDomain(entity, fc);
- })
- .collect(Collectors.toList());
- return Result.success(result);
+ return Result.success(mapWithFrameContracts(jpaRepository.findByType(type.name())));
} catch (Exception e) {
logger.trace("Database error in findByType", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
@@ -84,19 +75,24 @@ public class JpaCustomerRepository implements CustomerRepository {
@Override
public Result> findByStatus(CustomerStatus status) {
try {
- List result = jpaRepository.findByStatus(status.name()).stream()
- .map(entity -> {
- var fc = frameContractJpaRepository.findByCustomerId(entity.getId()).orElse(null);
- return mapper.toDomain(entity, fc);
- })
- .collect(Collectors.toList());
- return Result.success(result);
+ return Result.success(mapWithFrameContracts(jpaRepository.findByStatus(status.name())));
} catch (Exception e) {
logger.trace("Database error in findByStatus", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
+ private List mapWithFrameContracts(List entities) {
+ List customerIds = entities.stream()
+ .map(CustomerEntity::getId)
+ .toList();
+ Map fcMap = frameContractJpaRepository.findByCustomerIdIn(customerIds).stream()
+ .collect(Collectors.toMap(FrameContractEntity::getCustomerId, Function.identity()));
+ return entities.stream()
+ .map(entity -> mapper.toDomain(entity, fcMap.get(entity.getId())))
+ .collect(Collectors.toList());
+ }
+
@Override
@Transactional
public Result save(Customer customer) {
diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/BatchMapper.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/BatchMapper.java
index ec161a6..27e9824 100644
--- a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/BatchMapper.java
+++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/BatchMapper.java
@@ -128,6 +128,38 @@ public class BatchMapper {
);
}
+ public Batch toDomainSummary(BatchEntity entity) {
+ 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()),
+ RecipeId.of(entity.getRecipeId()),
+ BatchStatus.valueOf(entity.getStatus()),
+ Quantity.reconstitute(
+ entity.getPlannedQuantityAmount(),
+ UnitOfMeasure.valueOf(entity.getPlannedQuantityUnit())
+ ),
+ actualQuantity,
+ waste,
+ entity.getRemarks(),
+ entity.getProductionDate(),
+ entity.getBestBeforeDate(),
+ entity.getCreatedAt(),
+ entity.getUpdatedAt(),
+ entity.getCompletedAt(),
+ entity.getCancellationReason(),
+ entity.getCancelledAt(),
+ entity.getVersion(),
+ List.of()
+ );
+ }
+
private ConsumptionEntity toConsumptionEntity(Consumption c, BatchEntity parent) {
return new ConsumptionEntity(
c.id().value(),
diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaBatchRepository.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaBatchRepository.java
index 357d30f..82271de 100644
--- a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaBatchRepository.java
+++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaBatchRepository.java
@@ -108,6 +108,59 @@ public class JpaBatchRepository implements BatchRepository {
}
}
+ @Override
+ public Result> findAllSummary() {
+ try {
+ List result = jpaRepository.findAll().stream()
+ .map(mapper::toDomainSummary)
+ .collect(Collectors.toList());
+ return Result.success(result);
+ } catch (Exception e) {
+ logger.trace("Database error in findAllSummary", e);
+ return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
+ }
+ }
+
+ @Override
+ public Result> findByStatusSummary(BatchStatus status) {
+ try {
+ List result = jpaRepository.findByStatus(status.name()).stream()
+ .map(mapper::toDomainSummary)
+ .collect(Collectors.toList());
+ return Result.success(result);
+ } catch (Exception e) {
+ logger.trace("Database error in findByStatusSummary", e);
+ return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
+ }
+ }
+
+ @Override
+ public Result> findByProductionDateSummary(LocalDate date) {
+ try {
+ List result = jpaRepository.findByProductionDate(date).stream()
+ .map(mapper::toDomainSummary)
+ .collect(Collectors.toList());
+ return Result.success(result);
+ } catch (Exception e) {
+ logger.trace("Database error in findByProductionDateSummary", e);
+ return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
+ }
+ }
+
+ @Override
+ public Result> findByRecipeIdsSummary(List recipeIds) {
+ try {
+ List ids = recipeIds.stream().map(RecipeId::value).toList();
+ List result = jpaRepository.findByRecipeIdIn(ids).stream()
+ .map(mapper::toDomainSummary)
+ .collect(Collectors.toList());
+ return Result.success(result);
+ } catch (Exception e) {
+ logger.trace("Database error in findByRecipeIdsSummary", e);
+ return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
+ }
+ }
+
@Override
@Transactional
public Result save(Batch batch) {
diff --git a/backend/src/main/resources/db/changelog/changes/025-seed-production-order-permissions.xml b/backend/src/main/resources/db/changelog/changes/025-seed-production-order-permissions.xml
index f926a71..08c0aa1 100644
--- a/backend/src/main/resources/db/changelog/changes/025-seed-production-order-permissions.xml
+++ b/backend/src/main/resources/db/changelog/changes/025-seed-production-order-permissions.xml
@@ -6,7 +6,16 @@
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
- Add PRODUCTION_ORDER_READ and PRODUCTION_ORDER_WRITE permissions for ADMIN and PRODUCTION_MANAGER roles (idempotent)
+
+
+
+ SELECT COUNT(*) FROM role_permissions
+ WHERE role_id = 'c0a80121-0000-0000-0000-000000000001'
+ AND permission = 'PRODUCTION_ORDER_READ'
+
+
+
+ Add PRODUCTION_ORDER_READ and PRODUCTION_ORDER_WRITE permissions for ADMIN and PRODUCTION_MANAGER roles (skipped if already present from 002)
INSERT INTO role_permissions (role_id, permission) VALUES
diff --git a/backend/src/test/java/de/effigenix/application/production/ListBatchesTest.java b/backend/src/test/java/de/effigenix/application/production/ListBatchesTest.java
index 4de2203..d47b50a 100644
--- a/backend/src/test/java/de/effigenix/application/production/ListBatchesTest.java
+++ b/backend/src/test/java/de/effigenix/application/production/ListBatchesTest.java
@@ -81,7 +81,7 @@ class ListBatchesTest {
void should_ReturnAllBatches() {
var batches = List.of(sampleBatch("b1", BatchStatus.PLANNED), sampleBatch("b2", BatchStatus.PLANNED));
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
- when(batchRepository.findAll()).thenReturn(Result.success(batches));
+ when(batchRepository.findAllSummary()).thenReturn(Result.success(batches));
var result = listBatches.execute(performedBy);
@@ -93,7 +93,7 @@ class ListBatchesTest {
@DisplayName("should return empty list when no batches exist")
void should_ReturnEmptyList_When_NoBatchesExist() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
- when(batchRepository.findAll()).thenReturn(Result.success(List.of()));
+ when(batchRepository.findAllSummary()).thenReturn(Result.success(List.of()));
var result = listBatches.execute(performedBy);
@@ -105,7 +105,7 @@ class ListBatchesTest {
@DisplayName("should fail with RepositoryFailure when findAll fails")
void should_FailWithRepositoryFailure_When_FindAllFails() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
- when(batchRepository.findAll()).thenReturn(Result.failure(DB_ERROR));
+ when(batchRepository.findAllSummary()).thenReturn(Result.failure(DB_ERROR));
var result = listBatches.execute(performedBy);
@@ -135,7 +135,7 @@ class ListBatchesTest {
void should_ReturnBatches_FilteredByStatus() {
var batches = List.of(sampleBatch("b1", BatchStatus.PLANNED));
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
- when(batchRepository.findByStatus(BatchStatus.PLANNED)).thenReturn(Result.success(batches));
+ when(batchRepository.findByStatusSummary(BatchStatus.PLANNED)).thenReturn(Result.success(batches));
var result = listBatches.executeByStatus(BatchStatus.PLANNED, performedBy);
@@ -147,7 +147,7 @@ class ListBatchesTest {
@DisplayName("should return empty list when no batches match status")
void should_ReturnEmptyList_When_NoBatchesMatchStatus() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
- when(batchRepository.findByStatus(BatchStatus.PLANNED)).thenReturn(Result.success(List.of()));
+ when(batchRepository.findByStatusSummary(BatchStatus.PLANNED)).thenReturn(Result.success(List.of()));
var result = listBatches.executeByStatus(BatchStatus.PLANNED, performedBy);
@@ -159,7 +159,7 @@ class ListBatchesTest {
@DisplayName("should fail with RepositoryFailure when findByStatus fails")
void should_FailWithRepositoryFailure_When_FindByStatusFails() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
- when(batchRepository.findByStatus(BatchStatus.PLANNED)).thenReturn(Result.failure(DB_ERROR));
+ when(batchRepository.findByStatusSummary(BatchStatus.PLANNED)).thenReturn(Result.failure(DB_ERROR));
var result = listBatches.executeByStatus(BatchStatus.PLANNED, performedBy);
@@ -189,7 +189,7 @@ class ListBatchesTest {
void should_ReturnBatches_FilteredByProductionDate() {
var batches = List.of(sampleBatch("b1", BatchStatus.PLANNED));
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
- when(batchRepository.findByProductionDate(PRODUCTION_DATE)).thenReturn(Result.success(batches));
+ when(batchRepository.findByProductionDateSummary(PRODUCTION_DATE)).thenReturn(Result.success(batches));
var result = listBatches.executeByProductionDate(PRODUCTION_DATE, performedBy);
@@ -201,7 +201,7 @@ class ListBatchesTest {
@DisplayName("should return empty list when no batches match date")
void should_ReturnEmptyList_When_NoBatchesMatchDate() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
- when(batchRepository.findByProductionDate(PRODUCTION_DATE)).thenReturn(Result.success(List.of()));
+ when(batchRepository.findByProductionDateSummary(PRODUCTION_DATE)).thenReturn(Result.success(List.of()));
var result = listBatches.executeByProductionDate(PRODUCTION_DATE, performedBy);
@@ -213,7 +213,7 @@ class ListBatchesTest {
@DisplayName("should fail with RepositoryFailure when findByProductionDate fails")
void should_FailWithRepositoryFailure_When_FindByProductionDateFails() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
- when(batchRepository.findByProductionDate(PRODUCTION_DATE)).thenReturn(Result.failure(DB_ERROR));
+ when(batchRepository.findByProductionDateSummary(PRODUCTION_DATE)).thenReturn(Result.failure(DB_ERROR));
var result = listBatches.executeByProductionDate(PRODUCTION_DATE, performedBy);
@@ -245,7 +245,7 @@ class ListBatchesTest {
var batches = List.of(sampleBatch("b1", BatchStatus.PLANNED));
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(recipeRepository.findByArticleId("article-123")).thenReturn(Result.success(List.of(recipe)));
- when(batchRepository.findByRecipeIds(List.of(RecipeId.of("recipe-1")))).thenReturn(Result.success(batches));
+ when(batchRepository.findByRecipeIdsSummary(List.of(RecipeId.of("recipe-1")))).thenReturn(Result.success(batches));
var result = listBatches.executeByArticleId("article-123", performedBy);
@@ -272,7 +272,7 @@ class ListBatchesTest {
var recipe = sampleRecipe("recipe-1");
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(recipeRepository.findByArticleId("article-123")).thenReturn(Result.success(List.of(recipe)));
- when(batchRepository.findByRecipeIds(List.of(RecipeId.of("recipe-1")))).thenReturn(Result.success(List.of()));
+ when(batchRepository.findByRecipeIdsSummary(List.of(RecipeId.of("recipe-1")))).thenReturn(Result.success(List.of()));
var result = listBatches.executeByArticleId("article-123", performedBy);
@@ -299,7 +299,7 @@ class ListBatchesTest {
var recipe = sampleRecipe("recipe-1");
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(recipeRepository.findByArticleId("article-123")).thenReturn(Result.success(List.of(recipe)));
- when(batchRepository.findByRecipeIds(List.of(RecipeId.of("recipe-1")))).thenReturn(Result.failure(DB_ERROR));
+ when(batchRepository.findByRecipeIdsSummary(List.of(RecipeId.of("recipe-1")))).thenReturn(Result.failure(DB_ERROR));
var result = listBatches.executeByArticleId("article-123", performedBy);
diff --git a/loadtest/README.md b/loadtest/README.md
new file mode 100644
index 0000000..92e859d
--- /dev/null
+++ b/loadtest/README.md
@@ -0,0 +1,87 @@
+# Lasttests (Gatling)
+
+Last- und Performance-Tests fuer Effigenix ERP auf Basis von [Gatling](https://gatling.io/).
+
+## Voraussetzungen
+
+- Java 21
+- Maven
+- Docker oder Podman (fuer Testcontainers/PostgreSQL)
+- Backend muss vorher gebaut sein (`mvn install` im Root oder `backend/`)
+
+## Backend bauen
+
+Das Loadtest-Modul bindet das Backend als Dependency ein und startet es embedded.
+Daher muss das Backend-Artefakt im lokalen Maven-Repository vorhanden sein:
+
+```bash
+cd backend
+mvn clean install -DskipTests
+```
+
+## Lasttests starten
+
+```bash
+cd loadtest
+mvn gatling:test
+```
+
+Das startet automatisch:
+
+1. PostgreSQL via Testcontainers (Docker/Podman)
+2. Spring Boot embedded mit Liquibase-Migrationen
+3. Testdaten-Seeding (120 Artikel, 30 Lieferanten, 50 Kunden, 30 Rezepte, ~1500 Chargen, 100 Produktionsauftraege)
+4. Gatling-Simulation mit ~2500 Requests
+
+## Lastprofil
+
+| Szenario | User | Repeat | ~Requests |
+|---|---|---|---|
+| Stammdaten CRUD | 15 | 15x | ~240 |
+| Produktions-Workflow | 30 | 1x (Chain) | ~270 |
+| Lagerverwaltung | 15 | 15x | ~240 |
+| Produktion Read-Only | 50 | 15x | ~800 |
+| Stammdaten Read-Only | 50 | 15x | ~800 |
+| **Gesamt** | | | **~2350** |
+
+## Ergebnisse
+
+Nach dem Lauf liegt der HTML-Report unter:
+
+```
+loadtest/target/gatling//index.html
+```
+
+## Assertions
+
+- p95 < 500ms (global)
+- p99 < 1000ms (global)
+- Fehlerrate < 5%
+- Einzeldatensatz-Reads: mean < 20ms
+- Listen (wenig Daten): mean < 35ms
+- Listen (viel Daten): mean < 75ms
+- Write-Requests: mean < 50ms
+
+## Konfiguration
+
+- `src/test/resources/gatling.conf` - Gatling-Einstellungen (Charting, HTTP-Cache, Writer)
+- `src/test/resources/logback-test.xml` - Log-Level waehrend der Tests
+
+## Aufbau
+
+```
+loadtest/
+ src/test/java/de/effigenix/loadtest/
+ infrastructure/
+ LoadTestInfrastructure.java # Startet PostgreSQL + Spring Boot
+ LoadTestDataSeeder.java # Erzeugt realistische Testdaten via REST-API
+ scenario/
+ AuthenticationScenario.java # Login-Chain
+ MasterDataScenario.java # Artikel, Lieferanten, Kunden
+ ProductionScenario.java # Rezepte, Chargen, Produktionsauftraege
+ InventoryScenario.java # Lagerorte, Bestaende
+ simulation/
+ FullWorkloadSimulation.java # Haupt-Simulation (kombiniert alle Szenarien)
+ util/
+ JsonBodyBuilder.java # JSON-Request-Body-Erzeugung
+```
diff --git a/loadtest/pom.xml b/loadtest/pom.xml
new file mode 100644
index 0000000..80933f6
--- /dev/null
+++ b/loadtest/pom.xml
@@ -0,0 +1,95 @@
+
+
+ 4.0.0
+
+ de.effigenix
+ effigenix-loadtest
+ 0.1.0-SNAPSHOT
+ jar
+
+ Effigenix ERP Load Tests
+ Gatling-basierte Last- und Performance-Tests für Effigenix ERP
+
+
+ 21
+ 21
+ 21
+ UTF-8
+
+ 3.14.9
+ 4.21.0
+ 1.20.4
+ 3.2.2
+
+
+
+
+
+ io.gatling.highcharts
+ gatling-charts-highcharts
+ ${gatling.version}
+ test
+
+
+
+
+ org.testcontainers
+ postgresql
+ ${testcontainers.version}
+ test
+
+
+
+
+ de.effigenix
+ effigenix-erp
+ 0.1.0-SNAPSHOT
+ test
+
+
+
+
+ org.postgresql
+ postgresql
+ 42.7.2
+ test
+
+
+
+
+ src/test/java
+
+
+ src/test/resources
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ 21
+ 21
+
+
+
+
+ io.gatling
+ gatling-maven-plugin
+ ${gatling-maven-plugin.version}
+
+ de.effigenix.loadtest.simulation.FullWorkloadSimulation
+ true
+
+ -Xmx1g
+
+
+
+
+
+
diff --git a/loadtest/src/test/java/de/effigenix/loadtest/infrastructure/LoadTestDataSeeder.java b/loadtest/src/test/java/de/effigenix/loadtest/infrastructure/LoadTestDataSeeder.java
new file mode 100644
index 0000000..73af035
--- /dev/null
+++ b/loadtest/src/test/java/de/effigenix/loadtest/infrastructure/LoadTestDataSeeder.java
@@ -0,0 +1,602 @@
+package de.effigenix.loadtest.infrastructure;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.context.ConfigurableApplicationContext;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * Erzeugt realistische Testdaten via REST-API gegen die laufende Applikation.
+ * Simuliert einen mittelgroßen Fleischerei-Betrieb mit Jahresproduktion.
+ *
+ * Datenmengen:
+ * 15 Kategorien, 120 Artikel, 30 Lieferanten, 50 Kunden,
+ * 8 Lagerorte, 30 Rezepte, ~1500 Chargen (5-Jahres-Produktion),
+ * 100 Produktionsaufträge
+ */
+public final class LoadTestDataSeeder {
+
+ private static final String ADMIN_USER = "admin";
+ private static final String ADMIN_PASS = "admin123";
+
+ private final String baseUrl;
+ private final HttpClient http;
+ private final ObjectMapper mapper;
+
+ private String adminToken;
+
+ // Gesammelte IDs für spätere Referenz in Szenarien
+ private final List categoryIds = new ArrayList<>();
+ private final List articleIds = new ArrayList<>();
+ private final List supplierIds = new ArrayList<>();
+ private final List customerIds = new ArrayList<>();
+ private final List storageLocationIds = new ArrayList<>();
+ private final List recipeIds = new ArrayList<>();
+ private final List batchIds = new ArrayList<>();
+ private final List productionOrderIds = new ArrayList<>();
+
+ // Statische Felder für Zugriff aus Szenarien
+ private static List seededCategoryIds;
+ private static List seededArticleIds;
+ private static List seededSupplierIds;
+ private static List seededCustomerIds;
+ private static List seededStorageLocationIds;
+ private static List seededRecipeIds;
+ private static List seededBatchIds;
+ private static List seededProductionOrderIds;
+
+ public LoadTestDataSeeder(ConfigurableApplicationContext appContext) {
+ int port = appContext.getEnvironment()
+ .getProperty("local.server.port", Integer.class, 8080);
+ this.baseUrl = "http://localhost:" + port;
+ this.http = HttpClient.newHttpClient();
+ this.mapper = new ObjectMapper();
+ }
+
+ public void seed() {
+ try {
+ long start = System.currentTimeMillis();
+ login();
+ seedCategories();
+ seedArticles();
+ seedSuppliers();
+ seedCustomers();
+ seedStorageLocations();
+ seedRecipes();
+ seedBatchesForYear();
+ seedProductionOrders();
+
+ // Statische Referenzen setzen
+ seededCategoryIds = List.copyOf(categoryIds);
+ seededArticleIds = List.copyOf(articleIds);
+ seededSupplierIds = List.copyOf(supplierIds);
+ seededCustomerIds = List.copyOf(customerIds);
+ seededStorageLocationIds = List.copyOf(storageLocationIds);
+ seededRecipeIds = List.copyOf(recipeIds);
+ seededBatchIds = List.copyOf(batchIds);
+ seededProductionOrderIds = List.copyOf(productionOrderIds);
+
+ long duration = System.currentTimeMillis() - start;
+ System.out.printf(
+ "Seeded in %dms: %d Kategorien, %d Artikel, %d Lieferanten, %d Kunden, "
+ + "%d Lagerorte, %d Rezepte, %d Chargen, %d Produktionsaufträge%n",
+ duration, categoryIds.size(), articleIds.size(), supplierIds.size(),
+ customerIds.size(), storageLocationIds.size(), recipeIds.size(),
+ batchIds.size(), productionOrderIds.size());
+ } catch (Exception e) {
+ throw new RuntimeException("Testdaten-Seeding fehlgeschlagen", e);
+ }
+ }
+
+ private void login() throws Exception {
+ String body = """
+ {"username":"%s","password":"%s"}""".formatted(ADMIN_USER, ADMIN_PASS);
+ var response = post("/api/auth/login", body, null);
+ adminToken = mapper.readTree(response).get("accessToken").asText();
+ }
+
+ // ---- Kategorien (15) ----
+
+ private void seedCategories() throws Exception {
+ String[] categories = {
+ "Frischfleisch", "Wurstwaren", "Schinken", "Aufschnitt",
+ "Brühwurst", "Rohwurst", "Kochwurst", "Pasteten",
+ "Convenience", "Gewürze & Zutaten", "Verpackungsmaterial",
+ "Rohstoffe Schwein", "Rohstoffe Rind", "Geflügel", "Feinkost"
+ };
+ for (String name : categories) {
+ String body = """
+ {"name":"%s","description":"Kategorie %s"}""".formatted(name, name);
+ var json = mapper.readTree(post("/api/categories", body, adminToken));
+ categoryIds.add(json.get("id").asText());
+ }
+ }
+
+ // ---- Artikel (120) ----
+
+ private int articleSeq = 1;
+
+ private void seedArticles() throws Exception {
+ // Eigene Produkte (Wurstwaren, Schinken, Aufschnitt, Brühwurst etc.)
+ String[][] eigenprodukte = {
+ {"Bratwurst grob", "1", "KG", "WEIGHT_BASED", "8.90"},
+ {"Bratwurst fein", "1", "KG", "WEIGHT_BASED", "9.20"},
+ {"Nürnberger Rostbratwurst", "1", "PIECE_FIXED", "FIXED", "4.50"},
+ {"Currywurst", "1", "PIECE_FIXED", "FIXED", "3.90"},
+ {"Wiener Würstchen", "4", "PIECE_FIXED", "FIXED", "3.50"},
+ {"Bockwurst", "4", "PIECE_FIXED", "FIXED", "2.90"},
+ {"Knackwurst", "4", "PIECE_FIXED", "FIXED", "3.20"},
+ {"Weißwurst", "4", "PIECE_FIXED", "FIXED", "2.50"},
+ {"Frankfurter Würstchen", "4", "PIECE_FIXED", "FIXED", "3.80"},
+ {"Fleischwurst Ring", "4", "KG", "WEIGHT_BASED", "7.50"},
+ {"Lyoner", "3", "KG", "WEIGHT_BASED", "8.20"},
+ {"Bierschinken", "3", "KG", "WEIGHT_BASED", "8.50"},
+ {"Jagdwurst", "3", "KG", "WEIGHT_BASED", "7.80"},
+ {"Mortadella", "3", "KG", "WEIGHT_BASED", "9.20"},
+ {"Schinkenwurst", "3", "KG", "WEIGHT_BASED", "9.50"},
+ {"Salami Milano", "5", "KG", "WEIGHT_BASED", "22.50"},
+ {"Salami Ungarn", "5", "KG", "WEIGHT_BASED", "19.80"},
+ {"Cervelatwurst", "5", "KG", "WEIGHT_BASED", "18.90"},
+ {"Mettwurst grob", "5", "KG", "WEIGHT_BASED", "10.50"},
+ {"Mettwurst fein", "5", "KG", "WEIGHT_BASED", "11.20"},
+ {"Teewurst", "5", "KG", "WEIGHT_BASED", "11.90"},
+ {"Krakauer", "5", "KG", "WEIGHT_BASED", "9.80"},
+ {"Paprikawurst", "5", "KG", "WEIGHT_BASED", "10.20"},
+ {"Pfefferwurst", "5", "KG", "WEIGHT_BASED", "10.80"},
+ {"Leberwurst fein", "6", "KG", "WEIGHT_BASED", "7.90"},
+ {"Leberwurst grob", "6", "KG", "WEIGHT_BASED", "7.50"},
+ {"Blutwurst", "6", "KG", "WEIGHT_BASED", "6.50"},
+ {"Sülze", "6", "KG", "WEIGHT_BASED", "5.90"},
+ {"Presssack", "6", "KG", "WEIGHT_BASED", "6.80"},
+ {"Leberkäse", "7", "KG", "WEIGHT_BASED", "9.50"},
+ {"Fleischkäse Pikant", "7", "KG", "WEIGHT_BASED", "10.20"},
+ {"Pastete Champignon", "7", "KG", "WEIGHT_BASED", "12.50"},
+ {"Pastete Pfeffer", "7", "KG", "WEIGHT_BASED", "13.20"},
+ {"Schinken gekocht", "2", "KG", "WEIGHT_BASED", "18.90"},
+ {"Nussschinken", "2", "KG", "WEIGHT_BASED", "22.50"},
+ {"Lachsschinken", "2", "KG", "WEIGHT_BASED", "28.90"},
+ {"Schwarzwälder Schinken", "2", "KG", "WEIGHT_BASED", "26.50"},
+ {"Räucherschinken", "2", "KG", "WEIGHT_BASED", "21.90"},
+ {"Kasseler Nacken", "2", "KG", "WEIGHT_BASED", "14.50"},
+ {"Kasseler Rücken", "2", "KG", "WEIGHT_BASED", "15.90"},
+ {"Fleischsalat", "14", "KG", "WEIGHT_BASED", "8.90"},
+ {"Wurstsalat", "14", "KG", "WEIGHT_BASED", "9.50"},
+ {"Kartoffelsalat m. Fleischeinlage", "14", "KG", "WEIGHT_BASED", "7.50"},
+ {"Gulaschsuppe", "8", "KG", "WEIGHT_BASED", "6.50"},
+ {"Maultaschen", "8", "PIECE_FIXED", "FIXED", "5.90"},
+ {"Frikadellen", "8", "PIECE_FIXED", "FIXED", "1.80"},
+ {"Hackfleisch Schwein/Rind", "0", "KG", "WEIGHT_BASED", "8.90"},
+ {"Hackfleisch Rind", "0", "KG", "WEIGHT_BASED", "11.50"},
+ };
+
+ // Rohstoffe und Einkaufsartikel
+ String[][] rohstoffe = {
+ {"Schweinenacken", "11", "KG", "WEIGHT_BASED", "6.50"},
+ {"Schweinebauch", "11", "KG", "WEIGHT_BASED", "5.20"},
+ {"Schweinekeule", "11", "KG", "WEIGHT_BASED", "5.80"},
+ {"Schweineschulter", "11", "KG", "WEIGHT_BASED", "5.50"},
+ {"Schweinekopf", "11", "KG", "WEIGHT_BASED", "2.80"},
+ {"Schweinerücken", "11", "KG", "WEIGHT_BASED", "7.20"},
+ {"Schweineleber", "11", "KG", "WEIGHT_BASED", "3.50"},
+ {"Schweineblut", "11", "KG", "WEIGHT_BASED", "1.80"},
+ {"Schweinefett/Speck", "11", "KG", "WEIGHT_BASED", "3.20"},
+ {"Schwarte", "11", "KG", "WEIGHT_BASED", "2.50"},
+ {"Rinderfilet", "12", "KG", "WEIGHT_BASED", "45.00"},
+ {"Rindernuss", "12", "KG", "WEIGHT_BASED", "16.50"},
+ {"Rinderkeule", "12", "KG", "WEIGHT_BASED", "14.20"},
+ {"Rinderschulter", "12", "KG", "WEIGHT_BASED", "12.80"},
+ {"Rinderbrust", "12", "KG", "WEIGHT_BASED", "11.50"},
+ {"Gulasch Rind", "12", "KG", "WEIGHT_BASED", "16.50"},
+ {"Gulasch Schwein", "11", "KG", "WEIGHT_BASED", "10.90"},
+ {"Hähnchenbrustfilet", "13", "KG", "WEIGHT_BASED", "15.90"},
+ {"Hähnchenschenkel", "13", "KG", "WEIGHT_BASED", "6.50"},
+ {"Putenbrust", "13", "KG", "WEIGHT_BASED", "14.50"},
+ {"Putenkeule", "13", "KG", "WEIGHT_BASED", "8.90"},
+ {"Lammkeule", "0", "KG", "WEIGHT_BASED", "22.90"},
+ {"Lammrücken", "0", "KG", "WEIGHT_BASED", "28.50"},
+ {"Kalbsschnitzel", "0", "KG", "WEIGHT_BASED", "25.90"},
+ // Gewürze & Zutaten
+ {"Nitritpökelsalz", "9", "KG", "WEIGHT_BASED", "2.50"},
+ {"Pfeffer schwarz gemahlen", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "3.20"},
+ {"Pfeffer weiß gemahlen", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "3.80"},
+ {"Paprika edelsüß", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "2.80"},
+ {"Paprika rosenscharf", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "3.10"},
+ {"Knoblauch granuliert", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "4.50"},
+ {"Knoblauch frisch", "9", "KG", "WEIGHT_BASED", "8.90"},
+ {"Zwiebeln", "9", "KG", "WEIGHT_BASED", "1.20"},
+ {"Majoran gerebelt", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "3.90"},
+ {"Kümmel gemahlen", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "2.90"},
+ {"Kümmel ganz", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "2.50"},
+ {"Senfkörner", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "1.80"},
+ {"Muskatnuss gemahlen", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "5.50"},
+ {"Koriander gemahlen", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "2.80"},
+ {"Ingwer gemahlen", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "3.20"},
+ {"Zucker", "9", "KG", "WEIGHT_BASED", "1.50"},
+ {"Dextrose", "9", "KG", "WEIGHT_BASED", "2.80"},
+ {"Eiswasser", "9", "KG", "WEIGHT_BASED", "0.10"},
+ {"Phosphat E450", "9", "KG", "WEIGHT_BASED", "4.50"},
+ {"Ascorbinsäure E300", "9", "KG", "WEIGHT_BASED", "12.00"},
+ {"Starterkulturen", "9", "HUNDRED_GRAM", "WEIGHT_BASED", "18.50"},
+ {"Rauchsalz", "9", "KG", "WEIGHT_BASED", "5.50"},
+ // Verpackung & Därme
+ {"Schweinedarm 28/30", "10", "PIECE_FIXED", "FIXED", "15.00"},
+ {"Schweinedarm 32/34", "10", "PIECE_FIXED", "FIXED", "16.50"},
+ {"Schafsdarm 22/24", "10", "PIECE_FIXED", "FIXED", "22.00"},
+ {"Kunstdarm 45mm", "10", "PIECE_FIXED", "FIXED", "6.50"},
+ {"Kunstdarm 60mm", "10", "PIECE_FIXED", "FIXED", "8.50"},
+ {"Kunstdarm 90mm", "10", "PIECE_FIXED", "FIXED", "10.00"},
+ {"Netz Schweinenetz", "10", "PIECE_FIXED", "FIXED", "3.50"},
+ {"Vakuumbeutel 20x30", "10", "PIECE_FIXED", "FIXED", "0.15"},
+ {"Vakuumbeutel 30x40", "10", "PIECE_FIXED", "FIXED", "0.22"},
+ {"MAP-Schale 500g", "10", "PIECE_FIXED", "FIXED", "0.35"},
+ {"MAP-Schale 250g", "10", "PIECE_FIXED", "FIXED", "0.28"},
+ {"Etiketten Rolle 1000 St", "10", "PIECE_FIXED", "FIXED", "25.00"},
+ // Frischfleisch zum Verkauf
+ {"Schnitzel Schwein", "0", "KG", "WEIGHT_BASED", "12.90"},
+ {"Rouladen Rind", "0", "KG", "WEIGHT_BASED", "18.50"},
+ {"Braten Schwein", "0", "KG", "WEIGHT_BASED", "11.50"},
+ {"Spare Ribs", "0", "KG", "WEIGHT_BASED", "13.90"},
+ {"Kotelett Schwein", "0", "KG", "WEIGHT_BASED", "10.90"},
+ {"Filet Schwein", "0", "KG", "WEIGHT_BASED", "14.50"},
+ {"Tafelspitz", "12", "KG", "WEIGHT_BASED", "19.90"},
+ {"Suppenfleisch Rind", "12", "KG", "WEIGHT_BASED", "9.50"},
+ {"Beinscheibe Rind", "12", "KG", "WEIGHT_BASED", "8.50"},
+ {"Ochsenschwanz", "12", "KG", "WEIGHT_BASED", "12.50"},
+ };
+
+ for (String[][] batch : List.of(eigenprodukte, rohstoffe)) {
+ for (String[] a : batch) {
+ String catId = categoryIds.get(Integer.parseInt(a[1]) % categoryIds.size());
+ String num = "ART-%05d".formatted(articleSeq++);
+ String body = """
+ {"name":"%s","articleNumber":"%s","categoryId":"%s","unit":"%s","priceModel":"%s","price":%s}"""
+ .formatted(a[0], num, catId, a[2], a[3], a[4]);
+ var json = mapper.readTree(post("/api/articles", body, adminToken));
+ articleIds.add(json.get("id").asText());
+ }
+ }
+ }
+
+ // ---- Lieferanten (30) ----
+
+ private void seedSuppliers() throws Exception {
+ String[][] suppliers = {
+ {"Fleischgroßhandel Müller", "0711-1234567"},
+ {"Gewürzkontor Hamburg", "040-9876543"},
+ {"Darmfabrik Süd", "089-5551234"},
+ {"Bio-Fleisch Bauer Schmidt", "07151-8889999"},
+ {"Metzgereibedarf Weber", "0621-3334444"},
+ {"Nordisch Fleisch GmbH", "040-1112233"},
+ {"Schwäbische Landfleisch", "0711-5556677"},
+ {"Kräuter & Gewürze Maier", "089-4445566"},
+ {"Verpackung Express", "0621-7778899"},
+ {"Kühl-Logistik Klein", "0711-2223344"},
+ {"Rinderzucht Oberbayern", "08821-112233"},
+ {"Geflügelhof Sonnental", "07156-445566"},
+ {"Wurstgewürz Spezialitäten", "0911-778899"},
+ {"Hygiene-Bedarf Reinhardt", "0621-990011"},
+ {"Maschinenservice Fischer", "0711-667788"},
+ {"Verpackungsmaterial Nord", "040-223344"},
+ {"Lammfleisch Alpenland", "08821-556677"},
+ {"Wild & Geflügel Jäger", "07531-889900"},
+ {"Naturgewürze Bio", "089-112244"},
+ {"Fleisch-Import Europa", "069-334455"},
+ {"Schlachthof Reutlingen", "07121-556677"},
+ {"Gewürzhaus Orient", "0711-998877"},
+ {"Bayerische Fleischwerke", "089-665544"},
+ {"Darmhandel International", "040-332211"},
+ {"Fleischgroßmarkt Frankfurt", "069-112233"},
+ {"Verpackungswerk Heilbronn", "07131-443322"},
+ {"Salzhandel Südwest", "0711-887766"},
+ {"Starter-Kulturen Wagner", "0621-554433"},
+ {"Tiefkühl-Logistik Schwab", "07151-332211"},
+ {"Reinigungsmittel Haas", "0711-776655"},
+ };
+ for (String[] s : suppliers) {
+ String body = """
+ {"name":"%s","phone":"%s"}""".formatted(s[0], s[1]);
+ var json = mapper.readTree(post("/api/suppliers", body, adminToken));
+ supplierIds.add(json.get("id").asText());
+ }
+ }
+
+ // ---- Kunden (50) ----
+
+ private void seedCustomers() throws Exception {
+ String[][] customers = {
+ // Gastronomie B2B
+ {"Restaurant Zum Goldenen Hirsch", "B2B", "Königstraße 12", "70173", "Stuttgart"},
+ {"Gasthaus Linde", "B2B", "Hauptstraße 5", "70178", "Stuttgart"},
+ {"Hotel Schwabenstube", "B2B", "Schlossplatz 8", "70173", "Stuttgart"},
+ {"Pizzeria Bella Italia", "B2B", "Marienstraße 22", "70178", "Stuttgart"},
+ {"Restaurant Ochsen", "B2B", "Calwer Str. 31", "70173", "Stuttgart"},
+ {"Gasthof Adler", "B2B", "Tübinger Str. 44", "70178", "Stuttgart"},
+ {"Restaurant Asia Garden", "B2B", "Eberhardstr. 10", "70173", "Stuttgart"},
+ {"Steakhouse El Gaucho", "B2B", "Rotebühlplatz 1", "70178", "Stuttgart"},
+ {"Weinwirtschaft Zur Kiste", "B2B", "Kanalstr. 2", "70182", "Stuttgart"},
+ {"Brauerei Schönbuch", "B2B", "Böblinger Str. 101", "71032", "Böblingen"},
+ {"Hotel Pullman", "B2B", "Heilbronner Str. 88", "70191", "Stuttgart"},
+ {"Restaurant Zeppelin", "B2B", "Arnulf-Klett-Platz 7", "70173", "Stuttgart"},
+ // Kantinen B2B
+ {"Betriebskantine Bosch", "B2B", "Robert-Bosch-Str. 1", "70839", "Gerlingen"},
+ {"Betriebskantine Daimler", "B2B", "Mercedesstr. 120", "70372", "Stuttgart"},
+ {"Betriebskantine Porsche", "B2B", "Porschestr. 42", "70435", "Stuttgart"},
+ {"Uni-Mensa Stuttgart", "B2B", "Pfaffenwaldring 45", "70569", "Stuttgart"},
+ {"Kantine Landratsamt", "B2B", "Löwentorstr. 54", "70376", "Stuttgart"},
+ // Einzelhandel B2B
+ {"Metzgerei-Filiale Mitte", "B2B", "Marktplatz 3", "70173", "Stuttgart"},
+ {"Metzgerei-Filiale Vaihingen", "B2B", "Vaihinger Markt 8", "70563", "Stuttgart"},
+ {"Metzgerei-Filiale Degerloch", "B2B", "Epplestr. 12", "70597", "Stuttgart"},
+ {"Metzgerei-Filiale Fellbach", "B2B", "Bahnhofstr. 24", "70734", "Fellbach"},
+ {"Metzgerei-Filiale Esslingen", "B2B", "Pliensaustr. 6", "73728", "Esslingen"},
+ {"Bio-Laden Sonnenschein", "B2B", "Gutenbergstr. 15", "70176", "Stuttgart"},
+ {"Feinkost Böhm", "B2B", "Calwer Str. 58", "70173", "Stuttgart"},
+ {"Reformhaus Vital", "B2B", "Schulstr. 3", "70173", "Stuttgart"},
+ {"Hofladen Streuobstwiese", "B2B", "Gartenstr. 7", "70771", "Leinfelden"},
+ {"Wochenmarkt Stuttgart", "B2B", "Schillerplatz", "70173", "Stuttgart"},
+ // Großkunden B2B
+ {"Großmarkt Stuttgart", "B2B", "Heilbronner Str. 320", "70469", "Stuttgart"},
+ {"Edeka Großhandel Süd", "B2B", "Hedelfinger Str. 55", "70327", "Stuttgart"},
+ {"REWE Markt Stuttgart-Ost", "B2B", "Neckarstr. 190", "70190", "Stuttgart"},
+ {"Tegut Stuttgart", "B2B", "Eberhardstr. 35", "70173", "Stuttgart"},
+ {"Catering König", "B2B", "Industriestr. 22", "70565", "Stuttgart"},
+ {"Event-Catering Schwab", "B2B", "Ulmer Str. 10", "70188", "Stuttgart"},
+ {"Party-Service Müller", "B2B", "Pragstr. 44", "70376", "Stuttgart"},
+ {"Essen auf Rädern Süd", "B2B", "Böblinger Str. 22", "70199", "Stuttgart"},
+ {"Krankenhaus-Küche Olgahospital", "B2B", "Kriegsbergstr. 60", "70174", "Stuttgart"},
+ {"Seniorenheim Am Park", "B2B", "Parkweg 5", "70192", "Stuttgart"},
+ {"Kindergarten-Catering Sonnenkind", "B2B", "Rosensteinstr. 12", "70191", "Stuttgart"},
+ {"Flughafen-Gastronomie STR", "B2B", "Flughafenstr. 43", "70629", "Stuttgart"},
+ // Privatkunden B2C
+ {"Max Mustermann", "B2C", "Musterweg 1", "70173", "Stuttgart"},
+ {"Erika Musterfrau", "B2C", "Beispielgasse 7", "70178", "Stuttgart"},
+ {"Hans Wurstliebhaber", "B2C", "Fleischstr. 12", "70182", "Stuttgart"},
+ {"Familie Schmid", "B2C", "Gartenweg 3", "70563", "Stuttgart"},
+ {"Thomas Grillfan", "B2C", "Sonnenhalde 5", "70597", "Stuttgart"},
+ {"Andrea Koch", "B2C", "Küchenstr. 8", "70376", "Stuttgart"},
+ {"Peter Großeinkauf", "B2C", "Vorratsweg 22", "70435", "Stuttgart"},
+ {"Susanne Feinschmecker", "B2C", "Delikatessenstr. 1", "70173", "Stuttgart"},
+ {"Familie Weber", "B2C", "Am Weinberg 15", "70734", "Fellbach"},
+ {"Familie Bauer", "B2C", "Lindenstr. 30", "73728", "Esslingen"},
+ {"Verein SV Stuttgart 08", "B2C", "Sportplatzweg 1", "70469", "Stuttgart"},
+ };
+ int phoneSeq = 1000000;
+ for (String[] c : customers) {
+ String phone = "0711-%07d".formatted(phoneSeq++);
+ String body = """
+ {"name":"%s","type":"%s","street":"%s","postalCode":"%s","city":"%s","country":"DE","phone":"%s"}"""
+ .formatted(c[0], c[1], c[2], c[3], c[4], phone);
+ var json = mapper.readTree(post("/api/customers", body, adminToken));
+ customerIds.add(json.get("id").asText());
+ }
+ }
+
+ // ---- Lagerorte (8) ----
+
+ private void seedStorageLocations() throws Exception {
+ String[][] locations = {
+ {"Kühlhaus 1", "COLD_ROOM", "0", "4"},
+ {"Kühlhaus 2", "COLD_ROOM", "0", "4"},
+ {"Tiefkühllager", "FREEZER", "-22", "-18"},
+ {"Tiefkühllager Reserven", "FREEZER", "-22", "-18"},
+ {"Trockenlager", "DRY_STORAGE", null, null},
+ {"Gewürzlager", "DRY_STORAGE", null, null},
+ {"Theke Verkauf", "DISPLAY_COUNTER", "0", "7"},
+ {"Produktionshalle", "PRODUCTION_AREA", "10", "18"},
+ };
+ for (String[] loc : locations) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("{\"name\":\"").append(loc[0]).append("\",\"storageType\":\"").append(loc[1]).append("\"");
+ if (loc[2] != null) {
+ sb.append(",\"minTemperature\":\"").append(loc[2]).append("\"");
+ sb.append(",\"maxTemperature\":\"").append(loc[3]).append("\"");
+ }
+ sb.append("}");
+ var json = mapper.readTree(post("/api/inventory/storage-locations", sb.toString(), adminToken));
+ storageLocationIds.add(json.get("id").asText());
+ }
+ }
+
+ // ---- Rezepte (30) ----
+
+ private void seedRecipes() throws Exception {
+ String[][] recipes = {
+ // Brühwürste
+ {"Bratwurst grob", "FINISHED_PRODUCT", "88", "10", "10", "KILOGRAM"},
+ {"Bratwurst fein", "FINISHED_PRODUCT", "90", "10", "10", "KILOGRAM"},
+ {"Wiener Würstchen", "FINISHED_PRODUCT", "85", "14", "20", "KILOGRAM"},
+ {"Bockwurst", "FINISHED_PRODUCT", "86", "14", "15", "KILOGRAM"},
+ {"Fleischwurst Ring", "FINISHED_PRODUCT", "87", "10", "25", "KILOGRAM"},
+ {"Lyoner", "FINISHED_PRODUCT", "88", "10", "20", "KILOGRAM"},
+ {"Bierschinken", "FINISHED_PRODUCT", "82", "14", "15", "KILOGRAM"},
+ {"Jagdwurst", "FINISHED_PRODUCT", "85", "10", "20", "KILOGRAM"},
+ // Rohwürste
+ {"Salami Milano", "FINISHED_PRODUCT", "62", "28", "10", "KILOGRAM"},
+ {"Cervelatwurst", "FINISHED_PRODUCT", "65", "21", "10", "KILOGRAM"},
+ {"Mettwurst grob", "FINISHED_PRODUCT", "75", "14", "8", "KILOGRAM"},
+ {"Teewurst", "FINISHED_PRODUCT", "78", "7", "5", "KILOGRAM"},
+ // Kochwürste
+ {"Leberwurst fein", "FINISHED_PRODUCT", "80", "7", "10", "KILOGRAM"},
+ {"Leberwurst grob", "FINISHED_PRODUCT", "82", "7", "10", "KILOGRAM"},
+ {"Blutwurst", "FINISHED_PRODUCT", "85", "7", "10", "KILOGRAM"},
+ {"Sülze", "FINISHED_PRODUCT", "90", "7", "15", "KILOGRAM"},
+ // Schinken
+ {"Schinken gekocht", "FINISHED_PRODUCT", "72", "21", "12", "KILOGRAM"},
+ {"Nussschinken", "FINISHED_PRODUCT", "68", "28", "10", "KILOGRAM"},
+ {"Schwarzwälder Schinken", "FINISHED_PRODUCT", "55", "42", "8", "KILOGRAM"},
+ {"Kasseler Nacken", "FINISHED_PRODUCT", "75", "14", "15", "KILOGRAM"},
+ // Pasteten & Convenience
+ {"Leberkäse", "FINISHED_PRODUCT", "90", "3", "25", "KILOGRAM"},
+ {"Fleischkäse Pikant", "FINISHED_PRODUCT", "88", "3", "20", "KILOGRAM"},
+ {"Frikadellen", "FINISHED_PRODUCT", "92", "5", "30", "KILOGRAM"},
+ {"Maultaschen", "FINISHED_PRODUCT", "85", "7", "20", "KILOGRAM"},
+ // Halbfertigprodukte (Intermediate)
+ {"Brät fein", "INTERMEDIATE", "95", "1", "10", "KILOGRAM"},
+ {"Brät grob", "INTERMEDIATE", "95", "1", "10", "KILOGRAM"},
+ {"Gewürzmischung Bratwurst", "INTERMEDIATE", "100", "180", "2", "KILOGRAM"},
+ {"Gewürzmischung Salami", "INTERMEDIATE", "100", "180", "2", "KILOGRAM"},
+ {"Pökel-Lake Standard", "INTERMEDIATE", "100", "14", "50", "KILOGRAM"},
+ {"Hackfleisch gewürzt", "INTERMEDIATE", "95", "1", "5", "KILOGRAM"},
+ };
+
+ for (int i = 0; i < recipes.length; i++) {
+ String outputArticleId = articleIds.get(i % articleIds.size());
+ String body = """
+ {"name":"%s","version":1,"type":"%s","description":"Rezept für %s","yieldPercentage":%s,"shelfLifeDays":%s,"outputQuantity":"%s","outputUom":"%s","articleId":"%s"}"""
+ .formatted(recipes[i][0], recipes[i][1], recipes[i][0],
+ recipes[i][2], recipes[i][3], recipes[i][4], recipes[i][5], outputArticleId);
+ var json = mapper.readTree(post("/api/recipes", body, adminToken));
+ String recipeId = json.get("id").asText();
+ recipeIds.add(recipeId);
+
+ // 3-5 Zutaten pro Rezept (Rohstoffe aus Artikel-Pool)
+ int ingredientCount = 3 + (i % 3);
+ int rohstoffStart = 48; // Index der Rohstoff-Artikel
+ for (int j = 0; j < ingredientCount; j++) {
+ String ingredientArticleId = articleIds.get(rohstoffStart + ((i * 5 + j) % (articleIds.size() - rohstoffStart)));
+ String qty = "%.1f".formatted(0.5 + (j + 1) * 0.8);
+ String ingredientBody = """
+ {"position":%d,"articleId":"%s","quantity":"%s","uom":"KILOGRAM","substitutable":false}"""
+ .formatted(j + 1, ingredientArticleId, qty);
+ post("/api/recipes/" + recipeId + "/ingredients", ingredientBody, adminToken);
+ }
+
+ // 2-4 Produktionsschritte
+ String[][] steps = {
+ {"Rohstoffe vorbereiten und wiegen", "15", "18"},
+ {"Wolfen/Kuttern/Mischen", "30", "4"},
+ {"Füllen und Abdrehen/Formen", "25", "12"},
+ {"Brühen/Räuchern/Reifen", "60", "75"},
+ };
+ int stepCount = 2 + (i % 3);
+ for (int s = 0; s < stepCount && s < steps.length; s++) {
+ String stepBody = """
+ {"stepNumber":%d,"description":"%s","durationMinutes":%s,"temperatureCelsius":%s}"""
+ .formatted(s + 1, steps[s][0], steps[s][1], steps[s][2]);
+ post("/api/recipes/" + recipeId + "/steps", stepBody, adminToken);
+ }
+
+ // Rezept aktivieren
+ post("/api/recipes/" + recipeId + "/activate", "{}", adminToken);
+ }
+ }
+
+ // ---- Chargen: 5 Jahre Produktion (~1500 Chargen) ----
+
+ private void seedBatchesForYear() throws Exception {
+ var today = LocalDate.now();
+ var fiveYearsAgo = today.minusYears(5);
+ var rnd = ThreadLocalRandom.current();
+ long totalDays = java.time.temporal.ChronoUnit.DAYS.between(fiveYearsAgo, today);
+
+ // ~1500 Chargen über 5 Jahre verteilt (~1,2 pro Arbeitstag)
+ int batchCount = 1500;
+ int logged = 0;
+ for (int i = 0; i < batchCount; i++) {
+ LocalDate productionDate = fiveYearsAgo.plusDays(rnd.nextLong(totalDays));
+ LocalDate bestBefore = productionDate.plusDays(rnd.nextInt(7, 90));
+
+ String recipeId = recipeIds.get(rnd.nextInt(recipeIds.size()));
+ int qty = rnd.nextInt(5, 50);
+
+ String body = """
+ {"recipeId":"%s","plannedQuantity":"%d","plannedQuantityUnit":"KILOGRAM","productionDate":"%s","bestBeforeDate":"%s"}"""
+ .formatted(recipeId, qty, productionDate, bestBefore);
+
+ try {
+ var json = mapper.readTree(post("/api/production/batches", body, adminToken));
+ String batchId = json.get("id").asText();
+ batchIds.add(batchId);
+
+ // 70% der Chargen starten
+ if (rnd.nextDouble() < 0.70) {
+ tryPost("/api/production/batches/" + batchId + "/start", "{}", adminToken);
+ }
+ } catch (Exception e) {
+ // Einzelne Fehler ignorieren (z.B. Duplikat-Batch-Nummern)
+ }
+
+ // Fortschritt loggen
+ if ((i + 1) % 500 == 0) {
+ System.out.printf(" Chargen-Seeding: %d/%d ...%n", i + 1, batchCount);
+ }
+ }
+ }
+
+ // ---- Produktionsaufträge (100, ab heute geplant) ----
+
+ private void seedProductionOrders() throws Exception {
+ var today = LocalDate.now();
+ var rnd = ThreadLocalRandom.current();
+ String[] priorities = {"NORMAL", "NORMAL", "NORMAL", "URGENT"};
+
+ for (int i = 0; i < 100; i++) {
+ LocalDate plannedDate = today.plusDays(rnd.nextInt(0, 90));
+ String recipeId = recipeIds.get(rnd.nextInt(recipeIds.size()));
+ int qty = rnd.nextInt(10, 100);
+ String priority = priorities[rnd.nextInt(priorities.length)];
+
+ String body = """
+ {"recipeId":"%s","plannedQuantity":"%d","plannedQuantityUnit":"KILOGRAM","plannedDate":"%s","priority":"%s","notes":"Seeded Lasttest-Auftrag #%d"}"""
+ .formatted(recipeId, qty, plannedDate, priority, i + 1);
+
+ try {
+ var json = mapper.readTree(post("/api/production/production-orders", body, adminToken));
+ String orderId = json.get("id").asText();
+ productionOrderIds.add(orderId);
+
+ // 40% der Aufträge sofort freigeben
+ if (rnd.nextDouble() < 0.40) {
+ tryPost("/api/production/production-orders/" + orderId + "/release", "{}", adminToken);
+ }
+ } catch (Exception e) {
+ // Einzelne Fehler ignorieren
+ }
+ }
+ }
+
+ // ---- HTTP Helper ----
+
+ private String post(String path, String jsonBody, String token) throws Exception {
+ var builder = HttpRequest.newBuilder()
+ .uri(URI.create(baseUrl + path))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(jsonBody));
+ if (token != null) {
+ builder.header("Authorization", "Bearer " + token);
+ }
+ var response = http.send(builder.build(), HttpResponse.BodyHandlers.ofString());
+ if (response.statusCode() >= 400) {
+ throw new RuntimeException("HTTP %d auf %s: %s".formatted(
+ response.statusCode(), path, response.body()));
+ }
+ return response.body();
+ }
+
+ /** Post ohne Exception bei Fehler – für optionale Status-Übergänge. */
+ private void tryPost(String path, String jsonBody, String token) {
+ try {
+ post(path, jsonBody, token);
+ } catch (Exception ignored) {
+ // Status-Übergang fehlgeschlagen, z.B. wegen Vorbedingungen
+ }
+ }
+
+ // ---- Statische Getter für Szenarien ----
+
+ public static List categoryIds() { return seededCategoryIds; }
+ public static List articleIds() { return seededArticleIds; }
+ public static List supplierIds() { return seededSupplierIds; }
+ public static List customerIds() { return seededCustomerIds; }
+ public static List storageLocationIds() { return seededStorageLocationIds; }
+ public static List recipeIds() { return seededRecipeIds; }
+ public static List batchIds() { return seededBatchIds; }
+ public static List productionOrderIds() { return seededProductionOrderIds; }
+}
diff --git a/loadtest/src/test/java/de/effigenix/loadtest/infrastructure/LoadTestInfrastructure.java b/loadtest/src/test/java/de/effigenix/loadtest/infrastructure/LoadTestInfrastructure.java
new file mode 100644
index 0000000..bfa1578
--- /dev/null
+++ b/loadtest/src/test/java/de/effigenix/loadtest/infrastructure/LoadTestInfrastructure.java
@@ -0,0 +1,148 @@
+package de.effigenix.loadtest.infrastructure;
+
+import de.effigenix.EffigenixApplication;
+import org.springframework.boot.builder.SpringApplicationBuilder;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.testcontainers.containers.PostgreSQLContainer;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * Startet die komplette Effigenix-Infrastruktur für Lasttests:
+ * 1. PostgreSQL via Testcontainers (echtes DB-Verhalten)
+ * 2. Spring Boot embedded mit Liquibase-Migrationen
+ * 3. Testdaten via LoadTestDataSeeder
+ */
+public final class LoadTestInfrastructure {
+
+ private static final String POSTGRES_IMAGE = "postgres:16-alpine";
+ private static final String DB_NAME = "effigenix_loadtest";
+ private static final String DB_USER = "effigenix";
+ private static final String DB_PASS = "effigenix";
+
+ private static PostgreSQLContainer> postgres;
+ private static ConfigurableApplicationContext appContext;
+ private static int serverPort;
+
+ private LoadTestInfrastructure() {}
+
+ public static synchronized void start() {
+ if (appContext != null) {
+ return;
+ }
+
+ configureDockerEnvironment();
+
+ postgres = new PostgreSQLContainer<>(POSTGRES_IMAGE)
+ .withDatabaseName(DB_NAME)
+ .withUsername(DB_USER)
+ .withPassword(DB_PASS);
+ postgres.start();
+
+ String jdbcUrl = postgres.getJdbcUrl();
+
+ // System-Properties setzen BEVOR Spring startet, damit sie die
+ // application.yml-Defaults (${DB_URL:...}) überschreiben.
+ System.setProperty("DB_URL", jdbcUrl);
+ System.setProperty("DB_USERNAME", DB_USER);
+ System.setProperty("DB_PASSWORD", DB_PASS);
+ System.setProperty("spring.datasource.url", jdbcUrl);
+ System.setProperty("spring.datasource.username", DB_USER);
+ System.setProperty("spring.datasource.password", DB_PASS);
+ System.setProperty("server.port", "0");
+
+ appContext = new SpringApplicationBuilder(EffigenixApplication.class)
+ .properties(
+ "server.port=0",
+ "spring.datasource.url=" + jdbcUrl,
+ "spring.datasource.username=" + DB_USER,
+ "spring.datasource.password=" + DB_PASS,
+ "spring.datasource.driver-class-name=org.postgresql.Driver",
+ "spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect",
+ "spring.jpa.hibernate.ddl-auto=validate",
+ "spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml",
+ "spring.liquibase.contexts=dev",
+ "jwt.secret=LoadTestSecretKeyMin256BitsLongForHS256AlgorithmSecurityTest",
+ "jwt.expiration=28800000",
+ "jwt.refresh-expiration=604800000",
+ "effigenix.cors.allowed-origins=*",
+ "logging.level.root=WARN",
+ "logging.level.de.effigenix=INFO",
+ "springdoc.api-docs.enabled=false",
+ "springdoc.swagger-ui.enabled=false",
+ "sentry.dsn="
+ )
+ .run();
+
+ serverPort = appContext.getEnvironment()
+ .getProperty("local.server.port", Integer.class, 8080);
+
+ System.out.println("=== Effigenix ERP gestartet auf Port " + serverPort + " ===");
+ System.out.println("=== PostgreSQL: " + jdbcUrl + " ===");
+
+ new LoadTestDataSeeder(appContext).seed();
+
+ System.out.println("=== Testdaten erzeugt ===");
+ }
+
+ /**
+ * Erkennt die Docker/Podman-Umgebung und konfiguriert Testcontainers entsprechend.
+ */
+ private static void configureDockerEnvironment() {
+ if (System.getenv("DOCKER_HOST") != null) {
+ return;
+ }
+ if (Files.exists(Path.of("/var/run/docker.sock"))) {
+ return;
+ }
+
+ // Podman rootless Socket
+ Path podmanSocket = Path.of("/run/user/" + getUid() + "/podman/podman.sock");
+ if (Files.exists(podmanSocket)) {
+ System.setProperty("DOCKER_HOST", "unix://" + podmanSocket);
+ System.setProperty("TESTCONTAINERS_RYUK_DISABLED", "true");
+ System.out.println("=== Podman erkannt: " + podmanSocket + " ===");
+ return;
+ }
+
+ // Podman root Socket
+ Path podmanRootSocket = Path.of("/run/podman/podman.sock");
+ if (Files.exists(podmanRootSocket)) {
+ System.setProperty("DOCKER_HOST", "unix://" + podmanRootSocket);
+ System.setProperty("TESTCONTAINERS_RYUK_DISABLED", "true");
+ System.out.println("=== Podman (root) erkannt: " + podmanRootSocket + " ===");
+ }
+ }
+
+ private static String getUid() {
+ try {
+ var process = new ProcessBuilder("id", "-u").start();
+ return new String(process.getInputStream().readAllBytes()).trim();
+ } catch (Exception e) {
+ return "1000";
+ }
+ }
+
+ public static String baseUrl() {
+ if (appContext == null) {
+ throw new IllegalStateException("LoadTestInfrastructure.start() muss zuerst aufgerufen werden");
+ }
+ return "http://localhost:" + serverPort;
+ }
+
+ public static int port() {
+ return serverPort;
+ }
+
+ public static synchronized void stop() {
+ if (appContext != null) {
+ appContext.close();
+ appContext = null;
+ }
+ if (postgres != null) {
+ postgres.stop();
+ postgres = null;
+ }
+ }
+}
diff --git a/loadtest/src/test/java/de/effigenix/loadtest/scenario/AuthenticationScenario.java b/loadtest/src/test/java/de/effigenix/loadtest/scenario/AuthenticationScenario.java
new file mode 100644
index 0000000..1266704
--- /dev/null
+++ b/loadtest/src/test/java/de/effigenix/loadtest/scenario/AuthenticationScenario.java
@@ -0,0 +1,64 @@
+package de.effigenix.loadtest.scenario;
+
+import de.effigenix.loadtest.util.JsonBodyBuilder;
+import io.gatling.javaapi.core.ScenarioBuilder;
+import io.gatling.javaapi.http.HttpRequestActionBuilder;
+
+import static io.gatling.javaapi.core.CoreDsl.*;
+import static io.gatling.javaapi.http.HttpDsl.*;
+
+/**
+ * Authentifizierungs-Szenario: Login → Token extrahieren → Token in Folge-Requests verwenden.
+ */
+public final class AuthenticationScenario {
+
+ private AuthenticationScenario() {}
+
+ /**
+ * Login-Request der ein JWT-Token extrahiert und in der Session speichert.
+ */
+ public static HttpRequestActionBuilder login(String username, String password) {
+ return http("Login [" + username + "]")
+ .post("/api/auth/login")
+ .header("Content-Type", "application/json")
+ .body(StringBody(JsonBodyBuilder.loginBody(username, password)))
+ .check(
+ status().is(200),
+ jsonPath("$.accessToken").saveAs("accessToken"),
+ jsonPath("$.refreshToken").saveAs("refreshToken")
+ );
+ }
+
+ /**
+ * Token-Refresh-Request.
+ */
+ public static HttpRequestActionBuilder refreshToken() {
+ return http("Token Refresh")
+ .post("/api/auth/refresh")
+ .header("Content-Type", "application/json")
+ .body(StringBody("""
+ {"refreshToken":"#{refreshToken}"}"""))
+ .check(
+ status().is(200),
+ jsonPath("$.accessToken").saveAs("accessToken"),
+ jsonPath("$.refreshToken").saveAs("refreshToken")
+ );
+ }
+
+ /**
+ * Vollständiges Auth-Szenario: Login → Pause → Refresh → Pause → Logout.
+ */
+ public static ScenarioBuilder authWorkflow() {
+ return scenario("Authentication Workflow")
+ .exec(login("admin", "admin123"))
+ .pause(1, 3)
+ .exec(refreshToken())
+ .pause(1, 2)
+ .exec(
+ http("Logout")
+ .post("/api/auth/logout")
+ .header("Authorization", "Bearer #{accessToken}")
+ .check(status().in(200, 204))
+ );
+ }
+}
diff --git a/loadtest/src/test/java/de/effigenix/loadtest/scenario/InventoryScenario.java b/loadtest/src/test/java/de/effigenix/loadtest/scenario/InventoryScenario.java
new file mode 100644
index 0000000..aba6fe9
--- /dev/null
+++ b/loadtest/src/test/java/de/effigenix/loadtest/scenario/InventoryScenario.java
@@ -0,0 +1,88 @@
+package de.effigenix.loadtest.scenario;
+
+import de.effigenix.loadtest.infrastructure.LoadTestDataSeeder;
+import io.gatling.javaapi.core.ChainBuilder;
+import io.gatling.javaapi.core.ScenarioBuilder;
+
+import java.util.concurrent.ThreadLocalRandom;
+
+import static io.gatling.javaapi.core.CoreDsl.*;
+import static io.gatling.javaapi.http.HttpDsl.*;
+
+/**
+ * Lagerverwaltungs-Szenario: Lagerorte und Bestände verwalten.
+ */
+public final class InventoryScenario {
+
+ private InventoryScenario() {}
+
+ public static ChainBuilder listStorageLocations() {
+ return exec(
+ http("Lagerorte auflisten")
+ .get("/api/inventory/storage-locations")
+ .header("Authorization", "Bearer #{accessToken}")
+ .check(status().is(200))
+ );
+ }
+
+ public static ChainBuilder getRandomStorageLocation() {
+ return exec(session -> {
+ var ids = LoadTestDataSeeder.storageLocationIds();
+ String id = ids.get(ThreadLocalRandom.current().nextInt(ids.size()));
+ return session.set("storageLocationId", id);
+ }).exec(
+ http("Lagerort laden")
+ .get("/api/inventory/storage-locations/#{storageLocationId}")
+ .header("Authorization", "Bearer #{accessToken}")
+ .check(status().is(200))
+ );
+ }
+
+ public static ChainBuilder listStocks() {
+ return exec(
+ http("Bestände auflisten")
+ .get("/api/inventory/stocks")
+ .header("Authorization", "Bearer #{accessToken}")
+ .check(status().is(200))
+ );
+ }
+
+ public static ChainBuilder listStocksBelowMinimum() {
+ return exec(
+ http("Bestände unter Minimum")
+ .get("/api/inventory/stocks/below-minimum")
+ .header("Authorization", "Bearer #{accessToken}")
+ .check(status().is(200))
+ );
+ }
+
+ public static ChainBuilder listStocksByLocation() {
+ return exec(session -> {
+ var ids = LoadTestDataSeeder.storageLocationIds();
+ String id = ids.get(ThreadLocalRandom.current().nextInt(ids.size()));
+ return session.set("filterStorageLocationId", id);
+ }).exec(
+ http("Bestände nach Lagerort")
+ .get("/api/inventory/stocks?storageLocationId=#{filterStorageLocationId}")
+ .header("Authorization", "Bearer #{accessToken}")
+ .check(status().is(200))
+ );
+ }
+
+ /**
+ * Lagerverwaltungs-Workflow: Überwiegend Lese-Operationen.
+ */
+ public static ScenarioBuilder inventoryWorkflow() {
+ return scenario("Lagerverwaltung")
+ .exec(AuthenticationScenario.login("admin", "admin123"))
+ .repeat(15).on(
+ randomSwitch().on(
+ percent(25.0).then(listStocks()),
+ percent(20.0).then(listStorageLocations()),
+ percent(20.0).then(getRandomStorageLocation()),
+ percent(20.0).then(listStocksByLocation()),
+ percent(15.0).then(listStocksBelowMinimum())
+ ).pause(1, 3)
+ );
+ }
+}
diff --git a/loadtest/src/test/java/de/effigenix/loadtest/scenario/MasterDataScenario.java b/loadtest/src/test/java/de/effigenix/loadtest/scenario/MasterDataScenario.java
new file mode 100644
index 0000000..2ba4425
--- /dev/null
+++ b/loadtest/src/test/java/de/effigenix/loadtest/scenario/MasterDataScenario.java
@@ -0,0 +1,204 @@
+package de.effigenix.loadtest.scenario;
+
+import de.effigenix.loadtest.infrastructure.LoadTestDataSeeder;
+import de.effigenix.loadtest.util.JsonBodyBuilder;
+import io.gatling.javaapi.core.ChainBuilder;
+import io.gatling.javaapi.core.ScenarioBuilder;
+
+import java.util.concurrent.ThreadLocalRandom;
+
+import static io.gatling.javaapi.core.CoreDsl.*;
+import static io.gatling.javaapi.http.HttpDsl.*;
+
+/**
+ * Stammdaten-Szenario: Artikel, Lieferanten, Kunden – 80% Read, 20% Write.
+ */
+public final class MasterDataScenario {
+
+ private MasterDataScenario() {}
+
+ // ---- Artikel ----
+
+ public static ChainBuilder listArticles() {
+ return exec(
+ http("Artikel auflisten")
+ .get("/api/articles")
+ .header("Authorization", "Bearer #{accessToken}")
+ .check(status().is(200))
+ );
+ }
+
+ public static ChainBuilder getRandomArticle() {
+ return exec(session -> {
+ var ids = LoadTestDataSeeder.articleIds();
+ String id = ids.get(ThreadLocalRandom.current().nextInt(ids.size()));
+ return session.set("articleId", id);
+ }).exec(
+ http("Artikel laden")
+ .get("/api/articles/#{articleId}")
+ .header("Authorization", "Bearer #{accessToken}")
+ .check(status().is(200))
+ );
+ }
+
+ public static ChainBuilder createArticle() {
+ return exec(session -> {
+ var catIds = LoadTestDataSeeder.categoryIds();
+ String catId = catIds.get(ThreadLocalRandom.current().nextInt(catIds.size()));
+ return session.set("newArticleBody", JsonBodyBuilder.createArticleBody(catId));
+ }).exec(
+ http("Artikel anlegen")
+ .post("/api/articles")
+ .header("Authorization", "Bearer #{accessToken}")
+ .header("Content-Type", "application/json")
+ .body(StringBody("#{newArticleBody}"))
+ .check(status().in(200, 201, 400, 409, 500))
+ .check(jsonPath("$.id").optional().saveAs("createdArticleId"))
+ );
+ }
+
+ public static ChainBuilder updateArticle() {
+ return exec(session -> session.set("updateArticleBody", JsonBodyBuilder.updateArticleBody()))
+ .exec(
+ http("Artikel bearbeiten")
+ .put("/api/articles/#{createdArticleId}")
+ .header("Authorization", "Bearer #{accessToken}")
+ .header("Content-Type", "application/json")
+ .body(StringBody("#{updateArticleBody}"))
+ .check(status().is(200))
+ );
+ }
+
+ // ---- Lieferanten ----
+
+ public static ChainBuilder listSuppliers() {
+ return exec(
+ http("Lieferanten auflisten")
+ .get("/api/suppliers")
+ .header("Authorization", "Bearer #{accessToken}")
+ .check(status().is(200))
+ );
+ }
+
+ public static ChainBuilder getRandomSupplier() {
+ return exec(session -> {
+ var ids = LoadTestDataSeeder.supplierIds();
+ String id = ids.get(ThreadLocalRandom.current().nextInt(ids.size()));
+ return session.set("supplierId", id);
+ }).exec(
+ http("Lieferant laden")
+ .get("/api/suppliers/#{supplierId}")
+ .header("Authorization", "Bearer #{accessToken}")
+ .check(status().is(200))
+ );
+ }
+
+ public static ChainBuilder createSupplier() {
+ return exec(session -> session.set("newSupplierBody", JsonBodyBuilder.createSupplierBody()))
+ .exec(
+ http("Lieferant anlegen")
+ .post("/api/suppliers")
+ .header("Authorization", "Bearer #{accessToken}")
+ .header("Content-Type", "application/json")
+ .body(StringBody("#{newSupplierBody}"))
+ .check(status().in(200, 201, 400, 409, 500))
+ );
+ }
+
+ // ---- Kunden ----
+
+ public static ChainBuilder listCustomers() {
+ return exec(
+ http("Kunden auflisten")
+ .get("/api/customers")
+ .header("Authorization", "Bearer #{accessToken}")
+ .check(status().is(200))
+ );
+ }
+
+ public static ChainBuilder getRandomCustomer() {
+ return exec(session -> {
+ var ids = LoadTestDataSeeder.customerIds();
+ String id = ids.get(ThreadLocalRandom.current().nextInt(ids.size()));
+ return session.set("customerId", id);
+ }).exec(
+ http("Kunde laden")
+ .get("/api/customers/#{customerId}")
+ .header("Authorization", "Bearer #{accessToken}")
+ .check(status().is(200))
+ );
+ }
+
+ public static ChainBuilder createCustomer() {
+ return exec(session -> session.set("newCustomerBody", JsonBodyBuilder.createCustomerBody()))
+ .exec(
+ http("Kunde anlegen")
+ .post("/api/customers")
+ .header("Authorization", "Bearer #{accessToken}")
+ .header("Content-Type", "application/json")
+ .body(StringBody("#{newCustomerBody}"))
+ .check(status().in(200, 201, 400, 409, 500))
+ );
+ }
+
+ // ---- Kategorien ----
+
+ public static ChainBuilder listCategories() {
+ return exec(
+ http("Kategorien auflisten")
+ .get("/api/categories")
+ .header("Authorization", "Bearer #{accessToken}")
+ .check(status().is(200))
+ );
+ }
+
+ // ---- Zusammengesetztes Szenario ----
+
+ /**
+ * Stammdaten-CRUD-Workflow mit 80/20 Read/Write-Verteilung.
+ */
+ public static ScenarioBuilder masterDataWorkflow() {
+ return scenario("Stammdaten CRUD")
+ .exec(AuthenticationScenario.login("admin", "admin123"))
+ .repeat(15).on(
+ randomSwitch().on(
+ percent(20.0).then(listArticles()),
+ percent(15.0).then(getRandomArticle()),
+ percent(10.0).then(listSuppliers()),
+ percent(10.0).then(getRandomSupplier()),
+ percent(10.0).then(listCustomers()),
+ percent(10.0).then(getRandomCustomer()),
+ percent(5.0).then(listCategories()),
+ percent(5.0).then(createArticle()),
+ percent(5.0).then(createSupplier()),
+ percent(5.0).then(createCustomer()),
+ percent(5.0).then(
+ createArticle()
+ .pause(1)
+ .doIf(session -> session.contains("createdArticleId")).then(
+ exec(updateArticle())
+ )
+ )
+ ).pause(1, 3)
+ );
+ }
+
+ /**
+ * Read-Only-Stammdaten-Szenario für zusätzliche Leselast.
+ */
+ public static ScenarioBuilder masterDataReadOnly() {
+ return scenario("Stammdaten Read-Only")
+ .exec(AuthenticationScenario.login("admin", "admin123"))
+ .repeat(15).on(
+ randomSwitch().on(
+ percent(25.0).then(listArticles()),
+ percent(20.0).then(getRandomArticle()),
+ percent(15.0).then(listSuppliers()),
+ percent(15.0).then(getRandomSupplier()),
+ percent(10.0).then(listCustomers()),
+ percent(10.0).then(getRandomCustomer()),
+ percent(5.0).then(listCategories())
+ ).pause(1, 3)
+ );
+ }
+}
diff --git a/loadtest/src/test/java/de/effigenix/loadtest/scenario/ProductionScenario.java b/loadtest/src/test/java/de/effigenix/loadtest/scenario/ProductionScenario.java
new file mode 100644
index 0000000..60a06bc
--- /dev/null
+++ b/loadtest/src/test/java/de/effigenix/loadtest/scenario/ProductionScenario.java
@@ -0,0 +1,165 @@
+package de.effigenix.loadtest.scenario;
+
+import de.effigenix.loadtest.infrastructure.LoadTestDataSeeder;
+import de.effigenix.loadtest.util.JsonBodyBuilder;
+import io.gatling.javaapi.core.ChainBuilder;
+import io.gatling.javaapi.core.ScenarioBuilder;
+
+import java.util.concurrent.ThreadLocalRandom;
+
+import static io.gatling.javaapi.core.CoreDsl.*;
+import static io.gatling.javaapi.http.HttpDsl.*;
+
+/**
+ * Produktions-Szenario: Rezepte, Chargen, Produktionsaufträge.
+ * Bildet den End-to-End-Workflow der Produktion ab.
+ */
+public final class ProductionScenario {
+
+ private ProductionScenario() {}
+
+ // ---- Rezepte ----
+
+ public static ChainBuilder listRecipes() {
+ return exec(
+ http("Rezepte auflisten")
+ .get("/api/recipes")
+ .header("Authorization", "Bearer #{accessToken}")
+ .check(status().is(200))
+ );
+ }
+
+ public static ChainBuilder getRandomRecipe() {
+ return exec(session -> {
+ var ids = LoadTestDataSeeder.recipeIds();
+ String id = ids.get(ThreadLocalRandom.current().nextInt(ids.size()));
+ return session.set("recipeId", id);
+ }).exec(
+ http("Rezept laden")
+ .get("/api/recipes/#{recipeId}")
+ .header("Authorization", "Bearer #{accessToken}")
+ .check(status().is(200))
+ );
+ }
+
+ // ---- Chargen (Batches) ----
+
+ public static ChainBuilder listBatches() {
+ return exec(
+ http("Chargen auflisten")
+ .get("/api/production/batches")
+ .header("Authorization", "Bearer #{accessToken}")
+ .check(status().is(200))
+ );
+ }
+
+ public static ChainBuilder planBatch() {
+ return exec(session -> {
+ var ids = LoadTestDataSeeder.recipeIds();
+ String recipeId = ids.get(ThreadLocalRandom.current().nextInt(ids.size()));
+ return session.set("planBatchBody", JsonBodyBuilder.planBatchBody(recipeId));
+ }).exec(
+ http("Charge planen")
+ .post("/api/production/batches")
+ .header("Authorization", "Bearer #{accessToken}")
+ .header("Content-Type", "application/json")
+ .body(StringBody("#{planBatchBody}"))
+ .check(status().in(200, 201, 400, 409, 500))
+ .check(jsonPath("$.id").optional().saveAs("batchId"))
+ );
+ }
+
+ public static ChainBuilder startBatch() {
+ return exec(
+ http("Charge starten")
+ .post("/api/production/batches/#{batchId}/start")
+ .header("Authorization", "Bearer #{accessToken}")
+ .check(status().in(200, 400, 409, 500))
+ );
+ }
+
+ public static ChainBuilder completeBatch() {
+ return exec(
+ http("Charge abschließen")
+ .post("/api/production/batches/#{batchId}/complete")
+ .header("Authorization", "Bearer #{accessToken}")
+ .header("Content-Type", "application/json")
+ .body(StringBody(JsonBodyBuilder.completeBatchBody()))
+ .check(status().in(200, 400, 409, 500))
+ );
+ }
+
+ // ---- Produktionsaufträge ----
+
+ public static ChainBuilder createProductionOrder() {
+ return exec(session -> {
+ var ids = LoadTestDataSeeder.recipeIds();
+ String recipeId = ids.get(ThreadLocalRandom.current().nextInt(ids.size()));
+ return session.set("prodOrderBody", JsonBodyBuilder.createProductionOrderBody(recipeId));
+ }).exec(
+ http("Produktionsauftrag anlegen")
+ .post("/api/production/production-orders")
+ .header("Authorization", "Bearer #{accessToken}")
+ .header("Content-Type", "application/json")
+ .body(StringBody("#{prodOrderBody}"))
+ .check(status().in(200, 201, 400, 409, 500))
+ .check(jsonPath("$.id").optional().saveAs("productionOrderId"))
+ );
+ }
+
+ public static ChainBuilder releaseProductionOrder() {
+ return exec(
+ http("Produktionsauftrag freigeben")
+ .post("/api/production/production-orders/#{productionOrderId}/release")
+ .header("Authorization", "Bearer #{accessToken}")
+ .check(status().in(200, 400, 409, 500))
+ );
+ }
+
+ // ---- Zusammengesetztes Szenario ----
+
+ /**
+ * Produktions-Workflow: Rezepte lesen → Charge planen → starten → abschließen.
+ */
+ public static ScenarioBuilder productionWorkflow() {
+ return scenario("Produktions-Workflow")
+ .exec(AuthenticationScenario.login("admin", "admin123"))
+ .exec(listRecipes())
+ .pause(1, 2)
+ .exec(getRandomRecipe())
+ .pause(1, 2)
+ // Charge planen und durchlaufen (nur wenn planBatch erfolgreich)
+ .exec(planBatch())
+ .pause(1, 2)
+ .doIf(session -> session.contains("batchId")).then(
+ exec(startBatch())
+ .pause(2, 5)
+ .exec(completeBatch())
+ )
+ .pause(1, 2)
+ // Produktionsauftrag anlegen und freigeben
+ .exec(createProductionOrder())
+ .pause(1, 2)
+ .doIf(session -> session.contains("productionOrderId")).then(
+ exec(releaseProductionOrder())
+ )
+ .pause(1, 2)
+ // Nochmal Chargen-Liste prüfen
+ .exec(listBatches());
+ }
+
+ /**
+ * Read-Only-Produktions-Szenario für lesende Benutzer.
+ */
+ public static ScenarioBuilder productionReadOnly() {
+ return scenario("Produktion Read-Only")
+ .exec(AuthenticationScenario.login("admin", "admin123"))
+ .repeat(15).on(
+ randomSwitch().on(
+ percent(40.0).then(listRecipes()),
+ percent(30.0).then(getRandomRecipe()),
+ percent(30.0).then(listBatches())
+ ).pause(1, 3)
+ );
+ }
+}
diff --git a/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java b/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java
new file mode 100644
index 0000000..707c367
--- /dev/null
+++ b/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java
@@ -0,0 +1,118 @@
+package de.effigenix.loadtest.simulation;
+
+import de.effigenix.loadtest.infrastructure.LoadTestInfrastructure;
+import de.effigenix.loadtest.scenario.*;
+import io.gatling.javaapi.core.Simulation;
+import io.gatling.javaapi.http.HttpProtocolBuilder;
+
+import static io.gatling.javaapi.core.CoreDsl.*;
+import static io.gatling.javaapi.http.HttpDsl.*;
+
+/**
+ * Haupt-Simulation: Gemischte Last über alle Szenarien.
+ *
+ * Lastprofil (simuliert ~2500 Requests ≈ komprimiertes Jahres-Volumen):
+ * - 15 Admin-User: Stammdaten-CRUD (ramp 30s)
+ * - 30 Produktions-Worker: Produktions-Workflow (ramp 60s, sustained)
+ * - 15 Lager-Arbeiter: Lagerverwaltung (ramp 30s)
+ * - 50 Read-Only-User: Listen durchblättern (ramp 60s)
+ *
+ * Assertions:
+ * - p95 < 500ms (global)
+ * - p99 < 1000ms (global)
+ * - Error-Rate < 5%
+ */
+public class FullWorkloadSimulation extends Simulation {
+
+ // Infrastruktur muss vor der Feld-Initialisierung starten
+ static {
+ LoadTestInfrastructure.start();
+ }
+
+ @Override
+ public void after() {
+ LoadTestInfrastructure.stop();
+ }
+
+ HttpProtocolBuilder httpProtocol = http
+ .baseUrl(LoadTestInfrastructure.baseUrl())
+ .acceptHeader("application/json")
+ .contentTypeHeader("application/json")
+ .shareConnections();
+
+ {
+ setUp(
+ // Admin-User: Stammdaten-CRUD
+ MasterDataScenario.masterDataWorkflow()
+ .injectOpen(rampUsers(15).during(30)),
+
+ // Produktions-Worker: Vollständiger Produktions-Workflow
+ ProductionScenario.productionWorkflow()
+ .injectOpen(
+ rampUsers(10).during(30),
+ nothingFor(15),
+ rampUsers(10).during(30),
+ nothingFor(15),
+ rampUsers(10).during(30)
+ ),
+
+ // Lager-Arbeiter: Lagerverwaltung
+ InventoryScenario.inventoryWorkflow()
+ .injectOpen(rampUsers(15).during(30)),
+
+ // Read-Only-User: Listen durchblättern
+ ProductionScenario.productionReadOnly()
+ .injectOpen(
+ rampUsers(25).during(60),
+ nothingFor(15),
+ rampUsers(25).during(45)
+ ),
+
+ // Zusätzliche Read-Only auf Stammdaten
+ MasterDataScenario.masterDataReadOnly()
+ .injectOpen(
+ nothingFor(15),
+ rampUsers(25).during(45),
+ nothingFor(15),
+ rampUsers(25).during(45)
+ )
+ ).protocols(httpProtocol)
+ .assertions(
+ // Global: Login (BCrypt ~230ms) hebt den Schnitt
+ global().responseTime().percentile(95.0).lt(500),
+ global().responseTime().percentile(99.0).lt(1000),
+ global().failedRequests().percent().lt(5.0),
+
+ // Login darf langsam sein (BCrypt strength 12)
+ details("Login [admin]").responseTime().mean().lt(350),
+ details("Login [admin]").responseTime().percentile(95.0).lt(500),
+
+ // Einzeldatensatz-Reads: streng (mean < 20ms)
+ details("Rezept laden").responseTime().mean().lt(20),
+ details("Artikel laden").responseTime().mean().lt(20),
+ details("Lieferant laden").responseTime().mean().lt(20),
+ details("Kunde laden").responseTime().mean().lt(20),
+
+ // Listen mit wenig Daten (< 30 Einträge): mean < 35ms
+ details("Rezepte auflisten").responseTime().mean().lt(35),
+ details("Lagerorte auflisten").responseTime().mean().lt(35),
+ details("Bestände auflisten").responseTime().mean().lt(35),
+ details("Bestände unter Minimum").responseTime().mean().lt(35),
+ details("Bestände nach Lagerort").responseTime().mean().lt(35),
+ details("Lieferanten auflisten").responseTime().mean().lt(35),
+ details("Kategorien auflisten").responseTime().mean().lt(35),
+
+ // Listen mit viel Daten (50-300 Einträge): mean < 75ms
+ details("Chargen auflisten").responseTime().mean().lt(75),
+ details("Artikel auflisten").responseTime().mean().lt(75),
+ details("Kunden auflisten").responseTime().mean().lt(75),
+
+ // Garantiert vorkommende Write-Requests: moderat (mean < 50ms)
+ details("Charge planen").responseTime().mean().lt(50),
+ details("Charge starten").responseTime().mean().lt(50),
+ details("Charge abschließen").responseTime().mean().lt(50),
+ details("Produktionsauftrag anlegen").responseTime().mean().lt(50),
+ details("Produktionsauftrag freigeben").responseTime().mean().lt(50)
+ );
+ }
+}
diff --git a/loadtest/src/test/java/de/effigenix/loadtest/util/JsonBodyBuilder.java b/loadtest/src/test/java/de/effigenix/loadtest/util/JsonBodyBuilder.java
new file mode 100644
index 0000000..96602ff
--- /dev/null
+++ b/loadtest/src/test/java/de/effigenix/loadtest/util/JsonBodyBuilder.java
@@ -0,0 +1,97 @@
+package de.effigenix.loadtest.util;
+
+import java.time.LocalDate;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Hilfsklasse zum Erzeugen von JSON-Request-Bodies mit eindeutigen Werten.
+ * Atomare Counter verhindern Unique-Constraint-Verletzungen bei parallelen Writes.
+ */
+public final class JsonBodyBuilder {
+
+ private static final AtomicInteger ARTICLE_SEQ = new AtomicInteger(10000);
+ private static final AtomicInteger SUPPLIER_SEQ = new AtomicInteger(10000);
+ private static final AtomicInteger CUSTOMER_SEQ = new AtomicInteger(10000);
+ private static final AtomicInteger CATEGORY_SEQ = new AtomicInteger(10000);
+
+ private JsonBodyBuilder() {}
+
+ public static String loginBody(String username, String password) {
+ return """
+ {"username":"%s","password":"%s"}""".formatted(username, password);
+ }
+
+ public static String createArticleBody(String categoryId) {
+ int seq = ARTICLE_SEQ.getAndIncrement();
+ return """
+ {"name":"Lasttest-Artikel-%d","articleNumber":"LT-%05d","categoryId":"%s","unit":"KG","priceModel":"WEIGHT_BASED","price":9.99}"""
+ .formatted(seq, seq, categoryId);
+ }
+
+ public static String updateArticleBody() {
+ return """
+ {"name":"Aktualisiert-%d"}""".formatted(System.nanoTime() % 100000);
+ }
+
+ public static String createSupplierBody() {
+ int seq = SUPPLIER_SEQ.getAndIncrement();
+ return """
+ {"name":"Lasttest-Lieferant-%d","phone":"0711-%07d"}"""
+ .formatted(seq, seq);
+ }
+
+ public static String updateSupplierBody() {
+ return """
+ {"name":"Aktualisiert-Lieferant-%d"}""".formatted(System.nanoTime() % 100000);
+ }
+
+ public static String createCustomerBody() {
+ int seq = CUSTOMER_SEQ.getAndIncrement();
+ return """
+ {"name":"Lasttest-Kunde-%d","type":"B2B","street":"Teststr. %d","postalCode":"70173","city":"Stuttgart","country":"DE","phone":"0711-%07d"}"""
+ .formatted(seq, seq, seq);
+ }
+
+ public static String updateCustomerBody() {
+ return """
+ {"name":"Aktualisiert-Kunde-%d"}""".formatted(System.nanoTime() % 100000);
+ }
+
+ public static String createCategoryBody() {
+ int seq = CATEGORY_SEQ.getAndIncrement();
+ return """
+ {"name":"Lasttest-Kategorie-%d","description":"Automatisch erzeugt"}"""
+ .formatted(seq);
+ }
+
+ public static String createStorageLocationBody() {
+ return """
+ {"name":"LT-Lager-%d","storageType":"DRY_STORAGE"}"""
+ .formatted(System.nanoTime() % 100000);
+ }
+
+ public static String createStockBody(String articleId, String storageLocationId) {
+ return """
+ {"articleId":"%s","storageLocationId":"%s","minimumLevelAmount":"10","minimumLevelUnit":"KG"}"""
+ .formatted(articleId, storageLocationId);
+ }
+
+ public static String planBatchBody(String recipeId) {
+ var today = LocalDate.now();
+ var bestBefore = today.plusDays(14);
+ return """
+ {"recipeId":"%s","plannedQuantity":"10","plannedQuantityUnit":"KG","productionDate":"%s","bestBeforeDate":"%s"}"""
+ .formatted(recipeId, today, bestBefore);
+ }
+
+ public static String completeBatchBody() {
+ return """
+ {"actualQuantity":"9.5","actualQuantityUnit":"KG","waste":"0.5","wasteUnit":"KG","remarks":"Lasttest"}""";
+ }
+
+ public static String createProductionOrderBody(String recipeId) {
+ return """
+ {"recipeId":"%s","plannedQuantity":"20","plannedQuantityUnit":"KG","plannedDate":"%s","priority":"NORMAL"}"""
+ .formatted(recipeId, LocalDate.now().plusDays(1));
+ }
+}
diff --git a/loadtest/src/test/resources/gatling.conf b/loadtest/src/test/resources/gatling.conf
new file mode 100644
index 0000000..315bac7
--- /dev/null
+++ b/loadtest/src/test/resources/gatling.conf
@@ -0,0 +1,19 @@
+gatling {
+ charting {
+ indicators {
+ lowerBound = 50
+ higherBound = 250
+ }
+ }
+ http {
+ fetchedCssCacheMaxCapacity = 0
+ fetchedHtmlCacheMaxCapacity = 0
+ warmUpUrl = ""
+ }
+ data {
+ writers = [console, file]
+ console {
+ writePeriod = 10
+ }
+ }
+}
diff --git a/loadtest/src/test/resources/logback-test.xml b/loadtest/src/test/resources/logback-test.xml
new file mode 100644
index 0000000..4d88a2b
--- /dev/null
+++ b/loadtest/src/test/resources/logback-test.xml
@@ -0,0 +1,29 @@
+
+
+
+
+ %d{HH:mm:ss.SSS} [%-5level] %logger{36} - %msg%n
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+