feat: refine statistics ai responses

This commit is contained in:
2026-03-10 17:04:12 +01:00
parent 1445c23c0b
commit 2791f95fb4
19 changed files with 1060 additions and 278 deletions

View File

@@ -6,7 +6,7 @@
<groupId>de.assecutor.votianlt</groupId> <groupId>de.assecutor.votianlt</groupId>
<artifactId>votianlt</artifactId> <artifactId>votianlt</artifactId>
<version>0.9.9</version> <version>0.9.10</version>
<packaging>jar</packaging> <packaging>jar</packaging>

View File

@@ -14,6 +14,7 @@ import java.time.Duration;
import java.util.Base64; import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.regex.Pattern;
/** /**
* Direct REST client for LM Studio LLM API. Communicates via the * Direct REST client for LM Studio LLM API. Communicates via the
@@ -23,6 +24,9 @@ import java.util.Map;
@Slf4j @Slf4j
public class LlmRestClient { public class LlmRestClient {
private static final Pattern THINK_BLOCK_PATTERN = Pattern.compile("(?is)<think>.*?</think>");
private static final Pattern THINK_TAG_PATTERN = Pattern.compile("(?is)</?think>");
private final WebClient webClient; private final WebClient webClient;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final String model; private final String model;
@@ -94,7 +98,7 @@ public class LlmRestClient {
long duration = System.currentTimeMillis() - startTime; long duration = System.currentTimeMillis() - startTime;
log.info("LLM response received in {}ms", duration); log.info("LLM response received in {}ms", duration);
log.debug("Raw LLM response: {}", response); log.debug("LLM response payload received ({} chars)", response != null ? response.length() : 0);
return extractContent(response); return extractContent(response);
@@ -132,7 +136,12 @@ public class LlmRestClient {
log.warn("LLM response content is empty"); log.warn("LLM response content is empty");
return null; return null;
} }
return content; String sanitizedContent = sanitizeAssistantContent(content);
if (sanitizedContent.isBlank()) {
log.warn("LLM response content is empty after sanitization");
return null;
}
return sanitizedContent;
} }
log.warn("Unexpected response structure (no choices): {}", response); log.warn("Unexpected response structure (no choices): {}", response);
return null; return null;
@@ -141,4 +150,13 @@ public class LlmRestClient {
return null; return null;
} }
} }
private String sanitizeAssistantContent(String content) {
String sanitized = THINK_BLOCK_PATTERN.matcher(content).replaceAll(" ");
sanitized = THINK_TAG_PATTERN.matcher(sanitized).replaceAll(" ");
sanitized = sanitized.replace("\r", "");
sanitized = sanitized.replaceAll("[ \\t]+", " ");
sanitized = sanitized.replaceAll("\\n{3,}", "\n\n");
return sanitized.trim();
}
} }

View File

@@ -250,6 +250,7 @@ public class PickupStationDialog extends Dialog {
private Span cargoTabError; private Span cargoTabError;
private final DeliveryStationTile.TranslationHelper translationHelper; private final DeliveryStationTile.TranslationHelper translationHelper;
public PickupStationDialog(String dialogTitle, List<Customer> customers, public PickupStationDialog(String dialogTitle, List<Customer> customers,
DeliveryStationTile.TranslationHelper translationHelper, SaveListener saveListener, DeliveryStationTile.TranslationHelper translationHelper, SaveListener saveListener,
List<AppUser> availableAppUsers, AddressValidationService addressValidationService) { List<AppUser> availableAppUsers, AddressValidationService addressValidationService) {

View File

@@ -161,8 +161,8 @@ public class StationTile extends VerticalLayout {
private void addPreviewLine(String text) { private void addPreviewLine(String text) {
Span span = new Span(text); Span span = new Span(text);
span.getStyle().set("font-size", "var(--lumo-font-size-xs)").set("line-height", "1.2").set("word-break", span.getStyle().set("font-size", "var(--lumo-font-size-xs)").set("line-height", "1.2")
"break-word").set("color", "var(--lumo-secondary-text-color)"); .set("word-break", "break-word").set("color", "var(--lumo-secondary-text-color)");
previewContent.add(span); previewContent.add(span);
} }

View File

@@ -101,10 +101,12 @@ public class AddJobService {
Map<Integer, ObjectId> stationIdByOrder = buildStationIdByOrder(savedJob); Map<Integer, ObjectId> stationIdByOrder = buildStationIdByOrder(savedJob);
List<BaseTask> tasksToPersist = new ArrayList<>(); List<BaseTask> tasksToPersist = new ArrayList<>();
// Setze stationId und stelle sicher, dass taskOrder je Lieferstation korrekt ist // Setze stationId und stelle sicher, dass taskOrder je Lieferstation korrekt
// ist
for (BaseTask task : filteredTasks) { for (BaseTask task : filteredTasks) {
int stationOrder = task.getStationOrder() != null ? task.getStationOrder() : 0; int stationOrder = task.getStationOrder() != null ? task.getStationOrder() : 0;
ObjectId stationId = task.getStationId() != null ? task.getStationId() : stationIdByOrder.get(stationOrder); ObjectId stationId = task.getStationId() != null ? task.getStationId()
: stationIdByOrder.get(stationOrder);
if (stationId == null) { if (stationId == null) {
log.warn("Skipping task without resolvable stationId for job {} and stationOrder {}", jobId, log.warn("Skipping task without resolvable stationId for job {} and stationOrder {}", jobId,
stationOrder); stationOrder);
@@ -259,7 +261,8 @@ public class AddJobService {
continue; continue;
} }
if (task.getStationId() != null) { if (task.getStationId() != null) {
tasksByStationId.computeIfAbsent(task.getStationId().toHexString(), ignored -> new ArrayList<>()).add(task); tasksByStationId.computeIfAbsent(task.getStationId().toHexString(), ignored -> new ArrayList<>())
.add(task);
continue; continue;
} }

View File

@@ -120,7 +120,8 @@ public class AddressValidationService {
double lat = location.path("lat").asDouble(); double lat = location.path("lat").asDouble();
double lng = location.path("lng").asDouble(); double lng = location.path("lng").asDouble();
// Google liefert für valide Adressen nicht immer nur ROOFTOP/RANGE_INTERPOLATED. // Google liefert für valide Adressen nicht immer nur
// ROOFTOP/RANGE_INTERPOLATED.
// Für unseren Flow reicht ein erfolgreicher Geocoding-Treffer mit Koordinaten. // Für unseren Flow reicht ein erfolgreicher Geocoding-Treffer mit Koordinaten.
String locationType = geometry.path("location_type").asText(); String locationType = geometry.path("location_type").asText();
boolean hasCoordinates = location.hasNonNull("lat") && location.hasNonNull("lng"); boolean hasCoordinates = location.hasNonNull("lat") && location.hasNonNull("lng");
@@ -149,8 +150,7 @@ public class AddressValidationService {
if (result.isValid()) { if (result.isValid()) {
result.setValidationMessage("Adresse erfolgreich validiert"); result.setValidationMessage("Adresse erfolgreich validiert");
log.debug( log.debug("Adressvalidierung erfolgreich: {} -> {} (locationType={}, streetNumber={}, postalCode={})",
"Adressvalidierung erfolgreich: {} -> {} (locationType={}, streetNumber={}, postalCode={})",
addressString, formattedAddress, locationType, hasStreetNumber, hasPostalCode); addressString, formattedAddress, locationType, hasStreetNumber, hasPostalCode);
} else { } else {
result.setValidationMessage("Adresse gefunden, aber ohne verwertbare Koordinaten"); result.setValidationMessage("Adresse gefunden, aber ohne verwertbare Koordinaten");
@@ -238,9 +238,9 @@ public class AddressValidationService {
String destination = formatLatLng(destinationResult); String destination = formatLatLng(destinationResult);
// URL für die Directions API erstellen // URL für die Directions API erstellen
StringBuilder requestUrl = new StringBuilder(String.format( StringBuilder requestUrl = new StringBuilder(
"%s?origin=%s&destination=%s&mode=driving&key=%s&language=de&region=de", DIRECTIONS_API_URL, String.format("%s?origin=%s&destination=%s&mode=driving&key=%s&language=de&region=de",
origin, destination, googleMapsApiKey)); DIRECTIONS_API_URL, origin, destination, googleMapsApiKey));
if (stationResults.size() > 2) { if (stationResults.size() > 2) {
List<String> waypoints = stationResults.subList(1, stationResults.size() - 1).stream() List<String> waypoints = stationResults.subList(1, stationResults.size() - 1).stream()
@@ -305,8 +305,7 @@ public class AddressValidationService {
routeResult.setDurationSeconds(totalDurationSeconds); routeResult.setDurationSeconds(totalDurationSeconds);
routeResult.setFormattedDistance(distanceText); routeResult.setFormattedDistance(distanceText);
routeResult.setFormattedDuration(durationText); routeResult.setFormattedDuration(durationText);
routeResult.setRouteMessage( routeResult.setRouteMessage(String.format("Route: %s, Dauer: %s", distanceText, durationText));
String.format("Route: %s, Dauer: %s", distanceText, durationText));
log.debug("Routenberechnung erfolgreich: {} km, {} Min.", routeResult.getDistanceKm(), log.debug("Routenberechnung erfolgreich: {} km, {} Min.", routeResult.getDistanceKm(),
routeResult.getDurationMinutes()); routeResult.getDurationMinutes());

View File

@@ -1222,7 +1222,8 @@ public class AddJobView extends Main implements HasDynamicTitle {
Button addButton = new Button(getTranslation("addjob.services.dialog.add"), e -> { Button addButton = new Button(getTranslation("addjob.services.dialog.add"), e -> {
if (serviceCombo.getValue() != null && deliveryStationCombo.getValue() != null) { if (serviceCombo.getValue() != null && deliveryStationCombo.getValue() != null) {
selectedServices.add(new SelectedServiceEntry(serviceCombo.getValue(), deliveryStationCombo.getValue())); selectedServices
.add(new SelectedServiceEntry(serviceCombo.getValue(), deliveryStationCombo.getValue()));
servicesGrid.getDataProvider().refreshAll(); servicesGrid.getDataProvider().refreshAll();
updatePriceSummary(); updatePriceSummary();
triggerValidation(); triggerValidation();
@@ -1760,9 +1761,9 @@ public class AddJobView extends Main implements HasDynamicTitle {
// Validate all required fields using the binder // Validate all required fields using the binder
if (binder.writeBeanIfValid(job)) { if (binder.writeBeanIfValid(job)) {
// Preis nach dem Binder-Call berechnen (damit er nicht überschrieben wird) // Preis nach dem Binder-Call berechnen (damit er nicht überschrieben wird)
BigDecimal netTotal = selectedServices.stream() BigDecimal netTotal = selectedServices
.map(entry -> calculateServicePrice(entry.getService(), getEffectiveRouteDistance(entry), .stream().map(entry -> calculateServicePrice(entry.getService(),
getEffectiveRouteDuration(entry))) getEffectiveRouteDistance(entry), getEffectiveRouteDuration(entry)))
.reduce(BigDecimal.ZERO, BigDecimal::add); .reduce(BigDecimal.ZERO, BigDecimal::add);
job.setPrice(netTotal); job.setPrice(netTotal);
@@ -2176,7 +2177,8 @@ public class AddJobView extends Main implements HasDynamicTitle {
return new RouteCalculationBundle(totalRoute, deliveryRoutes); return new RouteCalculationBundle(totalRoute, deliveryRoutes);
} }
private RouteCalculationResult aggregateLegRoutes(Map<Integer, RouteCalculationResult> deliveryRoutes, int legCount) { private RouteCalculationResult aggregateLegRoutes(Map<Integer, RouteCalculationResult> deliveryRoutes,
int legCount) {
if (deliveryRoutes == null || deliveryRoutes.size() != legCount || legCount == 0) { if (deliveryRoutes == null || deliveryRoutes.size() != legCount || legCount == 0) {
return createInvalidRouteResult("Die Gesamtstrecke konnte nicht berechnet werden."); return createInvalidRouteResult("Die Gesamtstrecke konnte nicht berechnet werden.");
} }
@@ -2199,8 +2201,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
totalRoute.setDurationSeconds(totalDurationSeconds); totalRoute.setDurationSeconds(totalDurationSeconds);
totalRoute.setFormattedDistance(String.format(Locale.GERMANY, "%.1f km", totalDistanceKm)); totalRoute.setFormattedDistance(String.format(Locale.GERMANY, "%.1f km", totalDistanceKm));
totalRoute.setFormattedDuration(totalRoute.getFormattedDurationLong()); totalRoute.setFormattedDuration(totalRoute.getFormattedDurationLong());
totalRoute.setRouteMessage( totalRoute.setRouteMessage(String.format("Route: %s, Dauer: %s", totalRoute.getFormattedDistance(),
String.format("Route: %s, Dauer: %s", totalRoute.getFormattedDistance(),
totalRoute.getFormattedDurationLong())); totalRoute.getFormattedDurationLong()));
return totalRoute; return totalRoute;
} }
@@ -2311,8 +2312,8 @@ public class AddJobView extends Main implements HasDynamicTitle {
content.setPadding(false); content.setPadding(false);
content.setSpacing(true); content.setSpacing(true);
content.add(createRouteSummaryRow(getTranslation("addjob.route.distance"), routeResult.getFormattedDistance())); content.add(createRouteSummaryRow(getTranslation("addjob.route.distance"), routeResult.getFormattedDistance()));
content.add(createRouteSummaryRow(getTranslation("addjob.route.duration"), content.add(
routeResult.getFormattedDurationLong())); createRouteSummaryRow(getTranslation("addjob.route.duration"), routeResult.getFormattedDurationLong()));
Button closeButton = new Button(getTranslation("dialog.confirm"), event -> dialog.close()); Button closeButton = new Button(getTranslation("dialog.confirm"), event -> dialog.close());
closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);

View File

@@ -295,8 +295,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
return ""; return "";
}).setHeader(getTranslation("createinvoice.column.service")).setAutoWidth(true).setFlexGrow(2); }).setHeader(getTranslation("createinvoice.column.service")).setAutoWidth(true).setFlexGrow(2);
servicesGrid.addColumn(this::getDeliveryStationLabel).setHeader(getTranslation("addjob.services.deliverystation")) servicesGrid.addColumn(this::getDeliveryStationLabel)
.setAutoWidth(true).setFlexGrow(1); .setHeader(getTranslation("addjob.services.deliverystation")).setAutoWidth(true).setFlexGrow(1);
// Calculation basis column (read-only) // Calculation basis column (read-only)
servicesGrid.addColumn(row -> { servicesGrid.addColumn(row -> {

View File

@@ -1007,9 +1007,8 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
panel.add(invoiceHeader, senderCompany, senderName, senderAddress, senderCity, senderEmail, senderPhone, panel.add(invoiceHeader, senderCompany, senderName, senderAddress, senderCity, senderEmail, senderPhone,
invoiceNumber, servicesHeader, servicesListBlock, servicesNetBlock, servicesGrossBlock, customerHeader, invoiceNumber, servicesHeader, servicesListBlock, servicesNetBlock, servicesGrossBlock, customerHeader,
customerCompany, customerName, customerAddress, customerCity, customerCompany, customerName, customerAddress, customerCity, customerEmail, customerPhone, freeHeader,
customerEmail, customerPhone, freeHeader, textBlock, headerBlock, dateBlock, customerBlock, textBlock, headerBlock, dateBlock, customerBlock, companyBlock, amountBlock, lineBlock, imageBlock);
companyBlock, amountBlock, lineBlock, imageBlock);
return panel; return panel;
} }

View File

@@ -46,18 +46,15 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
invoiceGrid = new Grid<>(CustomerInvoice.class, false); invoiceGrid = new Grid<>(CustomerInvoice.class, false);
invoiceGrid.setWidthFull(); invoiceGrid.setWidthFull();
invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getInvoiceNumber(), invoice.getId())) invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getInvoiceNumber(), invoice.getId()))
.setHeader(getTranslation("invoices.column.number")) .setHeader(getTranslation("invoices.column.number")).setAutoWidth(true);
.setAutoWidth(true);
invoiceGrid.addColumn(this::getRecipientLabel).setHeader(getTranslation("invoices.column.customer")) invoiceGrid.addColumn(this::getRecipientLabel).setHeader(getTranslation("invoices.column.customer"))
.setAutoWidth(true); .setAutoWidth(true);
invoiceGrid.addColumn(invoice -> Optional.ofNullable(invoice.getInvoiceDate()).map(Object::toString).orElse("")) invoiceGrid.addColumn(invoice -> Optional.ofNullable(invoice.getInvoiceDate()).map(Object::toString).orElse(""))
.setHeader(getTranslation("invoices.column.date")) .setHeader(getTranslation("invoices.column.date")).setAutoWidth(true);
.setAutoWidth(true);
invoiceGrid.addColumn(this::formatAmount).setHeader(getTranslation("invoices.column.amount")) invoiceGrid.addColumn(this::formatAmount).setHeader(getTranslation("invoices.column.amount"))
.setAutoWidth(true); .setAutoWidth(true);
invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getDescription(), "")) invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getDescription(), ""))
.setHeader(getTranslation("invoices.column.description")) .setHeader(getTranslation("invoices.column.description")).setAutoWidth(true);
.setAutoWidth(true);
invoiceGrid.setSelectionMode(Grid.SelectionMode.SINGLE); invoiceGrid.setSelectionMode(Grid.SelectionMode.SINGLE);
invoiceGrid.getStyle().set("cursor", "pointer"); invoiceGrid.getStyle().set("cursor", "pointer");
@@ -75,8 +72,7 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
private void loadInvoices() { private void loadInvoices() {
String currentUserId = securityService.getCurrentUserId().toHexString(); String currentUserId = securityService.getCurrentUserId().toHexString();
List<CustomerInvoice> invoices = customerInvoiceRepository.findByUserId(currentUserId).stream() List<CustomerInvoice> invoices = customerInvoiceRepository.findByUserId(currentUserId).stream()
.filter(this::hasPdfData) .filter(this::hasPdfData).sorted((left, right) -> {
.sorted((left, right) -> {
if (left.getInvoiceDate() == null && right.getInvoiceDate() == null) { if (left.getInvoiceDate() == null && right.getInvoiceDate() == null) {
return 0; return 0;
} }
@@ -87,8 +83,7 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
return -1; return -1;
} }
return right.getInvoiceDate().compareTo(left.getInvoiceDate()); return right.getInvoiceDate().compareTo(left.getInvoiceDate());
}) }).toList();
.toList();
invoiceGrid.setItems(invoices); invoiceGrid.setItems(invoices);
if (invoices.isEmpty()) { if (invoices.isEmpty()) {
@@ -106,7 +101,8 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
return; return;
} }
StreamResource resource = new StreamResource(firstNonBlank(invoice.getInvoiceNumber(), invoice.getId()) + ".pdf", StreamResource resource = new StreamResource(
firstNonBlank(invoice.getInvoiceNumber(), invoice.getId()) + ".pdf",
() -> new ByteArrayInputStream(invoice.getPdfData())); () -> new ByteArrayInputStream(invoice.getPdfData()));
resource.setContentType("application/pdf"); resource.setContentType("application/pdf");
resource.setCacheTime(0); resource.setCacheTime(0);

View File

@@ -406,8 +406,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
private StationTile createDeliverySummaryTile(DeliveryStation station, int index, int stationCount, private StationTile createDeliverySummaryTile(DeliveryStation station, int index, int stationCount,
List<BaseTask> tasks) { List<BaseTask> tasks) {
String title = getTranslation("jobsummary.section.delivery") + " " String title = getTranslation("jobsummary.section.delivery") + " " + (stationCount > 1 ? (index + 1) + " " : "")
+ (stationCount > 1 ? (index + 1) + " " : "")
+ formatDateWithTime(station.getDeliveryDate(), station.getDeliveryTime()); + formatDateWithTime(station.getDeliveryDate(), station.getDeliveryTime());
List<BaseTask> stationTasks = getTasksForStation(station, tasks, false); List<BaseTask> stationTasks = getTasksForStation(station, tasks, false);
List<String> additionalLines = buildDeliverySummaryDetails(stationTasks); List<String> additionalLines = buildDeliverySummaryDetails(stationTasks);
@@ -443,8 +442,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
return tile; return tile;
} }
private StationTile createSummaryTile(StationTile.StationType type, int stationNumber, String title, private StationTile createSummaryTile(StationTile.StationType type, int stationNumber, String title, String company,
String company, String displayName, String street, String houseNumber, String zip, String city, String displayName, String street, String houseNumber, String zip, String city,
List<String> additionalLines) { List<String> additionalLines) {
StationTile tile = new StationTile(type, stationNumber, title.trim(), false); StationTile tile = new StationTile(type, stationNumber, title.trim(), false);
tile.setInteractive(false); tile.setInteractive(false);
@@ -1377,12 +1376,9 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
task.isCompleted() ? "var(--lumo-success-text-color)" : "var(--lumo-body-text-color)"); task.isCompleted() ? "var(--lumo-success-text-color)" : "var(--lumo-body-text-color)");
Span statusBadge = new Span(getTaskStatusLabel(task)); Span statusBadge = new Span(getTaskStatusLabel(task));
statusBadge.getStyle().set("font-size", "var(--lumo-font-size-xs)") statusBadge.getStyle().set("font-size", "var(--lumo-font-size-xs)").set("font-weight", "600")
.set("font-weight", "600") .set("padding", "0.2rem 0.55rem").set("border-radius", "999px")
.set("padding", "0.2rem 0.55rem") .set("background-color", task.isCompleted() ? "rgba(76, 175, 80, 0.15)" : "rgba(244, 67, 54, 0.12)")
.set("border-radius", "999px")
.set("background-color",
task.isCompleted() ? "rgba(76, 175, 80, 0.15)" : "rgba(244, 67, 54, 0.12)")
.set("color", task.isCompleted() ? "var(--lumo-success-text-color)" : "var(--lumo-error-text-color)"); .set("color", task.isCompleted() ? "var(--lumo-success-text-color)" : "var(--lumo-error-text-color)");
HorizontalLayout headerRow = new HorizontalLayout(taskName, statusBadge); HorizontalLayout headerRow = new HorizontalLayout(taskName, statusBadge);

View File

@@ -20,6 +20,7 @@ import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.HasDynamicTitle; import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Route; import com.vaadin.flow.router.Route;
import de.assecutor.votianlt.ai.service.AiStatisticsService; import de.assecutor.votianlt.ai.service.AiStatisticsService;
import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.util.DateTimeFormatUtil; import de.assecutor.votianlt.util.DateTimeFormatUtil;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -34,11 +35,13 @@ import java.util.UUID;
public class StatisticsView extends VerticalLayout implements HasDynamicTitle { public class StatisticsView extends VerticalLayout implements HasDynamicTitle {
private final AiStatisticsService aiStatisticsService; private final AiStatisticsService aiStatisticsService;
private final SecurityService securityService;
private final VerticalLayout chatContainer; private final VerticalLayout chatContainer;
private final TextField promptField; private final TextField promptField;
public StatisticsView(AiStatisticsService aiStatisticsService) { public StatisticsView(AiStatisticsService aiStatisticsService, SecurityService securityService) {
this.aiStatisticsService = aiStatisticsService; this.aiStatisticsService = aiStatisticsService;
this.securityService = securityService;
// Prompt Field initialisieren // Prompt Field initialisieren
this.promptField = new TextField(); this.promptField = new TextField();
@@ -166,9 +169,11 @@ public class StatisticsView extends VerticalLayout implements HasDynamicTitle {
// Async Anfrage an KI // Async Anfrage an KI
UI ui = UI.getCurrent(); UI ui = UI.getCurrent();
String currentUserId = securityService.getCurrentUserId().toHexString();
new Thread(() -> { new Thread(() -> {
try { try {
AiStatisticsService.StatisticsResponse response = aiStatisticsService.analyzeStatisticsQuery(prompt); AiStatisticsService.StatisticsResponse response = aiStatisticsService.analyzeStatisticsQuery(prompt,
currentUserId);
ui.access(() -> { ui.access(() -> {
chatContainer.remove(loadingMessage); chatContainer.remove(loadingMessage);
@@ -259,6 +264,13 @@ public class StatisticsView extends VerticalLayout implements HasDynamicTitle {
} }
} }
if (response.tableHtml() != null && !response.tableHtml().isBlank()) {
Div tableContainer = new Div();
tableContainer.getStyle().set("margin-top", "var(--lumo-space-m)").set("overflow", "auto");
tableContainer.getElement().setProperty("innerHTML", response.tableHtml());
bubble.add(tableContainer);
}
// Timestamp // Timestamp
Span time = new Span(DateTimeFormatUtil.formatTime(LocalDateTime.now())); Span time = new Span(DateTimeFormatUtil.formatTime(LocalDateTime.now()));
time.getStyle().set("font-size", "var(--lumo-font-size-xs)").set("color", "var(--lumo-secondary-text-color)") time.getStyle().set("font-size", "var(--lumo-font-size-xs)").set("color", "var(--lumo-secondary-text-color)")

View File

@@ -3,6 +3,7 @@ package de.assecutor.votianlt.repository;
import de.assecutor.votianlt.model.task.BaseTask; import de.assecutor.votianlt.model.task.BaseTask;
import org.bson.types.ObjectId; import org.bson.types.ObjectId;
import org.springframework.data.mongodb.repository.MongoRepository; import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;
import java.util.List; import java.util.List;
@@ -13,6 +14,9 @@ public interface TaskRepository extends MongoRepository<BaseTask, ObjectId> {
List<BaseTask> findByJobIdOrderByTaskOrderAsc(ObjectId jobId); List<BaseTask> findByJobIdOrderByTaskOrderAsc(ObjectId jobId);
@Query("{'job_id': {'$in': ?0}}")
List<BaseTask> findByJobIdIn(List<ObjectId> jobIds);
List<BaseTask> findByJobIdAndStationOrderOrderByTaskOrderAsc(ObjectId jobId, int stationOrder); List<BaseTask> findByJobIdAndStationOrderOrderByTaskOrderAsc(ObjectId jobId, int stationOrder);
/** /**

View File

@@ -821,8 +821,8 @@ public class CustomerInvoiceService {
html.append( html.append(
"<td style='text-align:left;padding:4px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:75%;'>") "<td style='text-align:left;padding:4px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:75%;'>")
.append(escapeHtml(name)).append("</td>"); .append(escapeHtml(name)).append("</td>");
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:25%;'>").append(netAmount) html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:25%;'>")
.append(" €</td>"); .append(netAmount).append(" €</td>");
html.append("</tr>"); html.append("</tr>");
} }
} }

View File

@@ -3,7 +3,6 @@ package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.repository.JobRepository; import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.repository.TaskRepository;
import de.assecutor.votianlt.repository.UserRepository; import de.assecutor.votianlt.repository.UserRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -22,7 +21,6 @@ import java.util.Optional;
public class EmailService { public class EmailService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final JobRepository jobRepository; private final JobRepository jobRepository;
private final TaskRepository taskRepository;
private final TaskAssignmentService taskAssignmentService; private final TaskAssignmentService taskAssignmentService;
private final JavaMailSender mailSender; private final JavaMailSender mailSender;

View File

@@ -2,20 +2,24 @@ package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobStatus; import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.model.task.BaseTask;
import de.assecutor.votianlt.repository.JobRepository; import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.repository.TaskRepository; import de.assecutor.votianlt.repository.TaskRepository;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.bson.types.ObjectId;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.Month; import java.time.Month;
import java.util.Comparator;
import java.util.EnumMap; import java.util.EnumMap;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Set; import java.util.Set;
/** /**
@@ -83,8 +87,7 @@ public class JobStatisticsService {
*/ */
public BigDecimal getTotalRevenue() { public BigDecimal getTotalRevenue() {
List<Job> allJobs = jobRepository.findAll(); List<Job> allJobs = jobRepository.findAll();
return allJobs.stream().map(Job::getPrice).filter(price -> price != null).reduce(BigDecimal.ZERO, return sumRevenue(allJobs);
BigDecimal::add);
} }
/** /**
@@ -94,13 +97,7 @@ public class JobStatisticsService {
Map<String, BigDecimal> revenueByCustomer = new HashMap<>(); Map<String, BigDecimal> revenueByCustomer = new HashMap<>();
List<Job> allJobs = jobRepository.findAll(); List<Job> allJobs = jobRepository.findAll();
for (Job job : allJobs) { mergeRevenueByCustomer(revenueByCustomer, allJobs);
String customer = job.getCustomerSelection();
if (customer != null && job.getPrice() != null) {
revenueByCustomer.merge(customer, job.getPrice(), BigDecimal::add);
}
}
return revenueByCustomer; return revenueByCustomer;
} }
@@ -145,24 +142,7 @@ public class JobStatisticsService {
if (customer == null || customer.isBlank()) { if (customer == null || customer.isBlank()) {
return List.of(); return List.of();
} }
// Trim and escape regex special characters for MongoDB return filterJobsByCustomer(jobRepository.findAll(), customer);
String trimmedCustomer = customer.trim();
String escapedCustomer = trimmedCustomer.replaceAll("([.^$*+?()\\[\\]{}|\\\\])", "\\\\$1");
// First try exact match (with optional whitespace)
String exactRegex = "^\\s*" + escapedCustomer + "\\s*$";
List<Job> jobs = jobRepository.findByCustomerSelectionIgnoreCase(exactRegex);
log.debug("getJobsByCustomer('{}') - exact regex: '{}' - found {} jobs", customer, exactRegex, jobs.size());
// If no exact match, try partial match (customer name contains the search term)
if (jobs.isEmpty()) {
String containsRegex = ".*" + escapedCustomer + ".*";
jobs = jobRepository.findByCustomerSelectionIgnoreCase(containsRegex);
log.debug("getJobsByCustomer('{}') - contains regex: '{}' - found {} jobs", customer, containsRegex,
jobs.size());
}
return jobs;
} }
/** /**
@@ -218,6 +198,72 @@ public class JobStatisticsService {
return jobRepository.findLatestJobs().stream().limit(limit).toList(); return jobRepository.findLatestJobs().stream().limit(limit).toList();
} }
public Map<JobStatus, Long> getJobCountsByStatusForUser(String createdBy) {
return countJobsByStatus(getJobsCreatedByUser(createdBy));
}
public long getTotalJobCountForUser(String createdBy) {
return getJobsCreatedByUser(createdBy).size();
}
public double getCompletionRateForUser(String createdBy) {
List<Job> userJobs = getJobsCreatedByUser(createdBy);
return calculateCompletionRate(userJobs);
}
public BigDecimal getTotalRevenueForUser(String createdBy) {
return sumRevenue(getJobsCreatedByUser(createdBy));
}
public BigDecimal getTotalRevenueForUserInRange(String createdBy, LocalDateTime start, LocalDateTime end) {
return sumRevenue(filterJobsByDateRange(getJobsCreatedByUser(createdBy), start, end));
}
public long getTotalJobCountForUserInRange(String createdBy, LocalDateTime start, LocalDateTime end) {
return filterJobsByDateRange(getJobsCreatedByUser(createdBy), start, end).size();
}
public List<Map.Entry<String, BigDecimal>> getTopCustomersByRevenueForUser(String createdBy, int limit) {
return getRevenueByCustomerForUser(createdBy).entrySet().stream()
.sorted((a, b) -> b.getValue().compareTo(a.getValue())).limit(limit).toList();
}
public List<Map.Entry<String, BigDecimal>> getTopCustomersByRevenueForUserInRange(String createdBy,
LocalDateTime start, LocalDateTime end, int limit) {
Map<String, BigDecimal> revenueByCustomer = new HashMap<>();
mergeRevenueByCustomer(revenueByCustomer, filterJobsByDateRange(getJobsCreatedByUser(createdBy), start, end));
return revenueByCustomer.entrySet().stream().sorted((a, b) -> b.getValue().compareTo(a.getValue())).limit(limit)
.toList();
}
public Map<String, BigDecimal> getRevenueByCustomerForUser(String createdBy) {
Map<String, BigDecimal> revenueByCustomer = new HashMap<>();
mergeRevenueByCustomer(revenueByCustomer, getJobsCreatedByUser(createdBy));
return revenueByCustomer;
}
public Map<Month, Long> getMonthlyJobCountsForUser(int year, String createdBy) {
return buildMonthlyJobCounts(filterJobsByYear(getJobsCreatedByUser(createdBy), year));
}
public List<Job> getJobsByCustomerForUser(String createdBy, String customer) {
return filterJobsByCustomer(getJobsCreatedByUser(createdBy), customer);
}
public Map<String, Long> getTaskCompletionStatsForUser(String createdBy) {
return buildTaskCompletionStats(getJobsCreatedByUser(createdBy));
}
public List<Job> getJobsByStatusForUser(String createdBy, JobStatus status) {
return getJobsCreatedByUser(createdBy).stream().filter(job -> job.getStatus() == status).toList();
}
public List<Job> getLatestJobsForUser(String createdBy, int limit) {
return getJobsCreatedByUser(createdBy).stream()
.sorted(Comparator.comparing(Job::getCreatedAt, Comparator.nullsLast(Comparator.reverseOrder())))
.limit(limit).toList();
}
// ==================== Filtered Statistics Methods ==================== // ==================== Filtered Statistics Methods ====================
/** /**
@@ -228,19 +274,21 @@ public class JobStatisticsService {
.distinct().sorted().toList(); .distinct().sorted().toList();
} }
public List<String> getAllCustomerNamesForUser(String createdBy) {
return getJobsCreatedByUser(createdBy).stream().map(Job::getCustomerSelection)
.filter(customer -> customer != null && !customer.isBlank()).distinct().sorted().toList();
}
/** /**
* Get job counts by status filtered by customer. * Get job counts by status filtered by customer.
*/ */
public Map<JobStatus, Long> getJobCountsByStatusForCustomer(String customer) { public Map<JobStatus, Long> getJobCountsByStatusForCustomer(String customer) {
List<Job> customerJobs = getJobsByCustomer(customer); List<Job> customerJobs = getJobsByCustomer(customer);
Map<JobStatus, Long> counts = new EnumMap<>(JobStatus.class); return countJobsByStatus(customerJobs);
for (JobStatus status : JobStatus.values()) {
counts.put(status, 0L);
} }
for (Job job : customerJobs) {
counts.computeIfPresent(job.getStatus(), (k, v) -> v + 1L); public Map<JobStatus, Long> getJobCountsByStatusForCustomerForUser(String createdBy, String customer) {
} return countJobsByStatus(getJobsByCustomerForUser(createdBy, customer));
return counts;
} }
/** /**
@@ -250,49 +298,51 @@ public class JobStatisticsService {
return getJobsByCustomer(customer).size(); return getJobsByCustomer(customer).size();
} }
public long getTotalJobCountForCustomerForUser(String createdBy, String customer) {
return getJobsByCustomerForUser(createdBy, customer).size();
}
public long getTotalJobCountForCustomerForUserInRange(String createdBy, String customer, LocalDateTime start,
LocalDateTime end) {
return filterJobsByDateRange(getJobsByCustomerForUser(createdBy, customer), start, end).size();
}
/** /**
* Get total revenue for a customer. * Get total revenue for a customer.
*/ */
public BigDecimal getTotalRevenueForCustomer(String customer) { public BigDecimal getTotalRevenueForCustomer(String customer) {
return getJobsByCustomer(customer).stream().map(Job::getPrice).filter(price -> price != null) return sumRevenue(getJobsByCustomer(customer));
.reduce(BigDecimal.ZERO, BigDecimal::add); }
public BigDecimal getTotalRevenueForCustomerForUser(String createdBy, String customer) {
return sumRevenue(getJobsByCustomerForUser(createdBy, customer));
}
public BigDecimal getTotalRevenueForCustomerForUserInRange(String createdBy, String customer, LocalDateTime start,
LocalDateTime end) {
return sumRevenue(filterJobsByDateRange(getJobsByCustomerForUser(createdBy, customer), start, end));
} }
/** /**
* Get completion rate for a customer. * Get completion rate for a customer.
*/ */
public double getCompletionRateForCustomer(String customer) { public double getCompletionRateForCustomer(String customer) {
List<Job> customerJobs = getJobsByCustomer(customer); return calculateCompletionRate(getJobsByCustomer(customer));
if (customerJobs.isEmpty()) {
return 0.0;
} }
long completed = customerJobs.stream().filter(j -> j.getStatus() == JobStatus.COMPLETED).count();
return (double) completed / customerJobs.size() * 100.0; public double getCompletionRateForCustomerForUser(String createdBy, String customer) {
return calculateCompletionRate(getJobsByCustomerForUser(createdBy, customer));
} }
/** /**
* Get monthly job counts for a customer in a specific year. * Get monthly job counts for a customer in a specific year.
*/ */
public Map<Month, Long> getMonthlyJobCountsForCustomer(int year, String customer) { public Map<Month, Long> getMonthlyJobCountsForCustomer(int year, String customer) {
Map<Month, Long> monthlyCounts = new LinkedHashMap<>(); return buildMonthlyJobCounts(filterJobsByYear(getJobsByCustomer(customer), year));
LocalDateTime yearStart = LocalDateTime.of(year, 1, 1, 0, 0);
LocalDateTime yearEnd = LocalDateTime.of(year, 12, 31, 23, 59, 59);
List<Job> customerJobs = getJobsByCustomer(customer).stream().filter(j -> j.getCreatedAt() != null
&& !j.getCreatedAt().isBefore(yearStart) && !j.getCreatedAt().isAfter(yearEnd)).toList();
// Initialize all months with 0
for (Month month : Month.values()) {
monthlyCounts.put(month, 0L);
} }
// Count jobs per month public Map<Month, Long> getMonthlyJobCountsForCustomerForUser(int year, String createdBy, String customer) {
for (Job job : customerJobs) { return buildMonthlyJobCounts(filterJobsByYear(getJobsByCustomerForUser(createdBy, customer), year));
Month month = job.getCreatedAt().getMonth();
monthlyCounts.computeIfPresent(month, (k, v) -> v + 1L);
}
return monthlyCounts;
} }
/** /**
@@ -306,6 +356,14 @@ public class JobStatisticsService {
return customerJobs.stream().filter(j -> j.getStatus() == status).toList(); return customerJobs.stream().filter(j -> j.getStatus() == status).toList();
} }
public List<Job> getJobsByCustomerAndStatusForUser(String createdBy, String customer, JobStatus status) {
List<Job> customerJobs = getJobsByCustomerForUser(createdBy, customer);
if (status == null) {
return customerJobs;
}
return customerJobs.stream().filter(job -> job.getStatus() == status).toList();
}
/** /**
* Find best matching customer name from query (fuzzy matching). * Find best matching customer name from query (fuzzy matching).
*/ */
@@ -316,6 +374,20 @@ public class JobStatisticsService {
String lowerQuery = query.toLowerCase(); String lowerQuery = query.toLowerCase();
List<String> allCustomers = getAllCustomerNames(); List<String> allCustomers = getAllCustomerNames();
return findMatchingCustomer(query, lowerQuery, allCustomers);
}
public String findMatchingCustomerForUser(String createdBy, String query) {
if (query == null || query.isBlank()) {
return null;
}
String lowerQuery = query.toLowerCase();
List<String> allCustomers = getAllCustomerNamesForUser(createdBy);
return findMatchingCustomer(query, lowerQuery, allCustomers);
}
private String findMatchingCustomer(String query, String lowerQuery, List<String> allCustomers) {
log.debug("findMatchingCustomer - Query: '{}', Available customers: {}", query, allCustomers); log.debug("findMatchingCustomer - Query: '{}', Available customers: {}", query, allCustomers);
// First: exact match (case insensitive) // First: exact match (case insensitive)
@@ -370,6 +442,113 @@ public class JobStatisticsService {
return null; return null;
} }
private List<Job> getJobsCreatedByUser(String createdBy) {
if (createdBy == null || createdBy.isBlank()) {
return List.of();
}
return jobRepository.findByCreatedBy(createdBy);
}
private Map<JobStatus, Long> countJobsByStatus(List<Job> jobs) {
Map<JobStatus, Long> counts = new EnumMap<>(JobStatus.class);
for (JobStatus status : JobStatus.values()) {
counts.put(status, 0L);
}
for (Job job : jobs) {
if (job.getStatus() != null) {
counts.computeIfPresent(job.getStatus(), (key, value) -> value + 1L);
}
}
return counts;
}
private BigDecimal sumRevenue(List<Job> jobs) {
return jobs.stream().map(Job::getPrice).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
}
private void mergeRevenueByCustomer(Map<String, BigDecimal> revenueByCustomer, List<Job> jobs) {
for (Job job : jobs) {
String customer = job.getCustomerSelection();
if (customer != null && job.getPrice() != null) {
revenueByCustomer.merge(customer, job.getPrice(), BigDecimal::add);
}
}
}
private double calculateCompletionRate(List<Job> jobs) {
if (jobs.isEmpty()) {
return 0.0;
}
long completed = jobs.stream().filter(job -> job.getStatus() == JobStatus.COMPLETED).count();
return (double) completed / jobs.size() * 100.0;
}
private List<Job> filterJobsByYear(List<Job> jobs, int year) {
LocalDateTime yearStart = LocalDateTime.of(year, 1, 1, 0, 0);
LocalDateTime yearEnd = LocalDateTime.of(year, 12, 31, 23, 59, 59);
return jobs.stream().filter(job -> job.getCreatedAt() != null && !job.getCreatedAt().isBefore(yearStart)
&& !job.getCreatedAt().isAfter(yearEnd)).toList();
}
private Map<Month, Long> buildMonthlyJobCounts(List<Job> jobs) {
Map<Month, Long> monthlyCounts = new LinkedHashMap<>();
for (Month month : Month.values()) {
monthlyCounts.put(month, 0L);
}
for (Job job : jobs) {
if (job.getCreatedAt() != null) {
monthlyCounts.computeIfPresent(job.getCreatedAt().getMonth(), (key, value) -> value + 1L);
}
}
return monthlyCounts;
}
private List<Job> filterJobsByDateRange(List<Job> jobs, LocalDateTime start, LocalDateTime end) {
return jobs.stream().filter(job -> job.getCreatedAt() != null && !job.getCreatedAt().isBefore(start)
&& !job.getCreatedAt().isAfter(end)).toList();
}
private List<Job> filterJobsByCustomer(List<Job> jobs, String customer) {
if (customer == null || customer.isBlank()) {
return List.of();
}
String normalizedCustomer = customer.trim();
List<Job> exactMatches = jobs.stream().filter(job -> job.getCustomerSelection() != null
&& job.getCustomerSelection().trim().equalsIgnoreCase(normalizedCustomer)).toList();
if (!exactMatches.isEmpty()) {
log.debug("filterJobsByCustomer('{}') - exact match found: {}", customer, exactMatches.size());
return exactMatches;
}
String lowerCustomer = normalizedCustomer.toLowerCase();
List<Job> partialMatches = jobs.stream().filter(job -> job.getCustomerSelection() != null
&& job.getCustomerSelection().toLowerCase().contains(lowerCustomer)).toList();
log.debug("filterJobsByCustomer('{}') - partial match found: {}", customer, partialMatches.size());
return partialMatches;
}
private Map<String, Long> buildTaskCompletionStats(List<Job> jobs) {
Map<String, Long> stats = new HashMap<>();
stats.put("completed", 0L);
stats.put("pending", 0L);
stats.put("total", 0L);
List<ObjectId> jobIds = jobs.stream().map(Job::getId).filter(Objects::nonNull).toList();
if (jobIds.isEmpty()) {
return stats;
}
List<BaseTask> tasks = taskRepository.findByJobIdIn(jobIds);
long completed = tasks.stream().filter(BaseTask::isCompleted).count();
long total = tasks.size();
stats.put("completed", completed);
stats.put("pending", total - completed);
stats.put("total", total);
return stats;
}
/** /**
* Extract potential customer name from a query string. Looks for patterns like * Extract potential customer name from a query string. Looks for patterns like
* "firma X", "kunde X", "für X", etc. * "firma X", "kunde X", "für X", etc.

View File

@@ -729,7 +729,7 @@ statistics.quick.jobcount.prompt=Wie viele Aufträge habe ich aktuell?
statistics.quick.revenue=Umsatz statistics.quick.revenue=Umsatz
statistics.quick.revenue.prompt=Wie hoch ist mein Umsatz diesen Monat? statistics.quick.revenue.prompt=Wie hoch ist mein Umsatz diesen Monat?
statistics.quick.trend=Trends statistics.quick.trend=Trends
statistics.quick.trend.prompt=Zeige mir Trends in den letzten 3 Monaten statistics.quick.trend.prompt=Zeige mir Trends in den letzten 3 Monaten als Balkendiagramm
statistics.ai.label=KI-Antwort statistics.ai.label=KI-Antwort
statistics.data.fetched=Daten wurden abgerufen statistics.data.fetched=Daten wurden abgerufen
statistics.loading=Berechne... statistics.loading=Berechne...