Erweiterungen

This commit is contained in:
2026-01-26 15:40:43 +01:00
parent e8904b9667
commit 5f8691caaf
15 changed files with 1673 additions and 257 deletions

28
pom.xml
View File

@@ -35,6 +35,14 @@
<type>pom</type> <type>pom</type>
<scope>import</scope> <scope>import</scope>
</dependency> </dependency>
<!-- Spring AI BOM -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>
@@ -147,6 +155,18 @@
<version>5.0.5</version> <version>5.0.5</version>
</dependency> </dependency>
<!-- Spring AI OpenAI (LM Studio kompatibel) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<!-- MCP Server mit WebMVC Transport -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
<!-- Test Dependencies --> <!-- Test Dependencies -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
@@ -321,6 +341,14 @@
<enabled>false</enabled> <enabled>false</enabled>
</snapshots> </snapshots>
</repository> </repository>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories> </repositories>
<pluginRepositories> <pluginRepositories>

View File

@@ -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;
}
}

View File

@@ -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<JobStatus, Long> statusCounts = statisticsService.getJobCountsByStatus();
List<String> labels = new ArrayList<>();
List<Long> data = new ArrayList<>();
// Moderne Farbpalette mit satteren Farben
List<String> 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<String> labels = new ArrayList<>();
List<Double> 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<String> 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<Month, Long> monthlyData = statisticsService.getMonthlyJobCounts(currentYear);
List<String> labels = List.of("Jan", "Feb", "Mär", "Apr", "Mai", "Jun",
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez");
List<Long> 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<String, Long> taskStats = statisticsService.getTaskCompletionStats();
List<String> labels = List.of("Erledigt", "Ausstehend");
List<Long> data = List.of(
taskStats.getOrDefault("completed", 0L),
taskStats.getOrDefault("pending", 0L)
);
List<String> colors = List.of("#22c55e", "#f59e0b");
return buildChartJson(labels, data, colors, "Aufgaben");
}
private String buildOverviewChartData() {
Map<JobStatus, Long> 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<String> labels = List.of("Gesamt", "Abgeschlossen", "In Bearbeitung", "Offen");
List<Long> data = List.of(total, completed, inProgress, open);
List<String> colors = List.of("#3b82f6", "#22c55e", "#f59e0b", "#06b6d4");
return buildChartJson(labels, data, colors, "Aufträge");
}
private String buildChartJson(List<String> labels, List<Long> data, List<String> 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<String> labels, List<Double> data, List<String> 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<String> 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());
}
};
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<String, Long> countsByStatus;
private long totalJobs;
private long completedJobs;
private long cancelledJobs;
private long inProgressJobs;
private double completionRate;
private BigDecimal totalRevenue;
private LocalDateTime queryTimestamp;
}

View File

@@ -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;
}

View File

@@ -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<JobQueryResult> 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<Job> 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<JobQueryResult> 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<JobQueryResult> 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<JobQueryResult> 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();
}
}

View File

@@ -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<JobStatus, Long> countsByStatus = statisticsService.getJobCountsByStatus();
Map<String, Long> 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<String, Long> getJobCountsByStatus() {
log.info("MCP Tool: Getting job counts by status");
Map<JobStatus, Long> 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<CustomerRevenueResult> 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<String, BigDecimal> revenueMap = statisticsService.getRevenueByCustomer();
List<de.assecutor.votianlt.model.Job> 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<String, Long> 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<Month, Long> 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);
}
}

View File

@@ -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<String, Long> 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<String, Long> 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);
}
}

View File

