Erweiterungen
This commit is contained in:
2
pom.xml
2
pom.xml
@@ -6,7 +6,7 @@
|
||||
|
||||
<groupId>de.assecutor.votianlt</groupId>
|
||||
<artifactId>votianlt</artifactId>
|
||||
<version>0.8.4</version>
|
||||
<version>0.8.5</version>
|
||||
|
||||
<packaging>jar</packaging>
|
||||
|
||||
|
||||
@@ -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<JobStatus, Long> 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<JobStatus, Long> statusCounts = customerFilter != null
|
||||
? statisticsService.getJobCountsByStatusForCustomer(customerFilter)
|
||||
: statisticsService.getJobCountsByStatus();
|
||||
|
||||
List<String> labels = new ArrayList<>();
|
||||
List<Long> 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<String> labels = List.of("Aufträge gesamt", "Abgeschlossen", "In Bearbeitung", "Umsatz (€/100)");
|
||||
List<Double> data = List.of(
|
||||
(double) totalJobs,
|
||||
(double) completed,
|
||||
(double) inProgress,
|
||||
totalRevenue.doubleValue() / 100 // Scale down for better visualization
|
||||
);
|
||||
List<String> colors = List.of("#3b82f6", "#22c55e", "#f59e0b", "#6366f1");
|
||||
|
||||
return buildChartJsonDouble(labels, data, colors, customer);
|
||||
}
|
||||
|
||||
private String buildTrendChartData(String customerFilter) {
|
||||
int currentYear = Year.now().getValue();
|
||||
Map<Month, Long> monthlyData = statisticsService.getMonthlyJobCounts(currentYear);
|
||||
Map<Month, Long> monthlyData = customerFilter != null
|
||||
? statisticsService.getMonthlyJobCountsForCustomer(currentYear, customerFilter)
|
||||
: statisticsService.getMonthlyJobCounts(currentYear);
|
||||
|
||||
List<String> 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<JobStatus, Long> statusCounts = statisticsService.getJobCountsByStatus();
|
||||
private String buildOverviewChartData(String customerFilter) {
|
||||
Map<JobStatus, Long> 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<Long> data = List.of(total, completed, inProgress, open);
|
||||
List<String> 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<String> labels, List<Long> data, List<String> 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<Job> 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<Job> 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());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -65,6 +65,12 @@ public interface JobRepository extends MongoRepository<Job, ObjectId> {
|
||||
*/
|
||||
List<Job> findByCustomerSelection(String customerSelection);
|
||||
|
||||
/**
|
||||
* Findet Aufträge anhand einer Kundenauswahl (case-insensitive)
|
||||
*/
|
||||
@Query("{ 'customerSelection': { $regex: ?0, $options: 'i' } }")
|
||||
List<Job> findByCustomerSelectionIgnoreCase(String customerSelection);
|
||||
|
||||
/**
|
||||
* Prüft, ob eine Auftragsnummer bereits existiert
|
||||
*/
|
||||
|
||||
@@ -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<Job> 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<Job> 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<Job> getLatestJobs(int limit) {
|
||||
return jobRepository.findLatestJobs().stream().limit(limit).toList();
|
||||
}
|
||||
|
||||
// ==================== Filtered Statistics Methods ====================
|
||||
|
||||
/**
|
||||
* Get all available customer names for autocomplete/filtering.
|
||||
*/
|
||||
public List<String> 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<JobStatus, Long> getJobCountsByStatusForCustomer(String customer) {
|
||||
List<Job> customerJobs = getJobsByCustomer(customer);
|
||||
Map<JobStatus, Long> 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<Job> 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<Month, Long> getMonthlyJobCountsForCustomer(int year, String customer) {
|
||||
Map<Month, Long> monthlyCounts = new LinkedHashMap<>();
|
||||
LocalDateTime yearStart = LocalDateTime.of(year, 1, 1, 0, 0);
|
||||
LocalDateTime yearEnd = LocalDateTime.of(year, 12, 31, 23, 59, 59);
|
||||
|
||||
List<Job> 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<Job> getJobsByCustomerAndStatus(String customer, JobStatus status) {
|
||||
List<Job> 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<String> 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> 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<BaseTask> tasks = createAllTaskTypes(job.getId());
|
||||
|
||||
// Create wrapper object similar to the spec
|
||||
record JobWithTasks(Job job, List<BaseTask> 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<BaseTask> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String, ClientState> connectedClients =
|
||||
(java.util.Map<String, ClientState>) 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<String> 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<String> 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<String, ClientState> connectedClients =
|
||||
(java.util.Map<String, ClientState>) ReflectionTestUtils.getField(service, "connectedClients");
|
||||
connectedClients.put("client-2", disconnectedState);
|
||||
|
||||
// When
|
||||
Set<String> 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<String, ClientState> connectedClients =
|
||||
(java.util.Map<String, ClientState>) 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<String, ClientState> connectedClients =
|
||||
(java.util.Map<String, ClientState>) ReflectionTestUtils.getField(service, "connectedClients");
|
||||
connectedClients.put("client-2", disconnectedState);
|
||||
|
||||
// When/Then
|
||||
assertThat(service.getConnectedClientCount()).isEqualTo(1);
|
||||
assertThat(service.getTotalClientCount()).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<SimpleMailMessage> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<JobHistory> 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<JobHistory> expectedHistory = Arrays.asList(history1, history2);
|
||||
|
||||
when(jobHistoryRepository.findByJobIdOrderByTimestampDesc(jobId)).thenReturn(expectedHistory);
|
||||
|
||||
// When
|
||||
List<JobHistory> 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<JobHistory> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user