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

feat(inventory): Bestandsbewegung erfassen (StockMovement) – Issue #15

Immutables StockMovement-Aggregate als Audit-Trail für jede Bestandsveränderung.
Domain-Invarianten: positive Quantity, Reason bei WASTE/ADJUSTMENT,
ReferenceDocumentId bei INTER_BRANCH_TRANSFER, Direction-Ableitung aus MovementType.

Domain: StockMovement, MovementType (8 Typen), MovementDirection, StockMovementError
Application: RecordStockMovement, GetStockMovement, ListStockMovements
Infrastructure: JPA-Persistence, REST-Controller (POST/GET), Liquibase 028+029
Tests: ~40 Domain-Unit-Tests, 18 Application-Tests, ~27 Integrationstests
Loadtest: Gatling-Szenarien für Bestandsbewegungen (Seeding, Read, Write)
This commit is contained in:
Sebastian Frick 2026-02-24 22:58:57 +01:00
parent 85f96d685e
commit fa6c0c2d70
32 changed files with 3229 additions and 9 deletions

View file

@ -41,6 +41,9 @@ public final class LoadTestDataSeeder {
private final List<String> recipeIds = new ArrayList<>();
private final List<String> batchIds = new ArrayList<>();
private final List<String> productionOrderIds = new ArrayList<>();
private final List<String> stockIds = new ArrayList<>();
private final List<String> stockBatchIds = new ArrayList<>();
private final List<String> stockMovementIds = new ArrayList<>();
// Statische Felder für Zugriff aus Szenarien
private static List<String> seededCategoryIds;
@ -51,6 +54,9 @@ public final class LoadTestDataSeeder {
private static List<String> seededRecipeIds;
private static List<String> seededBatchIds;
private static List<String> seededProductionOrderIds;
private static List<String> seededStockIds;
private static List<String> seededStockBatchIds;
private static List<String> seededStockMovementIds;
public LoadTestDataSeeder(ConfigurableApplicationContext appContext) {
int port = appContext.getEnvironment()
@ -72,6 +78,8 @@ public final class LoadTestDataSeeder {
seedRecipes();
seedBatchesForYear();
seedProductionOrders();
seedStocksAndBatches();
seedStockMovements();
// Statische Referenzen setzen
seededCategoryIds = List.copyOf(categoryIds);
@ -82,14 +90,19 @@ public final class LoadTestDataSeeder {
seededRecipeIds = List.copyOf(recipeIds);
seededBatchIds = List.copyOf(batchIds);
seededProductionOrderIds = List.copyOf(productionOrderIds);
seededStockIds = List.copyOf(stockIds);
seededStockBatchIds = List.copyOf(stockBatchIds);
seededStockMovementIds = List.copyOf(stockMovementIds);
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",
+ "%d Lagerorte, %d Rezepte, %d Chargen, %d Produktionsaufträge, "
+ "%d Bestände, %d Bestandsbewegungen%n",
duration, categoryIds.size(), articleIds.size(), supplierIds.size(),
customerIds.size(), storageLocationIds.size(), recipeIds.size(),
batchIds.size(), productionOrderIds.size());
batchIds.size(), productionOrderIds.size(),
stockIds.size(), stockMovementIds.size());
} catch (Exception e) {
throw new RuntimeException("Testdaten-Seeding fehlgeschlagen", e);
}
@ -562,6 +575,80 @@ public final class LoadTestDataSeeder {
}
}
// ---- Bestände & Chargen (20 Stocks mit je 2-3 Batches) ----
private void seedStocksAndBatches() throws Exception {
var rnd = ThreadLocalRandom.current();
var today = LocalDate.now();
// 20 Bestände: jeweils ein Artikel an einem Lagerort
int stockCount = Math.min(20, articleIds.size());
for (int i = 0; i < stockCount; i++) {
String articleId = articleIds.get(i);
String locationId = storageLocationIds.get(i % storageLocationIds.size());
String body = """
{"articleId":"%s","storageLocationId":"%s","minimumLevelAmount":"5.0","minimumLevelUnit":"KILOGRAM"}"""
.formatted(articleId, locationId);
try {
var json = mapper.readTree(post("/api/inventory/stocks", body, adminToken));
String stockId = json.get("id").asText();
stockIds.add(stockId);
// 2-3 Batches pro Stock
int batchCount = 2 + (i % 2);
for (int j = 0; j < batchCount; j++) {
String batchType = (j % 2 == 0) ? "PRODUCED" : "PURCHASED";
String batchRef = "CHARGE-%05d".formatted(i * 10 + j);
LocalDate expiry = today.plusDays(rnd.nextInt(14, 180));
int qty = rnd.nextInt(5, 50);
String batchBody = """
{"batchId":"%s","batchType":"%s","quantityAmount":"%d.0","quantityUnit":"KILOGRAM","expiryDate":"%s"}"""
.formatted(batchRef, batchType, qty, expiry);
try {
var batchJson = mapper.readTree(
post("/api/inventory/stocks/" + stockId + "/batches", batchBody, adminToken));
stockBatchIds.add(batchJson.get("id").asText());
} catch (Exception ignored) {}
}
} catch (Exception e) {
// Stock-Duplikat (gleicher Artikel/Lagerort) ignorieren
}
}
}
// ---- Bestandsbewegungen (50 Movements) ----
private void seedStockMovements() throws Exception {
if (stockIds.isEmpty() || stockBatchIds.isEmpty()) return;
var rnd = ThreadLocalRandom.current();
String[] movementTypes = {"GOODS_RECEIPT", "PRODUCTION_OUTPUT", "PRODUCTION_CONSUMPTION", "SALE", "RETURN"};
for (int i = 0; i < 50; i++) {
int stockIdx = i % stockIds.size();
String stockId = stockIds.get(stockIdx);
String articleId = articleIds.get(stockIdx);
String stockBatchId = stockBatchIds.get(i % stockBatchIds.size());
String batchRef = "CHARGE-%05d".formatted(i);
String batchType = (i % 2 == 0) ? "PRODUCED" : "PURCHASED";
String movementType = movementTypes[rnd.nextInt(movementTypes.length)];
int qty = rnd.nextInt(1, 20);
String body = """
{"stockId":"%s","articleId":"%s","stockBatchId":"%s","batchId":"%s","batchType":"%s","movementType":"%s","quantityAmount":"%d.0","quantityUnit":"KILOGRAM"}"""
.formatted(stockId, articleId, stockBatchId, batchRef, batchType, movementType, qty);
try {
var json = mapper.readTree(post("/api/inventory/stock-movements", body, adminToken));
stockMovementIds.add(json.get("id").asText());
} catch (Exception ignored) {}
}
}
// ---- HTTP Helper ----
private String post(String path, String jsonBody, String token) throws Exception {
@ -599,4 +686,7 @@ public final class LoadTestDataSeeder {
public static List<String> recipeIds() { return seededRecipeIds; }
public static List<String> batchIds() { return seededBatchIds; }
public static List<String> productionOrderIds() { return seededProductionOrderIds; }
public static List<String> stockIds() { return seededStockIds; }
public static List<String> stockBatchIds() { return seededStockBatchIds; }
public static List<String> stockMovementIds() { return seededStockMovementIds; }
}

View file

@ -8,6 +8,7 @@ import java.util.concurrent.ThreadLocalRandom;
import static io.gatling.javaapi.core.CoreDsl.*;
import static io.gatling.javaapi.http.HttpDsl.*;
import static io.gatling.javaapi.core.CoreDsl.StringBody;
/**
* Lagerverwaltungs-Szenario: Lagerorte und Bestände verwalten.
@ -69,19 +70,71 @@ public final class InventoryScenario {
);
}
public static ChainBuilder listStockMovements() {
return exec(
http("Bestandsbewegungen auflisten")
.get("/api/inventory/stock-movements")
.header("Authorization", "Bearer #{accessToken}")
.check(status().is(200))
);
}
public static ChainBuilder listStockMovementsByStock() {
return exec(session -> {
var ids = LoadTestDataSeeder.stockIds();
if (ids == null || ids.isEmpty()) return session;
String id = ids.get(ThreadLocalRandom.current().nextInt(ids.size()));
return session.set("filterMovementStockId", id);
}).exec(
http("Bestandsbewegungen nach Bestand")
.get("/api/inventory/stock-movements?stockId=#{filterMovementStockId}")
.header("Authorization", "Bearer #{accessToken}")
.check(status().is(200))
);
}
public static ChainBuilder recordStockMovement() {
return exec(session -> {
var rnd = ThreadLocalRandom.current();
var stockIds = LoadTestDataSeeder.stockIds();
var articleIds = LoadTestDataSeeder.articleIds();
var stockBatchIds = LoadTestDataSeeder.stockBatchIds();
if (stockIds == null || stockIds.isEmpty()
|| stockBatchIds == null || stockBatchIds.isEmpty()) return session;
int idx = rnd.nextInt(stockIds.size());
return session
.set("mvStockId", stockIds.get(idx))
.set("mvArticleId", articleIds.get(idx % articleIds.size()))
.set("mvStockBatchId", stockBatchIds.get(rnd.nextInt(stockBatchIds.size())))
.set("mvBatchRef", "LT-CHARGE-%06d".formatted(rnd.nextInt(999999)))
.set("mvQty", "%d.0".formatted(rnd.nextInt(1, 30)));
}).exec(
http("Bestandsbewegung erfassen")
.post("/api/inventory/stock-movements")
.header("Authorization", "Bearer #{accessToken}")
.body(StringBody("""
{"stockId":"#{mvStockId}","articleId":"#{mvArticleId}","stockBatchId":"#{mvStockBatchId}","batchId":"#{mvBatchRef}","batchType":"PRODUCED","movementType":"GOODS_RECEIPT","quantityAmount":"#{mvQty}","quantityUnit":"KILOGRAM"}"""))
.check(status().is(201))
);
}
/**
* Lagerverwaltungs-Workflow: Überwiegend Lese-Operationen.
* Lagerverwaltungs-Workflow: Überwiegend Lese-Operationen mit Bestandsbewegungen.
*/
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())
percent(20.0).then(listStocks()),
percent(15.0).then(listStorageLocations()),
percent(15.0).then(getRandomStorageLocation()),
percent(15.0).then(listStocksByLocation()),
percent(10.0).then(listStocksBelowMinimum()),
percent(10.0).then(listStockMovements()),
percent(5.0).then(listStockMovementsByStock()),
percent(10.0).then(recordStockMovement())
).pause(1, 3)
);
}

View file

@ -101,6 +101,8 @@ public class FullWorkloadSimulation extends Simulation {
details("Bestände nach Lagerort").responseTime().mean().lt(35),
details("Lieferanten auflisten").responseTime().mean().lt(35),
details("Kategorien auflisten").responseTime().mean().lt(35),
details("Bestandsbewegungen auflisten").responseTime().mean().lt(35),
details("Bestandsbewegungen nach Bestand").responseTime().mean().lt(35),
// Listen mit viel Daten (50-300 Einträge): mean < 75ms
details("Chargen auflisten").responseTime().mean().lt(75),
@ -112,7 +114,8 @@ public class FullWorkloadSimulation extends Simulation {
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)
details("Produktionsauftrag freigeben").responseTime().mean().lt(50),
details("Bestandsbewegung erfassen").responseTime().mean().lt(50)
);
}
}