@@ -1,303 +1,596 @@
package de.assecutor.votianlt.pages.view; 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.dependency.JavaScript;
import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H2; 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.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.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout; 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.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route; import com.vaadin.flow.router.Route;
import de.assecutor.votianlt.ai.service.AiStatisticsService;
import jakarta.annotation.security.RolesAllowed; 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) @Route(value = "statistics", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed({ "USER", "ADMIN" }) @RolesAllowed({ "USER", "ADMIN" })
@JavaScript("https://cdn.jsdelivr.net/npm/chart.js") @JavaScript("https://cdn.jsdelivr.net/npm/chart.js")
@Slf4j
public class StatisticsView extends VerticalLayout { 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(); setSizeFull();
setPadding(true); setPadding(false);
setSpacing(true); setSpacing(false);
H2 title = new H2("Statistiken"); // Header
add(title); HorizontalLayout header = createHeader();
add(header);
// KPI Cards // Chat Container mit Scroll
HorizontalLayout kpiLayout = createKpiCards(); chatContainer = new VerticalLayout();
add(kpiLayout); chatContainer.setWidthFull();
chatContainer.setPadding(true);
chatContainer.setSpacing(true);
chatContainer.getStyle().set("padding-bottom", "20px");
// Charts Layout Scroller scroller = new Scroller(chatContainer);
HorizontalLayout chartsLayout = new HorizontalLayout(); scroller.setSizeFull();
chartsLayout.setWidthFull(); scroller.setScrollDirection(Scroller.ScrollDirection.VERTICAL);
chartsLayout.setHeight("400px"); scroller.getStyle().set("background", "var(--lumo-contrast-5pct)");
chartsLayout.setSpacing(true);
// Aufträge pro Monat (Liniendiagramm) // Willkommensnachricht
Div monthlyOrdersChart = createMonthlyOrdersChart(); addSystemMessage("Willkommen bei der KI-Statistik-Analyse! Stelle mir Fragen zu deinen Aufträgen, " +
monthlyOrdersChart.setWidth("50%"); "z.B. \"Wie viele Aufträge sind offen?\" oder \"Zeige mir den Umsatz pro Kunde.\"");
monthlyOrdersChart.setHeight("100%");
chartsLayout.add(monthlyOrdersChart);
// Aufträge nach Status (Kreisdiagramm) add(scroller);
Div statusChart = createStatusPieChart(); setFlexGrow(1, scroller);
statusChart.setWidth("50%");
statusChart.setHeight("100%");
chartsLayout.add(statusChart);
add(chartsLayout); // Input Area
HorizontalLayout inputArea = createInputArea();
// Umsatz nach Kunden (Balkendiagramm) add(inputArea);
VerticalLayout revenueContainer = new VerticalLayout();
revenueContainer.setWidthFull();
revenueContainer.setHeight("400px");
revenueContainer.setPadding(false);
Div revenueChart = createRevenueByCustomerChart();
revenueChart.setSizeFull();
revenueContainer.add(revenueChart);
add(revenueContainer);
} }
private HorizontalLayout createKpiCards() { private HorizontalLayout createHeader() {
HorizontalLayout kpiLayout = new HorizontalLayout(); HorizontalLayout header = new HorizontalLayout();
kpiLayout.setWidthFull(); header.setWidthFull();
kpiLayout.setSpacing(true); header.setPadding(true);
kpiLayout.setJustifyContentMode(FlexComponent.JustifyContentMode.EVENLY); header.setAlignItems(FlexComponent.Alignment.CENTER);
header.getStyle()
.set("background", "var(--lumo-base-color)")
.set("border-bottom", "1px solid var(--lumo-contrast-10pct)");
// Gesamtaufträge Icon aiIcon = VaadinIcon.MAGIC.create();
Div totalOrdersCard = createKpiCard("Gesamtaufträge", "247", "success"); aiIcon.getStyle().set("color", "var(--lumo-primary-color)");
// Offene Aufträge H2 title = new H2("KI-Statistik-Assistent");
Div openOrdersCard = createKpiCard("Offene Aufträge", "34", "warning"); title.getStyle().set("margin", "0").set("font-size", "var(--lumo-font-size-xl)");
// Umsatz diesen Monat Span subtitle = new Span("Frage mich zu Aufträgen, Umsätzen und Statistiken");
Div revenueCard = createKpiCard("Umsatz (Monat)", "€ 24.500", "primary"); 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 header.add(aiIcon, title, subtitle);
Div newCustomersCard = createKpiCard("Neue Kunden", "12", "success"); return header;
kpiLayout.add(totalOrdersCard, openOrdersCard, revenueCard, newCustomersCard);
return kpiLayout;
} }
private Div createKpiCard(String title, String value, String theme) { private HorizontalLayout createInputArea() {
Div card = new Div(); HorizontalLayout inputArea = new HorizontalLayout();
card.addClassName("kpi-card"); inputArea.setWidthFull();
card.getStyle().set("background", "var(--lumo-base-color)") 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", "1px solid var(--lumo-contrast-10pct)")
.set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)") .set("padding", "var(--lumo-space-m)")
.set("text-align", "center").set("box-shadow", "var(--lumo-box-shadow-xs)").set("min-width", "150px"); .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); // AI Icon
titleElement.getStyle().set("margin", "0 0 var(--lumo-space-s) 0").set("font-size", "var(--lumo-font-size-s)"); HorizontalLayout header = new HorizontalLayout();
header.setAlignItems(FlexComponent.Alignment.CENTER);
header.setSpacing(true);
Span valueElement = new Span(value); Icon aiIcon = VaadinIcon.MAGIC.create();
valueElement.getStyle().set("font-size", "var(--lumo-font-size-xl)").set("font-weight", "bold").set("color", aiIcon.setSize("16px");
getThemeColor(theme)); aiIcon.getStyle().set("color", "var(--lumo-primary-color)");
card.add(titleElement, valueElement); Span aiLabel = new Span("KI-Assistent");
return card; 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) { private void addErrorMessage(String message) {
return switch (theme) { Div messageDiv = new Div();
case "success" -> "var(--lumo-success-color)"; messageDiv.getStyle()
case "warning" -> "var(--lumo-warning-color)"; .set("display", "flex")
case "error" -> "var(--lumo-error-color)"; .set("justify-content", "flex-start")
default -> "var(--lumo-primary-color)"; .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",
"<canvas id='" + canvasId + "' style='width: 100%; height: 100%;'></canvas>");
// 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() { private String formatMarkdown(String text) {
Div chartContainer = new Div(); if (text == null) return "";
chartContainer.setId("monthlyOrdersChart"); // Einfache Markdown-Formatierung
return text
String canvasHtml = "<canvas id='monthlyOrdersCanvas' style='width: 100%; height: 100%;'></canvas>"; .replace("\n", "<br>")
Html canvas = new Html(canvasHtml); .replaceAll("\\*\\*(.+?)\\*\\*", "<strong>$1</strong>")
chartContainer.add(canvas); .replaceAll("\\*(.+?)\\*", "<em>$1</em>")
.replaceAll("`(.+?)`", "<code>$1</code>");
String script = """
<script>
setTimeout(function() {
const ctx = document.getElementById('monthlyOrdersCanvas');
if (ctx) {
new Chart(ctx, {
type: 'line',
data: {
labels: ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'],
datasets: [{
label: '2024',
data: [15, 18, 22, 28, 32, 35, 42, 38, 41, 35, 28, 25],
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
tension: 0.1
}, {
label: '2023',
data: [12, 15, 18, 25, 28, 30, 35, 32, 36, 30, 25, 22],
borderColor: 'rgb(135, 206, 235)',
backgroundColor: 'rgba(135, 206, 235, 0.2)',
tension: 0.1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'Aufträge pro Monat'
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: 'Anzahl Aufträge'
}
}
}
}
});
}
}, 100);
</script>
""";
Html scriptElement = new Html(script);
chartContainer.add(scriptElement);
return chartContainer;
} }
private Div createStatusPieChart() { private void scrollToBottom() {
Div chartContainer = new Div(); chatContainer.getElement().executeJs(
chartContainer.setId("statusPieChart"); "this.parentElement.scrollTop = this.parentElement.scrollHeight");
String canvasHtml = "<canvas id='statusPieCanvas' style='width: 100%; height: 100%;'></canvas>";
Html canvas = new Html(canvasHtml);
chartContainer.add(canvas);
String script = """
<script>
setTimeout(function() {
const ctx = document.getElementById('statusPieCanvas');
if (ctx) {
new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Abgeschlossen', 'In Bearbeitung', 'Geplant', 'Storniert'],
datasets: [{
data: [156, 34, 28, 12],
backgroundColor: [
'rgba(54, 162, 235, 0.8)',
'rgba(255, 206, 86, 0.8)',
'rgba(75, 192, 192, 0.8)',
'rgba(255, 99, 132, 0.8)'
],
borderColor: [
'rgba(54, 162, 235, 1)',
'rgba(255, 206, 86, 1)',
'rgba(75, 192, 192, 1)',
'rgba(255, 99, 132, 1)'
],
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'Aufträge nach Status'
},
legend: {
position: 'right'
}
}
}
});
}
}, 100);
</script>
""";
Html scriptElement = new Html(script);
chartContainer.add(scriptElement);
return chartContainer;
} }
private Div createRevenueByCustomerChart() { @Override
Div chartContainer = new Div(); protected void onAttach(AttachEvent attachEvent) {
chartContainer.setId("revenueByCustomerChart"); super.onAttach(attachEvent);
scrollToBottom();
String canvasHtml = "<canvas id='revenueByCustomerCanvas' style='width: 100%; height: 100%;'></canvas>";
Html canvas = new Html(canvasHtml);
chartContainer.add(canvas);
String script = """
<script>
setTimeout(function() {
const ctx = document.getElementById('revenueByCustomerCanvas');
if (ctx) {
new Chart(ctx, {
type: 'bar',
data: {
labels: ['Firma A GmbH', 'Logistics B', 'Transport C', 'Spediteur D', 'Handel E',
'Industrie F', 'Service G', 'Vertrieb H', 'Export I', 'Import J'],
datasets: [{
label: 'Umsatz (€)',
data: [8500, 7200, 6800, 5900, 5400, 4800, 4200, 3900, 3500, 3100],
backgroundColor: 'rgba(75, 192, 192, 0.8)',
borderColor: 'rgba(75, 192, 192, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'Top 10 Kunden nach Umsatz'
},
legend: {
display: false
}
},
scales: {
x: {
ticks: {
maxRotation: 45,
minRotation: 45
}
},
y: {
beginAtZero: true,
title: {
display: true,
text: 'Umsatz (€)'
}
}
}
}
});
}
}, 100);
</script>
""";
Html scriptElement = new Html(script);
chartContainer.add(scriptElement);
return chartContainer;
} }
} }

