Erweiterungen
This commit is contained in:
28
pom.xml
28
pom.xml
@@ -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>
|
||||||
|
|||||||
36
src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java
Normal file
36
src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
128
src/main/java/de/assecutor/votianlt/mcp/tools/JobQueryTool.java
Normal file
128
src/main/java/de/assecutor/votianlt/mcp/tools/JobQueryTool.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getThemeColor(String theme) {
|
// Timestamp
|
||||||
return switch (theme) {
|
Span time = new Span(LocalDateTime.now().format(timeFormatter));
|
||||||
case "success" -> "var(--lumo-success-color)";
|
time.getStyle()
|
||||||
case "warning" -> "var(--lumo-warning-color)";
|
.set("font-size", "var(--lumo-font-size-xs)")
|
||||||
case "error" -> "var(--lumo-error-color)";
|
.set("color", "var(--lumo-secondary-text-color)")
|
||||||
default -> "var(--lumo-primary-color)";
|
.set("display", "block")
|
||||||
|
.set("margin-top", "var(--lumo-space-s)");
|
||||||
|
bubble.add(time);
|
||||||
|
|
||||||
|
messageDiv.add(bubble);
|
||||||
|
chatContainer.add(messageDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
"<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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user