diff --git a/pom.xml b/pom.xml
index 01bda3d..4cb2cd8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
de.assecutor.votianlt
votianlt
- 0.8.4
+ 0.8.5
jar
diff --git a/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java b/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java
index bb128dc..0aacecd 100644
--- a/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java
+++ b/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java
@@ -2,6 +2,7 @@ package de.assecutor.votianlt.ai.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
+import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.service.JobStatisticsService;
import lombok.extern.slf4j.Slf4j;
@@ -47,40 +48,32 @@ public class AiStatisticsService {
public StatisticsResponse analyzeStatisticsQuery(String userQuery) {
log.info("Processing statistics query: {}", userQuery);
- // Gather current statistics
- String statisticsContext = buildStatisticsContext();
-
- // Determine query type and prepare chart data
+ // Determine query type and prepare chart data (includes customer filter detection)
QueryAnalysis analysis = analyzeQueryType(userQuery);
- log.debug("Query analysis - Type: {}, Chart: {}", analysis.queryType, analysis.chartType);
+ log.debug("Query analysis - Type: {}, Chart: {}, Customer: {}, Status: {}",
+ analysis.queryType, analysis.chartType,
+ analysis.customerFilter != null ? analysis.customerFilter : "none",
+ analysis.statusFilter != null ? analysis.statusFilter : "none");
+
+ // Gather context (statistics or job list depending on query type)
+ String statisticsContext = buildStatisticsContext(analysis);
// Build prompt for LLM
String prompt = buildPrompt(userQuery, statisticsContext, analysis);
- // System prompt for the statistics assistant
- String systemPrompt = """
- Du bist ein hilfreicher Statistik-Assistent für ein Logistikunternehmen.
- Beantworte die Frage des Benutzers basierend auf den aktuellen Statistiken.
-
- WICHTIGE FORMATIERUNGSREGELN:
- - Verwende KEINE Tabellen (keine | oder --- Zeichen)
- - Die Daten werden bereits als interaktives Diagramm visualisiert
- - Fasse die wichtigsten Erkenntnisse in Fließtext oder kurzen Aufzählungen zusammen
- - Nenne konkrete Zahlen im Text, aber liste nicht alle Werte tabellarisch auf
-
- Antworte auf Deutsch, präzise und freundlich.
- Erkläre die Daten kurz und gib bei Bedarf Empfehlungen.
- Halte die Antwort kompakt (max. 3-4 Sätze für einfache Fragen, mehr für komplexe).
- """;
+ // System prompt - different for list vs statistics queries
+ String systemPrompt = analysis.queryType.equals("list")
+ ? buildListSystemPrompt()
+ : buildStatisticsSystemPrompt();
// Call LLM via direct REST client (like aimailassistant)
String llmResponse = llmClient.chat(systemPrompt, prompt);
- if (llmResponse != null) {
+ if (llmResponse != null && !llmResponse.isBlank()) {
log.info("LLM response received, length: {} chars", llmResponse.length());
return new StatisticsResponse(llmResponse, analysis.chartType, analysis.chartData);
} else {
- log.warn("LLM returned null response, using fallback");
+ log.warn("LLM returned null or blank response, using fallback");
return new StatisticsResponse(
buildFallbackResponse(analysis),
analysis.chartType,
@@ -92,45 +85,284 @@ public class AiStatisticsService {
private record QueryAnalysis(
String queryType,
String chartType,
- String chartData
+ String chartData,
+ String customerFilter, // null = no filter, show all data
+ JobStatus statusFilter // null = no status filter
) {}
+ private String buildStatisticsSystemPrompt() {
+ return """
+ Du bist ein Statistik-Assistent für ein Logistikunternehmen.
+
+ WICHTIG - ANTWORTSTIL:
+ - Beantworte NUR die gestellte Frage - keine zusätzlichen Informationen!
+ - Keine allgemeinen Tipps, Empfehlungen oder weiterführende Hinweise
+ - Keine Vergleiche mit anderen Daten, außer explizit gefragt
+ - Kurz und präzise: maximal 2-3 Sätze
+ - Nenne die relevanten Zahlen direkt
+
+ DIAGRAMM-HINWEIS:
+ - Ein Diagramm wird AUTOMATISCH angezeigt - nicht erwähnen oder beschreiben
+ - Sage NIEMALS "Ich kann kein Diagramm zeichnen" oder ähnliches
+
+ FORMATIERUNG:
+ - Keine Tabellen
+ - Keine langen Aufzählungen
+ - Fließtext bevorzugen
+
+ Antworte auf Deutsch.
+ """;
+ }
+
+ private String buildListSystemPrompt() {
+ return """
+ Du bist ein Assistent für ein Logistikunternehmen.
+
+ WICHTIG - ANTWORTSTIL:
+ - Der Benutzer fragt nach einer Liste von Jobs/Aufträgen
+ - Die Job-Liste wird bereits als Daten angezeigt
+ - Fasse nur kurz zusammen, was gefunden wurde (z.B. "Es wurden X Jobs gefunden")
+ - Keine detaillierte Auflistung der Jobs nötig - die Daten sind bereits sichtbar
+ - Maximal 1-2 Sätze
+
+ KEIN DIAGRAMM:
+ - Es wird KEIN Diagramm angezeigt bei Listen-Anfragen
+ - Erwähne keine Diagramme
+
+ Antworte auf Deutsch.
+ """;
+ }
+
+ /**
+ * Detect user-specified chart type from the query.
+ * Returns null if no specific chart type was requested.
+ */
+ private String detectUserChartTypePreference(String query) {
+ String lowerQuery = query.toLowerCase();
+
+ // Balkendiagramm / Bar Chart
+ if (lowerQuery.contains("balken") || lowerQuery.contains("bar chart") ||
+ lowerQuery.contains("säulen") || lowerQuery.contains("balkendiagramm")) {
+ return "bar";
+ }
+
+ // Tortendiagramm / Pie Chart
+ if (lowerQuery.contains("torten") || lowerQuery.contains("pie") ||
+ lowerQuery.contains("kreis") || lowerQuery.contains("tortendiagramm") ||
+ lowerQuery.contains("kreisdiagramm")) {
+ return "pie";
+ }
+
+ // Donut / Ring Chart
+ if (lowerQuery.contains("donut") || lowerQuery.contains("ring") ||
+ lowerQuery.contains("doughnut")) {
+ return "doughnut";
+ }
+
+ // Liniendiagramm / Line Chart
+ if (lowerQuery.contains("linie") || lowerQuery.contains("line") ||
+ lowerQuery.contains("liniendiagramm") || lowerQuery.contains("kurve") ||
+ lowerQuery.contains("graph")) {
+ return "line";
+ }
+
+ // Flächendiagramm / Area Chart
+ if (lowerQuery.contains("fläche") || lowerQuery.contains("area") ||
+ lowerQuery.contains("flächendiagramm")) {
+ return "line"; // Line with fill=true
+ }
+
+ // Radar Chart
+ if (lowerQuery.contains("radar") || lowerQuery.contains("netz") ||
+ lowerQuery.contains("spinne")) {
+ return "radar";
+ }
+
+ // Polararea Chart
+ if (lowerQuery.contains("polar")) {
+ return "polarArea";
+ }
+
+ return null; // No specific preference
+ }
+
private QueryAnalysis analyzeQueryType(String query) {
String lowerQuery = query.toLowerCase();
- // Status-bezogene Anfragen
- if (lowerQuery.contains("status") || lowerQuery.contains("offen") ||
- lowerQuery.contains("abgeschlossen") || lowerQuery.contains("zählen") ||
- lowerQuery.contains("anzahl") || lowerQuery.contains("wie viele")) {
- return new QueryAnalysis("status", "doughnut", buildStatusChartData());
+ // First, check if this is a LIST query (no chart needed)
+ boolean isListQuery = isListQuery(lowerQuery);
+ log.debug("Is list query: {}", isListQuery);
+
+ // Check if user specified a chart type (only relevant for non-list queries)
+ String userChartType = isListQuery ? null : detectUserChartTypePreference(query);
+ log.debug("User chart type preference: {}", userChartType != null ? userChartType : "none");
+
+ // Check if user specified a customer filter
+ String customerFilter = detectCustomerFilter(query);
+ log.debug("Customer filter: {}", customerFilter != null ? customerFilter : "none (showing all data)");
+
+ // Check if user specified a status filter
+ JobStatus statusFilter = detectStatusFilter(lowerQuery);
+ log.debug("Status filter: {}", statusFilter != null ? statusFilter : "none");
+
+ // For list queries, return no chart
+ if (isListQuery) {
+ return new QueryAnalysis("list", null, null, customerFilter, statusFilter);
}
+ // Determine query type and default chart type
+ String queryType;
+ String defaultChartType;
+ String chartData;
+
+ // Status-bezogene Anfragen (Statistik, nicht Liste)
+ if ((lowerQuery.contains("status") && (lowerQuery.contains("statistik") || lowerQuery.contains("verteilung") ||
+ lowerQuery.contains("übersicht") || lowerQuery.contains("wie viele"))) ||
+ lowerQuery.contains("zählen") || lowerQuery.contains("anzahl")) {
+ queryType = "status";
+ defaultChartType = "doughnut";
+ chartData = buildStatusChartData(customerFilter);
+ }
// Umsatz-bezogene Anfragen
- if (lowerQuery.contains("umsatz") || lowerQuery.contains("revenue") ||
- lowerQuery.contains("kunde") || lowerQuery.contains("customer") ||
+ else if (lowerQuery.contains("umsatz") || lowerQuery.contains("revenue") ||
lowerQuery.contains("einnahmen")) {
- return new QueryAnalysis("revenue", "bar", buildRevenueChartData());
+ queryType = "revenue";
+ defaultChartType = "bar";
+ chartData = customerFilter != null ? buildCustomerRevenueChartData(customerFilter) : buildRevenueChartData();
}
-
// Trend-bezogene Anfragen
- if (lowerQuery.contains("trend") || lowerQuery.contains("monat") ||
+ else if (lowerQuery.contains("trend") || lowerQuery.contains("monat") ||
lowerQuery.contains("entwicklung") || lowerQuery.contains("jahr") ||
lowerQuery.contains("verlauf")) {
- return new QueryAnalysis("trend", "line", buildTrendChartData());
+ queryType = "trend";
+ defaultChartType = "line";
+ chartData = buildTrendChartData(customerFilter);
}
-
// Task-bezogene Anfragen
- if (lowerQuery.contains("task") || lowerQuery.contains("aufgabe") ||
+ else if (lowerQuery.contains("task") || lowerQuery.contains("aufgabe") ||
lowerQuery.contains("erledigt")) {
- return new QueryAnalysis("tasks", "doughnut", buildTaskChartData());
+ queryType = "tasks";
+ defaultChartType = "doughnut";
+ chartData = buildTaskChartData();
+ }
+ // Allgemeine Übersicht
+ else {
+ queryType = "overview";
+ defaultChartType = "bar";
+ chartData = buildOverviewChartData(customerFilter);
}
- // Allgemeine Übersicht
- return new QueryAnalysis("overview", "bar", buildOverviewChartData());
+ // Use user's chart type preference if specified, otherwise use default
+ String chartType = userChartType != null ? userChartType : defaultChartType;
+
+ return new QueryAnalysis(queryType, chartType, chartData, customerFilter, null);
}
- private String buildStatusChartData() {
- Map statusCounts = statisticsService.getJobCountsByStatus();
+ /**
+ * Detect status filter from the query.
+ */
+ private JobStatus detectStatusFilter(String lowerQuery) {
+ if (lowerQuery.contains("angelegt") || lowerQuery.contains("erstellt") || lowerQuery.contains("created")) {
+ return JobStatus.CREATED;
+ }
+ if (lowerQuery.contains("in bearbeitung") || lowerQuery.contains("in progress")) {
+ return JobStatus.IN_PROGRESS;
+ }
+ if (lowerQuery.contains("abholung geplant") || lowerQuery.contains("pickup scheduled")) {
+ return JobStatus.PICKUP_SCHEDULED;
+ }
+ if (lowerQuery.contains("abgeholt") || lowerQuery.contains("picked up")) {
+ return JobStatus.PICKED_UP;
+ }
+ if (lowerQuery.contains("in transport") || lowerQuery.contains("in transit") || lowerQuery.contains("unterwegs")) {
+ return JobStatus.IN_TRANSIT;
+ }
+ if (lowerQuery.contains("zugestellt") || lowerQuery.contains("delivered") || lowerQuery.contains("geliefert")) {
+ return JobStatus.DELIVERED;
+ }
+ if (lowerQuery.contains("abgeschlossen") || lowerQuery.contains("completed") || lowerQuery.contains("fertig")) {
+ return JobStatus.COMPLETED;
+ }
+ if (lowerQuery.contains("storniert") || lowerQuery.contains("cancelled") || lowerQuery.contains("abgebrochen")) {
+ return JobStatus.CANCELLED;
+ }
+ return null;
+ }
+
+ /**
+ * Detect if the query is asking for a list of jobs (not statistics).
+ */
+ private boolean isListQuery(String lowerQuery) {
+ // Keywords that indicate a list/detail query (not statistics)
+ boolean hasListKeywords = lowerQuery.contains("zeige alle") ||
+ lowerQuery.contains("liste") ||
+ lowerQuery.contains("welche jobs") ||
+ lowerQuery.contains("alle jobs") ||
+ lowerQuery.contains("alle aufträge") ||
+ lowerQuery.contains("zeige die jobs") ||
+ lowerQuery.contains("zeige die aufträge") ||
+ lowerQuery.contains("zeig mir die") ||
+ lowerQuery.contains("gib mir die");
+
+ // Keywords that indicate statistics (override list detection)
+ boolean hasStatsKeywords = lowerQuery.contains("statistik") ||
+ lowerQuery.contains("diagramm") ||
+ lowerQuery.contains("chart") ||
+ lowerQuery.contains("verteilung") ||
+ lowerQuery.contains("wie viele") ||
+ lowerQuery.contains("anzahl") ||
+ lowerQuery.contains("zähle") ||
+ lowerQuery.contains("umsatz") ||
+ lowerQuery.contains("trend") ||
+ lowerQuery.contains("torte") ||
+ lowerQuery.contains("balken");
+
+ return hasListKeywords && !hasStatsKeywords;
+ }
+
+ /**
+ * Detect customer filter from the query.
+ * Returns the matching customer name or null if no filter detected.
+ */
+ private String detectCustomerFilter(String query) {
+ String lowerQuery = query.toLowerCase();
+
+ // Keywords that indicate a customer filter
+ String[] filterIndicators = {
+ "für ", "von ", "bei ", "kunde ", "firma ", "unternehmen ",
+ "für die firma ", "für den kunden ", "von der firma ", "vom kunden ",
+ "nur ", "ausschließlich ", "speziell "
+ };
+
+ // Check if any indicator is present
+ boolean hasIndicator = false;
+ String foundIndicator = null;
+ for (String indicator : filterIndicators) {
+ int idx = lowerQuery.indexOf(indicator);
+ if (idx >= 0) {
+ hasIndicator = true;
+ foundIndicator = indicator;
+ break;
+ }
+ }
+
+ if (!hasIndicator) {
+ log.debug("detectCustomerFilter - No filter indicator found in: '{}'", query);
+ return null;
+ }
+
+ log.debug("detectCustomerFilter - Found indicator '{}' in query: '{}'", foundIndicator, query);
+
+ // Try to find a matching customer in the query
+ String matchedCustomer = statisticsService.findMatchingCustomer(query);
+ log.debug("detectCustomerFilter - Matched customer: '{}'", matchedCustomer);
+ return matchedCustomer;
+ }
+
+ private String buildStatusChartData(String customerFilter) {
+ Map statusCounts = customerFilter != null
+ ? statisticsService.getJobCountsByStatusForCustomer(customerFilter)
+ : statisticsService.getJobCountsByStatus();
List labels = new ArrayList<>();
List data = new ArrayList<>();
@@ -154,7 +386,8 @@ public class AiStatisticsService {
}
}
- return buildChartJson(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())), "Aufträge");
+ String label = customerFilter != null ? "Aufträge (" + customerFilter + ")" : "Aufträge";
+ return buildChartJson(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())), label);
}
private String buildRevenueChartData() {
@@ -177,9 +410,30 @@ public class AiStatisticsService {
return buildChartJsonDouble(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())), "Umsatz (EUR)");
}
- private String buildTrendChartData() {
+ private String buildCustomerRevenueChartData(String customer) {
+ var statusCounts = statisticsService.getJobCountsByStatusForCustomer(customer);
+ var totalRevenue = statisticsService.getTotalRevenueForCustomer(customer);
+ long totalJobs = statisticsService.getTotalJobCountForCustomer(customer);
+ long completed = statusCounts.getOrDefault(JobStatus.COMPLETED, 0L);
+ long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L);
+
+ List labels = List.of("Aufträge gesamt", "Abgeschlossen", "In Bearbeitung", "Umsatz (€/100)");
+ List data = List.of(
+ (double) totalJobs,
+ (double) completed,
+ (double) inProgress,
+ totalRevenue.doubleValue() / 100 // Scale down for better visualization
+ );
+ List colors = List.of("#3b82f6", "#22c55e", "#f59e0b", "#6366f1");
+
+ return buildChartJsonDouble(labels, data, colors, customer);
+ }
+
+ private String buildTrendChartData(String customerFilter) {
int currentYear = Year.now().getValue();
- Map monthlyData = statisticsService.getMonthlyJobCounts(currentYear);
+ Map monthlyData = customerFilter != null
+ ? statisticsService.getMonthlyJobCountsForCustomer(currentYear, customerFilter)
+ : statisticsService.getMonthlyJobCounts(currentYear);
List labels = List.of("Jan", "Feb", "Mär", "Apr", "Mai", "Jun",
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez");
@@ -189,11 +443,15 @@ public class AiStatisticsService {
data.add(monthlyData.getOrDefault(month, 0L));
}
+ String datasetLabel = customerFilter != null
+ ? String.format("%s - %d", customerFilter, currentYear)
+ : String.format("Aufträge %d", currentYear);
+
return String.format("""
{
"labels": %s,
"datasets": [{
- "label": "Aufträge %d",
+ "label": "%s",
"data": %s,
"borderColor": "#6366f1",
"backgroundColor": "rgba(99, 102, 241, 0.15)",
@@ -206,7 +464,7 @@ public class AiStatisticsService {
"fill": true
}]
}
- """, toJsonArray(labels), currentYear, data);
+ """, toJsonArray(labels), datasetLabel, data);
}
private String buildTaskChartData() {
@@ -222,10 +480,14 @@ public class AiStatisticsService {
return buildChartJson(labels, data, colors, "Aufgaben");
}
- private String buildOverviewChartData() {
- Map statusCounts = statisticsService.getJobCountsByStatus();
+ private String buildOverviewChartData(String customerFilter) {
+ Map statusCounts = customerFilter != null
+ ? statisticsService.getJobCountsByStatusForCustomer(customerFilter)
+ : statisticsService.getJobCountsByStatus();
- long total = statisticsService.getTotalJobCount();
+ long total = customerFilter != null
+ ? statisticsService.getTotalJobCountForCustomer(customerFilter)
+ : statisticsService.getTotalJobCount();
long completed = statusCounts.getOrDefault(JobStatus.COMPLETED, 0L);
long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L);
long open = total - completed - statusCounts.getOrDefault(JobStatus.CANCELLED, 0L);
@@ -234,7 +496,8 @@ public class AiStatisticsService {
List data = List.of(total, completed, inProgress, open);
List colors = List.of("#3b82f6", "#22c55e", "#f59e0b", "#06b6d4");
- return buildChartJson(labels, data, colors, "Aufträge");
+ String label = customerFilter != null ? customerFilter : "Aufträge";
+ return buildChartJson(labels, data, colors, label);
}
private String buildChartJson(List labels, List data, List colors, String label) {
@@ -273,37 +536,103 @@ public class AiStatisticsService {
}
}
- private String buildStatisticsContext() {
+ private String buildStatisticsContext(QueryAnalysis analysis) {
+ StringBuilder context = new StringBuilder();
+ String customerFilter = analysis.customerFilter;
+ JobStatus statusFilter = analysis.statusFilter;
+
+ // For LIST queries, show actual job data
+ if ("list".equals(analysis.queryType)) {
+ return buildListContext(customerFilter, statusFilter);
+ }
+
+ // For statistics queries
+ if (customerFilter != null) {
+ // Filtered statistics for a specific customer
+ context.append(String.format("**Statistiken für Kunde: %s**\n\n", customerFilter));
+
+ var statusCounts = statisticsService.getJobCountsByStatusForCustomer(customerFilter);
+ context.append("**Aufträge nach Status:**\n");
+ statusCounts.forEach((status, count) -> {
+ if (count > 0) {
+ context.append(String.format("- %s: %d Aufträge\n", status.getDisplayName(), count));
+ }
+ });
+
+ context.append(String.format("\n**Übersicht für %s:**\n", customerFilter));
+ context.append(String.format("- Gesamtanzahl Aufträge: %d\n",
+ statisticsService.getTotalJobCountForCustomer(customerFilter)));
+ context.append(String.format("- Abschlussrate: %.1f%%\n",
+ statisticsService.getCompletionRateForCustomer(customerFilter)));
+ context.append(String.format("- Umsatz: %.2f EUR\n",
+ statisticsService.getTotalRevenueForCustomer(customerFilter)));
+ } else {
+ // General statistics (all data)
+ var statusCounts = statisticsService.getJobCountsByStatus();
+ context.append("**Aktuelle Auftragsstatistiken:**\n");
+ statusCounts.forEach((status, count) ->
+ context.append(String.format("- %s: %d Aufträge\n", status.getDisplayName(), count)));
+
+ context.append(String.format("\n**Gesamtübersicht:**\n"));
+ context.append(String.format("- Gesamtanzahl Aufträge: %d\n", statisticsService.getTotalJobCount()));
+ context.append(String.format("- Abschlussrate: %.1f%%\n", statisticsService.getCompletionRate()));
+ context.append(String.format("- Gesamtumsatz: %.2f EUR\n", statisticsService.getTotalRevenue()));
+
+ // Task statistics
+ var taskStats = statisticsService.getTaskCompletionStats();
+ context.append(String.format("\n**Aufgaben:**\n"));
+ context.append(String.format("- Gesamt: %d\n", taskStats.get("total")));
+ context.append(String.format("- Erledigt: %d\n", taskStats.get("completed")));
+ context.append(String.format("- Ausstehend: %d\n", taskStats.get("pending")));
+
+ // Top customers
+ var topCustomers = statisticsService.getTopCustomersByRevenue(5);
+ if (!topCustomers.isEmpty()) {
+ context.append("\n**Top 5 Kunden nach Umsatz:**\n");
+ for (var entry : topCustomers) {
+ context.append(String.format("- %s: %.2f EUR\n",
+ entry.getKey() != null ? entry.getKey() : "Unbekannt",
+ entry.getValue()));
+ }
+ }
+ }
+
+ return context.toString();
+ }
+
+ private String buildListContext(String customerFilter, JobStatus statusFilter) {
StringBuilder context = new StringBuilder();
- // Job counts by status
- var statusCounts = statisticsService.getJobCountsByStatus();
- context.append("**Aktuelle Auftragsstatistiken:**\n");
- statusCounts.forEach((status, count) ->
- context.append(String.format("- %s: %d Aufträge\n", status.getDisplayName(), count)));
+ List jobs;
+ if (customerFilter != null && statusFilter != null) {
+ jobs = statisticsService.getJobsByCustomerAndStatus(customerFilter, statusFilter);
+ context.append(String.format("**Jobs für %s mit Status %s:**\n\n",
+ customerFilter, statusFilter.getDisplayName()));
+ } else if (customerFilter != null) {
+ jobs = statisticsService.getJobsByCustomer(customerFilter);
+ context.append(String.format("**Jobs für %s:**\n\n", customerFilter));
+ } else if (statusFilter != null) {
+ jobs = statisticsService.getJobsByStatus(statusFilter);
+ context.append(String.format("**Jobs mit Status %s:**\n\n", statusFilter.getDisplayName()));
+ } else {
+ jobs = statisticsService.getLatestJobs(20);
+ context.append("**Aktuelle Jobs:**\n\n");
+ }
- // Totals
- context.append(String.format("\n**Gesamtübersicht:**\n"));
- context.append(String.format("- Gesamtanzahl Aufträge: %d\n", statisticsService.getTotalJobCount()));
- context.append(String.format("- Abschlussrate: %.1f%%\n", statisticsService.getCompletionRate()));
- context.append(String.format("- Gesamtumsatz: %.2f EUR\n", statisticsService.getTotalRevenue()));
+ context.append(String.format("Gefunden: %d Jobs\n\n", jobs.size()));
- // Task statistics
- var taskStats = statisticsService.getTaskCompletionStats();
- context.append(String.format("\n**Aufgaben:**\n"));
- context.append(String.format("- Gesamt: %d\n", taskStats.get("total")));
- context.append(String.format("- Erledigt: %d\n", taskStats.get("completed")));
- context.append(String.format("- Ausstehend: %d\n", taskStats.get("pending")));
-
- // Top customers
- var topCustomers = statisticsService.getTopCustomersByRevenue(5);
- if (!topCustomers.isEmpty()) {
- context.append("\n**Top 5 Kunden nach Umsatz:**\n");
- for (var entry : topCustomers) {
- context.append(String.format("- %s: %.2f EUR\n",
- entry.getKey() != null ? entry.getKey() : "Unbekannt",
- entry.getValue()));
+ // Show job summaries (max 10)
+ int shown = 0;
+ for (Job job : jobs) {
+ if (shown >= 10) {
+ context.append(String.format("... und %d weitere Jobs\n", jobs.size() - 10));
+ break;
}
+ context.append(String.format("- %s: %s (%s)\n",
+ job.getJobNumber() != null ? job.getJobNumber() : "Ohne Nr.",
+ job.getCustomerSelection() != null ? job.getCustomerSelection() : "Unbekannt",
+ job.getStatus().getDisplayName()));
+ shown++;
}
return context.toString();
@@ -319,37 +648,79 @@ public class AiStatisticsService {
}
private String buildFallbackResponse(QueryAnalysis analysis) {
+ String customer = analysis.customerFilter;
+ JobStatus statusFilter = analysis.statusFilter;
+
return switch (analysis.queryType) {
+ case "list" -> {
+ List jobs;
+ if (customer != null && statusFilter != null) {
+ jobs = statisticsService.getJobsByCustomerAndStatus(customer, statusFilter);
+ yield String.format("Es wurden %d Jobs für %s mit Status \"%s\" gefunden.",
+ jobs.size(), customer, statusFilter.getDisplayName());
+ } else if (customer != null) {
+ jobs = statisticsService.getJobsByCustomer(customer);
+ yield String.format("Es wurden %d Jobs für %s gefunden.", jobs.size(), customer);
+ } else if (statusFilter != null) {
+ jobs = statisticsService.getJobsByStatus(statusFilter);
+ yield String.format("Es wurden %d Jobs mit Status \"%s\" gefunden.",
+ jobs.size(), statusFilter.getDisplayName());
+ } else {
+ yield "Hier sind die aktuellen Jobs.";
+ }
+ }
case "status" -> {
- var counts = statisticsService.getJobCountsByStatus();
- StringBuilder sb = new StringBuilder("**Auftragsübersicht nach Status:**\n\n");
+ var counts = customer != null
+ ? statisticsService.getJobCountsByStatusForCustomer(customer)
+ : statisticsService.getJobCountsByStatus();
+ String title = customer != null
+ ? String.format("**Auftragsübersicht für %s:**\n\n", customer)
+ : "**Auftragsübersicht nach Status:**\n\n";
+ StringBuilder sb = new StringBuilder(title);
counts.forEach((status, count) -> {
if (count > 0) {
sb.append(String.format("- **%s:** %d Aufträge\n", status.getDisplayName(), count));
}
});
- sb.append(String.format("\n**Gesamt:** %d Aufträge", statisticsService.getTotalJobCount()));
+ long total = customer != null
+ ? statisticsService.getTotalJobCountForCustomer(customer)
+ : statisticsService.getTotalJobCount();
+ sb.append(String.format("\n**Gesamt:** %d Aufträge", total));
yield sb.toString();
}
case "revenue" -> {
- var topCustomers = statisticsService.getTopCustomersByRevenue(5);
- StringBuilder sb = new StringBuilder("**Top Kunden nach Umsatz:**\n\n");
- int rank = 1;
- for (var entry : topCustomers) {
- sb.append(String.format("%d. **%s:** %.2f EUR\n",
- rank++,
- entry.getKey() != null ? entry.getKey() : "Unbekannt",
- entry.getValue()));
+ if (customer != null) {
+ var revenue = statisticsService.getTotalRevenueForCustomer(customer);
+ var jobCount = statisticsService.getTotalJobCountForCustomer(customer);
+ yield String.format("**Umsatz für %s:**\n\n" +
+ "- **Gesamtumsatz:** %.2f EUR\n" +
+ "- **Aufträge:** %d",
+ customer, revenue, jobCount);
+ } else {
+ var topCustomers = statisticsService.getTopCustomersByRevenue(5);
+ StringBuilder sb = new StringBuilder("**Top Kunden nach Umsatz:**\n\n");
+ int rank = 1;
+ for (var entry : topCustomers) {
+ sb.append(String.format("%d. **%s:** %.2f EUR\n",
+ rank++,
+ entry.getKey() != null ? entry.getKey() : "Unbekannt",
+ entry.getValue()));
+ }
+ sb.append(String.format("\n**Gesamtumsatz:** %.2f EUR", statisticsService.getTotalRevenue()));
+ yield sb.toString();
}
- sb.append(String.format("\n**Gesamtumsatz:** %.2f EUR", statisticsService.getTotalRevenue()));
- yield sb.toString();
}
case "trend" -> {
int year = Year.now().getValue();
- var monthly = statisticsService.getMonthlyJobCounts(year);
+ var monthly = customer != null
+ ? statisticsService.getMonthlyJobCountsForCustomer(year, customer)
+ : statisticsService.getMonthlyJobCounts(year);
long total = monthly.values().stream().mapToLong(Long::longValue).sum();
- yield String.format("**Monatstrend %d:**\n\nInsgesamt wurden %d Aufträge erstellt. " +
- "Die Verteilung ist im Diagramm ersichtlich.", year, total);
+ String title = customer != null
+ ? String.format("**Monatstrend %d für %s:**", year, customer)
+ : String.format("**Monatstrend %d:**", year);
+ yield String.format("%s\n\nInsgesamt wurden %d Aufträge erstellt. " +
+ "Die Verteilung ist im Diagramm ersichtlich.", title, total);
}
case "tasks" -> {
var taskStats = statisticsService.getTaskCompletionStats();
@@ -363,13 +734,24 @@ public class AiStatisticsService {
total, completed, rate, taskStats.getOrDefault("pending", 0L));
}
default -> {
- yield String.format("**Übersicht:**\n\n" +
- "- **Aufträge gesamt:** %d\n" +
- "- **Abschlussrate:** %.1f%%\n" +
- "- **Gesamtumsatz:** %.2f EUR",
- statisticsService.getTotalJobCount(),
- statisticsService.getCompletionRate(),
- statisticsService.getTotalRevenue());
+ if (customer != null) {
+ yield String.format("**Übersicht für %s:**\n\n" +
+ "- **Aufträge gesamt:** %d\n" +
+ "- **Abschlussrate:** %.1f%%\n" +
+ "- **Umsatz:** %.2f EUR",
+ customer,
+ statisticsService.getTotalJobCountForCustomer(customer),
+ statisticsService.getCompletionRateForCustomer(customer),
+ statisticsService.getTotalRevenueForCustomer(customer));
+ } else {
+ yield String.format("**Übersicht:**\n\n" +
+ "- **Aufträge gesamt:** %d\n" +
+ "- **Abschlussrate:** %.1f%%\n" +
+ "- **Gesamtumsatz:** %.2f EUR",
+ statisticsService.getTotalJobCount(),
+ statisticsService.getCompletionRate(),
+ statisticsService.getTotalRevenue());
+ }
}
};
}
diff --git a/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java b/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java
index d827710..25fed44 100644
--- a/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java
+++ b/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java
@@ -106,16 +106,23 @@ public class LlmRestClient {
}
private String extractContent(String response) {
- if (response == null) {
+ if (response == null || response.isBlank()) {
+ log.warn("LLM returned null or blank response");
return null;
}
try {
JsonNode root = objectMapper.readTree(response);
JsonNode choices = root.path("choices");
if (choices.isArray() && !choices.isEmpty()) {
- return choices.get(0).path("message").path("content").asText();
+ String content = choices.get(0).path("message").path("content").asText();
+ // asText() returns empty string for null/missing nodes - treat as null
+ if (content == null || content.isBlank()) {
+ log.warn("LLM response content is empty");
+ return null;
+ }
+ return content;
}
- log.warn("Unexpected response structure: {}", response);
+ log.warn("Unexpected response structure (no choices): {}", response);
return null;
} catch (Exception e) {
log.error("Error parsing LLM response: {}", e.getMessage());
diff --git a/src/main/java/de/assecutor/votianlt/pages/view/StatisticsView.java b/src/main/java/de/assecutor/votianlt/pages/view/StatisticsView.java
index 50ef2ca..0e1f5ff 100644
--- a/src/main/java/de/assecutor/votianlt/pages/view/StatisticsView.java
+++ b/src/main/java/de/assecutor/votianlt/pages/view/StatisticsView.java
@@ -262,7 +262,11 @@ public class StatisticsView extends VerticalLayout {
// Text Response
Div textDiv = new Div();
textDiv.getStyle().set("margin-top", "var(--lumo-space-s)");
- textDiv.getElement().setProperty("innerHTML", formatMarkdown(response.textResponse()));
+ String responseText = response.textResponse();
+ if (responseText == null || responseText.isBlank()) {
+ responseText = "*Die Statistikdaten wurden erfolgreich abgerufen und werden im Diagramm angezeigt.*";
+ }
+ textDiv.getElement().setProperty("innerHTML", formatMarkdown(responseText));
bubble.add(textDiv);
// Chart wenn vorhanden
@@ -505,7 +509,7 @@ public class StatisticsView extends VerticalLayout {
}
}
""";
- case "doughnut", "pie" -> """
+ case "doughnut" -> """
{
responsive: true,
maintainAspectRatio: false,
@@ -535,6 +539,118 @@ public class StatisticsView extends VerticalLayout {
}
}
""";
+ case "pie" -> """
+ {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ position: 'right',
+ labels: {
+ usePointStyle: true,
+ padding: 15,
+ font: { size: 12 }
+ }
+ },
+ tooltip: {
+ backgroundColor: 'rgba(0,0,0,0.8)',
+ titleFont: { size: 14, weight: 'bold' },
+ bodyFont: { size: 13 },
+ padding: 12,
+ cornerRadius: 8
+ }
+ },
+ animation: {
+ animateRotate: true,
+ animateScale: true,
+ duration: 1000,
+ easing: 'easeOutQuart'
+ }
+ }
+ """;
+ case "radar" -> """
+ {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ position: 'top',
+ labels: {
+ usePointStyle: true,
+ padding: 15
+ }
+ },
+ tooltip: {
+ backgroundColor: 'rgba(0,0,0,0.8)',
+ titleFont: { size: 14, weight: 'bold' },
+ bodyFont: { size: 13 },
+ padding: 12,
+ cornerRadius: 8
+ }
+ },
+ scales: {
+ r: {
+ beginAtZero: true,
+ grid: {
+ color: 'rgba(0,0,0,0.1)'
+ },
+ pointLabels: {
+ font: { size: 12 }
+ }
+ }
+ },
+ elements: {
+ line: {
+ borderWidth: 2
+ },
+ point: {
+ radius: 4,
+ hoverRadius: 6
+ }
+ },
+ animation: {
+ duration: 1000,
+ easing: 'easeOutQuart'
+ }
+ }
+ """;
+ case "polarArea" -> """
+ {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ position: 'right',
+ labels: {
+ usePointStyle: true,
+ padding: 15,
+ font: { size: 12 }
+ }
+ },
+ tooltip: {
+ backgroundColor: 'rgba(0,0,0,0.8)',
+ titleFont: { size: 14, weight: 'bold' },
+ bodyFont: { size: 13 },
+ padding: 12,
+ cornerRadius: 8
+ }
+ },
+ scales: {
+ r: {
+ beginAtZero: true,
+ grid: {
+ color: 'rgba(0,0,0,0.1)'
+ }
+ }
+ },
+ animation: {
+ animateRotate: true,
+ animateScale: true,
+ duration: 1000,
+ easing: 'easeOutQuart'
+ }
+ }
+ """;
default -> """
{
responsive: true,
diff --git a/src/main/java/de/assecutor/votianlt/repository/JobRepository.java b/src/main/java/de/assecutor/votianlt/repository/JobRepository.java
index 9535c45..76640a8 100644
--- a/src/main/java/de/assecutor/votianlt/repository/JobRepository.java
+++ b/src/main/java/de/assecutor/votianlt/repository/JobRepository.java
@@ -65,6 +65,12 @@ public interface JobRepository extends MongoRepository {
*/
List findByCustomerSelection(String customerSelection);
+ /**
+ * Findet Aufträge anhand einer Kundenauswahl (case-insensitive)
+ */
+ @Query("{ 'customerSelection': { $regex: ?0, $options: 'i' } }")
+ List findByCustomerSelectionIgnoreCase(String customerSelection);
+
/**
* Prüft, ob eine Auftragsnummer bereits existiert
*/
diff --git a/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java b/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java
index 2d78908..ad6fc82 100644
--- a/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java
+++ b/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java
@@ -16,6 +16,7 @@ import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
/**
* Service for job statistics and aggregations.
@@ -142,10 +143,29 @@ public class JobStatisticsService {
}
/**
- * Get jobs by customer selection.
+ * Get jobs by customer selection (case-insensitive, flexible matching).
*/
public List getJobsByCustomer(String customer) {
- return jobRepository.findByCustomerSelection(customer);
+ if (customer == null || customer.isBlank()) {
+ return List.of();
+ }
+ // Trim and escape regex special characters for MongoDB
+ String trimmedCustomer = customer.trim();
+ String escapedCustomer = trimmedCustomer.replaceAll("([.^$*+?()\\[\\]{}|\\\\])", "\\\\$1");
+
+ // First try exact match (with optional whitespace)
+ String exactRegex = "^\\s*" + escapedCustomer + "\\s*$";
+ List jobs = jobRepository.findByCustomerSelectionIgnoreCase(exactRegex);
+ log.debug("getJobsByCustomer('{}') - exact regex: '{}' - found {} jobs", customer, exactRegex, jobs.size());
+
+ // If no exact match, try partial match (customer name contains the search term)
+ if (jobs.isEmpty()) {
+ String containsRegex = ".*" + escapedCustomer + ".*";
+ jobs = jobRepository.findByCustomerSelectionIgnoreCase(containsRegex);
+ log.debug("getJobsByCustomer('{}') - contains regex: '{}' - found {} jobs", customer, containsRegex, jobs.size());
+ }
+
+ return jobs;
}
/**
@@ -200,4 +220,227 @@ public class JobStatisticsService {
public List getLatestJobs(int limit) {
return jobRepository.findLatestJobs().stream().limit(limit).toList();
}
+
+ // ==================== Filtered Statistics Methods ====================
+
+ /**
+ * Get all available customer names for autocomplete/filtering.
+ */
+ public List getAllCustomerNames() {
+ return jobRepository.findAll().stream()
+ .map(Job::getCustomerSelection)
+ .filter(c -> c != null && !c.isBlank())
+ .distinct()
+ .sorted()
+ .toList();
+ }
+
+ /**
+ * Get job counts by status filtered by customer.
+ */
+ public Map getJobCountsByStatusForCustomer(String customer) {
+ List customerJobs = getJobsByCustomer(customer);
+ Map counts = new EnumMap<>(JobStatus.class);
+ for (JobStatus status : JobStatus.values()) {
+ counts.put(status, 0L);
+ }
+ for (Job job : customerJobs) {
+ counts.computeIfPresent(job.getStatus(), (k, v) -> v + 1L);
+ }
+ return counts;
+ }
+
+ /**
+ * Get total job count for a customer.
+ */
+ public long getTotalJobCountForCustomer(String customer) {
+ return getJobsByCustomer(customer).size();
+ }
+
+ /**
+ * Get total revenue for a customer.
+ */
+ public BigDecimal getTotalRevenueForCustomer(String customer) {
+ return getJobsByCustomer(customer).stream()
+ .map(Job::getPrice)
+ .filter(price -> price != null)
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+ }
+
+ /**
+ * Get completion rate for a customer.
+ */
+ public double getCompletionRateForCustomer(String customer) {
+ List customerJobs = getJobsByCustomer(customer);
+ if (customerJobs.isEmpty()) {
+ return 0.0;
+ }
+ long completed = customerJobs.stream()
+ .filter(j -> j.getStatus() == JobStatus.COMPLETED)
+ .count();
+ return (double) completed / customerJobs.size() * 100.0;
+ }
+
+ /**
+ * Get monthly job counts for a customer in a specific year.
+ */
+ public Map getMonthlyJobCountsForCustomer(int year, String customer) {
+ Map monthlyCounts = new LinkedHashMap<>();
+ LocalDateTime yearStart = LocalDateTime.of(year, 1, 1, 0, 0);
+ LocalDateTime yearEnd = LocalDateTime.of(year, 12, 31, 23, 59, 59);
+
+ List customerJobs = getJobsByCustomer(customer).stream()
+ .filter(j -> j.getCreatedAt() != null
+ && !j.getCreatedAt().isBefore(yearStart)
+ && !j.getCreatedAt().isAfter(yearEnd))
+ .toList();
+
+ // Initialize all months with 0
+ for (Month month : Month.values()) {
+ monthlyCounts.put(month, 0L);
+ }
+
+ // Count jobs per month
+ for (Job job : customerJobs) {
+ Month month = job.getCreatedAt().getMonth();
+ monthlyCounts.computeIfPresent(month, (k, v) -> v + 1L);
+ }
+
+ return monthlyCounts;
+ }
+
+ /**
+ * Get jobs filtered by customer and optionally by status.
+ */
+ public List getJobsByCustomerAndStatus(String customer, JobStatus status) {
+ List customerJobs = getJobsByCustomer(customer);
+ if (status == null) {
+ return customerJobs;
+ }
+ return customerJobs.stream()
+ .filter(j -> j.getStatus() == status)
+ .toList();
+ }
+
+ /**
+ * Find best matching customer name from query (fuzzy matching).
+ */
+ public String findMatchingCustomer(String query) {
+ if (query == null || query.isBlank()) {
+ return null;
+ }
+
+ String lowerQuery = query.toLowerCase();
+ List allCustomers = getAllCustomerNames();
+ log.debug("findMatchingCustomer - Query: '{}', Available customers: {}", query, allCustomers);
+
+ // First: exact match (case insensitive)
+ for (String customer : allCustomers) {
+ if (customer.equalsIgnoreCase(query)) {
+ log.debug("findMatchingCustomer - Exact match found: '{}'", customer);
+ return customer;
+ }
+ }
+
+ // Second: check if query contains a customer name
+ for (String customer : allCustomers) {
+ String lowerCustomer = customer.toLowerCase();
+ if (lowerQuery.contains(lowerCustomer)) {
+ log.debug("findMatchingCustomer - Query contains customer: '{}'", customer);
+ return customer;
+ }
+ }
+
+ // Third: extract potential customer name from query and check if it matches a customer
+ // This handles cases like "cAPPacity GmbH" matching "cAPPacity GmbH & Co. KG"
+ String extractedName = extractCustomerNameFromQuery(query);
+ if (extractedName != null) {
+ String lowerExtracted = extractedName.toLowerCase();
+ for (String customer : allCustomers) {
+ String lowerCustomer = customer.toLowerCase();
+ // Check if customer name starts with the extracted name, or contains it
+ if (lowerCustomer.startsWith(lowerExtracted) || lowerCustomer.contains(lowerExtracted)) {
+ log.debug("findMatchingCustomer - Extracted name '{}' matches customer: '{}'", extractedName, customer);
+ return customer;
+ }
+ }
+ }
+
+ // Fourth: word match (any significant word from the query matches customer name)
+ String[] queryWords = lowerQuery.split("\\s+");
+ for (String customer : allCustomers) {
+ String lowerCustomer = customer.toLowerCase();
+ for (String word : queryWords) {
+ // Skip common words and short words
+ if (word.length() >= 4 && !isCommonWord(word) && lowerCustomer.contains(word)) {
+ log.debug("findMatchingCustomer - Word match found: '{}' in '{}'", word, customer);
+ return customer;
+ }
+ }
+ }
+
+ log.debug("findMatchingCustomer - No match found for query: '{}'", query);
+ return null;
+ }
+
+ /**
+ * Extract potential customer name from a query string.
+ * Looks for patterns like "firma X", "kunde X", "für X", etc.
+ */
+ private String extractCustomerNameFromQuery(String query) {
+ String lowerQuery = query.toLowerCase();
+
+ // Patterns that typically precede a customer name
+ String[] patterns = {
+ "firma ", "kunde ", "kunden ", "unternehmen ",
+ "für die firma ", "für den kunden ", "von der firma ", "vom kunden ",
+ "der firma ", "des kunden ", "bei "
+ };
+
+ for (String pattern : patterns) {
+ int idx = lowerQuery.indexOf(pattern);
+ if (idx >= 0) {
+ // Extract everything after the pattern
+ int startIdx = idx + pattern.length();
+ String afterPattern = query.substring(startIdx).trim();
+
+ // Remove common trailing words
+ afterPattern = removeTrailingCommonWords(afterPattern);
+
+ if (!afterPattern.isBlank()) {
+ log.debug("extractCustomerNameFromQuery - Found potential name: '{}'", afterPattern);
+ return afterPattern;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Remove common trailing words from extracted customer name.
+ */
+ private String removeTrailingCommonWords(String text) {
+ String[] trailingPatterns = {
+ " an$", " anzeigen$", " zeigen$", " auflisten$", " liste$",
+ " status$", " mit status$", " die$", " der$", " das$"
+ };
+
+ String result = text;
+ for (String pattern : trailingPatterns) {
+ result = result.replaceAll("(?i)" + pattern, "").trim();
+ }
+
+ return result;
+ }
+
+ /**
+ * Check if a word is a common German/English word that should be ignored in matching.
+ */
+ private boolean isCommonWord(String word) {
+ return Set.of(
+ "zeige", "alle", "jobs", "der", "die", "das", "für", "von", "mit", "und",
+ "firma", "kunde", "status", "welche", "sind", "gmbh", "show", "all", "the"
+ ).contains(word.toLowerCase());
+ }
}
diff --git a/src/test/java/de/assecutor/votianlt/model/JobJsonSerializationTest.java b/src/test/java/de/assecutor/votianlt/model/JobJsonSerializationTest.java
deleted file mode 100644
index 2a575ab..0000000
--- a/src/test/java/de/assecutor/votianlt/model/JobJsonSerializationTest.java
+++ /dev/null
@@ -1,601 +0,0 @@
-package de.assecutor.votianlt.model;
-
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.SerializationFeature;
-import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
-import de.assecutor.votianlt.model.task.*;
-import org.bson.types.ObjectId;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Nested;
-import org.junit.jupiter.api.Test;
-
-import java.math.BigDecimal;
-import java.time.LocalDate;
-import java.time.LocalDateTime;
-import java.util.Arrays;
-import java.util.List;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Unit tests for Job and Task JSON serialization according to JOB_JSON.md specification.
- * See docs/JOB_JSON.md for the JSON structure documentation.
- */
-@DisplayName("Job JSON Serialization Tests")
-class JobJsonSerializationTest {
-
- private ObjectMapper objectMapper;
-
- @BeforeEach
- void setUp() {
- objectMapper = new ObjectMapper();
- objectMapper.registerModule(new JavaTimeModule());
- objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
- }
-
- @Nested
- @DisplayName("Job Serialization Tests")
- class JobSerializationTests {
-
- @Test
- @DisplayName("should serialize job with all fields according to spec")
- void shouldSerializeJobWithAllFields() throws JsonProcessingException {
- // Given
- Job job = createFullJob();
-
- // When
- String json = objectMapper.writeValueAsString(job);
- JsonNode node = objectMapper.readTree(json);
-
- // Then - verify all required fields according to JOB_JSON.md
- assertThat(node.has("id")).isTrue();
- assertThat(node.get("id").asText()).isNotEmpty();
-
- assertThat(node.get("jobNumber").asText()).isEqualTo("JOB-2024-001");
- assertThat(node.get("status").asText()).isEqualTo("IN_PROGRESS");
- assertThat(node.has("createdAt")).isTrue();
- assertThat(node.get("createdBy").asText()).isEqualTo("admin@example.com");
- assertThat(node.get("draft").asBoolean()).isFalse();
-
- // Pickup address
- assertThat(node.get("pickupCompany").asText()).isEqualTo("Absender GmbH");
- assertThat(node.get("pickupSalutation").asText()).isEqualTo("Herr");
- assertThat(node.get("pickupFirstName").asText()).isEqualTo("Max");
- assertThat(node.get("pickupLastName").asText()).isEqualTo("Mustermann");
- assertThat(node.get("pickupPhone").asText()).isEqualTo("+49 123 456789");
- assertThat(node.get("pickupStreet").asText()).isEqualTo("Musterstraße");
- assertThat(node.get("pickupHouseNumber").asText()).isEqualTo("42");
- assertThat(node.get("pickupAddressAddition").asText()).isEqualTo("2. OG");
- assertThat(node.get("pickupZip").asText()).isEqualTo("12345");
- assertThat(node.get("pickupCity").asText()).isEqualTo("Musterstadt");
-
- // Delivery address
- assertThat(node.get("deliveryCompany").asText()).isEqualTo("Empfänger AG");
- assertThat(node.get("deliverySalutation").asText()).isEqualTo("Frau");
- assertThat(node.get("deliveryFirstName").asText()).isEqualTo("Erika");
- assertThat(node.get("deliveryLastName").asText()).isEqualTo("Musterfrau");
- assertThat(node.get("deliveryPhone").asText()).isEqualTo("+49 987 654321");
- assertThat(node.get("deliveryStreet").asText()).isEqualTo("Beispielweg");
- assertThat(node.get("deliveryHouseNumber").asText()).isEqualTo("7");
- assertThat(node.get("deliveryZip").asText()).isEqualTo("54321");
- assertThat(node.get("deliveryCity").asText()).isEqualTo("Beispielstadt");
-
- // Digital processing
- assertThat(node.get("digitalProcessing").asBoolean()).isTrue();
- assertThat(node.get("appUser").asText()).isEqualTo("fahrer01");
-
- // Dates
- assertThat(node.has("pickupDate")).isTrue();
- assertThat(node.has("deliveryDate")).isTrue();
-
- // Other fields
- assertThat(node.get("remark").asText()).isEqualTo("Bitte zwischen 9-12 Uhr liefern");
- assertThat(node.get("price").decimalValue()).isEqualByComparingTo(new BigDecimal("150.00"));
- }
-
- @Test
- @DisplayName("should serialize job id as string")
- void shouldSerializeJobIdAsString() throws JsonProcessingException {
- // Given
- Job job = new Job();
- ObjectId objectId = new ObjectId("507f1f77bcf86cd799439011");
- job.setId(objectId);
- job.setJobNumber("TEST-001");
- job.setStatus(JobStatus.CREATED);
-
- // When
- String json = objectMapper.writeValueAsString(job);
- JsonNode node = objectMapper.readTree(json);
-
- // Then
- assertThat(node.get("id").asText()).isEqualTo("507f1f77bcf86cd799439011");
- assertThat(node.get("id").isTextual()).isTrue();
- }
-
- @Test
- @DisplayName("should serialize all job status values correctly")
- void shouldSerializeAllJobStatusValues() throws JsonProcessingException {
- // According to JOB_JSON.md: CREATED, IN_PROGRESS, PICKUP_SCHEDULED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED
- JobStatus[] expectedStatuses = {
- JobStatus.CREATED,
- JobStatus.IN_PROGRESS,
- JobStatus.PICKUP_SCHEDULED,
- JobStatus.PICKED_UP,
- JobStatus.IN_TRANSIT,
- JobStatus.DELIVERED,
- JobStatus.COMPLETED,
- JobStatus.CANCELLED
- };
-
- for (JobStatus status : expectedStatuses) {
- Job job = new Job();
- job.setId(new ObjectId());
- job.setStatus(status);
-
- String json = objectMapper.writeValueAsString(job);
- JsonNode node = objectMapper.readTree(json);
-
- assertThat(node.get("status").asText()).isEqualTo(status.name());
- }
- }
-
- @Test
- @DisplayName("should handle null optional fields gracefully")
- void shouldHandleNullOptionalFieldsGracefully() throws JsonProcessingException {
- // Given
- Job job = new Job();
- job.setId(new ObjectId());
- job.setJobNumber("JOB-001");
- job.setStatus(JobStatus.CREATED);
- // All other fields are null
-
- // When
- String json = objectMapper.writeValueAsString(job);
- JsonNode node = objectMapper.readTree(json);
-
- // Then - should serialize without error
- assertThat(node.get("jobNumber").asText()).isEqualTo("JOB-001");
- assertThat(node.get("deliveryAddressAddition").isNull()).isTrue();
- assertThat(node.get("remark").isNull()).isTrue();
- assertThat(node.get("price").isNull()).isTrue();
- }
- }
-
- @Nested
- @DisplayName("Task Serialization Tests")
- class TaskSerializationTests {
-
- @Test
- @DisplayName("should serialize ConfirmationTask according to spec")
- void shouldSerializeConfirmationTask() throws JsonProcessingException {
- // Given
- ConfirmationTask task = new ConfirmationTask("Ware übernommen");
- task.setId(new ObjectId("507f1f77bcf86cd799439012"));
- task.setJobId(new ObjectId("507f1f77bcf86cd799439011"));
- task.setTaskOrder(1);
- task.setDescription("Bitte bestätigen Sie die Übernahme der Ware");
- task.setCompleted(false);
-
- // When
- String json = objectMapper.writeValueAsString(task);
- JsonNode node = objectMapper.readTree(json);
-
- // Then
- assertThat(node.get("id").asText()).isEqualTo("507f1f77bcf86cd799439012");
- assertThat(node.get("jobId").asText()).isEqualTo("507f1f77bcf86cd799439011");
- assertThat(node.get("taskType").asText()).isEqualTo("CONFIRMATION");
- assertThat(node.get("taskOrder").asInt()).isEqualTo(1);
- assertThat(node.get("description").asText()).isEqualTo("Bitte bestätigen Sie die Übernahme der Ware");
- assertThat(node.get("completed").asBoolean()).isFalse();
-
- // taskSpecificData
- JsonNode specificData = node.get("taskSpecificData");
- assertThat(specificData).isNotNull();
- assertThat(specificData.get("taskType").asText()).isEqualTo("CONFIRMATION");
- assertThat(specificData.get("buttonText").asText()).isEqualTo("Ware übernommen");
- }
-
- @Test
- @DisplayName("should serialize SignatureTask according to spec")
- void shouldSerializeSignatureTask() throws JsonProcessingException {
- // Given
- SignatureTask task = new SignatureTask();
- task.setId(new ObjectId("507f1f77bcf86cd799439013"));
- task.setJobId(new ObjectId("507f1f77bcf86cd799439011"));
- task.setTaskOrder(2);
- task.setDescription("Unterschrift des Empfängers");
- task.setCompleted(false);
-
- // When
- String json = objectMapper.writeValueAsString(task);
- JsonNode node = objectMapper.readTree(json);
-
- // Then
- assertThat(node.get("taskType").asText()).isEqualTo("SIGNATURE");
-
- JsonNode specificData = node.get("taskSpecificData");
- assertThat(specificData).isNotNull();
- assertThat(specificData.get("taskType").asText()).isEqualTo("SIGNATURE");
- }
-
- @Test
- @DisplayName("should serialize PhotoTask according to spec")
- void shouldSerializePhotoTask() throws JsonProcessingException {
- // Given
- PhotoTask task = new PhotoTask(1, 5);
- task.setId(new ObjectId("507f1f77bcf86cd799439014"));
- task.setJobId(new ObjectId("507f1f77bcf86cd799439011"));
- task.setTaskOrder(3);
- task.setDescription("Fotos der Ware bei Abholung");
- task.setCompleted(false);
-
- // When
- String json = objectMapper.writeValueAsString(task);
- JsonNode node = objectMapper.readTree(json);
-
- // Then
- assertThat(node.get("taskType").asText()).isEqualTo("PHOTO");
-
- JsonNode specificData = node.get("taskSpecificData");
- assertThat(specificData).isNotNull();
- assertThat(specificData.get("taskType").asText()).isEqualTo("PHOTO");
- assertThat(specificData.get("minPhotoCount").asInt()).isEqualTo(1);
- assertThat(specificData.get("maxPhotoCount").asInt()).isEqualTo(5);
- }
-
- @Test
- @DisplayName("should serialize BarcodeTask according to spec")
- void shouldSerializeBarcodeTask() throws JsonProcessingException {
- // Given
- BarcodeTask task = new BarcodeTask(1, 10);
- task.setId(new ObjectId("507f1f77bcf86cd799439015"));
- task.setJobId(new ObjectId("507f1f77bcf86cd799439011"));
- task.setTaskOrder(4);
- task.setDescription("Scannen Sie alle Pakete");
- task.setCompleted(false);
-
- // When
- String json = objectMapper.writeValueAsString(task);
- JsonNode node = objectMapper.readTree(json);
-
- // Then
- assertThat(node.get("taskType").asText()).isEqualTo("BARCODE");
-
- JsonNode specificData = node.get("taskSpecificData");
- assertThat(specificData).isNotNull();
- assertThat(specificData.get("taskType").asText()).isEqualTo("BARCODE");
- assertThat(specificData.get("minBarcodeCount").asInt()).isEqualTo(1);
- assertThat(specificData.get("maxBarcodeCount").asInt()).isEqualTo(10);
- }
-
- @Test
- @DisplayName("should serialize TodoListTask according to spec")
- void shouldSerializeTodoListTask() throws JsonProcessingException {
- // Given
- List todoItems = Arrays.asList(
- "Verpackung auf Beschädigungen prüfen",
- "Anzahl der Pakete kontrollieren",
- "Lieferschein beiliegen"
- );
- TodoListTask task = new TodoListTask(todoItems);
- task.setId(new ObjectId("507f1f77bcf86cd799439016"));
- task.setJobId(new ObjectId("507f1f77bcf86cd799439011"));
- task.setTaskOrder(5);
- task.setDescription("Checkliste vor Auslieferung");
- task.setCompleted(false);
-
- // When
- String json = objectMapper.writeValueAsString(task);
- JsonNode node = objectMapper.readTree(json);
-
- // Then
- assertThat(node.get("taskType").asText()).isEqualTo("TODOLIST");
-
- JsonNode specificData = node.get("taskSpecificData");
- assertThat(specificData).isNotNull();
- assertThat(specificData.get("taskType").asText()).isEqualTo("TODOLIST");
- assertThat(specificData.get("todoItems").isArray()).isTrue();
- assertThat(specificData.get("todoItems").size()).isEqualTo(3);
- assertThat(specificData.get("todoItems").get(0).asText()).isEqualTo("Verpackung auf Beschädigungen prüfen");
- }
-
- @Test
- @DisplayName("should serialize CommentTask according to spec")
- void shouldSerializeCommentTask() throws JsonProcessingException {
- // Given
- CommentTask task = new CommentTask(null, false);
- task.setId(new ObjectId("507f1f77bcf86cd799439017"));
- task.setJobId(new ObjectId("507f1f77bcf86cd799439011"));
- task.setTaskOrder(6);
- task.setDescription("Anmerkungen zur Lieferung");
- task.setCompleted(false);
-
- // When
- String json = objectMapper.writeValueAsString(task);
- JsonNode node = objectMapper.readTree(json);
-
- // Then
- assertThat(node.get("taskType").asText()).isEqualTo("COMMENT");
-
- JsonNode specificData = node.get("taskSpecificData");
- assertThat(specificData).isNotNull();
- assertThat(specificData.get("taskType").asText()).isEqualTo("COMMENT");
- assertThat(specificData.get("commentText").isNull()).isTrue();
- assertThat(specificData.get("required").asBoolean()).isFalse();
- }
-
- @Test
- @DisplayName("should serialize completed task with completion data")
- void shouldSerializeCompletedTaskWithCompletionData() throws JsonProcessingException {
- // Given
- ConfirmationTask task = new ConfirmationTask("Ware übernommen");
- task.setId(new ObjectId("507f1f77bcf86cd799439012"));
- task.setJobId(new ObjectId("507f1f77bcf86cd799439011"));
- task.setTaskOrder(1);
- task.setDescription("Ware übernommen bestätigen");
- task.setCompleted(true);
- task.setCompletedAt(LocalDateTime.of(2024, 1, 20, 9, 15, 0));
- task.setCompletedBy("fahrer01");
-
- // When
- String json = objectMapper.writeValueAsString(task);
- JsonNode node = objectMapper.readTree(json);
-
- // Then
- assertThat(node.get("completed").asBoolean()).isTrue();
- assertThat(node.has("completedAt")).isTrue();
- assertThat(node.get("completedBy").asText()).isEqualTo("fahrer01");
- }
-
- @Test
- @DisplayName("should serialize all task types correctly")
- void shouldSerializeAllTaskTypes() throws JsonProcessingException {
- // According to JOB_JSON.md: CONFIRMATION, SIGNATURE, TODOLIST, PHOTO, BARCODE, COMMENT
- BaseTask[] tasks = {
- new ConfirmationTask("Test"),
- new SignatureTask(),
- new TodoListTask(List.of("Item")),
- new PhotoTask(1, 3),
- new BarcodeTask(1, 5),
- new CommentTask(null, false)
- };
-
- String[] expectedTypes = {"CONFIRMATION", "SIGNATURE", "TODOLIST", "PHOTO", "BARCODE", "COMMENT"};
-
- for (int i = 0; i < tasks.length; i++) {
- tasks[i].setId(new ObjectId());
- tasks[i].setJobId(new ObjectId());
-
- String json = objectMapper.writeValueAsString(tasks[i]);
- JsonNode node = objectMapper.readTree(json);
-
- assertThat(node.get("taskType").asText())
- .as("Task type for %s", tasks[i].getClass().getSimpleName())
- .isEqualTo(expectedTypes[i]);
- }
- }
- }
-
- @Nested
- @DisplayName("Job with Tasks Serialization Tests")
- class JobWithTasksSerializationTests {
-
- @Test
- @DisplayName("should serialize complete job with tasks structure")
- void shouldSerializeCompleteJobWithTasksStructure() throws JsonProcessingException {
- // Given
- Job job = createFullJob();
- List tasks = createAllTaskTypes(job.getId());
-
- // Create wrapper object similar to the spec
- record JobWithTasks(Job job, List tasks) {}
- JobWithTasks jobWithTasks = new JobWithTasks(job, tasks);
-
- // When
- String json = objectMapper.writeValueAsString(jobWithTasks);
- JsonNode node = objectMapper.readTree(json);
-
- // Then
- assertThat(node.has("job")).isTrue();
- assertThat(node.has("tasks")).isTrue();
- assertThat(node.get("tasks").isArray()).isTrue();
- assertThat(node.get("tasks").size()).isEqualTo(4);
-
- // Verify task types are serialized correctly
- JsonNode tasksNode = node.get("tasks");
- assertThat(tasksNode.get(0).get("taskType").asText()).isEqualTo("CONFIRMATION");
- assertThat(tasksNode.get(1).get("taskType").asText()).isEqualTo("PHOTO");
- assertThat(tasksNode.get(2).get("taskType").asText()).isEqualTo("BARCODE");
- assertThat(tasksNode.get(3).get("taskType").asText()).isEqualTo("SIGNATURE");
- }
- }
-
- @Nested
- @DisplayName("Field Type Tests")
- class FieldTypeTests {
-
- @Test
- @DisplayName("should serialize ObjectId as String (not ObjectId type)")
- void shouldSerializeObjectIdAsString() throws JsonProcessingException {
- // According to spec: id is String (ObjectId) - should be serialized as string
- Job job = new Job();
- job.setId(new ObjectId("507f1f77bcf86cd799439011"));
- job.setJobNumber("TEST");
- job.setStatus(JobStatus.CREATED);
-
- String json = objectMapper.writeValueAsString(job);
- JsonNode node = objectMapper.readTree(json);
-
- assertThat(node.get("id").isTextual()).isTrue();
- assertThat(node.get("id").asText()).hasSize(24); // ObjectId hex length
- }
-
- @Test
- @DisplayName("should serialize dates in ISO format")
- void shouldSerializeDatesInIsoFormat() throws JsonProcessingException {
- // According to spec: createdAt is ISO DateTime
- Job job = new Job();
- job.setId(new ObjectId());
- job.setJobNumber("TEST");
- job.setStatus(JobStatus.CREATED);
- job.setCreatedAt(LocalDateTime.of(2024, 1, 15, 10, 30, 0));
- job.setPickupDate(LocalDate.of(2024, 1, 20));
-
- String json = objectMapper.writeValueAsString(job);
- JsonNode node = objectMapper.readTree(json);
-
- assertThat(node.get("createdAt").asText()).isEqualTo("2024-01-15T10:30:00");
- assertThat(node.get("pickupDate").asText()).isEqualTo("2024-01-20");
- }
-
- @Test
- @DisplayName("should serialize price as decimal")
- void shouldSerializePriceAsDecimal() throws JsonProcessingException {
- // According to spec: price is Decimal
- Job job = new Job();
- job.setId(new ObjectId());
- job.setJobNumber("TEST");
- job.setStatus(JobStatus.CREATED);
- job.setPrice(new BigDecimal("150.50"));
-
- String json = objectMapper.writeValueAsString(job);
- JsonNode node = objectMapper.readTree(json);
-
- assertThat(node.get("price").isNumber()).isTrue();
- assertThat(node.get("price").decimalValue()).isEqualByComparingTo(new BigDecimal("150.50"));
- }
-
- @Test
- @DisplayName("should serialize taskOrder as Integer")
- void shouldSerializeTaskOrderAsInteger() throws JsonProcessingException {
- // According to spec: taskOrder is Integer
- ConfirmationTask task = new ConfirmationTask("Test");
- task.setId(new ObjectId());
- task.setJobId(new ObjectId());
- task.setTaskOrder(5);
-
- String json = objectMapper.writeValueAsString(task);
- JsonNode node = objectMapper.readTree(json);
-
- assertThat(node.get("taskOrder").isInt()).isTrue();
- assertThat(node.get("taskOrder").asInt()).isEqualTo(5);
- }
-
- @Test
- @DisplayName("should serialize boolean fields correctly")
- void shouldSerializeBooleanFieldsCorrectly() throws JsonProcessingException {
- // According to spec: isDraft, completed are Boolean
- Job job = new Job();
- job.setId(new ObjectId());
- job.setJobNumber("TEST");
- job.setStatus(JobStatus.CREATED);
- job.setDraft(true);
-
- ConfirmationTask task = new ConfirmationTask("Test");
- task.setId(new ObjectId());
- task.setJobId(new ObjectId());
- task.setCompleted(true);
-
- String jobJson = objectMapper.writeValueAsString(job);
- String taskJson = objectMapper.writeValueAsString(task);
-
- JsonNode jobNode = objectMapper.readTree(jobJson);
- JsonNode taskNode = objectMapper.readTree(taskJson);
-
- assertThat(jobNode.get("draft").isBoolean()).isTrue();
- assertThat(jobNode.get("draft").asBoolean()).isTrue();
- assertThat(taskNode.get("completed").isBoolean()).isTrue();
- assertThat(taskNode.get("completed").asBoolean()).isTrue();
- }
- }
-
- // Helper methods
-
- private Job createFullJob() {
- Job job = new Job();
- job.setId(new ObjectId("507f1f77bcf86cd799439011"));
- job.setJobNumber("JOB-2024-001");
- job.setStatus(JobStatus.IN_PROGRESS);
- job.setCreatedAt(LocalDateTime.of(2024, 1, 15, 10, 30, 0));
- job.setUpdatedAt(LocalDateTime.of(2024, 1, 15, 14, 45, 0));
- job.setCreatedBy("admin@example.com");
- job.setDraft(false);
- job.setCustomerSelection("Kunde01");
-
- // Pickup address
- job.setPickupCompany("Absender GmbH");
- job.setPickupSalutation("Herr");
- job.setPickupFirstName("Max");
- job.setPickupLastName("Mustermann");
- job.setPickupPhone("+49 123 456789");
- job.setPickupStreet("Musterstraße");
- job.setPickupHouseNumber("42");
- job.setPickupAddressAddition("2. OG");
- job.setPickupZip("12345");
- job.setPickupCity("Musterstadt");
-
- // Delivery address
- job.setDeliveryCompany("Empfänger AG");
- job.setDeliverySalutation("Frau");
- job.setDeliveryFirstName("Erika");
- job.setDeliveryLastName("Musterfrau");
- job.setDeliveryPhone("+49 987 654321");
- job.setDeliveryStreet("Beispielweg");
- job.setDeliveryHouseNumber("7");
- job.setDeliveryZip("54321");
- job.setDeliveryCity("Beispielstadt");
-
- // Digital processing
- job.setDigitalProcessing(true);
- job.setAppUser("fahrer01");
-
- // Dates
- job.setPickupDate(LocalDate.of(2024, 1, 20));
- job.setDeliveryDate(LocalDate.of(2024, 1, 21));
-
- // Other
- job.setRemark("Bitte zwischen 9-12 Uhr liefern");
- job.setPrice(new BigDecimal("150.00"));
-
- return job;
- }
-
- private List createAllTaskTypes(ObjectId jobId) {
- ConfirmationTask confirmationTask = new ConfirmationTask("Ware übernommen");
- confirmationTask.setId(new ObjectId("507f1f77bcf86cd799439012"));
- confirmationTask.setJobId(jobId);
- confirmationTask.setTaskOrder(1);
- confirmationTask.setDescription("Ware übernommen bestätigen");
- confirmationTask.setCompleted(true);
- confirmationTask.setCompletedAt(LocalDateTime.of(2024, 1, 20, 9, 15, 0));
- confirmationTask.setCompletedBy("fahrer01");
-
- PhotoTask photoTask = new PhotoTask(2, 5);
- photoTask.setId(new ObjectId("507f1f77bcf86cd799439013"));
- photoTask.setJobId(jobId);
- photoTask.setTaskOrder(2);
- photoTask.setDescription("Fotos bei Abholung");
- photoTask.setCompleted(true);
- photoTask.setCompletedAt(LocalDateTime.of(2024, 1, 20, 9, 20, 0));
- photoTask.setCompletedBy("fahrer01");
-
- BarcodeTask barcodeTask = new BarcodeTask(1, 3);
- barcodeTask.setId(new ObjectId("507f1f77bcf86cd799439014"));
- barcodeTask.setJobId(jobId);
- barcodeTask.setTaskOrder(3);
- barcodeTask.setDescription("Pakete scannen");
- barcodeTask.setCompleted(false);
-
- SignatureTask signatureTask = new SignatureTask();
- signatureTask.setId(new ObjectId("507f1f77bcf86cd799439015"));
- signatureTask.setJobId(jobId);
- signatureTask.setTaskOrder(4);
- signatureTask.setDescription("Unterschrift Empfänger");
- signatureTask.setCompleted(false);
-
- return Arrays.asList(confirmationTask, photoTask, barcodeTask, signatureTask);
- }
-}
diff --git a/src/test/java/de/assecutor/votianlt/service/ClientConnectionServiceTest.java b/src/test/java/de/assecutor/votianlt/service/ClientConnectionServiceTest.java
deleted file mode 100644
index e2eccf5..0000000
--- a/src/test/java/de/assecutor/votianlt/service/ClientConnectionServiceTest.java
+++ /dev/null
@@ -1,489 +0,0 @@
-package de.assecutor.votianlt.service;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-import de.assecutor.votianlt.messaging.delivery.MessageDeliveryService;
-import de.assecutor.votianlt.messaging.plugin.PluginException;
-import de.assecutor.votianlt.messaging.plugin.PluginManager;
-import de.assecutor.votianlt.messaging.plugin.SendOptions;
-import de.assecutor.votianlt.service.ClientConnectionService.ClientState;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Nested;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-import org.springframework.test.util.ReflectionTestUtils;
-
-import java.time.Instant;
-import java.util.Set;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.*;
-
-@ExtendWith(MockitoExtension.class)
-@DisplayName("ClientConnectionService Tests")
-class ClientConnectionServiceTest {
-
- @Mock
- private PluginManager pluginManager;
-
- @Mock
- private MessageDeliveryService messageDeliveryService;
-
- private ObjectMapper objectMapper;
- private ClientConnectionService service;
-
- @BeforeEach
- void setUp() {
- objectMapper = new ObjectMapper();
- service = new ClientConnectionService(pluginManager, objectMapper, messageDeliveryService);
- ReflectionTestUtils.setField(service, "pingIntervalSeconds", 15);
- ReflectionTestUtils.setField(service, "pingTimeoutSeconds", 5);
- }
-
- @Nested
- @DisplayName("registerClient Tests")
- class RegisterClientTests {
-
- @Test
- @DisplayName("should register new client successfully")
- void shouldRegisterNewClient() {
- // When
- service.registerClient("client-123", "user-456");
-
- // Then
- assertThat(service.isClientConnected("client-123")).isTrue();
- assertThat(service.getConnectedClientCount()).isEqualTo(1);
- }
-
- @Test
- @DisplayName("should not register client with null clientId")
- void shouldNotRegisterNullClientId() {
- // When
- service.registerClient(null, "user-456");
-
- // Then
- assertThat(service.getConnectedClientCount()).isZero();
- }
-
- @Test
- @DisplayName("should not register client with blank clientId")
- void shouldNotRegisterBlankClientId() {
- // When
- service.registerClient(" ", "user-456");
-
- // Then
- assertThat(service.getConnectedClientCount()).isZero();
- }
-
- @Test
- @DisplayName("should trigger retry for previously disconnected client")
- void shouldTriggerRetryForReconnectedClient() {
- // Given - register and mark as disconnected
- service.registerClient("client-123", "user-456");
-
- // Simulate disconnect by manipulating internal state
- ClientState state = service.getClientState("client-123");
- ClientState disconnectedState = state.withConnected(false);
- ReflectionTestUtils.invokeMethod(service, "registerClient", "client-123", "user-456");
-
- // First registration and re-registration
- service.registerClient("client-123", "user-456");
-
- // Then - verify the client is connected
- assertThat(service.isClientConnected("client-123")).isTrue();
- }
-
- @Test
- @DisplayName("should store client state correctly")
- void shouldStoreClientStateCorrectly() {
- // When
- service.registerClient("client-123", "user-456");
-
- // Then
- ClientState state = service.getClientState("client-123");
- assertThat(state).isNotNull();
- assertThat(state.clientId()).isEqualTo("client-123");
- assertThat(state.userId()).isEqualTo("user-456");
- assertThat(state.connected()).isTrue();
- assertThat(state.connectedAt()).isNotNull();
- }
- }
-
- @Nested
- @DisplayName("unregisterClient Tests")
- class UnregisterClientTests {
-
- @Test
- @DisplayName("should unregister existing client")
- void shouldUnregisterExistingClient() {
- // Given
- service.registerClient("client-123", "user-456");
-
- // When
- service.unregisterClient("client-123");
-
- // Then
- assertThat(service.isClientConnected("client-123")).isFalse();
- assertThat(service.getClientState("client-123")).isNull();
- assertThat(service.getConnectedClientCount()).isZero();
- }
-
- @Test
- @DisplayName("should handle unregistering non-existent client gracefully")
- void shouldHandleUnregisteringNonExistentClient() {
- // When/Then - should not throw
- service.unregisterClient("non-existent");
- assertThat(service.getConnectedClientCount()).isZero();
- }
- }
-
- @Nested
- @DisplayName("handlePong Tests")
- class HandlePongTests {
-
- @Test
- @DisplayName("should update client state on pong by clientId")
- void shouldUpdateClientStateOnPongByClientId() {
- // Given
- service.registerClient("client-123", "user-456");
- Instant beforePong = Instant.now();
-
- // When
- service.handlePong("client-123");
-
- // Then
- ClientState state = service.getClientState("client-123");
- assertThat(state.lastPongReceived()).isAfterOrEqualTo(beforePong);
- assertThat(state.connected()).isTrue();
- }
-
- @Test
- @DisplayName("should update client state on pong by userId")
- void shouldUpdateClientStateOnPongByUserId() {
- // Given
- service.registerClient("client-123", "user-456");
- Instant beforePong = Instant.now();
-
- // When
- service.handlePong("user-456");
-
- // Then
- ClientState state = service.getClientState("client-123");
- assertThat(state.lastPongReceived()).isAfterOrEqualTo(beforePong);
- }
-
- @Test
- @DisplayName("should handle pong from unknown client gracefully")
- void shouldHandlePongFromUnknownClient() {
- // When/Then - should not throw
- service.handlePong("unknown-client");
- }
-
- @Test
- @DisplayName("should handle null pong id gracefully")
- void shouldHandleNullPongId() {
- // When/Then - should not throw
- service.handlePong(null);
- }
-
- @Test
- @DisplayName("should handle blank pong id gracefully")
- void shouldHandleBlankPongId() {
- // When/Then - should not throw
- service.handlePong(" ");
- }
-
- @Test
- @DisplayName("should trigger retry when disconnected client sends pong")
- void shouldTriggerRetryWhenDisconnectedClientSendsPong() {
- // Given - create a disconnected client state
- service.registerClient("client-123", "user-456");
-
- // Simulate disconnect
- ClientState state = service.getClientState("client-123");
- ClientState disconnectedState = state.withConnected(false);
-
- // Use reflection to put the disconnected state
- java.util.Map connectedClients =
- (java.util.Map) ReflectionTestUtils.getField(service, "connectedClients");
- connectedClients.put("client-123", disconnectedState);
-
- // When
- service.handlePong("client-123");
-
- // Then
- verify(messageDeliveryService).retryPendingDeliveriesForClient("client-123");
- }
- }
-
- @Nested
- @DisplayName("isClientConnected Tests")
- class IsClientConnectedTests {
-
- @Test
- @DisplayName("should return true for connected client by clientId")
- void shouldReturnTrueForConnectedClientByClientId() {
- // Given
- service.registerClient("client-123", "user-456");
-
- // When/Then
- assertThat(service.isClientConnected("client-123")).isTrue();
- }
-
- @Test
- @DisplayName("should return true for connected client by userId")
- void shouldReturnTrueForConnectedClientByUserId() {
- // Given
- service.registerClient("client-123", "user-456");
-
- // When/Then
- assertThat(service.isClientConnected("user-456")).isTrue();
- }
-
- @Test
- @DisplayName("should return false for non-existent client")
- void shouldReturnFalseForNonExistentClient() {
- // When/Then
- assertThat(service.isClientConnected("non-existent")).isFalse();
- }
-
- @Test
- @DisplayName("should return false for null id")
- void shouldReturnFalseForNullId() {
- // When/Then
- assertThat(service.isClientConnected(null)).isFalse();
- }
-
- @Test
- @DisplayName("should return false for blank id")
- void shouldReturnFalseForBlankId() {
- // When/Then
- assertThat(service.isClientConnected(" ")).isFalse();
- }
- }
-
- @Nested
- @DisplayName("getConnectedClientIds Tests")
- class GetConnectedClientIdsTests {
-
- @Test
- @DisplayName("should return empty set when no clients connected")
- void shouldReturnEmptySetWhenNoClients() {
- // When
- Set ids = service.getConnectedClientIds();
-
- // Then
- assertThat(ids).isEmpty();
- }
-
- @Test
- @DisplayName("should return all connected client ids")
- void shouldReturnAllConnectedClientIds() {
- // Given
- service.registerClient("client-1", "user-1");
- service.registerClient("client-2", "user-2");
- service.registerClient("client-3", "user-3");
-
- // When
- Set ids = service.getConnectedClientIds();
-
- // Then
- assertThat(ids).containsExactlyInAnyOrder("client-1", "client-2", "client-3");
- }
-
- @Test
- @DisplayName("should not include disconnected clients")
- void shouldNotIncludeDisconnectedClients() {
- // Given
- service.registerClient("client-1", "user-1");
- service.registerClient("client-2", "user-2");
-
- // Simulate disconnect for client-2
- ClientState state = service.getClientState("client-2");
- ClientState disconnectedState = state.withConnected(false);
- java.util.Map connectedClients =
- (java.util.Map) ReflectionTestUtils.getField(service, "connectedClients");
- connectedClients.put("client-2", disconnectedState);
-
- // When
- Set ids = service.getConnectedClientIds();
-
- // Then
- assertThat(ids).containsExactly("client-1");
- }
- }
-
- @Nested
- @DisplayName("sendPingsToAllClients Tests")
- class SendPingsToAllClientsTests {
-
- @Test
- @DisplayName("should skip ping when plugin not connected")
- void shouldSkipPingWhenPluginNotConnected() throws PluginException {
- // Given
- service.registerClient("client-123", "user-456");
- when(pluginManager.isConnected()).thenReturn(false);
-
- // When
- service.sendPingsToAllClients();
-
- // Then
- verify(pluginManager, never()).sendToClient(anyString(), anyString(), any(), any());
- }
-
- @Test
- @DisplayName("should skip ping when no clients connected")
- void shouldSkipPingWhenNoClientsConnected() throws PluginException {
- // Given
- when(pluginManager.isConnected()).thenReturn(true);
-
- // When
- service.sendPingsToAllClients();
-
- // Then
- verify(pluginManager, never()).sendToClient(anyString(), anyString(), any(), any());
- }
-
- @Test
- @DisplayName("should send ping to connected clients")
- void shouldSendPingToConnectedClients() throws PluginException {
- // Given
- service.registerClient("client-123", "user-456");
- when(pluginManager.isConnected()).thenReturn(true);
-
- // When
- service.sendPingsToAllClients();
-
- // Then
- verify(pluginManager).sendToClient(eq("user-456"), eq("ping"), any(byte[].class), any(SendOptions.class));
- }
-
- @Test
- @DisplayName("should mark client as disconnected when ping times out")
- void shouldMarkClientAsDisconnectedWhenPingTimesOut() {
- // Given
- service.registerClient("client-123", "user-456");
- when(pluginManager.isConnected()).thenReturn(true);
-
- // First ping
- service.sendPingsToAllClients();
-
- // Simulate time passing - set lastPingSent to past
- ClientState state = service.getClientState("client-123");
- Instant pastTime = Instant.now().minusSeconds(10);
- ClientState stateWithOldPing = new ClientState(
- state.clientId(),
- state.userId(),
- true,
- pastTime,
- null,
- state.connectedAt()
- );
-
- java.util.Map connectedClients =
- (java.util.Map) ReflectionTestUtils.getField(service, "connectedClients");
- connectedClients.put("client-123", stateWithOldPing);
-
- // When - second ping cycle
- service.sendPingsToAllClients();
-
- // Then
- ClientState updatedState = service.getClientState("client-123");
- assertThat(updatedState.connected()).isFalse();
- }
- }
-
- @Nested
- @DisplayName("ClientState Record Tests")
- class ClientStateRecordTests {
-
- @Test
- @DisplayName("should create new state with ping sent")
- void shouldCreateNewStateWithPingSent() {
- // Given
- Instant now = Instant.now();
- ClientState state = new ClientState("client-1", "user-1", true, null, null, now);
-
- // When
- Instant pingSent = Instant.now();
- ClientState newState = state.withPingSent(pingSent);
-
- // Then
- assertThat(newState.lastPingSent()).isEqualTo(pingSent);
- assertThat(newState.clientId()).isEqualTo("client-1");
- assertThat(newState.userId()).isEqualTo("user-1");
- assertThat(newState.connected()).isTrue();
- }
-
- @Test
- @DisplayName("should create new state with pong received")
- void shouldCreateNewStateWithPongReceived() {
- // Given
- Instant now = Instant.now();
- ClientState state = new ClientState("client-1", "user-1", false, null, null, now);
-
- // When
- Instant pongReceived = Instant.now();
- ClientState newState = state.withPongReceived(pongReceived);
-
- // Then
- assertThat(newState.lastPongReceived()).isEqualTo(pongReceived);
- assertThat(newState.connected()).isTrue(); // withPongReceived sets connected to true
- }
-
- @Test
- @DisplayName("should create new state with connected flag")
- void shouldCreateNewStateWithConnectedFlag() {
- // Given
- Instant now = Instant.now();
- ClientState state = new ClientState("client-1", "user-1", true, null, null, now);
-
- // When
- ClientState newState = state.withConnected(false);
-
- // Then
- assertThat(newState.connected()).isFalse();
- assertThat(newState.clientId()).isEqualTo("client-1");
- }
- }
-
- @Nested
- @DisplayName("Count Methods Tests")
- class CountMethodsTests {
-
- @Test
- @DisplayName("should return correct connected client count")
- void shouldReturnCorrectConnectedClientCount() {
- // Given
- service.registerClient("client-1", "user-1");
- service.registerClient("client-2", "user-2");
-
- // When/Then
- assertThat(service.getConnectedClientCount()).isEqualTo(2);
- }
-
- @Test
- @DisplayName("should return correct total client count")
- void shouldReturnCorrectTotalClientCount() {
- // Given
- service.registerClient("client-1", "user-1");
- service.registerClient("client-2", "user-2");
-
- // Disconnect one client
- ClientState state = service.getClientState("client-2");
- ClientState disconnectedState = state.withConnected(false);
- java.util.Map connectedClients =
- (java.util.Map) ReflectionTestUtils.getField(service, "connectedClients");
- connectedClients.put("client-2", disconnectedState);
-
- // When/Then
- assertThat(service.getConnectedClientCount()).isEqualTo(1);
- assertThat(service.getTotalClientCount()).isEqualTo(2);
- }
- }
-}
diff --git a/src/test/java/de/assecutor/votianlt/service/EmailServiceTest.java b/src/test/java/de/assecutor/votianlt/service/EmailServiceTest.java
deleted file mode 100644
index 6e4abd8..0000000
--- a/src/test/java/de/assecutor/votianlt/service/EmailServiceTest.java
+++ /dev/null
@@ -1,402 +0,0 @@
-package de.assecutor.votianlt.service;
-
-import de.assecutor.votianlt.model.Job;
-import de.assecutor.votianlt.model.JobStatus;
-import de.assecutor.votianlt.model.User;
-import de.assecutor.votianlt.repository.JobRepository;
-import de.assecutor.votianlt.repository.TaskRepository;
-import de.assecutor.votianlt.repository.UserRepository;
-import org.bson.types.ObjectId;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Nested;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.ArgumentCaptor;
-import org.mockito.Captor;
-import org.mockito.InjectMocks;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-import org.springframework.mail.SimpleMailMessage;
-import org.springframework.mail.javamail.JavaMailSender;
-import org.springframework.test.util.ReflectionTestUtils;
-
-import java.time.LocalDateTime;
-import java.util.Collections;
-import java.util.Optional;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.*;
-
-@ExtendWith(MockitoExtension.class)
-@DisplayName("EmailService Tests")
-class EmailServiceTest {
-
- @Mock
- private UserRepository userRepository;
-
- @Mock
- private JobRepository jobRepository;
-
- @Mock
- private TaskRepository taskRepository;
-
- @Mock
- private JavaMailSender mailSender;
-
- @InjectMocks
- private EmailService emailService;
-
- @Captor
- private ArgumentCaptor messageCaptor;
-
- private static final String SMTP_USERNAME = "test@votianlt.de";
-
- @BeforeEach
- void setUp() {
- ReflectionTestUtils.setField(emailService, "smtpUsername", SMTP_USERNAME);
- }
-
- private User createTestUser() {
- User user = new User();
- user.setId(new ObjectId());
- user.setTitle("Dr.");
- user.setFirstname("Max");
- user.setName("Mustermann");
- user.setEmail("max.mustermann@example.com");
- return user;
- }
-
- private Job createTestJob() {
- Job job = new Job();
- job.setId(new ObjectId());
- job.setJobNumber("JOB-2024-001");
- job.setDeliveryCompany("Test GmbH");
- job.setPickupCity("Berlin");
- job.setDeliveryCity("Hamburg");
- job.setStatus(JobStatus.IN_PROGRESS);
- job.setCreatedAt(LocalDateTime.now());
- job.setCreatedBy(new ObjectId().toHexString());
- return job;
- }
-
- @Nested
- @DisplayName("sendTaskCompletionNotification Tests")
- class SendTaskCompletionNotificationTests {
-
- @Test
- @DisplayName("should send email when job and user exist with valid email")
- void shouldSendEmailWhenJobAndUserExist() {
- // Given
- Job job = createTestJob();
- User user = createTestUser();
- job.setCreatedBy(user.getId().toHexString());
-
- when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
- when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));
-
- // When
- emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-123", "app-user");
-
- // Then
- verify(mailSender).send(messageCaptor.capture());
- SimpleMailMessage sentMessage = messageCaptor.getValue();
-
- assertThat(sentMessage.getFrom()).isEqualTo(SMTP_USERNAME);
- assertThat(sentMessage.getTo()).containsExactly(user.getEmail());
- assertThat(sentMessage.getSubject()).contains("Aufgabe abgeschlossen");
- assertThat(sentMessage.getSubject()).contains(job.getJobNumber());
- assertThat(sentMessage.getText()).contains("Dr. Max Mustermann");
- assertThat(sentMessage.getText()).contains("Foto-Aufgabe");
- }
-
- @Test
- @DisplayName("should not send email when job not found")
- void shouldNotSendEmailWhenJobNotFound() {
- // Given
- ObjectId jobId = new ObjectId();
- when(jobRepository.findById(jobId)).thenReturn(Optional.empty());
-
- // When
- emailService.sendTaskCompletionNotification(jobId, "PHOTO", "task-123", "app-user");
-
- // Then
- verify(mailSender, never()).send(any(SimpleMailMessage.class));
- }
-
- @Test
- @DisplayName("should not send email when user not found")
- void shouldNotSendEmailWhenUserNotFound() {
- // Given
- Job job = createTestJob();
- when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
- when(userRepository.findById(any(ObjectId.class))).thenReturn(Optional.empty());
-
- // When
- emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-123", "app-user");
-
- // Then
- verify(mailSender, never()).send(any(SimpleMailMessage.class));
- }
-
- @Test
- @DisplayName("should not send email when user has no email address")
- void shouldNotSendEmailWhenUserHasNoEmail() {
- // Given
- Job job = createTestJob();
- User user = createTestUser();
- user.setEmail(null);
- job.setCreatedBy(user.getId().toHexString());
-
- when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
- when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));
-
- // When
- emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-123", "app-user");
-
- // Then
- verify(mailSender, never()).send(any(SimpleMailMessage.class));
- }
-
- @Test
- @DisplayName("should not send email when user has blank email address")
- void shouldNotSendEmailWhenUserHasBlankEmail() {
- // Given
- Job job = createTestJob();
- User user = createTestUser();
- user.setEmail(" ");
- job.setCreatedBy(user.getId().toHexString());
-
- when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
- when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));
-
- // When
- emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-123", "app-user");
-
- // Then
- verify(mailSender, never()).send(any(SimpleMailMessage.class));
- }
-
- @Test
- @DisplayName("should handle different task types correctly")
- void shouldHandleDifferentTaskTypes() {
- // Given
- Job job = createTestJob();
- User user = createTestUser();
- job.setCreatedBy(user.getId().toHexString());
-
- when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
- when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));
-
- // Test each task type
- String[][] taskTypes = {
- {"PHOTO", "Foto-Aufgabe"},
- {"SIGNATURE", "Unterschrift"},
- {"BARCODE", "Barcode scannen"},
- {"CONFIRMATION", "Bestätigung"},
- {"TODO_LIST", "Checkliste"},
- {"COMMENT", "Kommentar"},
- {"UNKNOWN_TYPE", "UNKNOWN_TYPE"}
- };
-
- for (String[] taskType : taskTypes) {
- reset(mailSender);
-
- // When
- emailService.sendTaskCompletionNotification(job.getId(), taskType[0], "task-123", "app-user");
-
- // Then
- verify(mailSender).send(messageCaptor.capture());
- assertThat(messageCaptor.getValue().getText()).contains(taskType[1]);
- }
- }
-
- @Test
- @DisplayName("should include route in email when cities are set")
- void shouldIncludeRouteWhenCitiesAreSet() {
- // Given
- Job job = createTestJob();
- User user = createTestUser();
- job.setCreatedBy(user.getId().toHexString());
-
- when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
- when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));
-
- // When
- emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-123", "app-user");
-
- // Then
- verify(mailSender).send(messageCaptor.capture());
- String text = messageCaptor.getValue().getText();
- assertThat(text).contains("Route: Berlin → Hamburg");
- }
- }
-
- @Nested
- @DisplayName("sendJobCompletionNotification Tests")
- class SendJobCompletionNotificationTests {
-
- @Test
- @DisplayName("should send email with task count when job is completed")
- void shouldSendEmailWithTaskCount() {
- // Given
- Job job = createTestJob();
- User user = createTestUser();
- job.setCreatedBy(user.getId().toHexString());
-
- when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
- when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));
- when(taskRepository.findByJobIdOrderByTaskOrderAsc(job.getId())).thenReturn(Collections.emptyList());
-
- // When
- emailService.sendJobCompletionNotification(job.getId(), "app-user");
-
- // Then
- verify(mailSender).send(messageCaptor.capture());
- SimpleMailMessage sentMessage = messageCaptor.getValue();
-
- assertThat(sentMessage.getSubject()).contains("Job abgeschlossen");
- assertThat(sentMessage.getText()).contains("alle Aufgaben für den folgenden Job wurden erfolgreich abgeschlossen");
- assertThat(sentMessage.getText()).contains("Anzahl erledigter Aufgaben: 0");
- }
-
- @Test
- @DisplayName("should not send email when job not found")
- void shouldNotSendEmailWhenJobNotFound() {
- // Given
- ObjectId jobId = new ObjectId();
- when(jobRepository.findById(jobId)).thenReturn(Optional.empty());
-
- // When
- emailService.sendJobCompletionNotification(jobId, "app-user");
-
- // Then
- verify(mailSender, never()).send(any(SimpleMailMessage.class));
- }
- }
-
- @Nested
- @DisplayName("sendJobCreationNotification Tests")
- class SendJobCreationNotificationTests {
-
- @Test
- @DisplayName("should send email when job is created")
- void shouldSendEmailWhenJobIsCreated() {
- // Given
- User user = createTestUser();
- Job job = createTestJob();
- job.setCreatedBy(user.getId().toHexString());
- job.setRemark("Wichtige Lieferung");
-
- when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
- when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));
- when(taskRepository.findByJobIdOrderByTaskOrderAsc(job.getId())).thenReturn(Collections.emptyList());
-
- // When
- emailService.sendJobCreationNotification(job.getId(), user.getId().toHexString());
-
- // Then
- verify(mailSender).send(messageCaptor.capture());
- SimpleMailMessage sentMessage = messageCaptor.getValue();
-
- assertThat(sentMessage.getSubject()).contains("Neuer Job erstellt");
- assertThat(sentMessage.getText()).contains("ein neuer Job wurde erfolgreich erstellt");
- assertThat(sentMessage.getText()).contains("Bemerkung: Wichtige Lieferung");
- }
-
- @Test
- @DisplayName("should handle invalid createdBy format")
- void shouldHandleInvalidCreatedByFormat() {
- // Given
- Job job = createTestJob();
- when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
-
- // When
- emailService.sendJobCreationNotification(job.getId(), "invalid-object-id");
-
- // Then
- verify(mailSender, never()).send(any(SimpleMailMessage.class));
- }
- }
-
- @Nested
- @DisplayName("sendSimpleEmail Tests")
- class SendSimpleEmailTests {
-
- @Test
- @DisplayName("should send simple email successfully")
- void shouldSendSimpleEmail() {
- // When
- emailService.sendSimpleEmail("recipient@example.com", "Test Subject", "Test Body");
-
- // Then
- verify(mailSender).send(messageCaptor.capture());
- SimpleMailMessage sentMessage = messageCaptor.getValue();
-
- assertThat(sentMessage.getFrom()).isEqualTo(SMTP_USERNAME);
- assertThat(sentMessage.getTo()).containsExactly("recipient@example.com");
- assertThat(sentMessage.getSubject()).isEqualTo("Test Subject");
- assertThat(sentMessage.getText()).isEqualTo("Test Body");
- }
-
- @Test
- @DisplayName("should throw exception when mail sending fails")
- void shouldThrowExceptionWhenMailSendingFails() {
- // Given
- doThrow(new RuntimeException("SMTP error")).when(mailSender).send(any(SimpleMailMessage.class));
-
- // When/Then
- assertThatThrownBy(() ->
- emailService.sendSimpleEmail("recipient@example.com", "Subject", "Body"))
- .isInstanceOf(RuntimeException.class)
- .hasMessageContaining("Failed to send email");
- }
- }
-
- @Nested
- @DisplayName("buildFullName Tests")
- class BuildFullNameTests {
-
- @Test
- @DisplayName("should build full name with title, firstname and lastname")
- void shouldBuildFullNameWithAllParts() {
- // Given
- Job job = createTestJob();
- User user = createTestUser();
- job.setCreatedBy(user.getId().toHexString());
-
- when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
- when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));
-
- // When
- emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-123", "app-user");
-
- // Then
- verify(mailSender).send(messageCaptor.capture());
- assertThat(messageCaptor.getValue().getText()).contains("Dr. Max Mustermann");
- }
-
- @Test
- @DisplayName("should use default name when user has no name parts")
- void shouldUseDefaultNameWhenNoNameParts() {
- // Given
- Job job = createTestJob();
- User user = new User();
- user.setId(new ObjectId());
- user.setEmail("test@example.com");
- job.setCreatedBy(user.getId().toHexString());
-
- when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
- when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));
-
- // When
- emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-123", "app-user");
-
- // Then
- verify(mailSender).send(messageCaptor.capture());
- assertThat(messageCaptor.getValue().getText()).contains("Hallo Benutzer");
- }
- }
-}
diff --git a/src/test/java/de/assecutor/votianlt/service/JobHistoryServiceTest.java b/src/test/java/de/assecutor/votianlt/service/JobHistoryServiceTest.java
deleted file mode 100644
index a7ba540..0000000
--- a/src/test/java/de/assecutor/votianlt/service/JobHistoryServiceTest.java
+++ /dev/null
@@ -1,536 +0,0 @@
-package de.assecutor.votianlt.service;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-import de.assecutor.votianlt.model.Job;
-import de.assecutor.votianlt.model.JobHistory;
-import de.assecutor.votianlt.model.JobHistoryType;
-import de.assecutor.votianlt.model.JobStatus;
-import de.assecutor.votianlt.repository.JobHistoryRepository;
-import org.bson.types.ObjectId;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Nested;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.ArgumentCaptor;
-import org.mockito.Captor;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-
-import java.time.LocalDateTime;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.*;
-
-@ExtendWith(MockitoExtension.class)
-@DisplayName("JobHistoryService Tests")
-class JobHistoryServiceTest {
-
- @Mock
- private JobHistoryRepository jobHistoryRepository;
-
- private ObjectMapper objectMapper;
- private JobHistoryService service;
-
- @Captor
- private ArgumentCaptor historyCaptor;
-
- @BeforeEach
- void setUp() {
- objectMapper = new ObjectMapper();
- objectMapper.findAndRegisterModules();
- service = new JobHistoryService(jobHistoryRepository, objectMapper);
- }
-
- private Job createTestJob() {
- Job job = new Job();
- job.setId(new ObjectId());
- job.setJobNumber("JOB-2024-001");
- job.setDeliveryCompany("Test GmbH");
- job.setPickupCity("Berlin");
- job.setDeliveryCity("Hamburg");
- job.setStatus(JobStatus.CREATED);
- job.setCreatedAt(LocalDateTime.now());
- job.setRemark("Test Bemerkung");
- return job;
- }
-
- @Nested
- @DisplayName("logJobCreation Tests")
- class LogJobCreationTests {
-
- @Test
- @DisplayName("should log job creation with job number")
- void shouldLogJobCreationWithJobNumber() {
- // Given
- Job job = createTestJob();
- String createdBy = "user-123";
-
- // When
- service.logJobCreation(job, createdBy);
-
- // Then
- verify(jobHistoryRepository).save(historyCaptor.capture());
- JobHistory history = historyCaptor.getValue();
-
- assertThat(history.getJobId()).isEqualTo(job.getId());
- assertThat(history.getReason()).isEqualTo("Job erstellt");
- assertThat(history.getDescription()).contains("JOB-2024-001");
- assertThat(history.getChangedBy()).isEqualTo(createdBy);
- assertThat(history.getChangeType()).isEqualTo(JobHistoryType.CREATE);
- assertThat(history.getNewValue()).isEqualTo("Job erstellt");
- }
-
- @Test
- @DisplayName("should log job creation without job number")
- void shouldLogJobCreationWithoutJobNumber() {
- // Given
- Job job = createTestJob();
- job.setJobNumber(null);
- String createdBy = "user-123";
-
- // When
- service.logJobCreation(job, createdBy);
-
- // Then
- verify(jobHistoryRepository).save(historyCaptor.capture());
- JobHistory history = historyCaptor.getValue();
-
- assertThat(history.getDescription()).contains("Ohne Nummer");
- }
-
- @Test
- @DisplayName("should include delivery company in details")
- void shouldIncludeDeliveryCompanyInDetails() {
- // Given
- Job job = createTestJob();
- String createdBy = "user-123";
-
- // When
- service.logJobCreation(job, createdBy);
-
- // Then
- verify(jobHistoryRepository).save(historyCaptor.capture());
- JobHistory history = historyCaptor.getValue();
-
- assertThat(history.getDetails()).contains("Test GmbH");
- }
-
- @Test
- @DisplayName("should handle exception gracefully")
- void shouldHandleExceptionGracefully() {
- // Given
- Job job = createTestJob();
- when(jobHistoryRepository.save(any())).thenThrow(new RuntimeException("DB error"));
-
- // When/Then - should not throw
- service.logJobCreation(job, "user-123");
-
- verify(jobHistoryRepository).save(any());
- }
- }
-
- @Nested
- @DisplayName("logStatusChange Tests")
- class LogStatusChangeTests {
-
- @Test
- @DisplayName("should log status change correctly")
- void shouldLogStatusChangeCorrectly() {
- // Given
- Job job = createTestJob();
- JobStatus oldStatus = JobStatus.CREATED;
- JobStatus newStatus = JobStatus.IN_PROGRESS;
-
- // When
- service.logStatusChange(job, oldStatus, newStatus, "user-123");
-
- // Then
- verify(jobHistoryRepository).save(historyCaptor.capture());
- JobHistory history = historyCaptor.getValue();
-
- assertThat(history.getJobId()).isEqualTo(job.getId());
- assertThat(history.getReason()).isEqualTo("Status-Änderung");
- assertThat(history.getDescription()).contains("Erstellt");
- assertThat(history.getDescription()).contains("In Bearbeitung");
- assertThat(history.getChangeType()).isEqualTo(JobHistoryType.STATUS_CHANGE);
- assertThat(history.getOldValue()).isEqualTo("Erstellt");
- assertThat(history.getNewValue()).isEqualTo("In Bearbeitung");
- }
-
- @Test
- @DisplayName("should handle null old status")
- void shouldHandleNullOldStatus() {
- // Given
- Job job = createTestJob();
- JobStatus newStatus = JobStatus.CREATED;
-
- // When
- service.logStatusChange(job, null, newStatus, "user-123");
-
- // Then
- verify(jobHistoryRepository).save(historyCaptor.capture());
- JobHistory history = historyCaptor.getValue();
-
- assertThat(history.getDescription()).contains("Unbekannt");
- assertThat(history.getOldValue()).isNull();
- }
-
- @Test
- @DisplayName("should handle null new status")
- void shouldHandleNullNewStatus() {
- // Given
- Job job = createTestJob();
- JobStatus oldStatus = JobStatus.IN_PROGRESS;
-
- // When
- service.logStatusChange(job, oldStatus, null, "user-123");
-
- // Then
- verify(jobHistoryRepository).save(historyCaptor.capture());
- JobHistory history = historyCaptor.getValue();
-
- assertThat(history.getDescription()).contains("Unbekannt");
- assertThat(history.getNewValue()).isNull();
- }
-
- @Test
- @DisplayName("should format all status types correctly")
- void shouldFormatAllStatusTypesCorrectly() {
- // Given
- Job job = createTestJob();
- JobStatus[] statuses = JobStatus.values();
-
- for (JobStatus status : statuses) {
- reset(jobHistoryRepository);
-
- // When
- service.logStatusChange(job, JobStatus.CREATED, status, "user-123");
-
- // Then
- verify(jobHistoryRepository).save(historyCaptor.capture());
- assertThat(historyCaptor.getValue().getNewValue()).isNotNull();
- }
- }
- }
-
- @Nested
- @DisplayName("logJobUpdate Tests")
- class LogJobUpdateTests {
-
- @Test
- @DisplayName("should log job update with reason")
- void shouldLogJobUpdateWithReason() {
- // Given
- Job oldJob = createTestJob();
- Job newJob = createTestJob();
- newJob.setId(oldJob.getId());
- newJob.setDeliveryCompany("Neue GmbH");
-
- // When
- service.logJobUpdate(oldJob, newJob, "user-123", "Kundendaten geändert");
-
- // Then
- verify(jobHistoryRepository).save(historyCaptor.capture());
- JobHistory history = historyCaptor.getValue();
-
- assertThat(history.getReason()).isEqualTo("Kundendaten geändert");
- assertThat(history.getChangeType()).isEqualTo(JobHistoryType.UPDATE);
- assertThat(history.getDescription()).contains("Kunde");
- }
-
- @Test
- @DisplayName("should use default reason when null")
- void shouldUseDefaultReasonWhenNull() {
- // Given
- Job oldJob = createTestJob();
- Job newJob = createTestJob();
- newJob.setId(oldJob.getId());
-
- // When
- service.logJobUpdate(oldJob, newJob, "user-123", null);
-
- // Then
- verify(jobHistoryRepository).save(historyCaptor.capture());
- JobHistory history = historyCaptor.getValue();
-
- assertThat(history.getReason()).isEqualTo("Job aktualisiert");
- }
-
- @Test
- @DisplayName("should detect city changes")
- void shouldDetectCityChanges() {
- // Given
- Job oldJob = createTestJob();
- Job newJob = createTestJob();
- newJob.setId(oldJob.getId());
- newJob.setPickupCity("München");
-
- // When
- service.logJobUpdate(oldJob, newJob, "user-123", null);
-
- // Then
- verify(jobHistoryRepository).save(historyCaptor.capture());
- JobHistory history = historyCaptor.getValue();
-
- assertThat(history.getDescription()).contains("Orte");
- }
-
- @Test
- @DisplayName("should detect remark changes")
- void shouldDetectRemarkChanges() {
- // Given
- Job oldJob = createTestJob();
- Job newJob = createTestJob();
- newJob.setId(oldJob.getId());
- newJob.setRemark("Neue Bemerkung");
-
- // When
- service.logJobUpdate(oldJob, newJob, "user-123", null);
-
- // Then
- verify(jobHistoryRepository).save(historyCaptor.capture());
- JobHistory history = historyCaptor.getValue();
-
- assertThat(history.getDescription()).contains("Bemerkung");
- }
-
- @Test
- @DisplayName("should handle null old job")
- void shouldHandleNullOldJob() {
- // Given
- Job newJob = createTestJob();
-
- // When
- service.logJobUpdate(null, newJob, "user-123", null);
-
- // Then
- verify(jobHistoryRepository).save(historyCaptor.capture());
- JobHistory history = historyCaptor.getValue();
-
- assertThat(history.getDescription()).isEqualTo("Job-Daten aktualisiert");
- }
- }
-
- @Nested
- @DisplayName("logTaskCompletion Tests")
- class LogTaskCompletionTests {
-
- @Test
- @DisplayName("should log task completion with basic info")
- void shouldLogTaskCompletionWithBasicInfo() {
- // Given
- ObjectId jobId = new ObjectId();
-
- // When
- service.logTaskCompletion(jobId, "PHOTO", "task-123", "app-user");
-
- // Then
- verify(jobHistoryRepository).save(historyCaptor.capture());
- JobHistory history = historyCaptor.getValue();
-
- assertThat(history.getJobId()).isEqualTo(jobId);
- assertThat(history.getReason()).isEqualTo("Aufgabe abgeschlossen");
- assertThat(history.getDescription()).contains("PHOTO");
- assertThat(history.getChangedBy()).isEqualTo("app-user");
- assertThat(history.getChangeType()).isEqualTo(JobHistoryType.TASK_COMPLETED);
- }
-
- @Test
- @DisplayName("should log task completion with display name and extra data")
- void shouldLogTaskCompletionWithDisplayNameAndExtraData() {
- // Given
- ObjectId jobId = new ObjectId();
-
- // When
- service.logTaskCompletion(jobId, "PHOTO", "task-123", "app-user",
- "Ablieferungsfoto", "3 Fotos hochgeladen");
-
- // Then
- verify(jobHistoryRepository).save(historyCaptor.capture());
- JobHistory history = historyCaptor.getValue();
-
- assertThat(history.getDescription()).contains("Ablieferungsfoto");
- assertThat(history.getDescription()).contains("3 Fotos hochgeladen");
- assertThat(history.getDetails()).contains("Task-ID: task-123");
- assertThat(history.getDetails()).contains("Task-Typ: PHOTO");
- assertThat(history.getDetails()).contains("Name: Ablieferungsfoto");
- assertThat(history.getDetails()).contains("Zusatzdaten: 3 Fotos hochgeladen");
- }
-
- @Test
- @DisplayName("should handle null display name")
- void shouldHandleNullDisplayName() {
- // Given
- ObjectId jobId = new ObjectId();
-
- // When
- service.logTaskCompletion(jobId, "BARCODE", "task-123", "app-user", null, null);
-
- // Then
- verify(jobHistoryRepository).save(historyCaptor.capture());
- JobHistory history = historyCaptor.getValue();
-
- assertThat(history.getDescription()).contains("BARCODE");
- }
- }
-
- @Nested
- @DisplayName("logJobAssignment Tests")
- class LogJobAssignmentTests {
-
- @Test
- @DisplayName("should log new assignment")
- void shouldLogNewAssignment() {
- // Given
- Job job = createTestJob();
-
- // When
- service.logJobAssignment(job, null, "Max Mustermann", "user-123");
-
- // Then
- verify(jobHistoryRepository).save(historyCaptor.capture());
- JobHistory history = historyCaptor.getValue();
-
- assertThat(history.getReason()).isEqualTo("Zuweisung geändert");
- assertThat(history.getDescription()).isEqualTo("Job zugewiesen an: Max Mustermann");
- assertThat(history.getChangeType()).isEqualTo(JobHistoryType.ASSIGNMENT);
- assertThat(history.getOldValue()).isNull();
- assertThat(history.getNewValue()).isEqualTo("Max Mustermann");
- }
-
- @Test
- @DisplayName("should log assignment removal")
- void shouldLogAssignmentRemoval() {
- // Given
- Job job = createTestJob();
-
- // When
- service.logJobAssignment(job, "Max Mustermann", null, "user-123");
-
- // Then
- verify(jobHistoryRepository).save(historyCaptor.capture());
- JobHistory history = historyCaptor.getValue();
-
- assertThat(history.getDescription()).isEqualTo("Job-Zuweisung entfernt von: Max Mustermann");
- }
-
- @Test
- @DisplayName("should log assignment change")
- void shouldLogAssignmentChange() {
- // Given
- Job job = createTestJob();
-
- // When
- service.logJobAssignment(job, "Max Mustermann", "Erika Musterfrau", "user-123");
-
- // Then
- verify(jobHistoryRepository).save(historyCaptor.capture());
- JobHistory history = historyCaptor.getValue();
-
- assertThat(history.getDescription())
- .isEqualTo("Job-Zuweisung geändert von Max Mustermann zu Erika Musterfrau");
- }
- }
-
- @Nested
- @DisplayName("logCustomEvent Tests")
- class LogCustomEventTests {
-
- @Test
- @DisplayName("should log custom event correctly")
- void shouldLogCustomEventCorrectly() {
- // Given
- ObjectId jobId = new ObjectId();
-
- // When
- service.logCustomEvent(jobId, "Export", "Job als PDF exportiert",
- "user-123", JobHistoryType.EXPORT);
-
- // Then
- verify(jobHistoryRepository).save(historyCaptor.capture());
- JobHistory history = historyCaptor.getValue();
-
- assertThat(history.getJobId()).isEqualTo(jobId);
- assertThat(history.getReason()).isEqualTo("Export");
- assertThat(history.getDescription()).isEqualTo("Job als PDF exportiert");
- assertThat(history.getChangedBy()).isEqualTo("user-123");
- assertThat(history.getChangeType()).isEqualTo(JobHistoryType.EXPORT);
- }
- }
-
- @Nested
- @DisplayName("getJobHistory Tests")
- class GetJobHistoryTests {
-
- @Test
- @DisplayName("should return job history from repository")
- void shouldReturnJobHistoryFromRepository() {
- // Given
- ObjectId jobId = new ObjectId();
- JobHistory history1 = new JobHistory(jobId, "Event 1", "Description 1", "user-1");
- JobHistory history2 = new JobHistory(jobId, "Event 2", "Description 2", "user-2");
- List expectedHistory = Arrays.asList(history1, history2);
-
- when(jobHistoryRepository.findByJobIdOrderByTimestampDesc(jobId)).thenReturn(expectedHistory);
-
- // When
- List result = service.getJobHistory(jobId);
-
- // Then
- assertThat(result).hasSize(2);
- assertThat(result).containsExactlyElementsOf(expectedHistory);
- }
-
- @Test
- @DisplayName("should return empty list when no history exists")
- void shouldReturnEmptyListWhenNoHistoryExists() {
- // Given
- ObjectId jobId = new ObjectId();
- when(jobHistoryRepository.findByJobIdOrderByTimestampDesc(jobId)).thenReturn(Collections.emptyList());
-
- // When
- List result = service.getJobHistory(jobId);
-
- // Then
- assertThat(result).isEmpty();
- }
- }
-
- @Nested
- @DisplayName("getJobHistoryCount Tests")
- class GetJobHistoryCountTests {
-
- @Test
- @DisplayName("should return correct count from repository")
- void shouldReturnCorrectCountFromRepository() {
- // Given
- ObjectId jobId = new ObjectId();
- when(jobHistoryRepository.countByJobId(jobId)).thenReturn(5L);
-
- // When
- long count = service.getJobHistoryCount(jobId);
-
- // Then
- assertThat(count).isEqualTo(5L);
- }
-
- @Test
- @DisplayName("should return zero when no history exists")
- void shouldReturnZeroWhenNoHistoryExists() {
- // Given
- ObjectId jobId = new ObjectId();
- when(jobHistoryRepository.countByJobId(jobId)).thenReturn(0L);
-
- // When
- long count = service.getJobHistoryCount(jobId);
-
- // Then
- assertThat(count).isZero();
- }
- }
-}