View File

@@ -26,7 +26,8 @@ public class SecurityConfig extends VaadinWebSecurity {
new AntPathRequestMatcher("/sw.js"), new AntPathRequestMatcher("/offline.html"), new AntPathRequestMatcher("/sw.js"), new AntPathRequestMatcher("/offline.html"),
new AntPathRequestMatcher("/frontend/**"), new AntPathRequestMatcher("/webjars/**"), new AntPathRequestMatcher("/frontend/**"), new AntPathRequestMatcher("/webjars/**"),
new AntPathRequestMatcher("/h2-console/**"), new AntPathRequestMatcher("/h2-console/**"),
new AntPathRequestMatcher("/frontend-es5/**", "/frontend-es6/**")) new AntPathRequestMatcher("/frontend-es5/**", "/frontend-es6/**"),
new AntPathRequestMatcher("/mcp/**"))
.permitAll()); .permitAll());
// Standard-CSRF-Konfiguration // Standard-CSRF-Konfiguration

View File

@@ -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<JobStatus, Long> getJobCountsByStatus() {
Map<JobStatus, Long> 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<Job> 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<Job> 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<String, BigDecimal> getRevenueByCustomer() {
Map<String, BigDecimal> revenueByCustomer = new HashMap<>();
List<Job> 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<Map.Entry<String, BigDecimal>> 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<Month, Long> getMonthlyJobCounts(int year) {
Map<Month, Long> monthlyCounts = new LinkedHashMap<>();
LocalDateTime yearStart = LocalDateTime.of(year, 1, 1, 0, 0);
LocalDateTime yearEnd = LocalDateTime.of(year, 12, 31, 23, 59, 59);
List<Job> 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<Job> getJobsByCustomer(String customer) {
return jobRepository.findByCustomerSelection(customer);
}
/**
* Get task completion statistics.
*/
public Map<String, Long> getTaskCompletionStats() {
Map<String, Long> 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<Job> getJobsByStatus(JobStatus status) {
return jobRepository.findByStatus(status);
}
/**
* Get jobs assigned to an app user.
*/
public List<Job> 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<Job> getJobsByPickupCity(String city) {
return jobRepository.findByPickupCity(city);
}
/**
* Get jobs by delivery city.
*/
public List<Job> getJobsByDeliveryCity(String city) {
return jobRepository.findByDeliveryCity(city);
}
/**
* Get latest jobs.
*/
public List<Job> getLatestJobs(int limit) {
return jobRepository.findLatestJobs().stream().limit(limit).toList();
}
}

View File

@@ -104,3 +104,19 @@ app.version=@project.version@
# Google Maps API Key # Google Maps API Key
app.google.maps.api-key=AIzaSyDnbitL06iLp3elmj-WtPudCykX9xvXcVE 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