Erweiterungen
This commit is contained in:
2
pom.xml
2
pom.xml
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<groupId>de.assecutor.votianlt</groupId>
|
<groupId>de.assecutor.votianlt</groupId>
|
||||||
<artifactId>votianlt</artifactId>
|
<artifactId>votianlt</artifactId>
|
||||||
<version>0.8.4</version>
|
<version>0.8.5</version>
|
||||||
|
|
||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package de.assecutor.votianlt.ai.service;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import de.assecutor.votianlt.model.Job;
|
||||||
import de.assecutor.votianlt.model.JobStatus;
|
import de.assecutor.votianlt.model.JobStatus;
|
||||||
import de.assecutor.votianlt.service.JobStatisticsService;
|
import de.assecutor.votianlt.service.JobStatisticsService;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -47,40 +48,32 @@ public class AiStatisticsService {
|
|||||||
public StatisticsResponse analyzeStatisticsQuery(String userQuery) {
|
public StatisticsResponse analyzeStatisticsQuery(String userQuery) {
|
||||||
log.info("Processing statistics query: {}", userQuery);
|
log.info("Processing statistics query: {}", userQuery);
|
||||||
|
|
||||||
// Gather current statistics
|
// Determine query type and prepare chart data (includes customer filter detection)
|
||||||
String statisticsContext = buildStatisticsContext();
|
|
||||||
|
|
||||||
// Determine query type and prepare chart data
|
|
||||||
QueryAnalysis analysis = analyzeQueryType(userQuery);
|
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
|
// Build prompt for LLM
|
||||||
String prompt = buildPrompt(userQuery, statisticsContext, analysis);
|
String prompt = buildPrompt(userQuery, statisticsContext, analysis);
|
||||||
|
|
||||||
// System prompt for the statistics assistant
|
// System prompt - different for list vs statistics queries
|
||||||
String systemPrompt = """
|
String systemPrompt = analysis.queryType.equals("list")
|
||||||
Du bist ein hilfreicher Statistik-Assistent für ein Logistikunternehmen.
|
? buildListSystemPrompt()
|
||||||
Beantworte die Frage des Benutzers basierend auf den aktuellen Statistiken.
|
: buildStatisticsSystemPrompt();
|
||||||
|
|
||||||
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).
|
|
||||||
""";
|
|
||||||
|
|
||||||
// Call LLM via direct REST client (like aimailassistant)
|
// Call LLM via direct REST client (like aimailassistant)
|
||||||
String llmResponse = llmClient.chat(systemPrompt, prompt);
|
String llmResponse = llmClient.chat(systemPrompt, prompt);
|
||||||
|
|
||||||
if (llmResponse != null) {
|
if (llmResponse != null && !llmResponse.isBlank()) {
|
||||||
log.info("LLM response received, length: {} chars", llmResponse.length());
|
log.info("LLM response received, length: {} chars", llmResponse.length());
|
||||||
return new StatisticsResponse(llmResponse, analysis.chartType, analysis.chartData);
|
return new StatisticsResponse(llmResponse, analysis.chartType, analysis.chartData);
|
||||||
} else {
|
} else {
|
||||||
log.warn("LLM returned null response, using fallback");
|
log.warn("LLM returned null or blank response, using fallback");
|
||||||
return new StatisticsResponse(
|
return new StatisticsResponse(
|
||||||
buildFallbackResponse(analysis),
|
buildFallbackResponse(analysis),
|
||||||
analysis.chartType,
|
analysis.chartType,
|
||||||
@@ -92,45 +85,284 @@ public class AiStatisticsService {
|
|||||||
private record QueryAnalysis(
|
private record QueryAnalysis(
|
||||||
String queryType,
|
String queryType,
|
||||||
String chartType,
|
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) {
|
private QueryAnalysis analyzeQueryType(String query) {
|
||||||
String lowerQuery = query.toLowerCase();
|
String lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
// Status-bezogene Anfragen
|
// First, check if this is a LIST query (no chart needed)
|
||||||
if (lowerQuery.contains("status") || lowerQuery.contains("offen") ||
|
boolean isListQuery = isListQuery(lowerQuery);
|
||||||
lowerQuery.contains("abgeschlossen") || lowerQuery.contains("zählen") ||
|
log.debug("Is list query: {}", isListQuery);
|
||||||
lowerQuery.contains("anzahl") || lowerQuery.contains("wie viele")) {
|
|
||||||
return new QueryAnalysis("status", "doughnut", buildStatusChartData());
|
// 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
|
// Umsatz-bezogene Anfragen
|
||||||
if (lowerQuery.contains("umsatz") || lowerQuery.contains("revenue") ||
|
else if (lowerQuery.contains("umsatz") || lowerQuery.contains("revenue") ||
|
||||||
lowerQuery.contains("kunde") || lowerQuery.contains("customer") ||
|
|
||||||
lowerQuery.contains("einnahmen")) {
|
lowerQuery.contains("einnahmen")) {
|
||||||
return new QueryAnalysis("revenue", "bar", buildRevenueChartData());
|
queryType = "revenue";
|
||||||
|
defaultChartType = "bar";
|
||||||
|
chartData = customerFilter != null ? buildCustomerRevenueChartData(customerFilter) : buildRevenueChartData();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trend-bezogene Anfragen
|
// 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("entwicklung") || lowerQuery.contains("jahr") ||
|
||||||
lowerQuery.contains("verlauf")) {
|
lowerQuery.contains("verlauf")) {
|
||||||
return new QueryAnalysis("trend", "line", buildTrendChartData());
|
queryType = "trend";
|
||||||
|
defaultChartType = "line";
|
||||||
|
chartData = buildTrendChartData(customerFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task-bezogene Anfragen
|
// Task-bezogene Anfragen
|
||||||
if (lowerQuery.contains("task") || lowerQuery.contains("aufgabe") ||
|
else if (lowerQuery.contains("task") || lowerQuery.contains("aufgabe") ||
|
||||||
lowerQuery.contains("erledigt")) {
|
lowerQuery.contains("erledigt")) {
|
||||||
return new QueryAnalysis("tasks", "doughnut", buildTaskChartData());
|
queryType = "tasks";
|
||||||
|
defaultChartType = "doughnut";
|
||||||
|
chartData = buildTaskChartData();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allgemeine Übersicht
|
// Allgemeine Übersicht
|
||||||
return new QueryAnalysis("overview", "bar", buildOverviewChartData());
|
else {
|
||||||
|
queryType = "overview";
|
||||||
|
defaultChartType = "bar";
|
||||||
|
chartData = buildOverviewChartData(customerFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildStatusChartData() {
|
// Use user's chart type preference if specified, otherwise use default
|
||||||
Map<JobStatus, Long> statusCounts = statisticsService.getJobCountsByStatus();
|
String chartType = userChartType != null ? userChartType : defaultChartType;
|
||||||
|
|
||||||
|
return new QueryAnalysis(queryType, chartType, chartData, customerFilter, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<String> labels = new ArrayList<>();
|
||||||
List<Long> data = 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() {
|
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)");
|
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();
|
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",
|
List<String> labels = List.of("Jan", "Feb", "Mär", "Apr", "Mai", "Jun",
|
||||||
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez");
|
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez");
|
||||||
@@ -189,11 +443,15 @@ public class AiStatisticsService {
|
|||||||
data.add(monthlyData.getOrDefault(month, 0L));
|
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("""
|
return String.format("""
|
||||||
{
|
{
|
||||||
"labels": %s,
|
"labels": %s,
|
||||||
"datasets": [{
|
"datasets": [{
|
||||||
"label": "Aufträge %d",
|
"label": "%s",
|
||||||
"data": %s,
|
"data": %s,
|
||||||
"borderColor": "#6366f1",
|
"borderColor": "#6366f1",
|
||||||
"backgroundColor": "rgba(99, 102, 241, 0.15)",
|
"backgroundColor": "rgba(99, 102, 241, 0.15)",
|
||||||
@@ -206,7 +464,7 @@ public class AiStatisticsService {
|
|||||||
"fill": true
|
"fill": true
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
""", toJsonArray(labels), currentYear, data);
|
""", toJsonArray(labels), datasetLabel, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildTaskChartData() {
|
private String buildTaskChartData() {
|
||||||
@@ -222,10 +480,14 @@ public class AiStatisticsService {
|
|||||||
return buildChartJson(labels, data, colors, "Aufgaben");
|
return buildChartJson(labels, data, colors, "Aufgaben");
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildOverviewChartData() {
|
private String buildOverviewChartData(String customerFilter) {
|
||||||
Map<JobStatus, Long> statusCounts = statisticsService.getJobCountsByStatus();
|
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 completed = statusCounts.getOrDefault(JobStatus.COMPLETED, 0L);
|
||||||
long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L);
|
long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L);
|
||||||
long open = total - completed - statusCounts.getOrDefault(JobStatus.CANCELLED, 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<Long> data = List.of(total, completed, inProgress, open);
|
||||||
List<String> colors = List.of("#3b82f6", "#22c55e", "#f59e0b", "#06b6d4");
|
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) {
|
private String buildChartJson(List<String> labels, List<Long> data, List<String> colors, String label) {
|
||||||
@@ -273,16 +536,43 @@ public class AiStatisticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildStatisticsContext() {
|
private String buildStatisticsContext(QueryAnalysis analysis) {
|
||||||
StringBuilder context = new StringBuilder();
|
StringBuilder context = new StringBuilder();
|
||||||
|
String customerFilter = analysis.customerFilter;
|
||||||
|
JobStatus statusFilter = analysis.statusFilter;
|
||||||
|
|
||||||
// Job counts by status
|
// 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();
|
var statusCounts = statisticsService.getJobCountsByStatus();
|
||||||
context.append("**Aktuelle Auftragsstatistiken:**\n");
|
context.append("**Aktuelle Auftragsstatistiken:**\n");
|
||||||
statusCounts.forEach((status, count) ->
|
statusCounts.forEach((status, count) ->
|
||||||
context.append(String.format("- %s: %d Aufträge\n", status.getDisplayName(), count)));
|
context.append(String.format("- %s: %d Aufträge\n", status.getDisplayName(), count)));
|
||||||
|
|
||||||
// Totals
|
|
||||||
context.append(String.format("\n**Gesamtübersicht:**\n"));
|
context.append(String.format("\n**Gesamtübersicht:**\n"));
|
||||||
context.append(String.format("- Gesamtanzahl Aufträge: %d\n", statisticsService.getTotalJobCount()));
|
context.append(String.format("- Gesamtanzahl Aufträge: %d\n", statisticsService.getTotalJobCount()));
|
||||||
context.append(String.format("- Abschlussrate: %.1f%%\n", statisticsService.getCompletionRate()));
|
context.append(String.format("- Abschlussrate: %.1f%%\n", statisticsService.getCompletionRate()));
|
||||||
@@ -305,6 +595,45 @@ public class AiStatisticsService {
|
|||||||
entry.getValue()));
|
entry.getValue()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildListContext(String customerFilter, JobStatus statusFilter) {
|
||||||
|
StringBuilder context = new StringBuilder();
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.append(String.format("Gefunden: %d Jobs\n\n", jobs.size()));
|
||||||
|
|
||||||
|
// 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();
|
return context.toString();
|
||||||
}
|
}
|
||||||
@@ -319,19 +648,55 @@ public class AiStatisticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String buildFallbackResponse(QueryAnalysis analysis) {
|
private String buildFallbackResponse(QueryAnalysis analysis) {
|
||||||
|
String customer = analysis.customerFilter;
|
||||||
|
JobStatus statusFilter = analysis.statusFilter;
|
||||||
|
|
||||||
return switch (analysis.queryType) {
|
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" -> {
|
case "status" -> {
|
||||||
var counts = statisticsService.getJobCountsByStatus();
|
var counts = customer != null
|
||||||
StringBuilder sb = new StringBuilder("**Auftragsübersicht nach Status:**\n\n");
|
? 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) -> {
|
counts.forEach((status, count) -> {
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
sb.append(String.format("- **%s:** %d Aufträge\n", status.getDisplayName(), count));
|
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();
|
yield sb.toString();
|
||||||
}
|
}
|
||||||
case "revenue" -> {
|
case "revenue" -> {
|
||||||
|
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);
|
var topCustomers = statisticsService.getTopCustomersByRevenue(5);
|
||||||
StringBuilder sb = new StringBuilder("**Top Kunden nach Umsatz:**\n\n");
|
StringBuilder sb = new StringBuilder("**Top Kunden nach Umsatz:**\n\n");
|
||||||
int rank = 1;
|
int rank = 1;
|
||||||
@@ -344,12 +709,18 @@ public class AiStatisticsService {
|
|||||||
sb.append(String.format("\n**Gesamtumsatz:** %.2f EUR", statisticsService.getTotalRevenue()));
|
sb.append(String.format("\n**Gesamtumsatz:** %.2f EUR", statisticsService.getTotalRevenue()));
|
||||||
yield sb.toString();
|
yield sb.toString();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
case "trend" -> {
|
case "trend" -> {
|
||||||
int year = Year.now().getValue();
|
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();
|
long total = monthly.values().stream().mapToLong(Long::longValue).sum();
|
||||||
yield String.format("**Monatstrend %d:**\n\nInsgesamt wurden %d Aufträge erstellt. " +
|
String title = customer != null
|
||||||
"Die Verteilung ist im Diagramm ersichtlich.", year, total);
|
? 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" -> {
|
case "tasks" -> {
|
||||||
var taskStats = statisticsService.getTaskCompletionStats();
|
var taskStats = statisticsService.getTaskCompletionStats();
|
||||||
@@ -363,6 +734,16 @@ public class AiStatisticsService {
|
|||||||
total, completed, rate, taskStats.getOrDefault("pending", 0L));
|
total, completed, rate, taskStats.getOrDefault("pending", 0L));
|
||||||
}
|
}
|
||||||
default -> {
|
default -> {
|
||||||
|
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" +
|
yield String.format("**Übersicht:**\n\n" +
|
||||||
"- **Aufträge gesamt:** %d\n" +
|
"- **Aufträge gesamt:** %d\n" +
|
||||||
"- **Abschlussrate:** %.1f%%\n" +
|
"- **Abschlussrate:** %.1f%%\n" +
|
||||||
@@ -371,6 +752,7 @@ public class AiStatisticsService {
|
|||||||
statisticsService.getCompletionRate(),
|
statisticsService.getCompletionRate(),
|
||||||
statisticsService.getTotalRevenue());
|
statisticsService.getTotalRevenue());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,16 +106,23 @@ public class LlmRestClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String extractContent(String response) {
|
private String extractContent(String response) {
|
||||||
if (response == null) {
|
if (response == null || response.isBlank()) {
|
||||||
|
log.warn("LLM returned null or blank response");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
JsonNode root = objectMapper.readTree(response);
|
JsonNode root = objectMapper.readTree(response);
|
||||||
JsonNode choices = root.path("choices");
|
JsonNode choices = root.path("choices");
|
||||||
if (choices.isArray() && !choices.isEmpty()) {
|
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;
|
||||||
}
|
}
|
||||||
log.warn("Unexpected response structure: {}", response);
|
return content;
|
||||||
|
}
|
||||||
|
log.warn("Unexpected response structure (no choices): {}", response);
|
||||||
return null;
|
return null;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Error parsing LLM response: {}", e.getMessage());
|
log.error("Error parsing LLM response: {}", e.getMessage());
|
||||||
|
|||||||
@@ -262,7 +262,11 @@ public class StatisticsView extends VerticalLayout {
|
|||||||
// Text Response
|
// Text Response
|
||||||
Div textDiv = new Div();
|
Div textDiv = new Div();
|
||||||
textDiv.getStyle().set("margin-top", "var(--lumo-space-s)");
|
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);
|
bubble.add(textDiv);
|
||||||
|
|
||||||
// Chart wenn vorhanden
|
// Chart wenn vorhanden
|
||||||
@@ -505,7 +509,7 @@ public class StatisticsView extends VerticalLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
""";
|
""";
|
||||||
case "doughnut", "pie" -> """
|
case "doughnut" -> """
|
||||||
{
|
{
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
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 -> """
|
default -> """
|
||||||
{
|
{
|
||||||
responsive: true,
|
responsive: true,
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ public interface JobRepository extends MongoRepository<Job, ObjectId> {
|
|||||||
*/
|
*/
|
||||||
List<Job> findByCustomerSelection(String customerSelection);
|
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
|
* Prüft, ob eine Auftragsnummer bereits existiert
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import java.util.HashMap;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for job statistics and aggregations.
|
* 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) {
|
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) {
|
public List<Job> getLatestJobs(int limit) {
|
||||||
return jobRepository.findLatestJobs().stream().limit(limit).toList();
|
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