mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 08:29:36 +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:
parent
85f96d685e
commit
fa6c0c2d70
32 changed files with 3229 additions and 9 deletions
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue