diff --git a/pom.xml b/pom.xml index e303a17..2274684 100644 --- a/pom.xml +++ b/pom.xml @@ -35,6 +35,14 @@ pom import + + + org.springframework.ai + spring-ai-bom + 1.0.0 + pom + import + @@ -147,6 +155,18 @@ 5.0.5 + + + org.springframework.ai + spring-ai-starter-model-openai + + + + + org.springframework.ai + spring-ai-starter-mcp-server-webmvc + + org.springframework.boot @@ -321,6 +341,14 @@ false + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + false + + diff --git a/src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java b/src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java new file mode 100644 index 0000000..1e01f60 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java @@ -0,0 +1,36 @@ +package de.assecutor.votianlt.ai.config; + +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration for LLM integration via LM Studio. + * LM Studio provides an OpenAI-compatible API. + */ +@Configuration +@Slf4j +public class LlmConfig { + + @Value("${spring.ai.openai.base-url:http://192.168.180.10:1234}") + private String baseUrl; + + @Value("${spring.ai.openai.chat.options.model:local-model}") + private String model; + + @PostConstruct + public void logConfig() { + log.info("LLM Configuration initialized:"); + log.info(" Base URL: {}", baseUrl); + log.info(" Model: {}", model); + } + + public String getBaseUrl() { + return baseUrl; + } + + public String getModel() { + return model; + } +} diff --git a/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java b/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java new file mode 100644 index 0000000..d2a611b --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java @@ -0,0 +1,383 @@ +package de.assecutor.votianlt.ai.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.assecutor.votianlt.model.JobStatus; +import de.assecutor.votianlt.service.JobStatisticsService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.stereotype.Service; + +import java.time.Month; +import java.time.Year; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Service for AI-assisted statistics analysis with chart visualization. + * Uses LM Studio via OpenAI-compatible API and local job statistics. + */ +@Service +@Slf4j +public class AiStatisticsService { + + private final ChatClient chatClient; + private final JobStatisticsService statisticsService; + private final ObjectMapper objectMapper; + + public AiStatisticsService(ChatModel chatModel, JobStatisticsService statisticsService) { + this.chatClient = ChatClient.builder(chatModel).build(); + this.statisticsService = statisticsService; + this.objectMapper = new ObjectMapper(); + log.info("AiStatisticsService initialized"); + } + + /** + * Response record containing text and optional chart data. + */ + public record StatisticsResponse( + String textResponse, + String chartType, + String chartData + ) {} + + /** + * Analyze a statistics query and return a response with optional visualization. + */ + public StatisticsResponse analyzeStatisticsQuery(String userQuery) { + log.info("Processing statistics query: {}", userQuery); + + // Gather current statistics + String statisticsContext = buildStatisticsContext(); + + // Determine query type and prepare chart data + QueryAnalysis analysis = analyzeQueryType(userQuery); + + // Build prompt for LLM + String prompt = buildPrompt(userQuery, statisticsContext, analysis); + + try { + // Get LLM response + String llmResponse = chatClient.prompt() + .user(prompt) + .call() + .content(); + + log.info("LLM response received"); + + // Build chart data based on query type + String chartType = analysis.chartType; + String chartData = analysis.chartData; + + return new StatisticsResponse(llmResponse, chartType, chartData); + + } catch (Exception e) { + log.error("Error calling LLM: {}", e.getMessage(), e); + // Fallback: Return statistics without LLM analysis + return new StatisticsResponse( + buildFallbackResponse(analysis), + analysis.chartType, + analysis.chartData + ); + } + } + + private record QueryAnalysis( + String queryType, + String chartType, + String chartData + ) {} + + private QueryAnalysis analyzeQueryType(String query) { + String lowerQuery = query.toLowerCase(); + + // Status-bezogene Anfragen + if (lowerQuery.contains("status") || lowerQuery.contains("offen") || + lowerQuery.contains("abgeschlossen") || lowerQuery.contains("zählen") || + lowerQuery.contains("anzahl") || lowerQuery.contains("wie viele")) { + return new QueryAnalysis("status", "doughnut", buildStatusChartData()); + } + + // Umsatz-bezogene Anfragen + if (lowerQuery.contains("umsatz") || lowerQuery.contains("revenue") || + lowerQuery.contains("kunde") || lowerQuery.contains("customer") || + lowerQuery.contains("einnahmen")) { + return new QueryAnalysis("revenue", "bar", buildRevenueChartData()); + } + + // Trend-bezogene Anfragen + if (lowerQuery.contains("trend") || lowerQuery.contains("monat") || + lowerQuery.contains("entwicklung") || lowerQuery.contains("jahr") || + lowerQuery.contains("verlauf")) { + return new QueryAnalysis("trend", "line", buildTrendChartData()); + } + + // Task-bezogene Anfragen + if (lowerQuery.contains("task") || lowerQuery.contains("aufgabe") || + lowerQuery.contains("erledigt")) { + return new QueryAnalysis("tasks", "doughnut", buildTaskChartData()); + } + + // Allgemeine Übersicht + return new QueryAnalysis("overview", "bar", buildOverviewChartData()); + } + + private String buildStatusChartData() { + Map statusCounts = statisticsService.getJobCountsByStatus(); + + List labels = new ArrayList<>(); + List data = new ArrayList<>(); + // Moderne Farbpalette mit satteren Farben + List colors = List.of( + "#06b6d4", // CREATED - cyan + "#f59e0b", // IN_PROGRESS - amber + "#3b82f6", // PICKUP_SCHEDULED - blau + "#8b5cf6", // PICKED_UP - violett + "#f97316", // IN_TRANSIT - orange + "#22c55e", // DELIVERED - grün + "#6366f1", // COMPLETED - indigo + "#ef4444" // CANCELLED - rot + ); + + for (JobStatus status : JobStatus.values()) { + Long count = statusCounts.getOrDefault(status, 0L); + if (count > 0) { + labels.add(status.getDisplayName()); + data.add(count); + } + } + + return buildChartJson(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())), "Aufträge"); + } + + private String buildRevenueChartData() { + var topCustomers = statisticsService.getTopCustomersByRevenue(10); + + 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()); + } + + // 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)"); + } + + private String buildTrendChartData() { + int currentYear = Year.now().getValue(); + Map monthlyData = statisticsService.getMonthlyJobCounts(currentYear); + + List labels = List.of("Jan", "Feb", "Mär", "Apr", "Mai", "Jun", + "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"); + List data = new ArrayList<>(); + + for (Month month : Month.values()) { + data.add(monthlyData.getOrDefault(month, 0L)); + } + + return String.format(""" + { + "labels": %s, + "datasets": [{ + "label": "Aufträge %d", + "data": %s, + "borderColor": "#6366f1", + "backgroundColor": "rgba(99, 102, 241, 0.15)", + "pointBackgroundColor": "#6366f1", + "pointBorderColor": "#fff", + "pointBorderWidth": 2, + "pointRadius": 5, + "pointHoverRadius": 7, + "tension": 0.4, + "fill": true + }] + } + """, toJsonArray(labels), currentYear, data); + } + + private String buildTaskChartData() { + Map taskStats = statisticsService.getTaskCompletionStats(); + + List labels = List.of("Erledigt", "Ausstehend"); + List data = List.of( + taskStats.getOrDefault("completed", 0L), + taskStats.getOrDefault("pending", 0L) + ); + List colors = List.of("#22c55e", "#f59e0b"); + + return buildChartJson(labels, data, colors, "Aufgaben"); + } + + private String buildOverviewChartData() { + Map statusCounts = statisticsService.getJobCountsByStatus(); + + long total = statisticsService.getTotalJobCount(); + long completed = statusCounts.getOrDefault(JobStatus.COMPLETED, 0L); + long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L); + long open = total - completed - statusCounts.getOrDefault(JobStatus.CANCELLED, 0L); + + List labels = List.of("Gesamt", "Abgeschlossen", "In Bearbeitung", "Offen"); + List data = List.of(total, completed, inProgress, open); + List colors = List.of("#3b82f6", "#22c55e", "#f59e0b", "#06b6d4"); + + return buildChartJson(labels, data, colors, "Aufträge"); + } + + private String buildChartJson(List labels, List data, List colors, String label) { + return String.format(""" + { + "labels": %s, + "datasets": [{ + "label": "%s", + "data": %s, + "backgroundColor": %s, + "borderWidth": 1 + }] + } + """, toJsonArray(labels), label, data, toJsonArray(colors)); + } + + private String buildChartJsonDouble(List labels, List data, List colors, String label) { + return String.format(""" + { + "labels": %s, + "datasets": [{ + "label": "%s", + "data": %s, + "backgroundColor": %s, + "borderWidth": 1 + }] + } + """, toJsonArray(labels), label, data, toJsonArray(colors)); + } + + private String toJsonArray(List list) { + try { + return objectMapper.writeValueAsString(list); + } catch (JsonProcessingException e) { + return "[]"; + } + } + + private String buildStatisticsContext() { + StringBuilder context = new StringBuilder(); + + // Job counts by status + var statusCounts = statisticsService.getJobCountsByStatus(); + context.append("**Aktuelle Auftragsstatistiken:**\n"); + statusCounts.forEach((status, count) -> + context.append(String.format("- %s: %d Aufträge\n", status.getDisplayName(), count))); + + // Totals + context.append(String.format("\n**Gesamtübersicht:**\n")); + context.append(String.format("- Gesamtanzahl Aufträge: %d\n", statisticsService.getTotalJobCount())); + context.append(String.format("- Abschlussrate: %.1f%%\n", statisticsService.getCompletionRate())); + context.append(String.format("- Gesamtumsatz: %.2f EUR\n", statisticsService.getTotalRevenue())); + + // Task statistics + var taskStats = statisticsService.getTaskCompletionStats(); + context.append(String.format("\n**Aufgaben:**\n")); + context.append(String.format("- Gesamt: %d\n", taskStats.get("total"))); + context.append(String.format("- Erledigt: %d\n", taskStats.get("completed"))); + context.append(String.format("- Ausstehend: %d\n", taskStats.get("pending"))); + + // Top customers + var topCustomers = statisticsService.getTopCustomersByRevenue(5); + if (!topCustomers.isEmpty()) { + context.append("\n**Top 5 Kunden nach Umsatz:**\n"); + for (var entry : topCustomers) { + context.append(String.format("- %s: %.2f EUR\n", + entry.getKey() != null ? entry.getKey() : "Unbekannt", + entry.getValue())); + } + } + + return context.toString(); + } + + private String buildPrompt(String userQuery, String statisticsContext, QueryAnalysis analysis) { + return String.format(""" + Du bist ein hilfreicher Statistik-Assistent für ein Logistikunternehmen. + Beantworte die Frage des Benutzers basierend auf den aktuellen Statistiken. + + %s + + **Benutzerfrage:** %s + + WICHTIGE FORMATIERUNGSREGELN: + - Verwende KEINE Tabellen (keine | oder --- Zeichen) + - Die Daten werden bereits als interaktives Diagramm visualisiert + - Fasse die wichtigsten Erkenntnisse in Fließtext oder kurzen Aufzählungen zusammen + - Nenne konkrete Zahlen im Text, aber liste nicht alle Werte tabellarisch auf + + Antworte auf Deutsch, präzise und freundlich. + Erkläre die Daten kurz und gib bei Bedarf Empfehlungen. + Halte die Antwort kompakt (max. 3-4 Sätze für einfache Fragen, mehr für komplexe). + """, statisticsContext, userQuery); + } + + private String buildFallbackResponse(QueryAnalysis analysis) { + return switch (analysis.queryType) { + case "status" -> { + var counts = statisticsService.getJobCountsByStatus(); + StringBuilder sb = new StringBuilder("**Auftragsübersicht nach Status:**\n\n"); + counts.forEach((status, count) -> { + if (count > 0) { + sb.append(String.format("- **%s:** %d Aufträge\n", status.getDisplayName(), count)); + } + }); + sb.append(String.format("\n**Gesamt:** %d Aufträge", statisticsService.getTotalJobCount())); + yield sb.toString(); + } + case "revenue" -> { + var topCustomers = statisticsService.getTopCustomersByRevenue(5); + StringBuilder sb = new StringBuilder("**Top Kunden nach Umsatz:**\n\n"); + int rank = 1; + for (var entry : topCustomers) { + sb.append(String.format("%d. **%s:** %.2f EUR\n", + rank++, + entry.getKey() != null ? entry.getKey() : "Unbekannt", + entry.getValue())); + } + sb.append(String.format("\n**Gesamtumsatz:** %.2f EUR", statisticsService.getTotalRevenue())); + yield sb.toString(); + } + case "trend" -> { + int year = Year.now().getValue(); + var monthly = statisticsService.getMonthlyJobCounts(year); + long total = monthly.values().stream().mapToLong(Long::longValue).sum(); + yield String.format("**Monatstrend %d:**\n\nInsgesamt wurden %d Aufträge erstellt. " + + "Die Verteilung ist im Diagramm ersichtlich.", year, total); + } + case "tasks" -> { + var taskStats = statisticsService.getTaskCompletionStats(); + 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, taskStats.getOrDefault("pending", 0L)); + } + default -> { + yield String.format("**Übersicht:**\n\n" + + "- **Aufträge gesamt:** %d\n" + + "- **Abschlussrate:** %.1f%%\n" + + "- **Gesamtumsatz:** %.2f EUR", + statisticsService.getTotalJobCount(), + statisticsService.getCompletionRate(), + statisticsService.getTotalRevenue()); + } + }; + } +} diff --git a/src/main/java/de/assecutor/votianlt/mcp/config/McpServerConfig.java b/src/main/java/de/assecutor/votianlt/mcp/config/McpServerConfig.java new file mode 100644 index 0000000..ae7d91e --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/mcp/config/McpServerConfig.java @@ -0,0 +1,37 @@ +package de.assecutor.votianlt.mcp.config; + +import de.assecutor.votianlt.mcp.tools.JobQueryTool; +import de.assecutor.votianlt.mcp.tools.JobStatisticsTool; +import de.assecutor.votianlt.mcp.tools.TaskCompletionTool; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.ToolCallbackProvider; +import org.springframework.ai.tool.method.MethodToolCallbackProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration for the MCP (Model Context Protocol) server. + * Registers all MCP tools for job statistics and queries. + */ +@Configuration +@Slf4j +public class McpServerConfig { + + @Bean + public ToolCallbackProvider jobStatisticsToolProvider(JobStatisticsTool jobStatisticsTool) { + log.info("Registering JobStatisticsTool for MCP server"); + return MethodToolCallbackProvider.builder().toolObjects(jobStatisticsTool).build(); + } + + @Bean + public ToolCallbackProvider jobQueryToolProvider(JobQueryTool jobQueryTool) { + log.info("Registering JobQueryTool for MCP server"); + return MethodToolCallbackProvider.builder().toolObjects(jobQueryTool).build(); + } + + @Bean + public ToolCallbackProvider taskCompletionToolProvider(TaskCompletionTool taskCompletionTool) { + log.info("Registering TaskCompletionTool for MCP server"); + return MethodToolCallbackProvider.builder().toolObjects(taskCompletionTool).build(); + } +} diff --git a/src/main/java/de/assecutor/votianlt/mcp/dto/CustomerRevenueResult.java b/src/main/java/de/assecutor/votianlt/mcp/dto/CustomerRevenueResult.java new file mode 100644 index 0000000..f952025 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/mcp/dto/CustomerRevenueResult.java @@ -0,0 +1,22 @@ +package de.assecutor.votianlt.mcp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * DTO for customer revenue results. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CustomerRevenueResult { + + private String customer; + private BigDecimal revenue; + private long jobCount; +} diff --git a/src/main/java/de/assecutor/votianlt/mcp/dto/JobQueryResult.java b/src/main/java/de/assecutor/votianlt/mcp/dto/JobQueryResult.java new file mode 100644 index 0000000..88adb4b --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/mcp/dto/JobQueryResult.java @@ -0,0 +1,34 @@ +package de.assecutor.votianlt.mcp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * DTO for job query results returned by MCP tools. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class JobQueryResult { + + private String jobId; + private String jobNumber; + private String status; + private String statusDisplayName; + private String customer; + private String pickupCity; + private String deliveryCity; + private LocalDate pickupDate; + private LocalDate deliveryDate; + private BigDecimal price; + private LocalDateTime createdAt; + private String assignedAppUser; + private boolean digitalProcessing; +} diff --git a/src/main/java/de/assecutor/votianlt/mcp/dto/JobStatisticsResult.java b/src/main/java/de/assecutor/votianlt/mcp/dto/JobStatisticsResult.java new file mode 100644 index 0000000..32ebcea --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/mcp/dto/JobStatisticsResult.java @@ -0,0 +1,29 @@ +package de.assecutor.votianlt.mcp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Map; + +/** + * DTO for job statistics results returned by MCP tools. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class JobStatisticsResult { + + private Map countsByStatus; + private long totalJobs; + private long completedJobs; + private long cancelledJobs; + private long inProgressJobs; + private double completionRate; + private BigDecimal totalRevenue; + private LocalDateTime queryTimestamp; +} diff --git a/src/main/java/de/assecutor/votianlt/mcp/dto/TaskCompletionResult.java b/src/main/java/de/assecutor/votianlt/mcp/dto/TaskCompletionResult.java new file mode 100644 index 0000000..8805d1d --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/mcp/dto/TaskCompletionResult.java @@ -0,0 +1,21 @@ +package de.assecutor.votianlt.mcp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * DTO for task completion statistics. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TaskCompletionResult { + + private long totalTasks; + private long completedTasks; + private long pendingTasks; + private double completionRate; +} diff --git a/src/main/java/de/assecutor/votianlt/mcp/tools/JobQueryTool.java b/src/main/java/de/assecutor/votianlt/mcp/tools/JobQueryTool.java new file mode 100644 index 0000000..75009cd --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/mcp/tools/JobQueryTool.java @@ -0,0 +1,128 @@ +package de.assecutor.votianlt.mcp.tools; + +import de.assecutor.votianlt.mcp.dto.JobQueryResult; +import de.assecutor.votianlt.model.Job; +import de.assecutor.votianlt.model.JobStatus; +import de.assecutor.votianlt.service.JobStatisticsService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * MCP Tool for querying jobs with various filters. + */ +@Component +@Slf4j +public class JobQueryTool { + + private final JobStatisticsService statisticsService; + + public JobQueryTool(JobStatisticsService statisticsService) { + this.statisticsService = statisticsService; + } + + @Tool(description = "Query jobs with optional filters. Returns a list of jobs matching the criteria.") + public List queryJobs( + @ToolParam(description = "Optional: Job status filter (CREATED, IN_PROGRESS, PICKUP_SCHEDULED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED)") + String status, + @ToolParam(description = "Optional: Customer name filter") String customer, + @ToolParam(description = "Optional: Pickup city filter") String pickupCity, + @ToolParam(description = "Optional: Delivery city filter") String deliveryCity, + @ToolParam(description = "Maximum results to return (default 50)") Integer limit) { + log.info("MCP Tool: Querying jobs with filters - status: {}, customer: {}, pickupCity: {}, deliveryCity: {}", + status, customer, pickupCity, deliveryCity); + + int actualLimit = limit != null ? limit : 50; + List jobs; + + if (status != null && !status.isBlank()) { + JobStatus jobStatus = JobStatus.valueOf(status.toUpperCase()); + jobs = statisticsService.getJobsByStatus(jobStatus); + } else if (customer != null && !customer.isBlank()) { + jobs = statisticsService.getJobsByCustomer(customer); + } else if (pickupCity != null && !pickupCity.isBlank()) { + jobs = statisticsService.getJobsByPickupCity(pickupCity); + } else if (deliveryCity != null && !deliveryCity.isBlank()) { + jobs = statisticsService.getJobsByDeliveryCity(deliveryCity); + } else { + jobs = statisticsService.getLatestJobs(actualLimit); + } + + return jobs.stream() + .limit(actualLimit) + .map(this::toQueryResult) + .toList(); + } + + @Tool(description = "Get detailed information about a specific job by its job number") + public JobQueryResult getJobByNumber( + @ToolParam(description = "The job number to look up (e.g., JOB-2024-0001)") String jobNumber) { + log.info("MCP Tool: Getting job by number: {}", jobNumber); + + Job job = statisticsService.getJobByNumber(jobNumber); + if (job == null) { + return null; + } + return toQueryResult(job); + } + + @Tool(description = "Get jobs assigned to a specific mobile app user") + public List getJobsByAppUser( + @ToolParam(description = "App user identifier") String appUser) { + log.info("MCP Tool: Getting jobs for app user: {}", appUser); + + return statisticsService.getJobsByAppUser(appUser).stream() + .map(this::toQueryResult) + .toList(); + } + + @Tool(description = "Get the most recent jobs, sorted by creation date descending") + public List getLatestJobs( + @ToolParam(description = "Number of jobs to return (default 10)") Integer limit) { + log.info("MCP Tool: Getting latest jobs, limit: {}", limit); + + int actualLimit = limit != null ? limit : 10; + return statisticsService.getLatestJobs(actualLimit).stream() + .map(this::toQueryResult) + .toList(); + } + + @Tool(description = "Get jobs created within a specific date range") + public List getJobsByDateRange( + @ToolParam(description = "Start date in ISO format (e.g., 2024-01-01T00:00:00)") String startDate, + @ToolParam(description = "End date in ISO format (e.g., 2024-12-31T23:59:59)") String endDate, + @ToolParam(description = "Maximum results to return (default 100)") Integer limit) { + log.info("MCP Tool: Getting jobs for date range: {} to {}", startDate, endDate); + + LocalDateTime start = LocalDateTime.parse(startDate); + LocalDateTime end = LocalDateTime.parse(endDate); + int actualLimit = limit != null ? limit : 100; + + return statisticsService.getJobsByDateRange(start, end).stream() + .limit(actualLimit) + .map(this::toQueryResult) + .toList(); + } + + private JobQueryResult toQueryResult(Job job) { + return JobQueryResult.builder() + .jobId(job.getIdAsString()) + .jobNumber(job.getJobNumber()) + .status(job.getStatus() != null ? job.getStatus().name() : null) + .statusDisplayName(job.getStatus() != null ? job.getStatus().getDisplayName() : null) + .customer(job.getCustomerSelection()) + .pickupCity(job.getPickupCity()) + .deliveryCity(job.getDeliveryCity()) + .pickupDate(job.getPickupDate()) + .deliveryDate(job.getDeliveryDate()) + .price(job.getPrice()) + .createdAt(job.getCreatedAt()) + .assignedAppUser(job.getAppUser()) + .digitalProcessing(job.isDigitalProcessing()) + .build(); + } +} diff --git a/src/main/java/de/assecutor/votianlt/mcp/tools/JobStatisticsTool.java b/src/main/java/de/assecutor/votianlt/mcp/tools/JobStatisticsTool.java new file mode 100644 index 0000000..700cbd8 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/mcp/tools/JobStatisticsTool.java @@ -0,0 +1,128 @@ +package de.assecutor.votianlt.mcp.tools; + +import de.assecutor.votianlt.mcp.dto.CustomerRevenueResult; +import de.assecutor.votianlt.mcp.dto.JobStatisticsResult; +import de.assecutor.votianlt.model.JobStatus; +import de.assecutor.votianlt.service.JobStatisticsService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.Month; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * MCP Tool for job statistics queries. + * Provides various statistics and aggregations about jobs. + */ +@Component +@Slf4j +public class JobStatisticsTool { + + private final JobStatisticsService statisticsService; + + public JobStatisticsTool(JobStatisticsService statisticsService) { + this.statisticsService = statisticsService; + } + + @Tool(description = "Get comprehensive job statistics including counts by status, completion rates, and revenue metrics") + public JobStatisticsResult getJobStatistics() { + log.info("MCP Tool: Getting job statistics"); + + Map countsByStatus = statisticsService.getJobCountsByStatus(); + Map statusCounts = countsByStatus.entrySet().stream() + .collect(Collectors.toMap(e -> e.getKey().name(), Map.Entry::getValue)); + + long completed = countsByStatus.getOrDefault(JobStatus.COMPLETED, 0L); + long cancelled = countsByStatus.getOrDefault(JobStatus.CANCELLED, 0L); + long inProgress = countsByStatus.getOrDefault(JobStatus.IN_PROGRESS, 0L); + + return JobStatisticsResult.builder() + .countsByStatus(statusCounts) + .totalJobs(statisticsService.getTotalJobCount()) + .completedJobs(completed) + .cancelledJobs(cancelled) + .inProgressJobs(inProgress) + .completionRate(statisticsService.getCompletionRate()) + .totalRevenue(statisticsService.getTotalRevenue()) + .queryTimestamp(LocalDateTime.now()) + .build(); + } + + @Tool(description = "Get job counts grouped by status (CREATED, IN_PROGRESS, PICKUP_SCHEDULED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED)") + public Map getJobCountsByStatus() { + log.info("MCP Tool: Getting job counts by status"); + + Map counts = statisticsService.getJobCountsByStatus(); + return counts.entrySet().stream() + .collect(Collectors.toMap( + e -> e.getKey().name() + " (" + e.getKey().getDisplayName() + ")", + Map.Entry::getValue)); + } + + @Tool(description = "Get the completion rate as a percentage (completed jobs / total jobs * 100)") + public String getCompletionRate() { + log.info("MCP Tool: Getting completion rate"); + + double rate = statisticsService.getCompletionRate(); + return String.format("%.2f%%", rate); + } + + @Tool(description = "Get revenue statistics grouped by customer, sorted by revenue descending") + public List getRevenueByCustomer( + @ToolParam(description = "Maximum number of customers to return (default 10)") Integer limit) { + log.info("MCP Tool: Getting revenue by customer, limit: {}", limit); + + int actualLimit = limit != null ? limit : 10; + Map revenueMap = statisticsService.getRevenueByCustomer(); + List allJobs = statisticsService.getJobsByStatus(null); + + return statisticsService.getTopCustomersByRevenue(actualLimit).stream() + .map(entry -> { + String customer = entry.getKey(); + long jobCount = statisticsService.getJobsByCustomer(customer).size(); + return CustomerRevenueResult.builder() + .customer(customer) + .revenue(entry.getValue()) + .jobCount(jobCount) + .build(); + }) + .toList(); + } + + @Tool(description = "Get monthly job trend data for a specific year showing job counts per month") + public Map getMonthlyJobTrend( + @ToolParam(description = "Year for the trend data (e.g., 2024)") int year) { + log.info("MCP Tool: Getting monthly job trend for year: {}", year); + + Map monthlyData = statisticsService.getMonthlyJobCounts(year); + return monthlyData.entrySet().stream() + .collect(Collectors.toMap( + e -> e.getKey().toString(), + Map.Entry::getValue)); + } + + @Tool(description = "Get total revenue from all jobs") + public String getTotalRevenue() { + log.info("MCP Tool: Getting total revenue"); + + BigDecimal revenue = statisticsService.getTotalRevenue(); + return String.format("%.2f EUR", revenue); + } + + @Tool(description = "Get job count for a specific date range") + public long getJobCountByDateRange( + @ToolParam(description = "Start date in ISO format (e.g., 2024-01-01T00:00:00)") String startDate, + @ToolParam(description = "End date in ISO format (e.g., 2024-12-31T23:59:59)") String endDate) { + log.info("MCP Tool: Getting job count for date range: {} to {}", startDate, endDate); + + LocalDateTime start = LocalDateTime.parse(startDate); + LocalDateTime end = LocalDateTime.parse(endDate); + return statisticsService.getJobCountByDateRange(start, end); + } +} diff --git a/src/main/java/de/assecutor/votianlt/mcp/tools/TaskCompletionTool.java b/src/main/java/de/assecutor/votianlt/mcp/tools/TaskCompletionTool.java new file mode 100644 index 0000000..65cad94 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/mcp/tools/TaskCompletionTool.java @@ -0,0 +1,58 @@ +package de.assecutor.votianlt.mcp.tools; + +import de.assecutor.votianlt.mcp.dto.TaskCompletionResult; +import de.assecutor.votianlt.service.JobStatisticsService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * MCP Tool for task completion statistics and data. + */ +@Component +@Slf4j +public class TaskCompletionTool { + + private final JobStatisticsService statisticsService; + + public TaskCompletionTool(JobStatisticsService statisticsService) { + this.statisticsService = statisticsService; + } + + @Tool(description = "Get overall task completion statistics including total, completed, pending tasks and completion rate") + public TaskCompletionResult getTaskCompletionStats() { + log.info("MCP Tool: Getting task completion statistics"); + + Map stats = statisticsService.getTaskCompletionStats(); + long total = stats.getOrDefault("total", 0L); + long completed = stats.getOrDefault("completed", 0L); + long pending = stats.getOrDefault("pending", 0L); + + double completionRate = total > 0 ? (double) completed / total * 100.0 : 0.0; + + return TaskCompletionResult.builder() + .totalTasks(total) + .completedTasks(completed) + .pendingTasks(pending) + .completionRate(completionRate) + .build(); + } + + @Tool(description = "Get a summary of task completion as a formatted string") + public String getTaskCompletionSummary() { + log.info("MCP Tool: Getting task completion summary"); + + Map stats = statisticsService.getTaskCompletionStats(); + long total = stats.getOrDefault("total", 0L); + long completed = stats.getOrDefault("completed", 0L); + long pending = stats.getOrDefault("pending", 0L); + + double completionRate = total > 0 ? (double) completed / total * 100.0 : 0.0; + + return String.format( + "Task Statistics: %d total tasks, %d completed (%.1f%%), %d pending", + total, completed, completionRate, pending); + } +} 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 20f926f..9254780 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/StatisticsView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/StatisticsView.java @@ -1,303 +1,596 @@ package de.assecutor.votianlt.pages.view; -import com.vaadin.flow.component.Html; +import com.vaadin.flow.component.AttachEvent; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; import com.vaadin.flow.component.dependency.JavaScript; import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.H2; -import com.vaadin.flow.component.html.H3; +import com.vaadin.flow.component.html.Paragraph; import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.component.icon.VaadinIcon; import com.vaadin.flow.component.orderedlayout.FlexComponent; import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.Scroller; import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; +import de.assecutor.votianlt.ai.service.AiStatisticsService; import jakarta.annotation.security.RolesAllowed; +import lombok.extern.slf4j.Slf4j; -@PageTitle("Statistiken") +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.UUID; + +@PageTitle("KI-Statistiken") @Route(value = "statistics", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) @RolesAllowed({ "USER", "ADMIN" }) @JavaScript("https://cdn.jsdelivr.net/npm/chart.js") +@Slf4j public class StatisticsView extends VerticalLayout { - public StatisticsView() { + private final AiStatisticsService aiStatisticsService; + private final VerticalLayout chatContainer; + private final TextField promptField; + private final DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("HH:mm"); + + public StatisticsView(AiStatisticsService aiStatisticsService) { + this.aiStatisticsService = aiStatisticsService; + + // Prompt Field initialisieren + this.promptField = new TextField(); + this.promptField.setPlaceholder("Stelle eine Frage zu deinen Statistiken..."); + this.promptField.setWidthFull(); + this.promptField.setClearButtonVisible(true); + this.promptField.addKeyPressListener(Key.ENTER, e -> sendPrompt()); + setSizeFull(); - setPadding(true); - setSpacing(true); + setPadding(false); + setSpacing(false); - H2 title = new H2("Statistiken"); - add(title); + // Header + HorizontalLayout header = createHeader(); + add(header); - // KPI Cards - HorizontalLayout kpiLayout = createKpiCards(); - add(kpiLayout); + // Chat Container mit Scroll + chatContainer = new VerticalLayout(); + chatContainer.setWidthFull(); + chatContainer.setPadding(true); + chatContainer.setSpacing(true); + chatContainer.getStyle().set("padding-bottom", "20px"); - // Charts Layout - HorizontalLayout chartsLayout = new HorizontalLayout(); - chartsLayout.setWidthFull(); - chartsLayout.setHeight("400px"); - chartsLayout.setSpacing(true); + Scroller scroller = new Scroller(chatContainer); + scroller.setSizeFull(); + scroller.setScrollDirection(Scroller.ScrollDirection.VERTICAL); + scroller.getStyle().set("background", "var(--lumo-contrast-5pct)"); - // Aufträge pro Monat (Liniendiagramm) - Div monthlyOrdersChart = createMonthlyOrdersChart(); - monthlyOrdersChart.setWidth("50%"); - monthlyOrdersChart.setHeight("100%"); - chartsLayout.add(monthlyOrdersChart); + // Willkommensnachricht + addSystemMessage("Willkommen bei der KI-Statistik-Analyse! Stelle mir Fragen zu deinen Aufträgen, " + + "z.B. \"Wie viele Aufträge sind offen?\" oder \"Zeige mir den Umsatz pro Kunde.\""); - // Aufträge nach Status (Kreisdiagramm) - Div statusChart = createStatusPieChart(); - statusChart.setWidth("50%"); - statusChart.setHeight("100%"); - chartsLayout.add(statusChart); + add(scroller); + setFlexGrow(1, scroller); - add(chartsLayout); - - // Umsatz nach Kunden (Balkendiagramm) - VerticalLayout revenueContainer = new VerticalLayout(); - revenueContainer.setWidthFull(); - revenueContainer.setHeight("400px"); - revenueContainer.setPadding(false); - - Div revenueChart = createRevenueByCustomerChart(); - revenueChart.setSizeFull(); - revenueContainer.add(revenueChart); - - add(revenueContainer); + // Input Area + HorizontalLayout inputArea = createInputArea(); + add(inputArea); } - private HorizontalLayout createKpiCards() { - HorizontalLayout kpiLayout = new HorizontalLayout(); - kpiLayout.setWidthFull(); - kpiLayout.setSpacing(true); - kpiLayout.setJustifyContentMode(FlexComponent.JustifyContentMode.EVENLY); + private HorizontalLayout createHeader() { + HorizontalLayout header = new HorizontalLayout(); + header.setWidthFull(); + header.setPadding(true); + header.setAlignItems(FlexComponent.Alignment.CENTER); + header.getStyle() + .set("background", "var(--lumo-base-color)") + .set("border-bottom", "1px solid var(--lumo-contrast-10pct)"); - // Gesamtaufträge - Div totalOrdersCard = createKpiCard("Gesamtaufträge", "247", "success"); + Icon aiIcon = VaadinIcon.MAGIC.create(); + aiIcon.getStyle().set("color", "var(--lumo-primary-color)"); - // Offene Aufträge - Div openOrdersCard = createKpiCard("Offene Aufträge", "34", "warning"); + H2 title = new H2("KI-Statistik-Assistent"); + title.getStyle().set("margin", "0").set("font-size", "var(--lumo-font-size-xl)"); - // Umsatz diesen Monat - Div revenueCard = createKpiCard("Umsatz (Monat)", "€ 24.500", "primary"); + Span subtitle = new Span("Frage mich zu Aufträgen, Umsätzen und Statistiken"); + subtitle.getStyle() + .set("color", "var(--lumo-secondary-text-color)") + .set("font-size", "var(--lumo-font-size-s)") + .set("margin-left", "var(--lumo-space-m)"); - // Neue Kunden - Div newCustomersCard = createKpiCard("Neue Kunden", "12", "success"); - - kpiLayout.add(totalOrdersCard, openOrdersCard, revenueCard, newCustomersCard); - return kpiLayout; + header.add(aiIcon, title, subtitle); + return header; } - private Div createKpiCard(String title, String value, String theme) { - Div card = new Div(); - card.addClassName("kpi-card"); - card.getStyle().set("background", "var(--lumo-base-color)") + private HorizontalLayout createInputArea() { + HorizontalLayout inputArea = new HorizontalLayout(); + inputArea.setWidthFull(); + inputArea.setPadding(true); + inputArea.setSpacing(true); + inputArea.setAlignItems(FlexComponent.Alignment.CENTER); + inputArea.getStyle() + .set("background", "var(--lumo-base-color)") + .set("border-top", "1px solid var(--lumo-contrast-10pct)"); + + Button sendButton = new Button(VaadinIcon.PAPERPLANE.create()); + sendButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + sendButton.addClickListener(e -> sendPrompt()); + sendButton.getStyle().set("min-width", "50px"); + + // Quick Action Buttons + Button jobCountBtn = createQuickActionButton("Aufträge zählen", "Wie viele Aufträge gibt es insgesamt und nach Status?"); + Button revenueBtn = createQuickActionButton("Umsatz", "Zeige mir den Umsatz pro Kunde."); + Button trendBtn = createQuickActionButton("Monatstrend", "Zeige mir den Monatstrend der Aufträge für dieses Jahr."); + + HorizontalLayout quickActions = new HorizontalLayout(jobCountBtn, revenueBtn, trendBtn); + quickActions.setSpacing(true); + + VerticalLayout inputWrapper = new VerticalLayout(); + inputWrapper.setPadding(false); + inputWrapper.setSpacing(true); + inputWrapper.setWidthFull(); + + HorizontalLayout inputRow = new HorizontalLayout(promptField, sendButton); + inputRow.setWidthFull(); + inputRow.setFlexGrow(1, promptField); + + inputWrapper.add(quickActions, inputRow); + + inputArea.add(inputWrapper); + inputArea.setFlexGrow(1, inputWrapper); + + return inputArea; + } + + private Button createQuickActionButton(String text, String prompt) { + Button button = new Button(text); + button.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_TERTIARY); + button.addClickListener(e -> { + promptField.setValue(prompt); + sendPrompt(); + }); + return button; + } + + private void sendPrompt() { + String prompt = promptField.getValue(); + if (prompt == null || prompt.isBlank()) { + return; + } + + // User Message anzeigen + addUserMessage(prompt); + promptField.clear(); + + // Loading Indicator + Div loadingMessage = createLoadingMessage(); + chatContainer.add(loadingMessage); + scrollToBottom(); + + // Async Anfrage an KI + UI ui = UI.getCurrent(); + new Thread(() -> { + try { + AiStatisticsService.StatisticsResponse response = aiStatisticsService.analyzeStatisticsQuery(prompt); + + ui.access(() -> { + chatContainer.remove(loadingMessage); + addAiResponse(response); + scrollToBottom(); + }); + } catch (Exception e) { + log.error("Error processing AI request", e); + ui.access(() -> { + chatContainer.remove(loadingMessage); + addErrorMessage("Entschuldigung, es gab einen Fehler bei der Verarbeitung: " + e.getMessage()); + scrollToBottom(); + }); + } + }).start(); + } + + private void addUserMessage(String message) { + Div messageDiv = new Div(); + messageDiv.addClassName("chat-message"); + messageDiv.addClassName("user-message"); + messageDiv.getStyle() + .set("display", "flex") + .set("justify-content", "flex-end") + .set("margin-bottom", "var(--lumo-space-m)"); + + Div bubble = new Div(); + bubble.getStyle() + .set("background", "var(--lumo-primary-color)") + .set("color", "var(--lumo-primary-contrast-color)") + .set("padding", "var(--lumo-space-s) var(--lumo-space-m)") + .set("border-radius", "var(--lumo-border-radius-l)") + .set("max-width", "70%") + .set("word-wrap", "break-word"); + + Paragraph text = new Paragraph(message); + text.getStyle().set("margin", "0"); + + Span time = new Span(LocalDateTime.now().format(timeFormatter)); + time.getStyle() + .set("font-size", "var(--lumo-font-size-xs)") + .set("opacity", "0.7") + .set("display", "block") + .set("text-align", "right") + .set("margin-top", "var(--lumo-space-xs)"); + + bubble.add(text, time); + messageDiv.add(bubble); + chatContainer.add(messageDiv); + } + + private void addSystemMessage(String message) { + Div messageDiv = new Div(); + messageDiv.getStyle() + .set("text-align", "center") + .set("margin", "var(--lumo-space-m) 0"); + + Span text = new Span(message); + text.getStyle() + .set("background", "var(--lumo-contrast-10pct)") + .set("padding", "var(--lumo-space-s) var(--lumo-space-m)") + .set("border-radius", "var(--lumo-border-radius-m)") + .set("font-size", "var(--lumo-font-size-s)") + .set("color", "var(--lumo-secondary-text-color)"); + + messageDiv.add(text); + chatContainer.add(messageDiv); + } + + private void addAiResponse(AiStatisticsService.StatisticsResponse response) { + Div messageDiv = new Div(); + messageDiv.addClassName("chat-message"); + messageDiv.addClassName("ai-message"); + messageDiv.getStyle() + .set("display", "flex") + .set("justify-content", "flex-start") + .set("margin-bottom", "var(--lumo-space-m)"); + + Div bubble = new Div(); + bubble.getStyle() + .set("background", "var(--lumo-base-color)") .set("border", "1px solid var(--lumo-contrast-10pct)") - .set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)") - .set("text-align", "center").set("box-shadow", "var(--lumo-box-shadow-xs)").set("min-width", "150px"); + .set("padding", "var(--lumo-space-m)") + .set("border-radius", "var(--lumo-border-radius-l)") + .set("max-width", "85%") + .set("box-shadow", "var(--lumo-box-shadow-xs)"); - H3 titleElement = new H3(title); - titleElement.getStyle().set("margin", "0 0 var(--lumo-space-s) 0").set("font-size", "var(--lumo-font-size-s)"); + // AI Icon + HorizontalLayout header = new HorizontalLayout(); + header.setAlignItems(FlexComponent.Alignment.CENTER); + header.setSpacing(true); - Span valueElement = new Span(value); - valueElement.getStyle().set("font-size", "var(--lumo-font-size-xl)").set("font-weight", "bold").set("color", - getThemeColor(theme)); + Icon aiIcon = VaadinIcon.MAGIC.create(); + aiIcon.setSize("16px"); + aiIcon.getStyle().set("color", "var(--lumo-primary-color)"); - card.add(titleElement, valueElement); - return card; + Span aiLabel = new Span("KI-Assistent"); + aiLabel.getStyle() + .set("font-weight", "bold") + .set("font-size", "var(--lumo-font-size-s)"); + + header.add(aiIcon, aiLabel); + bubble.add(header); + + // Text Response + Div textDiv = new Div(); + textDiv.getStyle().set("margin-top", "var(--lumo-space-s)"); + textDiv.getElement().setProperty("innerHTML", formatMarkdown(response.textResponse())); + bubble.add(textDiv); + + // Chart wenn vorhanden + if (response.chartData() != null && !response.chartData().isEmpty()) { + Div chartContainer = createChart(response.chartType(), response.chartData()); + if (chartContainer != null) { + chartContainer.getStyle() + .set("margin-top", "var(--lumo-space-m)") + .set("height", "300px"); + bubble.add(chartContainer); + } + } + + // Timestamp + Span time = new Span(LocalDateTime.now().format(timeFormatter)); + time.getStyle() + .set("font-size", "var(--lumo-font-size-xs)") + .set("color", "var(--lumo-secondary-text-color)") + .set("display", "block") + .set("margin-top", "var(--lumo-space-s)"); + bubble.add(time); + + messageDiv.add(bubble); + chatContainer.add(messageDiv); } - private String getThemeColor(String theme) { - return switch (theme) { - case "success" -> "var(--lumo-success-color)"; - case "warning" -> "var(--lumo-warning-color)"; - case "error" -> "var(--lumo-error-color)"; - default -> "var(--lumo-primary-color)"; + private void addErrorMessage(String message) { + Div messageDiv = new Div(); + messageDiv.getStyle() + .set("display", "flex") + .set("justify-content", "flex-start") + .set("margin-bottom", "var(--lumo-space-m)"); + + Div bubble = new Div(); + bubble.getStyle() + .set("background", "var(--lumo-error-color-10pct)") + .set("border", "1px solid var(--lumo-error-color)") + .set("padding", "var(--lumo-space-s) var(--lumo-space-m)") + .set("border-radius", "var(--lumo-border-radius-l)") + .set("max-width", "70%"); + + Icon errorIcon = VaadinIcon.EXCLAMATION_CIRCLE.create(); + errorIcon.setSize("16px"); + errorIcon.getStyle().set("color", "var(--lumo-error-color)"); + + Span text = new Span(message); + text.getStyle().set("color", "var(--lumo-error-text-color)"); + + HorizontalLayout content = new HorizontalLayout(errorIcon, text); + content.setAlignItems(FlexComponent.Alignment.CENTER); + content.setSpacing(true); + + bubble.add(content); + messageDiv.add(bubble); + chatContainer.add(messageDiv); + } + + private Div createLoadingMessage() { + Div messageDiv = new Div(); + messageDiv.getStyle() + .set("display", "flex") + .set("justify-content", "flex-start") + .set("margin-bottom", "var(--lumo-space-m)"); + + Div bubble = new Div(); + bubble.getStyle() + .set("background", "var(--lumo-base-color)") + .set("border", "1px solid var(--lumo-contrast-10pct)") + .set("padding", "var(--lumo-space-s) var(--lumo-space-m)") + .set("border-radius", "var(--lumo-border-radius-l)"); + + Span dots = new Span("Analysiere..."); + dots.getStyle() + .set("color", "var(--lumo-secondary-text-color)") + .set("font-style", "italic"); + + bubble.add(dots); + messageDiv.add(bubble); + return messageDiv; + } + + private Div createChart(String chartType, String chartData) { + if (chartType == null || chartData == null) { + return null; + } + + String canvasId = "chart-" + UUID.randomUUID().toString().substring(0, 8); + Div chartContainer = new Div(); + chartContainer.addClassName("chart-wrapper"); + chartContainer.getStyle() + .set("background", "var(--lumo-contrast-5pct)") + .set("border-radius", "var(--lumo-border-radius-m)") + .set("padding", "var(--lumo-space-s)"); + + chartContainer.getElement().setProperty("innerHTML", + ""); + + // Moderne Chart.js Konfiguration mit Animationen und Styling + String chartOptions = getChartOptions(chartType); + + // JavaScript direkt mit eingebetteter Konfiguration ausführen + String script = String.format(""" + (function() { + function initChart() { + if (typeof Chart === 'undefined') { + console.log('Chart.js not loaded yet, retrying...'); + setTimeout(initChart, 100); + return; + } + const canvas = document.getElementById('%s'); + if (!canvas) { + console.log('Canvas not found, retrying...'); + setTimeout(initChart, 50); + return; + } + // Vorheriges Chart zerstören falls vorhanden + if (canvas.chartInstance) { + canvas.chartInstance.destroy(); + } + const ctx = canvas.getContext('2d'); + try { + canvas.chartInstance = new Chart(ctx, { + type: '%s', + data: %s, + options: %s + }); + console.log('Chart created successfully'); + } catch (e) { + console.error('Chart creation error:', e); + } + } + initChart(); + })(); + """, canvasId, chartType, chartData, chartOptions); + + chartContainer.getElement().executeJs(script); + + return chartContainer; + } + + private String getChartOptions(String chartType) { + // Gradient und moderne Farben für verschiedene Chart-Typen + return switch (chartType) { + case "line" -> """ + { + responsive: true, + maintainAspectRatio: false, + interaction: { + intersect: false, + mode: 'index' + }, + plugins: { + legend: { + position: 'bottom', + labels: { + usePointStyle: true, + padding: 20 + } + }, + tooltip: { + backgroundColor: 'rgba(0,0,0,0.8)', + titleFont: { size: 14, weight: 'bold' }, + bodyFont: { size: 13 }, + padding: 12, + cornerRadius: 8 + } + }, + scales: { + y: { + beginAtZero: true, + grid: { + color: 'rgba(0,0,0,0.05)' + } + }, + x: { + grid: { + display: false + } + } + }, + elements: { + line: { + tension: 0.4, + borderWidth: 3 + }, + point: { + radius: 4, + hoverRadius: 6, + hitRadius: 10 + } + }, + animation: { + duration: 1000, + easing: 'easeOutQuart' + } + } + """; + case "bar" -> """ + { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom', + labels: { + usePointStyle: true, + padding: 20 + } + }, + tooltip: { + backgroundColor: 'rgba(0,0,0,0.8)', + titleFont: { size: 14, weight: 'bold' }, + bodyFont: { size: 13 }, + padding: 12, + cornerRadius: 8 + } + }, + scales: { + y: { + beginAtZero: true, + grid: { + color: 'rgba(0,0,0,0.05)' + } + }, + x: { + grid: { + display: false + } + } + }, + elements: { + bar: { + borderRadius: 6, + borderSkipped: false + } + }, + animation: { + duration: 800, + easing: 'easeOutQuart' + } + } + """; + case "doughnut", "pie" -> """ + { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'right', + labels: { + usePointStyle: true, + padding: 15, + font: { size: 12 } + } + }, + tooltip: { + backgroundColor: 'rgba(0,0,0,0.8)', + titleFont: { size: 14, weight: 'bold' }, + bodyFont: { size: 13 }, + padding: 12, + cornerRadius: 8 + } + }, + cutout: '60%', + animation: { + animateRotate: true, + animateScale: true, + duration: 1000, + easing: 'easeOutQuart' + } + } + """; + default -> """ + { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom' + } + }, + animation: { + duration: 800 + } + } + """; }; } - private Div createMonthlyOrdersChart() { - Div chartContainer = new Div(); - chartContainer.setId("monthlyOrdersChart"); - - String canvasHtml = ""; - Html canvas = new Html(canvasHtml); - chartContainer.add(canvas); - - String script = """ - - """; - - Html scriptElement = new Html(script); - chartContainer.add(scriptElement); - - return chartContainer; + private String formatMarkdown(String text) { + if (text == null) return ""; + // Einfache Markdown-Formatierung + return text + .replace("\n", "
") + .replaceAll("\\*\\*(.+?)\\*\\*", "$1") + .replaceAll("\\*(.+?)\\*", "$1") + .replaceAll("`(.+?)`", "$1"); } - private Div createStatusPieChart() { - Div chartContainer = new Div(); - chartContainer.setId("statusPieChart"); - - String canvasHtml = ""; - Html canvas = new Html(canvasHtml); - chartContainer.add(canvas); - - String script = """ - - """; - - Html scriptElement = new Html(script); - chartContainer.add(scriptElement); - - return chartContainer; + private void scrollToBottom() { + chatContainer.getElement().executeJs( + "this.parentElement.scrollTop = this.parentElement.scrollHeight"); } - private Div createRevenueByCustomerChart() { - Div chartContainer = new Div(); - chartContainer.setId("revenueByCustomerChart"); - - String canvasHtml = ""; - Html canvas = new Html(canvasHtml); - chartContainer.add(canvas); - - String script = """ - - """; - - Html scriptElement = new Html(script); - chartContainer.add(scriptElement); - - return chartContainer; + @Override + protected void onAttach(AttachEvent attachEvent) { + super.onAttach(attachEvent); + scrollToBottom(); } } diff --git a/src/main/java/de/assecutor/votianlt/security/SecurityConfig.java b/src/main/java/de/assecutor/votianlt/security/SecurityConfig.java index f2b29d2..43fb777 100644 --- a/src/main/java/de/assecutor/votianlt/security/SecurityConfig.java +++ b/src/main/java/de/assecutor/votianlt/security/SecurityConfig.java @@ -26,7 +26,8 @@ public class SecurityConfig extends VaadinWebSecurity { new AntPathRequestMatcher("/sw.js"), new AntPathRequestMatcher("/offline.html"), new AntPathRequestMatcher("/frontend/**"), new AntPathRequestMatcher("/webjars/**"), new AntPathRequestMatcher("/h2-console/**"), - new AntPathRequestMatcher("/frontend-es5/**", "/frontend-es6/**")) + new AntPathRequestMatcher("/frontend-es5/**", "/frontend-es6/**"), + new AntPathRequestMatcher("/mcp/**")) .permitAll()); // Standard-CSRF-Konfiguration diff --git a/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java b/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java new file mode 100644 index 0000000..e0c0559 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java @@ -0,0 +1,202 @@ +package de.assecutor.votianlt.service; + +import de.assecutor.votianlt.model.Job; +import de.assecutor.votianlt.model.JobStatus; +import de.assecutor.votianlt.repository.JobRepository; +import de.assecutor.votianlt.repository.TaskRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.Month; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Service for job statistics and aggregations. + * Provides data for MCP tools and reporting. + */ +@Service +@Slf4j +public class JobStatisticsService { + + private final JobRepository jobRepository; + private final TaskRepository taskRepository; + + public JobStatisticsService(JobRepository jobRepository, TaskRepository taskRepository) { + this.jobRepository = jobRepository; + this.taskRepository = taskRepository; + } + + /** + * Get job counts grouped by status. + */ + public Map getJobCountsByStatus() { + Map counts = new EnumMap<>(JobStatus.class); + for (JobStatus status : JobStatus.values()) { + counts.put(status, jobRepository.countByStatus(status)); + } + return counts; + } + + /** + * Get total number of jobs. + */ + public long getTotalJobCount() { + return jobRepository.count(); + } + + /** + * Get jobs created within a date range. + */ + public List getJobsByDateRange(LocalDateTime start, LocalDateTime end) { + return jobRepository.findByCreatedAtBetween(start, end); + } + + /** + * Get count of jobs created within a date range. + */ + public long getJobCountByDateRange(LocalDateTime start, LocalDateTime end) { + return jobRepository.findByCreatedAtBetween(start, end).size(); + } + + /** + * Calculate completion rate (completed jobs / total jobs). + */ + public double getCompletionRate() { + long total = jobRepository.count(); + if (total == 0) { + return 0.0; + } + long completed = jobRepository.countByStatus(JobStatus.COMPLETED); + return (double) completed / total * 100.0; + } + + /** + * Get total revenue from all jobs. + */ + public BigDecimal getTotalRevenue() { + List allJobs = jobRepository.findAll(); + return allJobs.stream() + .map(Job::getPrice) + .filter(price -> price != null) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + /** + * Get revenue grouped by customer. + */ + public Map getRevenueByCustomer() { + 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); + } + } + + return revenueByCustomer; + } + + /** + * Get top customers by revenue. + */ + public List> getTopCustomersByRevenue(int limit) { + return getRevenueByCustomer().entrySet().stream() + .sorted((a, b) -> b.getValue().compareTo(a.getValue())) + .limit(limit) + .toList(); + } + + /** + * Get monthly job counts for a specific year. + */ + public Map getMonthlyJobCounts(int year) { + Map monthlyCounts = new LinkedHashMap<>(); + LocalDateTime yearStart = LocalDateTime.of(year, 1, 1, 0, 0); + LocalDateTime yearEnd = LocalDateTime.of(year, 12, 31, 23, 59, 59); + + List yearJobs = jobRepository.findByCreatedAtBetween(yearStart, yearEnd); + + // Initialize all months with 0 + for (Month month : Month.values()) { + monthlyCounts.put(month, 0L); + } + + // Count jobs per month + for (Job job : yearJobs) { + if (job.getCreatedAt() != null) { + Month month = job.getCreatedAt().getMonth(); + monthlyCounts.merge(month, 1L, Long::sum); + } + } + + return monthlyCounts; + } + + /** + * Get jobs by customer selection. + */ + public List getJobsByCustomer(String customer) { + return jobRepository.findByCustomerSelection(customer); + } + + /** + * Get task completion statistics. + */ + public Map getTaskCompletionStats() { + Map stats = new HashMap<>(); + stats.put("completed", taskRepository.countByCompleted(true)); + stats.put("pending", taskRepository.countByCompleted(false)); + stats.put("total", taskRepository.count()); + return stats; + } + + /** + * Get jobs by status. + */ + public List getJobsByStatus(JobStatus status) { + return jobRepository.findByStatus(status); + } + + /** + * Get jobs assigned to an app user. + */ + public List getJobsByAppUser(String appUser) { + return jobRepository.findByAppUser(appUser); + } + + /** + * Get job by job number. + */ + public Job getJobByNumber(String jobNumber) { + return jobRepository.findByJobNumber(jobNumber).orElse(null); + } + + /** + * Get jobs by pickup city. + */ + public List getJobsByPickupCity(String city) { + return jobRepository.findByPickupCity(city); + } + + /** + * Get jobs by delivery city. + */ + public List getJobsByDeliveryCity(String city) { + return jobRepository.findByDeliveryCity(city); + } + + /** + * Get latest jobs. + */ + public List getLatestJobs(int limit) { + return jobRepository.findLatestJobs().stream().limit(limit).toList(); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 45749b3..d9ab0bf 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -103,4 +103,20 @@ app.client.ping.timeout-seconds=5 app.version=@project.version@ # Google Maps API Key -app.google.maps.api-key=AIzaSyDnbitL06iLp3elmj-WtPudCykX9xvXcVE \ No newline at end of file +app.google.maps.api-key=AIzaSyDnbitL06iLp3elmj-WtPudCykX9xvXcVE + +# =========================================== +# LLM Configuration (LM Studio) +# =========================================== +spring.ai.openai.base-url=http://192.168.180.10:1234 +spring.ai.openai.api-key=not-used +spring.ai.openai.chat.options.model=local-model +spring.ai.openai.chat.options.temperature=0.7 + +# =========================================== +# MCP Server Configuration +# =========================================== +spring.ai.mcp.server.enabled=true +spring.ai.mcp.server.name=votianlt-mcp-server +spring.ai.mcp.server.version=1.0.0 +spring.ai.mcp.server.sse-message-endpoint=/mcp/message \ No newline at end of file