diff --git a/pom.xml b/pom.xml index 4aede84..98ed03b 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ de.assecutor.votianlt votianlt - 0.9.9 + 0.9.10 jar diff --git a/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java b/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java index 913ef6c..8d63337 100644 --- a/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java +++ b/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java @@ -8,10 +8,15 @@ import de.assecutor.votianlt.service.JobStatisticsService; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import java.math.BigDecimal; +import java.time.LocalDateTime; import java.time.Month; import java.time.Year; +import java.time.YearMonth; +import java.time.format.TextStyle; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.Map; /** @@ -36,24 +41,38 @@ public class AiStatisticsService { /** * Response record containing text and optional chart data. */ - public record StatisticsResponse(String textResponse, String chartType, String chartData) { + public record StatisticsResponse(String textResponse, String chartType, String chartData, String tableHtml) { + } + + private enum TimeScope { + ALL_TIME, CURRENT_MONTH, CURRENT_YEAR + } + + private record LlmVisualizationDecision(String answer, boolean showChart, boolean showTable) { + } + + private record RevenueFacts(BigDecimal totalRevenue, long jobCount, List> topCustomers) { } /** * Analyze a statistics query and return a response with optional visualization. */ - public StatisticsResponse analyzeStatisticsQuery(String userQuery) { - log.info("Processing statistics query: {}", userQuery); + public StatisticsResponse analyzeStatisticsQuery(String userQuery, String currentUserId) { + log.info("Processing statistics query for user {}: {}", currentUserId, userQuery); + + if (currentUserId == null || currentUserId.isBlank()) { + throw new IllegalArgumentException("currentUserId must not be blank"); + } // Determine query type and prepare chart data (includes customer filter // detection) - QueryAnalysis analysis = analyzeQueryType(userQuery); + QueryAnalysis analysis = analyzeQueryType(userQuery, currentUserId); 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); + String statisticsContext = buildStatisticsContext(currentUserId, analysis); // Build prompt for LLM String prompt = buildPrompt(userQuery, statisticsContext, analysis); @@ -66,39 +85,65 @@ public class AiStatisticsService { String llmResponse = llmClient.chat(systemPrompt, prompt); if (llmResponse != null && !llmResponse.isBlank()) { - log.info("LLM response received, length: {} chars", llmResponse.length()); - return new StatisticsResponse(llmResponse, analysis.chartType, analysis.chartData); + LlmVisualizationDecision decision = parseVisualizationDecision(llmResponse, analysis, currentUserId); + log.info("LLM response received, length: {} chars, showChart: {}, showTable: {}", llmResponse.length(), + decision.showChart(), decision.showTable()); + + String chartType = decision.showChart() ? analysis.chartType : null; + String chartData = decision.showChart() ? analysis.chartData : null; + String tableHtml = decision.showTable() ? buildTableHtml(currentUserId, analysis) : null; + String responseText = selectAnswerText(decision.answer(), currentUserId, analysis); + return new StatisticsResponse(responseText, chartType, chartData, tableHtml); } else { log.warn("LLM returned null or blank response, using fallback"); - return new StatisticsResponse(buildFallbackResponse(analysis), analysis.chartType, analysis.chartData); + return new StatisticsResponse(buildFallbackResponse(currentUserId, analysis), null, null, null); } } - private record QueryAnalysis(String queryType, String chartType, String chartData, String customerFilter, // null = - // no - // filter, - // show - // all - // data - JobStatus statusFilter // null = no status filter - ) { + private record QueryAnalysis(String queryType, String chartType, String chartData, boolean tableRequested, + String customerFilter, JobStatus statusFilter, TimeScope timeScope, boolean revenueBreakdownRequested, + boolean amountOnlyRequested) { } private String buildStatisticsSystemPrompt() { return """ Du bist ein Statistik-Assistent für ein Logistikunternehmen. + DATENSCHUTZ: + - Nutze ausschließlich den bereitgestellten Datenkontext + - Der Datenkontext enthält ausschließlich Daten des aktuell angemeldeten Benutzers + - Untersuche, erwähne oder vermute niemals Daten anderer Benutzer + WICHTIG - ANTWORTSTIL: + - Gib ausschließlich ein JSON-Objekt im geforderten Format aus + - Gib niemals Gedankengänge, Zwischenschritte oder Tags wie aus + - Wiederhole, paraphrasiere oder fasse die Benutzerfrage nicht zusammen + - Vermeide Formulierungen wie "Sie fragen", "Du fragst", "Die Frage ist", "Gewünscht ist" oder "Der Benutzer möchte wissen" + - Im Feld "answer" beginnt die Antwort direkt mit dem Ergebnis - 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 + - Jede genannte Zahl muss exakt aus dem bereitgestellten Kontext stammen DIAGRAMM-HINWEIS: - - Ein Diagramm wird AUTOMATISCH angezeigt - nicht erwähnen oder beschreiben + - Falls ein Diagramm angezeigt wird, erwähne oder beschreibe es nicht - Sage NIEMALS "Ich kann kein Diagramm zeichnen" oder ähnliches + TABELLEN-HINWEIS: + - Falls eine Tabelle angezeigt wird, erwähne oder beschreibe sie nicht + - Verwende keine Tabellen im Antworttext + + AUSGABEFORMAT: + - Antworte IMMER als gültiges JSON-Objekt + - Format exakt: {"answer":"...","showChart":true|false,"showTable":true|false} + - "answer" enthält nur den sichtbaren Antworttext für den Benutzer + - "showChart" ist nur dann true, wenn ein angefordertes Diagramm anhand der Daten sinnvoll ist + - "showTable" ist nur dann true, wenn eine angeforderte Tabelle anhand der Daten sinnvoll ist + - Wenn eine Visualisierung nicht sinnvoll ist, setze das Flag auf false und erwähne das nicht im Antworttext + - Gib ausschließlich das JSON-Objekt aus, keinen zusätzlichen Text + FORMATIERUNG: - Keine Tabellen - Keine langen Aufzählungen @@ -112,17 +157,40 @@ public class AiStatisticsService { return """ Du bist ein Assistent für ein Logistikunternehmen. + DATENSCHUTZ: + - Nutze ausschließlich den bereitgestellten Datenkontext + - Der Datenkontext enthält ausschließlich Daten des aktuell angemeldeten Benutzers + - Untersuche, erwähne oder vermute niemals Daten anderer Benutzer + 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 + - Gib ausschließlich ein JSON-Objekt im geforderten Format aus + - Gib niemals Gedankengänge, Zwischenschritte oder Tags wie aus + - Wiederhole, paraphrasiere oder fasse die Benutzerfrage nicht zusammen + - Vermeide Formulierungen wie "Sie fragen", "Du fragst", "Die Frage ist", "Gewünscht ist" oder "Der Benutzer möchte wissen" + - Im Feld "answer" beginnt die Antwort direkt mit dem Ergebnis + - Nenne nur das Ergebnis der Suche, z.B. die Anzahl der Treffer oder die relevanten Jobnummern + - Keine Einleitung, keine Meta-Kommentare, keine Wiederholung der Anfrage - Maximal 1-2 Sätze + - Jede genannte Zahl muss exakt aus dem bereitgestellten Kontext stammen KEIN DIAGRAMM: - Es wird KEIN Diagramm angezeigt bei Listen-Anfragen - Erwähne keine Diagramme + TABELLEN: + - Falls eine Tabelle angezeigt wird, erwähne oder beschreibe sie nicht + - Verwende keine Tabellen im Antworttext + + AUSGABEFORMAT: + - Antworte IMMER als gültiges JSON-Objekt + - Format exakt: {"answer":"...","showChart":true|false,"showTable":true|false} + - "answer" enthält nur den sichtbaren Antworttext für den Benutzer + - "showChart" ist nur dann true, wenn ein angefordertes Diagramm anhand der Daten sinnvoll ist + - "showTable" ist nur dann true, wenn eine angeforderte Tabelle anhand der Daten sinnvoll ist + - Wenn eine Visualisierung nicht sinnvoll ist, setze das Flag auf false und erwähne das nicht im Antworttext + - Gib ausschließlich das JSON-Objekt aus, keinen zusätzlichen Text + Antworte auf Deutsch. """; } @@ -175,7 +243,66 @@ public class AiStatisticsService { return null; // No specific preference } - private QueryAnalysis analyzeQueryType(String query) { + /** + * Detect if the user explicitly wants a chart or diagram. + */ + private boolean isChartRequested(String lowerQuery, String specificChartType) { + if (specificChartType != null) { + return true; + } + + return lowerQuery.contains("diagramm") || lowerQuery.contains("chart") || lowerQuery.contains("grafik") + || lowerQuery.contains("grafisch") || lowerQuery.contains("visualisier") + || lowerQuery.contains("visualisierung") || lowerQuery.contains("darstellen") + || lowerQuery.contains("abbilden"); + } + + /** + * Detect if the user explicitly wants a table. + */ + private boolean isTableRequested(String lowerQuery) { + return lowerQuery.contains("tabelle") || lowerQuery.contains("tabellarisch") + || lowerQuery.contains("tabellenform") || lowerQuery.contains("als tabelle") + || lowerQuery.contains("als table") || lowerQuery.contains("table"); + } + + private TimeScope detectTimeScope(String lowerQuery) { + if (lowerQuery.contains("diesen monat") || lowerQuery.contains("diesem monat") + || lowerQuery.contains("aktuellen monat") || lowerQuery.contains("monat bisher")) { + return TimeScope.CURRENT_MONTH; + } + if (lowerQuery.contains("dieses jahr") || lowerQuery.contains("diesem jahr") + || lowerQuery.contains("aktuelles jahr") || lowerQuery.contains("jahr bisher")) { + return TimeScope.CURRENT_YEAR; + } + return TimeScope.ALL_TIME; + } + + private boolean isRevenueBreakdownRequested(String lowerQuery) { + return lowerQuery.contains("top kunden") || lowerQuery.contains("kundenumsatz") + || lowerQuery.contains("umsatz nach kunden") || lowerQuery.contains("umsatz pro kunde") + || lowerQuery.contains("umsatz je kunde") || lowerQuery.contains("pro kunde") + || lowerQuery.contains("je kunde"); + } + + private boolean isAmountOnlyRequested(String lowerQuery) { + return lowerQuery.contains("nur den betrag") || lowerQuery.contains("nur betrag") + || lowerQuery.contains("nur den wert") || lowerQuery.contains("nur wert") + || lowerQuery.contains("nur in euro") || lowerQuery.contains("nur euro") + || lowerQuery.contains("ausschließlich den betrag") + || lowerQuery.contains("ausschliesslich den betrag"); + } + + private boolean isCountQuery(String lowerQuery) { + boolean asksForCount = lowerQuery.contains("wie viele") || lowerQuery.contains("wieviele") + || lowerQuery.contains("anzahl") || lowerQuery.contains("zähle") || lowerQuery.contains("zaehle") + || lowerQuery.contains("insgesamt"); + boolean asksForDistribution = lowerQuery.contains("status") || lowerQuery.contains("verteilung") + || lowerQuery.contains("übersicht"); + return asksForCount && !asksForDistribution; + } + + private QueryAnalysis analyzeQueryType(String query, String currentUserId) { String lowerQuery = query.toLowerCase(); // First, check if this is a LIST query (no chart needed) @@ -186,8 +313,17 @@ public class AiStatisticsService { String userChartType = isListQuery ? null : detectUserChartTypePreference(query); log.debug("User chart type preference: {}", userChartType != null ? userChartType : "none"); + boolean chartRequested = !isListQuery && isChartRequested(lowerQuery, userChartType); + log.debug("Chart requested: {}", chartRequested); + boolean tableRequested = isTableRequested(lowerQuery); + log.debug("Table requested: {}", tableRequested); + TimeScope timeScope = detectTimeScope(lowerQuery); + log.debug("Time scope: {}", timeScope); + boolean amountOnlyRequested = isAmountOnlyRequested(lowerQuery); + log.debug("Amount-only requested: {}", amountOnlyRequested); + // Check if user specified a customer filter - String customerFilter = detectCustomerFilter(query); + String customerFilter = detectCustomerFilter(query, currentUserId); log.debug("Customer filter: {}", customerFilter != null ? customerFilter : "none (showing all data)"); // Check if user specified a status filter @@ -196,53 +332,90 @@ public class AiStatisticsService { // For list queries, return no chart if (isListQuery) { - return new QueryAnalysis("list", null, null, customerFilter, statusFilter); + return new QueryAnalysis("list", null, null, tableRequested, customerFilter, statusFilter, timeScope, + false, amountOnlyRequested); } // 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")) { + if (isCountQuery(lowerQuery)) { + queryType = "count"; + defaultChartType = "bar"; + } + // Status-bezogene Anfragen (Statistik, nicht Liste) + else if ((lowerQuery.contains("status") + && (lowerQuery.contains("statistik") || lowerQuery.contains("verteilung") + || lowerQuery.contains("übersicht") || lowerQuery.contains("wie viele") + || lowerQuery.contains("anzahl"))) + || (lowerQuery.contains("anzahl") && lowerQuery.contains("verteilung")) + || lowerQuery.contains("zählen")) { queryType = "status"; defaultChartType = "doughnut"; - chartData = buildStatusChartData(customerFilter); } // Umsatz-bezogene Anfragen else if (lowerQuery.contains("umsatz") || lowerQuery.contains("revenue") || lowerQuery.contains("einnahmen")) { queryType = "revenue"; defaultChartType = "bar"; - chartData = customerFilter != null ? buildCustomerRevenueChartData(customerFilter) - : buildRevenueChartData(); } // Trend-bezogene Anfragen else if (lowerQuery.contains("trend") || lowerQuery.contains("monat") || lowerQuery.contains("entwicklung") || lowerQuery.contains("jahr") || lowerQuery.contains("verlauf")) { queryType = "trend"; defaultChartType = "line"; - chartData = buildTrendChartData(customerFilter); } // Task-bezogene Anfragen else if (lowerQuery.contains("task") || lowerQuery.contains("aufgabe") || lowerQuery.contains("erledigt")) { queryType = "tasks"; defaultChartType = "doughnut"; - chartData = buildTaskChartData(); } // Allgemeine Übersicht else { queryType = "overview"; defaultChartType = "bar"; - chartData = buildOverviewChartData(customerFilter); } - // Use user's chart type preference if specified, otherwise use default - String chartType = userChartType != null ? userChartType : defaultChartType; + boolean revenueBreakdownRequested = "revenue".equals(queryType) && customerFilter == null + && isRevenueBreakdownRequested(lowerQuery); + log.debug("Revenue breakdown requested: {}", revenueBreakdownRequested); - return new QueryAnalysis(queryType, chartType, chartData, customerFilter, null); + String chartType = null; + String chartData = null; + if (chartRequested) { + // Use user's chart type preference if specified, otherwise use default + chartType = userChartType != null ? userChartType : defaultChartType; + chartData = buildChartData(queryType, currentUserId, customerFilter, statusFilter, timeScope, + revenueBreakdownRequested); + } + + return new QueryAnalysis(queryType, chartType, chartData, tableRequested, customerFilter, statusFilter, + timeScope, revenueBreakdownRequested, amountOnlyRequested); + } + + private String buildChartData(String queryType, String currentUserId, String customerFilter, + JobStatus statusFilter, TimeScope timeScope, boolean revenueBreakdownRequested) { + return switch (queryType) { + case "count" -> buildCountChartData(currentUserId, customerFilter, statusFilter); + case "status" -> buildStatusChartData(currentUserId, customerFilter); + case "revenue" -> buildRevenueChartData(currentUserId, customerFilter, timeScope, revenueBreakdownRequested); + case "trend" -> buildTrendChartData(currentUserId, customerFilter); + case "tasks" -> buildTaskChartData(currentUserId); + default -> buildOverviewChartData(currentUserId, customerFilter); + }; + } + + private String buildTableHtml(String currentUserId, QueryAnalysis analysis) { + return switch (analysis.queryType) { + case "count" -> buildCountTableHtml(currentUserId, analysis.customerFilter, analysis.statusFilter); + case "list" -> buildJobsTableHtml(currentUserId, analysis.customerFilter, analysis.statusFilter); + case "status" -> buildStatusTableHtml(currentUserId, analysis.customerFilter); + case "revenue" -> buildRevenueTableHtml(currentUserId, analysis); + case "trend" -> buildTrendTableHtml(currentUserId, analysis.customerFilter); + case "tasks" -> buildTasksTableHtml(currentUserId); + default -> buildOverviewTableHtml(currentUserId, analysis.customerFilter); + }; } /** @@ -302,7 +475,7 @@ public class AiStatisticsService { * Detect customer filter from the query. Returns the matching customer name or * null if no filter detected. */ - private String detectCustomerFilter(String query) { + private String detectCustomerFilter(String query, String currentUserId) { String lowerQuery = query.toLowerCase(); // Keywords that indicate a customer filter @@ -329,15 +502,21 @@ public class AiStatisticsService { log.debug("detectCustomerFilter - Found indicator '{}' in query: '{}'", foundIndicator, query); // Try to find a matching customer in the query - String matchedCustomer = statisticsService.findMatchingCustomer(query); + String matchedCustomer = statisticsService.findMatchingCustomerForUser(currentUserId, query); log.debug("detectCustomerFilter - Matched customer: '{}'", matchedCustomer); return matchedCustomer; } - private String buildStatusChartData(String customerFilter) { + private String buildCountChartData(String currentUserId, String customerFilter, JobStatus statusFilter) { + long count = getFilteredJobCount(currentUserId, customerFilter, statusFilter); + String label = buildCountMetricLabel(customerFilter, statusFilter); + return buildChartJson(List.of(label), List.of(count), List.of("#3b82f6"), "Aufträge"); + } + + private String buildStatusChartData(String currentUserId, String customerFilter) { Map statusCounts = customerFilter != null - ? statisticsService.getJobCountsByStatusForCustomer(customerFilter) - : statisticsService.getJobCountsByStatus(); + ? statisticsService.getJobCountsByStatusForCustomerForUser(currentUserId, customerFilter) + : statisticsService.getJobCountsByStatusForUser(currentUserId); List labels = new ArrayList<>(); List data = new ArrayList<>(); @@ -364,45 +543,62 @@ public class AiStatisticsService { return buildChartJson(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())), label); } - private String buildRevenueChartData() { - var topCustomers = statisticsService.getTopCustomersByRevenue(10); + private String buildRevenueChartData(String currentUserId, String customerFilter, TimeScope timeScope, + boolean revenueBreakdownRequested) { + RevenueFacts facts = buildRevenueFacts(currentUserId, customerFilter, timeScope, revenueBreakdownRequested); - List labels = new ArrayList<>(); - List data = new ArrayList<>(); - - for (var entry : topCustomers) { - labels.add(entry.getKey() != null ? entry.getKey() : "Unbekannt"); - data.add(entry.getValue().doubleValue()); + if (revenueBreakdownRequested && !facts.topCustomers().isEmpty()) { + List labels = new ArrayList<>(); + List data = new ArrayList<>(); + for (var entry : facts.topCustomers()) { + labels.add(entry.getKey() != null ? entry.getKey() : "Unbekannt"); + data.add(entry.getValue().doubleValue()); + } + List colors = List.of("#6366f1", "#8b5cf6", "#a855f7", "#c084fc", "#d8b4fe", "#e9d5ff", + "#f3e8ff", "#faf5ff", "#ede9fe", "#ddd6fe"); + return buildChartJsonDouble(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())), + "Umsatz (EUR)"); } - // Gradient-ähnliche Farbpalette für Balken - List colors = List.of("#6366f1", "#8b5cf6", "#a855f7", "#c084fc", "#d8b4fe", "#e9d5ff", "#f3e8ff", - "#faf5ff", "#ede9fe", "#ddd6fe"); - return buildChartJsonDouble(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())), - "Umsatz (EUR)"); + return buildChartJsonDouble(List.of(buildRevenueMetricLabel(customerFilter, timeScope)), + List.of(facts.totalRevenue().doubleValue()), List.of("#6366f1"), "Umsatz (EUR)"); } - 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); + private RevenueFacts buildRevenueFacts(String currentUserId, String customerFilter, TimeScope timeScope, + boolean revenueBreakdownRequested) { + if (timeScope == TimeScope.ALL_TIME) { + if (customerFilter != null) { + return new RevenueFacts(statisticsService.getTotalRevenueForCustomerForUser(currentUserId, customerFilter), + statisticsService.getTotalJobCountForCustomerForUser(currentUserId, customerFilter), List.of()); + } + List> topCustomers = revenueBreakdownRequested + ? statisticsService.getTopCustomersByRevenueForUser(currentUserId, 10) + : List.of(); + return new RevenueFacts(statisticsService.getTotalRevenueForUser(currentUserId), + statisticsService.getTotalJobCountForUser(currentUserId), topCustomers); + } - List labels = List.of("Aufträge gesamt", "Abgeschlossen", "In Bearbeitung", "Umsatz (€/100)"); - List data = List.of((double) totalJobs, (double) completed, (double) inProgress, - totalRevenue.doubleValue() / 100 // Scale down for better visualization - ); - List colors = List.of("#3b82f6", "#22c55e", "#f59e0b", "#6366f1"); + LocalDateTime start = getRangeStart(timeScope); + LocalDateTime end = LocalDateTime.now(); + if (customerFilter != null) { + return new RevenueFacts( + statisticsService.getTotalRevenueForCustomerForUserInRange(currentUserId, customerFilter, start, end), + statisticsService.getTotalJobCountForCustomerForUserInRange(currentUserId, customerFilter, start, end), + List.of()); + } - return buildChartJsonDouble(labels, data, colors, customer); + List> topCustomers = revenueBreakdownRequested + ? statisticsService.getTopCustomersByRevenueForUserInRange(currentUserId, start, end, 10) + : List.of(); + return new RevenueFacts(statisticsService.getTotalRevenueForUserInRange(currentUserId, start, end), + statisticsService.getTotalJobCountForUserInRange(currentUserId, start, end), topCustomers); } - private String buildTrendChartData(String customerFilter) { + private String buildTrendChartData(String currentUserId, String customerFilter) { int currentYear = Year.now().getValue(); Map monthlyData = customerFilter != null - ? statisticsService.getMonthlyJobCountsForCustomer(currentYear, customerFilter) - : statisticsService.getMonthlyJobCounts(currentYear); + ? statisticsService.getMonthlyJobCountsForCustomerForUser(currentYear, currentUserId, customerFilter) + : statisticsService.getMonthlyJobCountsForUser(currentYear, currentUserId); List labels = List.of("Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"); @@ -435,8 +631,8 @@ public class AiStatisticsService { """, toJsonArray(labels), datasetLabel, data); } - private String buildTaskChartData() { - Map taskStats = statisticsService.getTaskCompletionStats(); + private String buildTaskChartData(String currentUserId) { + Map taskStats = statisticsService.getTaskCompletionStatsForUser(currentUserId); List labels = List.of("Erledigt", "Ausstehend"); List data = List.of(taskStats.getOrDefault("completed", 0L), taskStats.getOrDefault("pending", 0L)); @@ -445,13 +641,14 @@ public class AiStatisticsService { return buildChartJson(labels, data, colors, "Aufgaben"); } - private String buildOverviewChartData(String customerFilter) { + private String buildOverviewChartData(String currentUserId, String customerFilter) { Map statusCounts = customerFilter != null - ? statisticsService.getJobCountsByStatusForCustomer(customerFilter) - : statisticsService.getJobCountsByStatus(); + ? statisticsService.getJobCountsByStatusForCustomerForUser(currentUserId, customerFilter) + : statisticsService.getJobCountsByStatusForUser(currentUserId); - long total = customerFilter != null ? statisticsService.getTotalJobCountForCustomer(customerFilter) - : statisticsService.getTotalJobCount(); + long total = customerFilter != null + ? statisticsService.getTotalJobCountForCustomerForUser(currentUserId, customerFilter) + : statisticsService.getTotalJobCountForUser(currentUserId); long completed = statusCounts.getOrDefault(JobStatus.COMPLETED, 0L); long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L); long open = total - completed - statusCounts.getOrDefault(JobStatus.CANCELLED, 0L); @@ -500,22 +697,31 @@ public class AiStatisticsService { } } - private String buildStatisticsContext(QueryAnalysis analysis) { + private String buildStatisticsContext(String currentUserId, QueryAnalysis analysis) { StringBuilder context = new StringBuilder(); String customerFilter = analysis.customerFilter; JobStatus statusFilter = analysis.statusFilter; // For LIST queries, show actual job data if ("list".equals(analysis.queryType)) { - return buildListContext(customerFilter, statusFilter); + return buildListContext(currentUserId, customerFilter, statusFilter); + } + + if ("count".equals(analysis.queryType)) { + return buildCountContext(currentUserId, customerFilter, statusFilter); + } + + if ("revenue".equals(analysis.queryType)) { + return buildRevenueContext(currentUserId, analysis); } // For statistics queries if (customerFilter != null) { // Filtered statistics for a specific customer - context.append(String.format("**Statistiken für Kunde: %s**\n\n", customerFilter)); + context.append( + String.format("**Statistiken des angemeldeten Benutzers für Kunde: %s**\n\n", customerFilter)); - var statusCounts = statisticsService.getJobCountsByStatusForCustomer(customerFilter); + var statusCounts = statisticsService.getJobCountsByStatusForCustomerForUser(currentUserId, customerFilter); context.append("**Aufträge nach Status:**\n"); statusCounts.forEach((status, count) -> { if (count > 0) { @@ -525,32 +731,35 @@ public class AiStatisticsService { context.append(String.format("\n**Übersicht für %s:**\n", customerFilter)); context.append(String.format("- Gesamtanzahl Aufträge: %d\n", - statisticsService.getTotalJobCountForCustomer(customerFilter))); + statisticsService.getTotalJobCountForCustomerForUser(currentUserId, customerFilter))); context.append(String.format("- Abschlussrate: %.1f%%\n", - statisticsService.getCompletionRateForCustomer(customerFilter))); + statisticsService.getCompletionRateForCustomerForUser(currentUserId, customerFilter))); context.append(String.format("- Umsatz: %.2f EUR\n", - statisticsService.getTotalRevenueForCustomer(customerFilter))); + statisticsService.getTotalRevenueForCustomerForUser(currentUserId, customerFilter))); } else { - // General statistics (all data) - var statusCounts = statisticsService.getJobCountsByStatus(); - context.append("**Aktuelle Auftragsstatistiken:**\n"); + // General statistics (current user only) + var statusCounts = statisticsService.getJobCountsByStatusForUser(currentUserId); + context.append("**Aktuelle Auftragsstatistiken des angemeldeten Benutzers:**\n"); statusCounts.forEach((status, count) -> context .append(String.format("- %s: %d Aufträge\n", status.getDisplayName(), count))); context.append(String.format("\n**Gesamtübersicht:**\n")); - context.append(String.format("- Gesamtanzahl Aufträge: %d\n", statisticsService.getTotalJobCount())); - context.append(String.format("- Abschlussrate: %.1f%%\n", statisticsService.getCompletionRate())); - context.append(String.format("- Gesamtumsatz: %.2f EUR\n", statisticsService.getTotalRevenue())); + context.append(String.format("- Gesamtanzahl Aufträge: %d\n", + statisticsService.getTotalJobCountForUser(currentUserId))); + context.append(String.format("- Abschlussrate: %.1f%%\n", + statisticsService.getCompletionRateForUser(currentUserId))); + context.append(String.format("- Gesamtumsatz: %.2f EUR\n", + statisticsService.getTotalRevenueForUser(currentUserId))); // Task statistics - var taskStats = statisticsService.getTaskCompletionStats(); + var taskStats = statisticsService.getTaskCompletionStatsForUser(currentUserId); context.append(String.format("\n**Aufgaben:**\n")); context.append(String.format("- Gesamt: %d\n", taskStats.get("total"))); context.append(String.format("- Erledigt: %d\n", taskStats.get("completed"))); context.append(String.format("- Ausstehend: %d\n", taskStats.get("pending"))); // Top customers - var topCustomers = statisticsService.getTopCustomersByRevenue(5); + var topCustomers = statisticsService.getTopCustomersByRevenueForUser(currentUserId, 5); if (!topCustomers.isEmpty()) { context.append("\n**Top 5 Kunden nach Umsatz:**\n"); for (var entry : topCustomers) { @@ -563,23 +772,49 @@ public class AiStatisticsService { return context.toString(); } - private String buildListContext(String customerFilter, JobStatus statusFilter) { + private String buildRevenueContext(String currentUserId, QueryAnalysis analysis) { + RevenueFacts facts = buildRevenueFacts(currentUserId, analysis.customerFilter, analysis.timeScope, + analysis.revenueBreakdownRequested); + + StringBuilder context = new StringBuilder("**Verbindliche Umsatzdaten des angemeldeten Benutzers:**\n"); + context.append(String.format("- Zeitraum: %s\n", buildTimeScopeLabel(analysis.timeScope))); + if (analysis.customerFilter != null) { + context.append(String.format("- Kunde: %s\n", analysis.customerFilter)); + } + context.append(String.format("- Maßgeblicher Gesamtumsatz: %s\n", formatCurrency(facts.totalRevenue()))); + context.append(String.format("- Zugehörige Aufträge: %d\n", facts.jobCount())); + + if (analysis.revenueBreakdownRequested && !facts.topCustomers().isEmpty()) { + context.append("\n**Umsatz nach Kunden:**\n"); + for (var entry : facts.topCustomers()) { + context.append(String.format("- %s: %s\n", entry.getKey() != null ? entry.getKey() : "Unbekannt", + formatCurrency(entry.getValue()))); + } + } + + context.append("\n**Hinweis:**\n"); + context.append("- Die Zeile \"Maßgeblicher Gesamtumsatz\" ist die verbindliche Antwortgröße\n"); + return context.toString(); + } + + private String buildListContext(String currentUserId, String customerFilter, JobStatus statusFilter) { StringBuilder context = new StringBuilder(); List jobs; if (customerFilter != null && statusFilter != null) { - jobs = statisticsService.getJobsByCustomerAndStatus(customerFilter, statusFilter); - context.append( - String.format("**Jobs für %s mit Status %s:**\n\n", customerFilter, statusFilter.getDisplayName())); + jobs = statisticsService.getJobsByCustomerAndStatusForUser(currentUserId, customerFilter, statusFilter); + context.append(String.format("**Jobs des angemeldeten Benutzers 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)); + jobs = statisticsService.getJobsByCustomerForUser(currentUserId, customerFilter); + context.append(String.format("**Jobs des angemeldeten Benutzers 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())); + jobs = statisticsService.getJobsByStatusForUser(currentUserId, statusFilter); + context.append(String.format("**Jobs des angemeldeten Benutzers mit Status %s:**\n\n", + statusFilter.getDisplayName())); } else { - jobs = statisticsService.getLatestJobs(20); - context.append("**Aktuelle Jobs:**\n\n"); + jobs = statisticsService.getLatestJobsForUser(currentUserId, 20); + context.append("**Aktuelle Jobs des angemeldeten Benutzers:**\n\n"); } context.append(String.format("Gefunden: %d Jobs\n\n", jobs.size())); @@ -601,106 +836,447 @@ public class AiStatisticsService { return context.toString(); } + private String buildCountContext(String currentUserId, String customerFilter, JobStatus statusFilter) { + long count = getFilteredJobCount(currentUserId, customerFilter, statusFilter); + String metricLabel = buildCountMetricLabel(customerFilter, statusFilter); + + return String.format(""" + **Verbindliche Zählung des angemeldeten Benutzers:** + - %s: %d + + **Hinweis:** + - Diese Zahl ist die maßgebliche Kennzahl für die Antwort + """, metricLabel, count); + } + private String buildPrompt(String userQuery, String statisticsContext, QueryAnalysis analysis) { // User prompt contains only the context and question (system prompt is passed // separately) return String.format(""" + Antworte ausschließlich mit einem JSON-Objekt im geforderten Format. + Gib keine Denkblöcke, keine Analyse und keine Tags wie aus. + Paraphrasiere die Frage nicht. + Verwende keine Tabellen im Antworttext. + Gewünschtes Diagramm angefordert: %s + Gewünschte Tabelle angefordert: %s + Setze "showChart" nur dann auf true, wenn das angeforderte Diagramm wirklich sinnvoll ist. + Setze "showTable" nur dann auf true, wenn die angeforderte Tabelle wirklich sinnvoll ist. + Ein Diagramm ist nicht sinnvoll, wenn es zu wenige aussagekräftige Datenpunkte gibt, fast alle Werte 0 sind + oder die vorhandenen Daten nicht sauber zur angefragten Darstellung passen. + Jede genannte Zahl muss exakt dem bereitgestellten Kontext entsprechen. + Wenn ein Diagramm oder eine Tabelle nicht sinnvoll ist, setze nur das Flag auf false und schreibe keinen Hinweis + wie "Die Anzeige von Diagrammen ist nicht möglich." in die Antwort. + + Hinweis: Der folgende Kontext enthält ausschließlich Daten des aktuell angemeldeten Benutzers. + %s **Benutzerfrage:** %s - """, statisticsContext, userQuery); + """, analysis.chartType != null, analysis.tableRequested, statisticsContext, userQuery); } - private String buildFallbackResponse(QueryAnalysis analysis) { + private LlmVisualizationDecision parseVisualizationDecision(String llmResponse, QueryAnalysis analysis, + String currentUserId) { + try { + String json = extractJsonObject(llmResponse); + var root = objectMapper.readTree(json); + String answer = root.path("answer").asText("").trim(); + if (answer.isBlank()) { + answer = buildFallbackResponse(currentUserId, analysis); + } + boolean showChart = parseDecisionFlag(root.path("showChart")) && analysis.chartType != null + && analysis.chartData != null; + boolean showTable = parseDecisionFlag(root.path("showTable")) && analysis.tableRequested; + return new LlmVisualizationDecision(answer, showChart, showTable); + } catch (Exception e) { + log.warn("Could not parse structured visualization decision: {}", e.getMessage()); + return new LlmVisualizationDecision(buildFallbackResponse(currentUserId, analysis), false, false); + } + } + + private boolean parseDecisionFlag(com.fasterxml.jackson.databind.JsonNode node) { + if (node == null || node.isMissingNode() || node.isNull()) { + return false; + } + if (node.isBoolean()) { + return node.asBoolean(); + } + if (node.isTextual()) { + String value = node.asText("").trim().toLowerCase(); + return "true".equals(value) || "yes".equals(value) || "ja".equals(value) || "1".equals(value); + } + if (node.isInt() || node.isLong()) { + return node.asInt() != 0; + } + return false; + } + + private String extractJsonObject(String raw) { + int start = raw.indexOf('{'); + int end = raw.lastIndexOf('}'); + if (start >= 0 && end > start) { + return raw.substring(start, end + 1); + } + return raw; + } + + private String buildCountTableHtml(String currentUserId, String customerFilter, JobStatus statusFilter) { + long count = getFilteredJobCount(currentUserId, customerFilter, statusFilter); + String metricLabel = buildCountMetricLabel(customerFilter, statusFilter); + return buildHtmlTable(List.of("Kennzahl", "Wert"), List.of(List.of(metricLabel, String.valueOf(count)))); + } + + private String buildJobsTableHtml(String currentUserId, String customerFilter, JobStatus statusFilter) { + List jobs; + if (customerFilter != null && statusFilter != null) { + jobs = statisticsService.getJobsByCustomerAndStatusForUser(currentUserId, customerFilter, statusFilter); + } else if (customerFilter != null) { + jobs = statisticsService.getJobsByCustomerForUser(currentUserId, customerFilter); + } else if (statusFilter != null) { + jobs = statisticsService.getJobsByStatusForUser(currentUserId, statusFilter); + } else { + jobs = statisticsService.getLatestJobsForUser(currentUserId, 20); + } + + List> rows = jobs.stream().limit(20) + .map(job -> List.of(job.getJobNumber() != null ? job.getJobNumber() : "Ohne Nr.", + job.getCustomerSelection() != null ? job.getCustomerSelection() : "Unbekannt", + job.getStatus() != null ? job.getStatus().getDisplayName() : "Unbekannt")) + .toList(); + return buildHtmlTable(List.of("Jobnummer", "Kunde", "Status"), rows); + } + + private String buildStatusTableHtml(String currentUserId, String customerFilter) { + Map counts = customerFilter != null + ? statisticsService.getJobCountsByStatusForCustomerForUser(currentUserId, customerFilter) + : statisticsService.getJobCountsByStatusForUser(currentUserId); + + List> rows = new ArrayList<>(); + for (JobStatus status : JobStatus.values()) { + rows.add(List.of(status.getDisplayName(), String.valueOf(counts.getOrDefault(status, 0L)))); + } + return buildHtmlTable(List.of("Status", "Anzahl"), rows); + } + + private String buildRevenueTableHtml(String currentUserId, QueryAnalysis analysis) { + RevenueFacts facts = buildRevenueFacts(currentUserId, analysis.customerFilter, analysis.timeScope, + analysis.revenueBreakdownRequested); + + if (analysis.revenueBreakdownRequested && !facts.topCustomers().isEmpty()) { + List> rows = facts.topCustomers().stream() + .map(entry -> List.of(entry.getKey() != null ? entry.getKey() : "Unbekannt", + formatCurrency(entry.getValue()))) + .toList(); + return buildHtmlTable(List.of("Kunde", "Umsatz"), rows); + } + + List> rows = List.of( + List.of("Zeitraum", buildTimeScopeLabel(analysis.timeScope)), + List.of("Kennzahl", buildRevenueMetricLabel(analysis.customerFilter, analysis.timeScope)), + List.of("Aufträge", String.valueOf(facts.jobCount())), + List.of("Umsatz", formatCurrency(facts.totalRevenue()))); + return buildHtmlTable(List.of("Merkmal", "Wert"), rows); + } + + private String buildTrendTableHtml(String currentUserId, String customerFilter) { + int year = Year.now().getValue(); + Map monthlyData = customerFilter != null + ? statisticsService.getMonthlyJobCountsForCustomerForUser(year, currentUserId, customerFilter) + : statisticsService.getMonthlyJobCountsForUser(year, currentUserId); + + List> rows = new ArrayList<>(); + for (Month month : Month.values()) { + rows.add(List.of(month.name(), String.valueOf(monthlyData.getOrDefault(month, 0L)))); + } + return buildHtmlTable(List.of("Monat", "Aufträge"), rows); + } + + private String buildTasksTableHtml(String currentUserId) { + Map taskStats = statisticsService.getTaskCompletionStatsForUser(currentUserId); + List> rows = List.of(List.of("Gesamt", String.valueOf(taskStats.getOrDefault("total", 0L))), + List.of("Erledigt", String.valueOf(taskStats.getOrDefault("completed", 0L))), + List.of("Ausstehend", String.valueOf(taskStats.getOrDefault("pending", 0L)))); + return buildHtmlTable(List.of("Kennzahl", "Wert"), rows); + } + + private String buildOverviewTableHtml(String currentUserId, String customerFilter) { + if (customerFilter != null) { + List> rows = List.of( + List.of("Aufträge", String.valueOf( + statisticsService.getTotalJobCountForCustomerForUser(currentUserId, customerFilter))), + List.of("Abschlussrate", String.format("%.1f%%", + statisticsService.getCompletionRateForCustomerForUser(currentUserId, customerFilter))), + List.of("Umsatz", String.format("%.2f EUR", + statisticsService.getTotalRevenueForCustomerForUser(currentUserId, customerFilter)))); + return buildHtmlTable(List.of("Kennzahl", "Wert"), rows); + } + + List> rows = List.of( + List.of("Aufträge", String.valueOf(statisticsService.getTotalJobCountForUser(currentUserId))), + List.of("Abschlussrate", + String.format("%.1f%%", statisticsService.getCompletionRateForUser(currentUserId))), + List.of("Gesamtumsatz", String.format("%.2f EUR", statisticsService.getTotalRevenueForUser(currentUserId)))); + return buildHtmlTable(List.of("Kennzahl", "Wert"), rows); + } + + private String buildHtmlTable(List headers, List> rows) { + StringBuilder html = new StringBuilder(); + html.append( + ""); + html.append(""); + for (String header : headers) { + html.append(""); + } + html.append(""); + + for (List row : rows) { + html.append(""); + for (String cell : row) { + html.append( + ""); + } + html.append(""); + } + + html.append("
") + .append(escapeHtml(header)).append("
") + .append(escapeHtml(cell)).append("
"); + return html.toString(); + } + + private String escapeHtml(String value) { + if (value == null) { + return ""; + } + return value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """) + .replace("'", "'"); + } + + private LocalDateTime getRangeStart(TimeScope timeScope) { + return switch (timeScope) { + case CURRENT_MONTH -> YearMonth.now().atDay(1).atStartOfDay(); + case CURRENT_YEAR -> Year.now().atDay(1).atStartOfDay(); + case ALL_TIME -> LocalDateTime.MIN; + }; + } + + private String buildTimeScopeLabel(TimeScope timeScope) { + return switch (timeScope) { + case CURRENT_MONTH -> { + YearMonth yearMonth = YearMonth.now(); + String monthName = yearMonth.getMonth().getDisplayName(TextStyle.FULL, Locale.GERMAN); + yield monthName.substring(0, 1).toUpperCase(Locale.GERMAN) + monthName.substring(1) + " " + + yearMonth.getYear(); + } + case CURRENT_YEAR -> String.valueOf(Year.now().getValue()); + case ALL_TIME -> "Gesamt"; + }; + } + + private String buildTimeScopePhrase(TimeScope timeScope) { + return switch (timeScope) { + case CURRENT_MONTH -> "in diesem Monat"; + case CURRENT_YEAR -> "in diesem Jahr"; + case ALL_TIME -> "insgesamt"; + }; + } + + private String buildRevenueMetricLabel(String customerFilter, TimeScope timeScope) { + String scopeLabel = switch (timeScope) { + case CURRENT_MONTH -> "Umsatz im Monat"; + case CURRENT_YEAR -> "Umsatz im Jahr"; + case ALL_TIME -> "Gesamtumsatz"; + }; + + if (customerFilter != null) { + return scopeLabel + " für " + customerFilter; + } + return scopeLabel; + } + + private String formatCurrency(BigDecimal amount) { + return String.format(Locale.GERMAN, "%.2f EUR", amount); + } + + private String selectAnswerText(String llmAnswer, String currentUserId, QueryAnalysis analysis) { + if (shouldUseDeterministicAnswer(analysis)) { + return buildFallbackResponse(currentUserId, analysis); + } + if (llmAnswer == null || llmAnswer.isBlank()) { + return buildFallbackResponse(currentUserId, analysis); + } + return llmAnswer.trim(); + } + + private boolean shouldUseDeterministicAnswer(QueryAnalysis analysis) { + return switch (analysis.queryType) { + case "count", "status", "overview", "revenue", "tasks" -> true; + default -> false; + }; + } + + private long getFilteredJobCount(String currentUserId, String customerFilter, JobStatus statusFilter) { + if (customerFilter != null && statusFilter != null) { + return statisticsService.getJobsByCustomerAndStatusForUser(currentUserId, customerFilter, statusFilter) + .size(); + } + if (customerFilter != null) { + return statisticsService.getTotalJobCountForCustomerForUser(currentUserId, customerFilter); + } + if (statusFilter != null) { + return statisticsService.getJobsByStatusForUser(currentUserId, statusFilter).size(); + } + return statisticsService.getTotalJobCountForUser(currentUserId); + } + + private String buildCountMetricLabel(String customerFilter, JobStatus statusFilter) { + if (customerFilter != null && statusFilter != null) { + return String.format("%s (%s)", customerFilter, statusFilter.getDisplayName()); + } + if (customerFilter != null) { + return String.format("Aufträge für %s", customerFilter); + } + if (statusFilter != null) { + return String.format("Aufträge mit Status %s", statusFilter.getDisplayName()); + } + return "Aufträge gesamt"; + } + + private String formatOrderCount(long count) { + return count == 1 ? "1 Auftrag" : count + " Aufträge"; + } + + private String buildRevenueAnswer(String currentUserId, QueryAnalysis analysis) { + RevenueFacts facts = buildRevenueFacts(currentUserId, analysis.customerFilter, analysis.timeScope, + analysis.revenueBreakdownRequested); + + if (analysis.amountOnlyRequested) { + return formatCurrency(facts.totalRevenue()) + "."; + } + + if (analysis.customerFilter != null) { + return String.format("Ihr Umsatz %s für %s beträgt %s.", buildTimeScopePhrase(analysis.timeScope), + analysis.customerFilter, formatCurrency(facts.totalRevenue())); + } + + if (analysis.revenueBreakdownRequested && !facts.topCustomers().isEmpty()) { + StringBuilder answer = new StringBuilder(); + int shown = 0; + for (var entry : facts.topCustomers()) { + if (shown >= 3) { + break; + } + if (!answer.isEmpty()) { + answer.append(" "); + } + answer.append(String.format("%d. %s: %s.", shown + 1, + entry.getKey() != null ? entry.getKey() : "Unbekannt", formatCurrency(entry.getValue()))); + shown++; + } + if (!answer.isEmpty()) { + answer.append(" "); + } + answer.append(String.format("Gesamtumsatz %s: %s.", buildTimeScopePhrase(analysis.timeScope), + formatCurrency(facts.totalRevenue()))); + return answer.toString(); + } + + return String.format("Ihr Umsatz %s beträgt %s.", buildTimeScopePhrase(analysis.timeScope), + formatCurrency(facts.totalRevenue())); + } + + private String buildFallbackResponse(String currentUserId, QueryAnalysis analysis) { String customer = analysis.customerFilter; JobStatus statusFilter = analysis.statusFilter; return switch (analysis.queryType) { + case "count" -> { + long count = getFilteredJobCount(currentUserId, customer, statusFilter); + if (customer != null && statusFilter != null) { + yield String.format("Sie haben aktuell %s für %s mit Status %s.", formatOrderCount(count), customer, + statusFilter.getDisplayName()); + } + if (customer != null) { + yield String.format("Sie haben aktuell %s für %s.", formatOrderCount(count), customer); + } + if (statusFilter != null) { + yield String.format("Sie haben aktuell %s mit Status %s.", formatOrderCount(count), + statusFilter.getDisplayName()); + } + yield String.format("Sie haben aktuell insgesamt %s.", formatOrderCount(count)); + } case "list" -> { List jobs; if (customer != null && statusFilter != null) { - jobs = statisticsService.getJobsByCustomerAndStatus(customer, statusFilter); - yield String.format("Es wurden %d Jobs für %s mit Status \"%s\" gefunden.", jobs.size(), customer, + jobs = statisticsService.getJobsByCustomerAndStatusForUser(currentUserId, customer, statusFilter); + yield String.format("%d Jobs für %s mit Status %s.", 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); + jobs = statisticsService.getJobsByCustomerForUser(currentUserId, customer); + yield String.format("%d Jobs für %s.", 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()); + jobs = statisticsService.getJobsByStatusForUser(currentUserId, statusFilter); + yield String.format("%d Jobs mit Status %s.", jobs.size(), statusFilter.getDisplayName()); } else { - yield "Hier sind die aktuellen Jobs."; + jobs = statisticsService.getLatestJobsForUser(currentUserId, 20); + yield String.format("%d aktuelle Jobs.", jobs.size()); } } case "status" -> { - var counts = customer != null ? statisticsService.getJobCountsByStatusForCustomer(customer) - : statisticsService.getJobCountsByStatus(); - String title = customer != null ? String.format("**Auftragsübersicht für %s:**\n\n", customer) - : "**Auftragsübersicht nach Status:**\n\n"; - StringBuilder sb = new StringBuilder(title); + var counts = customer != null + ? statisticsService.getJobCountsByStatusForCustomerForUser(currentUserId, customer) + : statisticsService.getJobCountsByStatusForUser(currentUserId); + StringBuilder sb = new StringBuilder(); counts.forEach((status, count) -> { if (count > 0) { - sb.append(String.format("- **%s:** %d Aufträge\n", status.getDisplayName(), count)); + if (!sb.isEmpty()) { + sb.append(" "); + } + sb.append(String.format("%s: %d.", status.getDisplayName(), count)); } }); - long total = customer != null ? statisticsService.getTotalJobCountForCustomer(customer) - : statisticsService.getTotalJobCount(); - sb.append(String.format("\n**Gesamt:** %d Aufträge", total)); + long total = customer != null + ? statisticsService.getTotalJobCountForCustomerForUser(currentUserId, customer) + : statisticsService.getTotalJobCountForUser(currentUserId); + if (!sb.isEmpty()) { + sb.append(" "); + } + sb.append(String.format("Gesamt: %d Aufträge.", total)); yield sb.toString(); } 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); - StringBuilder sb = new StringBuilder("**Top Kunden nach Umsatz:**\n\n"); - int rank = 1; - for (var entry : topCustomers) { - sb.append(String.format("%d. **%s:** %.2f EUR\n", rank++, - entry.getKey() != null ? entry.getKey() : "Unbekannt", entry.getValue())); - } - sb.append(String.format("\n**Gesamtumsatz:** %.2f EUR", statisticsService.getTotalRevenue())); - yield sb.toString(); - } + yield buildRevenueAnswer(currentUserId, analysis); } case "trend" -> { int year = Year.now().getValue(); - var monthly = customer != null ? statisticsService.getMonthlyJobCountsForCustomer(year, customer) - : statisticsService.getMonthlyJobCounts(year); + var monthly = customer != null + ? statisticsService.getMonthlyJobCountsForCustomerForUser(year, currentUserId, customer) + : statisticsService.getMonthlyJobCountsForUser(year, currentUserId); long total = monthly.values().stream().mapToLong(Long::longValue).sum(); - String title = customer != null ? String.format("**Monatstrend %d für %s:**", year, customer) - : String.format("**Monatstrend %d:**", year); - yield String.format( - "%s\n\nInsgesamt wurden %d Aufträge erstellt. " + "Die Verteilung ist im Diagramm ersichtlich.", - title, total); + if (customer != null) { + yield String.format("%d wurden für %s insgesamt %d Aufträge erstellt.", year, customer, total); + } + yield String.format("%d wurden insgesamt %d Aufträge erstellt.", year, total); } case "tasks" -> { - var taskStats = statisticsService.getTaskCompletionStats(); + var taskStats = statisticsService.getTaskCompletionStatsForUser(currentUserId); long total = taskStats.getOrDefault("total", 0L); long completed = taskStats.getOrDefault("completed", 0L); double rate = total > 0 ? (double) completed / total * 100 : 0; - yield String.format("**Aufgabenstatistik:**\n\n" + "- **Gesamt:** %d Aufgaben\n" - + "- **Erledigt:** %d (%.1f%%)\n" + "- **Ausstehend:** %d", total, completed, rate, + yield String.format("%d von %d Aufgaben erledigt (%.1f%%), %d ausstehend.", completed, total, rate, taskStats.getOrDefault("pending", 0L)); } 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)); + yield String.format("%s: %d Aufträge, Abschlussrate %.1f%%, Umsatz %.2f EUR.", customer, + statisticsService.getTotalJobCountForCustomerForUser(currentUserId, customer), + statisticsService.getCompletionRateForCustomerForUser(currentUserId, customer), + statisticsService.getTotalRevenueForCustomerForUser(currentUserId, customer)); } else { - yield String.format( - "**Übersicht:**\n\n" + "- **Aufträge gesamt:** %d\n" + "- **Abschlussrate:** %.1f%%\n" - + "- **Gesamtumsatz:** %.2f EUR", - statisticsService.getTotalJobCount(), statisticsService.getCompletionRate(), - statisticsService.getTotalRevenue()); + yield String.format("%d Aufträge, Abschlussrate %.1f%%, Gesamtumsatz %.2f EUR.", + statisticsService.getTotalJobCountForUser(currentUserId), + statisticsService.getCompletionRateForUser(currentUserId), + statisticsService.getTotalRevenueForUser(currentUserId)); } } }; diff --git a/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java b/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java index 2967b10..6cbeb08 100644 --- a/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java +++ b/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java @@ -14,6 +14,7 @@ import java.time.Duration; import java.util.Base64; import java.util.List; import java.util.Map; +import java.util.regex.Pattern; /** * Direct REST client for LM Studio LLM API. Communicates via the @@ -23,6 +24,9 @@ import java.util.Map; @Slf4j public class LlmRestClient { + private static final Pattern THINK_BLOCK_PATTERN = Pattern.compile("(?is).*?"); + private static final Pattern THINK_TAG_PATTERN = Pattern.compile("(?is)"); + private final WebClient webClient; private final ObjectMapper objectMapper; private final String model; @@ -94,7 +98,7 @@ public class LlmRestClient { long duration = System.currentTimeMillis() - startTime; log.info("LLM response received in {}ms", duration); - log.debug("Raw LLM response: {}", response); + log.debug("LLM response payload received ({} chars)", response != null ? response.length() : 0); return extractContent(response); @@ -132,7 +136,12 @@ public class LlmRestClient { log.warn("LLM response content is empty"); return null; } - return content; + String sanitizedContent = sanitizeAssistantContent(content); + if (sanitizedContent.isBlank()) { + log.warn("LLM response content is empty after sanitization"); + return null; + } + return sanitizedContent; } log.warn("Unexpected response structure (no choices): {}", response); return null; @@ -141,4 +150,13 @@ public class LlmRestClient { return null; } } + + private String sanitizeAssistantContent(String content) { + String sanitized = THINK_BLOCK_PATTERN.matcher(content).replaceAll(" "); + sanitized = THINK_TAG_PATTERN.matcher(sanitized).replaceAll(" "); + sanitized = sanitized.replace("\r", ""); + sanitized = sanitized.replaceAll("[ \\t]+", " "); + sanitized = sanitized.replaceAll("\\n{3,}", "\n\n"); + return sanitized.trim(); + } } diff --git a/src/main/java/de/assecutor/votianlt/pages/base/ui/component/PickupStationDialog.java b/src/main/java/de/assecutor/votianlt/pages/base/ui/component/PickupStationDialog.java index d1da820..da01d8a 100644 --- a/src/main/java/de/assecutor/votianlt/pages/base/ui/component/PickupStationDialog.java +++ b/src/main/java/de/assecutor/votianlt/pages/base/ui/component/PickupStationDialog.java @@ -250,6 +250,7 @@ public class PickupStationDialog extends Dialog { private Span cargoTabError; private final DeliveryStationTile.TranslationHelper translationHelper; + public PickupStationDialog(String dialogTitle, List customers, DeliveryStationTile.TranslationHelper translationHelper, SaveListener saveListener, List availableAppUsers, AddressValidationService addressValidationService) { diff --git a/src/main/java/de/assecutor/votianlt/pages/base/ui/component/StationTile.java b/src/main/java/de/assecutor/votianlt/pages/base/ui/component/StationTile.java index ea157af..263fad1 100644 --- a/src/main/java/de/assecutor/votianlt/pages/base/ui/component/StationTile.java +++ b/src/main/java/de/assecutor/votianlt/pages/base/ui/component/StationTile.java @@ -161,8 +161,8 @@ public class StationTile extends VerticalLayout { private void addPreviewLine(String text) { Span span = new Span(text); - span.getStyle().set("font-size", "var(--lumo-font-size-xs)").set("line-height", "1.2").set("word-break", - "break-word").set("color", "var(--lumo-secondary-text-color)"); + span.getStyle().set("font-size", "var(--lumo-font-size-xs)").set("line-height", "1.2") + .set("word-break", "break-word").set("color", "var(--lumo-secondary-text-color)"); previewContent.add(span); } diff --git a/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java b/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java index 3f43c36..b11ae83 100644 --- a/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java +++ b/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java @@ -101,10 +101,12 @@ public class AddJobService { Map stationIdByOrder = buildStationIdByOrder(savedJob); List tasksToPersist = new ArrayList<>(); - // Setze stationId und stelle sicher, dass taskOrder je Lieferstation korrekt ist + // Setze stationId und stelle sicher, dass taskOrder je Lieferstation korrekt + // ist for (BaseTask task : filteredTasks) { int stationOrder = task.getStationOrder() != null ? task.getStationOrder() : 0; - ObjectId stationId = task.getStationId() != null ? task.getStationId() : stationIdByOrder.get(stationOrder); + ObjectId stationId = task.getStationId() != null ? task.getStationId() + : stationIdByOrder.get(stationOrder); if (stationId == null) { log.warn("Skipping task without resolvable stationId for job {} and stationOrder {}", jobId, stationOrder); @@ -259,7 +261,8 @@ public class AddJobService { continue; } if (task.getStationId() != null) { - tasksByStationId.computeIfAbsent(task.getStationId().toHexString(), ignored -> new ArrayList<>()).add(task); + tasksByStationId.computeIfAbsent(task.getStationId().toHexString(), ignored -> new ArrayList<>()) + .add(task); continue; } diff --git a/src/main/java/de/assecutor/votianlt/pages/service/AddressValidationService.java b/src/main/java/de/assecutor/votianlt/pages/service/AddressValidationService.java index 2851585..c310a18 100644 --- a/src/main/java/de/assecutor/votianlt/pages/service/AddressValidationService.java +++ b/src/main/java/de/assecutor/votianlt/pages/service/AddressValidationService.java @@ -120,7 +120,8 @@ public class AddressValidationService { double lat = location.path("lat").asDouble(); double lng = location.path("lng").asDouble(); - // Google liefert für valide Adressen nicht immer nur ROOFTOP/RANGE_INTERPOLATED. + // Google liefert für valide Adressen nicht immer nur + // ROOFTOP/RANGE_INTERPOLATED. // Für unseren Flow reicht ein erfolgreicher Geocoding-Treffer mit Koordinaten. String locationType = geometry.path("location_type").asText(); boolean hasCoordinates = location.hasNonNull("lat") && location.hasNonNull("lng"); @@ -149,8 +150,7 @@ public class AddressValidationService { if (result.isValid()) { result.setValidationMessage("Adresse erfolgreich validiert"); - log.debug( - "Adressvalidierung erfolgreich: {} -> {} (locationType={}, streetNumber={}, postalCode={})", + log.debug("Adressvalidierung erfolgreich: {} -> {} (locationType={}, streetNumber={}, postalCode={})", addressString, formattedAddress, locationType, hasStreetNumber, hasPostalCode); } else { result.setValidationMessage("Adresse gefunden, aber ohne verwertbare Koordinaten"); @@ -238,9 +238,9 @@ public class AddressValidationService { String destination = formatLatLng(destinationResult); // URL für die Directions API erstellen - StringBuilder requestUrl = new StringBuilder(String.format( - "%s?origin=%s&destination=%s&mode=driving&key=%s&language=de®ion=de", DIRECTIONS_API_URL, - origin, destination, googleMapsApiKey)); + StringBuilder requestUrl = new StringBuilder( + String.format("%s?origin=%s&destination=%s&mode=driving&key=%s&language=de®ion=de", + DIRECTIONS_API_URL, origin, destination, googleMapsApiKey)); if (stationResults.size() > 2) { List waypoints = stationResults.subList(1, stationResults.size() - 1).stream() @@ -305,8 +305,7 @@ public class AddressValidationService { routeResult.setDurationSeconds(totalDurationSeconds); routeResult.setFormattedDistance(distanceText); routeResult.setFormattedDuration(durationText); - routeResult.setRouteMessage( - String.format("Route: %s, Dauer: %s", distanceText, durationText)); + routeResult.setRouteMessage(String.format("Route: %s, Dauer: %s", distanceText, durationText)); log.debug("Routenberechnung erfolgreich: {} km, {} Min.", routeResult.getDistanceKm(), routeResult.getDurationMinutes()); diff --git a/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java b/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java index f502fe9..95b2ff4 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java @@ -1222,7 +1222,8 @@ public class AddJobView extends Main implements HasDynamicTitle { Button addButton = new Button(getTranslation("addjob.services.dialog.add"), e -> { if (serviceCombo.getValue() != null && deliveryStationCombo.getValue() != null) { - selectedServices.add(new SelectedServiceEntry(serviceCombo.getValue(), deliveryStationCombo.getValue())); + selectedServices + .add(new SelectedServiceEntry(serviceCombo.getValue(), deliveryStationCombo.getValue())); servicesGrid.getDataProvider().refreshAll(); updatePriceSummary(); triggerValidation(); @@ -1760,9 +1761,9 @@ public class AddJobView extends Main implements HasDynamicTitle { // Validate all required fields using the binder if (binder.writeBeanIfValid(job)) { // Preis nach dem Binder-Call berechnen (damit er nicht überschrieben wird) - BigDecimal netTotal = selectedServices.stream() - .map(entry -> calculateServicePrice(entry.getService(), getEffectiveRouteDistance(entry), - getEffectiveRouteDuration(entry))) + BigDecimal netTotal = selectedServices + .stream().map(entry -> calculateServicePrice(entry.getService(), + getEffectiveRouteDistance(entry), getEffectiveRouteDuration(entry))) .reduce(BigDecimal.ZERO, BigDecimal::add); job.setPrice(netTotal); @@ -2176,7 +2177,8 @@ public class AddJobView extends Main implements HasDynamicTitle { return new RouteCalculationBundle(totalRoute, deliveryRoutes); } - private RouteCalculationResult aggregateLegRoutes(Map deliveryRoutes, int legCount) { + private RouteCalculationResult aggregateLegRoutes(Map deliveryRoutes, + int legCount) { if (deliveryRoutes == null || deliveryRoutes.size() != legCount || legCount == 0) { return createInvalidRouteResult("Die Gesamtstrecke konnte nicht berechnet werden."); } @@ -2199,9 +2201,8 @@ public class AddJobView extends Main implements HasDynamicTitle { totalRoute.setDurationSeconds(totalDurationSeconds); totalRoute.setFormattedDistance(String.format(Locale.GERMANY, "%.1f km", totalDistanceKm)); totalRoute.setFormattedDuration(totalRoute.getFormattedDurationLong()); - totalRoute.setRouteMessage( - String.format("Route: %s, Dauer: %s", totalRoute.getFormattedDistance(), - totalRoute.getFormattedDurationLong())); + totalRoute.setRouteMessage(String.format("Route: %s, Dauer: %s", totalRoute.getFormattedDistance(), + totalRoute.getFormattedDurationLong())); return totalRoute; } @@ -2311,8 +2312,8 @@ public class AddJobView extends Main implements HasDynamicTitle { content.setPadding(false); content.setSpacing(true); content.add(createRouteSummaryRow(getTranslation("addjob.route.distance"), routeResult.getFormattedDistance())); - content.add(createRouteSummaryRow(getTranslation("addjob.route.duration"), - routeResult.getFormattedDurationLong())); + content.add( + createRouteSummaryRow(getTranslation("addjob.route.duration"), routeResult.getFormattedDurationLong())); Button closeButton = new Button(getTranslation("dialog.confirm"), event -> dialog.close()); closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); diff --git a/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java b/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java index 11e0279..1a473e0 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java @@ -295,8 +295,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter return ""; }).setHeader(getTranslation("createinvoice.column.service")).setAutoWidth(true).setFlexGrow(2); - servicesGrid.addColumn(this::getDeliveryStationLabel).setHeader(getTranslation("addjob.services.deliverystation")) - .setAutoWidth(true).setFlexGrow(1); + servicesGrid.addColumn(this::getDeliveryStationLabel) + .setHeader(getTranslation("addjob.services.deliverystation")).setAutoWidth(true).setFlexGrow(1); // Calculation basis column (read-only) servicesGrid.addColumn(row -> { diff --git a/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java b/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java index e9dcbdd..47830b8 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java @@ -1007,9 +1007,8 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle panel.add(invoiceHeader, senderCompany, senderName, senderAddress, senderCity, senderEmail, senderPhone, invoiceNumber, servicesHeader, servicesListBlock, servicesNetBlock, servicesGrossBlock, customerHeader, - customerCompany, customerName, customerAddress, customerCity, - customerEmail, customerPhone, freeHeader, textBlock, headerBlock, dateBlock, customerBlock, - companyBlock, amountBlock, lineBlock, imageBlock); + customerCompany, customerName, customerAddress, customerCity, customerEmail, customerPhone, freeHeader, + textBlock, headerBlock, dateBlock, customerBlock, companyBlock, amountBlock, lineBlock, imageBlock); return panel; } diff --git a/src/main/java/de/assecutor/votianlt/pages/view/InvoicesView.java b/src/main/java/de/assecutor/votianlt/pages/view/InvoicesView.java index e1968aa..8307b9d 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/InvoicesView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/InvoicesView.java @@ -46,18 +46,15 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle { invoiceGrid = new Grid<>(CustomerInvoice.class, false); invoiceGrid.setWidthFull(); invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getInvoiceNumber(), invoice.getId())) - .setHeader(getTranslation("invoices.column.number")) - .setAutoWidth(true); + .setHeader(getTranslation("invoices.column.number")).setAutoWidth(true); invoiceGrid.addColumn(this::getRecipientLabel).setHeader(getTranslation("invoices.column.customer")) .setAutoWidth(true); invoiceGrid.addColumn(invoice -> Optional.ofNullable(invoice.getInvoiceDate()).map(Object::toString).orElse("")) - .setHeader(getTranslation("invoices.column.date")) - .setAutoWidth(true); + .setHeader(getTranslation("invoices.column.date")).setAutoWidth(true); invoiceGrid.addColumn(this::formatAmount).setHeader(getTranslation("invoices.column.amount")) .setAutoWidth(true); invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getDescription(), "")) - .setHeader(getTranslation("invoices.column.description")) - .setAutoWidth(true); + .setHeader(getTranslation("invoices.column.description")).setAutoWidth(true); invoiceGrid.setSelectionMode(Grid.SelectionMode.SINGLE); invoiceGrid.getStyle().set("cursor", "pointer"); @@ -75,8 +72,7 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle { private void loadInvoices() { String currentUserId = securityService.getCurrentUserId().toHexString(); List invoices = customerInvoiceRepository.findByUserId(currentUserId).stream() - .filter(this::hasPdfData) - .sorted((left, right) -> { + .filter(this::hasPdfData).sorted((left, right) -> { if (left.getInvoiceDate() == null && right.getInvoiceDate() == null) { return 0; } @@ -87,8 +83,7 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle { return -1; } return right.getInvoiceDate().compareTo(left.getInvoiceDate()); - }) - .toList(); + }).toList(); invoiceGrid.setItems(invoices); if (invoices.isEmpty()) { @@ -106,7 +101,8 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle { return; } - StreamResource resource = new StreamResource(firstNonBlank(invoice.getInvoiceNumber(), invoice.getId()) + ".pdf", + StreamResource resource = new StreamResource( + firstNonBlank(invoice.getInvoiceNumber(), invoice.getId()) + ".pdf", () -> new ByteArrayInputStream(invoice.getPdfData())); resource.setContentType("application/pdf"); resource.setCacheTime(0); diff --git a/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java b/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java index aae97d3..eaf608a 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java @@ -406,8 +406,7 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has private StationTile createDeliverySummaryTile(DeliveryStation station, int index, int stationCount, List tasks) { - String title = getTranslation("jobsummary.section.delivery") + " " - + (stationCount > 1 ? (index + 1) + " " : "") + String title = getTranslation("jobsummary.section.delivery") + " " + (stationCount > 1 ? (index + 1) + " " : "") + formatDateWithTime(station.getDeliveryDate(), station.getDeliveryTime()); List stationTasks = getTasksForStation(station, tasks, false); List additionalLines = buildDeliverySummaryDetails(stationTasks); @@ -443,8 +442,8 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has return tile; } - private StationTile createSummaryTile(StationTile.StationType type, int stationNumber, String title, - String company, String displayName, String street, String houseNumber, String zip, String city, + private StationTile createSummaryTile(StationTile.StationType type, int stationNumber, String title, String company, + String displayName, String street, String houseNumber, String zip, String city, List additionalLines) { StationTile tile = new StationTile(type, stationNumber, title.trim(), false); tile.setInteractive(false); @@ -1377,12 +1376,9 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has task.isCompleted() ? "var(--lumo-success-text-color)" : "var(--lumo-body-text-color)"); Span statusBadge = new Span(getTaskStatusLabel(task)); - statusBadge.getStyle().set("font-size", "var(--lumo-font-size-xs)") - .set("font-weight", "600") - .set("padding", "0.2rem 0.55rem") - .set("border-radius", "999px") - .set("background-color", - task.isCompleted() ? "rgba(76, 175, 80, 0.15)" : "rgba(244, 67, 54, 0.12)") + statusBadge.getStyle().set("font-size", "var(--lumo-font-size-xs)").set("font-weight", "600") + .set("padding", "0.2rem 0.55rem").set("border-radius", "999px") + .set("background-color", task.isCompleted() ? "rgba(76, 175, 80, 0.15)" : "rgba(244, 67, 54, 0.12)") .set("color", task.isCompleted() ? "var(--lumo-success-text-color)" : "var(--lumo-error-text-color)"); HorizontalLayout headerRow = new HorizontalLayout(taskName, statusBadge); diff --git a/src/main/java/de/assecutor/votianlt/pages/view/StatisticsView.java b/src/main/java/de/assecutor/votianlt/pages/view/StatisticsView.java index 5df7c74..8d6cdcf 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/StatisticsView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/StatisticsView.java @@ -20,6 +20,7 @@ import com.vaadin.flow.component.textfield.TextField; import com.vaadin.flow.router.HasDynamicTitle; import com.vaadin.flow.router.Route; import de.assecutor.votianlt.ai.service.AiStatisticsService; +import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.util.DateTimeFormatUtil; import jakarta.annotation.security.RolesAllowed; import lombok.extern.slf4j.Slf4j; @@ -34,11 +35,13 @@ import java.util.UUID; public class StatisticsView extends VerticalLayout implements HasDynamicTitle { private final AiStatisticsService aiStatisticsService; + private final SecurityService securityService; private final VerticalLayout chatContainer; private final TextField promptField; - public StatisticsView(AiStatisticsService aiStatisticsService) { + public StatisticsView(AiStatisticsService aiStatisticsService, SecurityService securityService) { this.aiStatisticsService = aiStatisticsService; + this.securityService = securityService; // Prompt Field initialisieren this.promptField = new TextField(); @@ -166,9 +169,11 @@ public class StatisticsView extends VerticalLayout implements HasDynamicTitle { // Async Anfrage an KI UI ui = UI.getCurrent(); + String currentUserId = securityService.getCurrentUserId().toHexString(); new Thread(() -> { try { - AiStatisticsService.StatisticsResponse response = aiStatisticsService.analyzeStatisticsQuery(prompt); + AiStatisticsService.StatisticsResponse response = aiStatisticsService.analyzeStatisticsQuery(prompt, + currentUserId); ui.access(() -> { chatContainer.remove(loadingMessage); @@ -259,6 +264,13 @@ public class StatisticsView extends VerticalLayout implements HasDynamicTitle { } } + if (response.tableHtml() != null && !response.tableHtml().isBlank()) { + Div tableContainer = new Div(); + tableContainer.getStyle().set("margin-top", "var(--lumo-space-m)").set("overflow", "auto"); + tableContainer.getElement().setProperty("innerHTML", response.tableHtml()); + bubble.add(tableContainer); + } + // Timestamp Span time = new Span(DateTimeFormatUtil.formatTime(LocalDateTime.now())); time.getStyle().set("font-size", "var(--lumo-font-size-xs)").set("color", "var(--lumo-secondary-text-color)") diff --git a/src/main/java/de/assecutor/votianlt/repository/TaskRepository.java b/src/main/java/de/assecutor/votianlt/repository/TaskRepository.java index bcc42fd..cfbf002 100644 --- a/src/main/java/de/assecutor/votianlt/repository/TaskRepository.java +++ b/src/main/java/de/assecutor/votianlt/repository/TaskRepository.java @@ -3,6 +3,7 @@ package de.assecutor.votianlt.repository; import de.assecutor.votianlt.model.task.BaseTask; import org.bson.types.ObjectId; import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.data.mongodb.repository.Query; import java.util.List; @@ -13,6 +14,9 @@ public interface TaskRepository extends MongoRepository { List findByJobIdOrderByTaskOrderAsc(ObjectId jobId); + @Query("{'job_id': {'$in': ?0}}") + List findByJobIdIn(List jobIds); + List findByJobIdAndStationOrderOrderByTaskOrderAsc(ObjectId jobId, int stationOrder); /** diff --git a/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java b/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java index d4dec25..53ca97d 100644 --- a/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java +++ b/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java @@ -821,8 +821,8 @@ public class CustomerInvoiceService { html.append( "") .append(escapeHtml(name)).append(""); - html.append("").append(netAmount) - .append(" €"); + html.append("") + .append(netAmount).append(" €"); html.append(""); } } diff --git a/src/main/java/de/assecutor/votianlt/service/EmailService.java b/src/main/java/de/assecutor/votianlt/service/EmailService.java index 0f8c89b..432bdf0 100644 --- a/src/main/java/de/assecutor/votianlt/service/EmailService.java +++ b/src/main/java/de/assecutor/votianlt/service/EmailService.java @@ -3,7 +3,6 @@ package de.assecutor.votianlt.service; import de.assecutor.votianlt.model.Job; 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 lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,7 +21,6 @@ import java.util.Optional; public class EmailService { private final UserRepository userRepository; private final JobRepository jobRepository; - private final TaskRepository taskRepository; private final TaskAssignmentService taskAssignmentService; private final JavaMailSender mailSender; diff --git a/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java b/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java index 72fca38..ad2a62e 100644 --- a/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java +++ b/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java @@ -2,20 +2,24 @@ package de.assecutor.votianlt.service; import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.JobStatus; +import de.assecutor.votianlt.model.task.BaseTask; import de.assecutor.votianlt.repository.JobRepository; import de.assecutor.votianlt.repository.TaskRepository; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.bson.types.ObjectId; import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.Month; +import java.util.Comparator; import java.util.EnumMap; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; /** @@ -83,8 +87,7 @@ public class JobStatisticsService { */ public BigDecimal getTotalRevenue() { List allJobs = jobRepository.findAll(); - return allJobs.stream().map(Job::getPrice).filter(price -> price != null).reduce(BigDecimal.ZERO, - BigDecimal::add); + return sumRevenue(allJobs); } /** @@ -94,13 +97,7 @@ public class JobStatisticsService { Map revenueByCustomer = new HashMap<>(); List allJobs = jobRepository.findAll(); - for (Job job : allJobs) { - String customer = job.getCustomerSelection(); - if (customer != null && job.getPrice() != null) { - revenueByCustomer.merge(customer, job.getPrice(), BigDecimal::add); - } - } - + mergeRevenueByCustomer(revenueByCustomer, allJobs); return revenueByCustomer; } @@ -145,24 +142,7 @@ public class JobStatisticsService { if (customer == null || customer.isBlank()) { return List.of(); } - // Trim and escape regex special characters for MongoDB - String trimmedCustomer = customer.trim(); - String escapedCustomer = trimmedCustomer.replaceAll("([.^$*+?()\\[\\]{}|\\\\])", "\\\\$1"); - - // First try exact match (with optional whitespace) - String exactRegex = "^\\s*" + escapedCustomer + "\\s*$"; - List jobs = jobRepository.findByCustomerSelectionIgnoreCase(exactRegex); - log.debug("getJobsByCustomer('{}') - exact regex: '{}' - found {} jobs", customer, exactRegex, jobs.size()); - - // If no exact match, try partial match (customer name contains the search term) - if (jobs.isEmpty()) { - String containsRegex = ".*" + escapedCustomer + ".*"; - jobs = jobRepository.findByCustomerSelectionIgnoreCase(containsRegex); - log.debug("getJobsByCustomer('{}') - contains regex: '{}' - found {} jobs", customer, containsRegex, - jobs.size()); - } - - return jobs; + return filterJobsByCustomer(jobRepository.findAll(), customer); } /** @@ -218,6 +198,72 @@ public class JobStatisticsService { return jobRepository.findLatestJobs().stream().limit(limit).toList(); } + public Map getJobCountsByStatusForUser(String createdBy) { + return countJobsByStatus(getJobsCreatedByUser(createdBy)); + } + + public long getTotalJobCountForUser(String createdBy) { + return getJobsCreatedByUser(createdBy).size(); + } + + public double getCompletionRateForUser(String createdBy) { + List userJobs = getJobsCreatedByUser(createdBy); + return calculateCompletionRate(userJobs); + } + + public BigDecimal getTotalRevenueForUser(String createdBy) { + return sumRevenue(getJobsCreatedByUser(createdBy)); + } + + public BigDecimal getTotalRevenueForUserInRange(String createdBy, LocalDateTime start, LocalDateTime end) { + return sumRevenue(filterJobsByDateRange(getJobsCreatedByUser(createdBy), start, end)); + } + + public long getTotalJobCountForUserInRange(String createdBy, LocalDateTime start, LocalDateTime end) { + return filterJobsByDateRange(getJobsCreatedByUser(createdBy), start, end).size(); + } + + public List> getTopCustomersByRevenueForUser(String createdBy, int limit) { + return getRevenueByCustomerForUser(createdBy).entrySet().stream() + .sorted((a, b) -> b.getValue().compareTo(a.getValue())).limit(limit).toList(); + } + + public List> getTopCustomersByRevenueForUserInRange(String createdBy, + LocalDateTime start, LocalDateTime end, int limit) { + Map revenueByCustomer = new HashMap<>(); + mergeRevenueByCustomer(revenueByCustomer, filterJobsByDateRange(getJobsCreatedByUser(createdBy), start, end)); + return revenueByCustomer.entrySet().stream().sorted((a, b) -> b.getValue().compareTo(a.getValue())).limit(limit) + .toList(); + } + + public Map getRevenueByCustomerForUser(String createdBy) { + Map revenueByCustomer = new HashMap<>(); + mergeRevenueByCustomer(revenueByCustomer, getJobsCreatedByUser(createdBy)); + return revenueByCustomer; + } + + public Map getMonthlyJobCountsForUser(int year, String createdBy) { + return buildMonthlyJobCounts(filterJobsByYear(getJobsCreatedByUser(createdBy), year)); + } + + public List getJobsByCustomerForUser(String createdBy, String customer) { + return filterJobsByCustomer(getJobsCreatedByUser(createdBy), customer); + } + + public Map getTaskCompletionStatsForUser(String createdBy) { + return buildTaskCompletionStats(getJobsCreatedByUser(createdBy)); + } + + public List getJobsByStatusForUser(String createdBy, JobStatus status) { + return getJobsCreatedByUser(createdBy).stream().filter(job -> job.getStatus() == status).toList(); + } + + public List getLatestJobsForUser(String createdBy, int limit) { + return getJobsCreatedByUser(createdBy).stream() + .sorted(Comparator.comparing(Job::getCreatedAt, Comparator.nullsLast(Comparator.reverseOrder()))) + .limit(limit).toList(); + } + // ==================== Filtered Statistics Methods ==================== /** @@ -228,19 +274,21 @@ public class JobStatisticsService { .distinct().sorted().toList(); } + public List getAllCustomerNamesForUser(String createdBy) { + return getJobsCreatedByUser(createdBy).stream().map(Job::getCustomerSelection) + .filter(customer -> customer != null && !customer.isBlank()).distinct().sorted().toList(); + } + /** * Get job counts by status filtered by customer. */ public Map getJobCountsByStatusForCustomer(String customer) { List customerJobs = getJobsByCustomer(customer); - Map counts = new EnumMap<>(JobStatus.class); - for (JobStatus status : JobStatus.values()) { - counts.put(status, 0L); - } - for (Job job : customerJobs) { - counts.computeIfPresent(job.getStatus(), (k, v) -> v + 1L); - } - return counts; + return countJobsByStatus(customerJobs); + } + + public Map getJobCountsByStatusForCustomerForUser(String createdBy, String customer) { + return countJobsByStatus(getJobsByCustomerForUser(createdBy, customer)); } /** @@ -250,49 +298,51 @@ public class JobStatisticsService { return getJobsByCustomer(customer).size(); } + public long getTotalJobCountForCustomerForUser(String createdBy, String customer) { + return getJobsByCustomerForUser(createdBy, customer).size(); + } + + public long getTotalJobCountForCustomerForUserInRange(String createdBy, String customer, LocalDateTime start, + LocalDateTime end) { + return filterJobsByDateRange(getJobsByCustomerForUser(createdBy, customer), start, end).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); + return sumRevenue(getJobsByCustomer(customer)); + } + + public BigDecimal getTotalRevenueForCustomerForUser(String createdBy, String customer) { + return sumRevenue(getJobsByCustomerForUser(createdBy, customer)); + } + + public BigDecimal getTotalRevenueForCustomerForUserInRange(String createdBy, String customer, LocalDateTime start, + LocalDateTime end) { + return sumRevenue(filterJobsByDateRange(getJobsByCustomerForUser(createdBy, customer), start, end)); } /** * Get completion rate for a customer. */ public double getCompletionRateForCustomer(String customer) { - List customerJobs = getJobsByCustomer(customer); - if (customerJobs.isEmpty()) { - return 0.0; - } - long completed = customerJobs.stream().filter(j -> j.getStatus() == JobStatus.COMPLETED).count(); - return (double) completed / customerJobs.size() * 100.0; + return calculateCompletionRate(getJobsByCustomer(customer)); + } + + public double getCompletionRateForCustomerForUser(String createdBy, String customer) { + return calculateCompletionRate(getJobsByCustomerForUser(createdBy, customer)); } /** * Get monthly job counts for a customer in a specific year. */ public Map getMonthlyJobCountsForCustomer(int year, String customer) { - Map monthlyCounts = new LinkedHashMap<>(); - LocalDateTime yearStart = LocalDateTime.of(year, 1, 1, 0, 0); - LocalDateTime yearEnd = LocalDateTime.of(year, 12, 31, 23, 59, 59); + return buildMonthlyJobCounts(filterJobsByYear(getJobsByCustomer(customer), year)); + } - List customerJobs = getJobsByCustomer(customer).stream().filter(j -> j.getCreatedAt() != null - && !j.getCreatedAt().isBefore(yearStart) && !j.getCreatedAt().isAfter(yearEnd)).toList(); - - // Initialize all months with 0 - for (Month month : Month.values()) { - monthlyCounts.put(month, 0L); - } - - // Count jobs per month - for (Job job : customerJobs) { - Month month = job.getCreatedAt().getMonth(); - monthlyCounts.computeIfPresent(month, (k, v) -> v + 1L); - } - - return monthlyCounts; + public Map getMonthlyJobCountsForCustomerForUser(int year, String createdBy, String customer) { + return buildMonthlyJobCounts(filterJobsByYear(getJobsByCustomerForUser(createdBy, customer), year)); } /** @@ -306,6 +356,14 @@ public class JobStatisticsService { return customerJobs.stream().filter(j -> j.getStatus() == status).toList(); } + public List getJobsByCustomerAndStatusForUser(String createdBy, String customer, JobStatus status) { + List customerJobs = getJobsByCustomerForUser(createdBy, customer); + if (status == null) { + return customerJobs; + } + return customerJobs.stream().filter(job -> job.getStatus() == status).toList(); + } + /** * Find best matching customer name from query (fuzzy matching). */ @@ -316,6 +374,20 @@ public class JobStatisticsService { String lowerQuery = query.toLowerCase(); List allCustomers = getAllCustomerNames(); + return findMatchingCustomer(query, lowerQuery, allCustomers); + } + + public String findMatchingCustomerForUser(String createdBy, String query) { + if (query == null || query.isBlank()) { + return null; + } + + String lowerQuery = query.toLowerCase(); + List allCustomers = getAllCustomerNamesForUser(createdBy); + return findMatchingCustomer(query, lowerQuery, allCustomers); + } + + private String findMatchingCustomer(String query, String lowerQuery, List allCustomers) { log.debug("findMatchingCustomer - Query: '{}', Available customers: {}", query, allCustomers); // First: exact match (case insensitive) @@ -370,6 +442,113 @@ public class JobStatisticsService { return null; } + private List getJobsCreatedByUser(String createdBy) { + if (createdBy == null || createdBy.isBlank()) { + return List.of(); + } + return jobRepository.findByCreatedBy(createdBy); + } + + private Map countJobsByStatus(List jobs) { + Map counts = new EnumMap<>(JobStatus.class); + for (JobStatus status : JobStatus.values()) { + counts.put(status, 0L); + } + for (Job job : jobs) { + if (job.getStatus() != null) { + counts.computeIfPresent(job.getStatus(), (key, value) -> value + 1L); + } + } + return counts; + } + + private BigDecimal sumRevenue(List jobs) { + return jobs.stream().map(Job::getPrice).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); + } + + private void mergeRevenueByCustomer(Map revenueByCustomer, List jobs) { + for (Job job : jobs) { + String customer = job.getCustomerSelection(); + if (customer != null && job.getPrice() != null) { + revenueByCustomer.merge(customer, job.getPrice(), BigDecimal::add); + } + } + } + + private double calculateCompletionRate(List jobs) { + if (jobs.isEmpty()) { + return 0.0; + } + long completed = jobs.stream().filter(job -> job.getStatus() == JobStatus.COMPLETED).count(); + return (double) completed / jobs.size() * 100.0; + } + + private List filterJobsByYear(List jobs, int year) { + LocalDateTime yearStart = LocalDateTime.of(year, 1, 1, 0, 0); + LocalDateTime yearEnd = LocalDateTime.of(year, 12, 31, 23, 59, 59); + return jobs.stream().filter(job -> job.getCreatedAt() != null && !job.getCreatedAt().isBefore(yearStart) + && !job.getCreatedAt().isAfter(yearEnd)).toList(); + } + + private Map buildMonthlyJobCounts(List jobs) { + Map monthlyCounts = new LinkedHashMap<>(); + for (Month month : Month.values()) { + monthlyCounts.put(month, 0L); + } + for (Job job : jobs) { + if (job.getCreatedAt() != null) { + monthlyCounts.computeIfPresent(job.getCreatedAt().getMonth(), (key, value) -> value + 1L); + } + } + return monthlyCounts; + } + + private List filterJobsByDateRange(List jobs, LocalDateTime start, LocalDateTime end) { + return jobs.stream().filter(job -> job.getCreatedAt() != null && !job.getCreatedAt().isBefore(start) + && !job.getCreatedAt().isAfter(end)).toList(); + } + + private List filterJobsByCustomer(List jobs, String customer) { + if (customer == null || customer.isBlank()) { + return List.of(); + } + + String normalizedCustomer = customer.trim(); + List exactMatches = jobs.stream().filter(job -> job.getCustomerSelection() != null + && job.getCustomerSelection().trim().equalsIgnoreCase(normalizedCustomer)).toList(); + if (!exactMatches.isEmpty()) { + log.debug("filterJobsByCustomer('{}') - exact match found: {}", customer, exactMatches.size()); + return exactMatches; + } + + String lowerCustomer = normalizedCustomer.toLowerCase(); + List partialMatches = jobs.stream().filter(job -> job.getCustomerSelection() != null + && job.getCustomerSelection().toLowerCase().contains(lowerCustomer)).toList(); + log.debug("filterJobsByCustomer('{}') - partial match found: {}", customer, partialMatches.size()); + return partialMatches; + } + + private Map buildTaskCompletionStats(List jobs) { + Map stats = new HashMap<>(); + stats.put("completed", 0L); + stats.put("pending", 0L); + stats.put("total", 0L); + + List jobIds = jobs.stream().map(Job::getId).filter(Objects::nonNull).toList(); + if (jobIds.isEmpty()) { + return stats; + } + + List tasks = taskRepository.findByJobIdIn(jobIds); + long completed = tasks.stream().filter(BaseTask::isCompleted).count(); + long total = tasks.size(); + + stats.put("completed", completed); + stats.put("pending", total - completed); + stats.put("total", total); + return stats; + } + /** * Extract potential customer name from a query string. Looks for patterns like * "firma X", "kunde X", "für X", etc. diff --git a/src/main/java/de/assecutor/votianlt/service/TaskAssignmentService.java b/src/main/java/de/assecutor/votianlt/service/TaskAssignmentService.java index ed4fbee..dbd856c 100644 --- a/src/main/java/de/assecutor/votianlt/service/TaskAssignmentService.java +++ b/src/main/java/de/assecutor/votianlt/service/TaskAssignmentService.java @@ -88,7 +88,7 @@ public class TaskAssignmentService { } return tasks.stream().filter(Objects::nonNull) - .sorted(Comparator.comparingInt(task -> resolveStationOrder(task, stationOrderById)) + .sorted(Comparator. comparingInt(task -> resolveStationOrder(task, stationOrderById)) .thenComparingInt(task -> task.getTaskOrder() != null ? task.getTaskOrder() : 0)) .toList(); } diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 7862a92..4eec798 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -729,7 +729,7 @@ statistics.quick.jobcount.prompt=Wie viele Aufträge habe ich aktuell? statistics.quick.revenue=Umsatz statistics.quick.revenue.prompt=Wie hoch ist mein Umsatz diesen Monat? statistics.quick.trend=Trends -statistics.quick.trend.prompt=Zeige mir Trends in den letzten 3 Monaten +statistics.quick.trend.prompt=Zeige mir Trends in den letzten 3 Monaten als Balkendiagramm statistics.ai.label=KI-Antwort statistics.data.fetched=Daten wurden abgerufen statistics.loading=Berechne...