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>
<artifactId>votianlt</artifactId>
<version>0.9.9</version>
<version>0.9.10</version>
<packaging>jar</packaging>

View File

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

View File

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

View File

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

View File

@@ -101,10 +101,12 @@ public class AddJobService {
Map<Integer, ObjectId> stationIdByOrder = buildStationIdByOrder(savedJob);
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) {
int stationOrder = task.getStationOrder() != null ? task.getStationOrder() : 0;
ObjectId stationId = task.getStationId() != null ? task.getStationId() : stationIdByOrder.get(stationOrder);
ObjectId stationId = task.getStationId() != null ? task.getStationId()
: stationIdByOrder.get(stationOrder);
if (stationId == null) {
log.warn("Skipping task without resolvable stationId for job {} and stationOrder {}", jobId,
stationOrder);
@@ -259,7 +261,8 @@ public class AddJobService {
continue;
}
if (task.getStationId() != null) {
tasksByStationId.computeIfAbsent(task.getStationId().toHexString(), ignored -> new ArrayList<>()).add(task);
tasksByStationId.computeIfAbsent(task.getStationId().toHexString(), ignored -> new ArrayList<>())
.add(task);
continue;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -821,8 +821,8 @@ public class CustomerInvoiceService {
html.append(
"<td style='text-align:left;padding:4px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:75%;'>")
.append(escapeHtml(name)).append("</td>");
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:25%;'>").append(netAmount)
.append(" €</td>");
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:25%;'>")
.append(netAmount).append(" €</td>");
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.User;
import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.repository.TaskRepository;
import de.assecutor.votianlt.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -22,7 +21,6 @@ import java.util.Optional;
public class EmailService {
private final UserRepository userRepository;
private final JobRepository jobRepository;
private final TaskRepository taskRepository;
private final TaskAssignmentService taskAssignmentService;
private final JavaMailSender mailSender;

View File

@@ -2,20 +2,24 @@ package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.model.task.BaseTask;
import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.repository.TaskRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.bson.types.ObjectId;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.Month;
import java.util.Comparator;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
@@ -83,8 +87,7 @@ public class JobStatisticsService {
*/
public BigDecimal getTotalRevenue() {
List<Job> allJobs = jobRepository.findAll();
return allJobs.stream().map(Job::getPrice).filter(price -> price != null).reduce(BigDecimal.ZERO,
BigDecimal::add);
return sumRevenue(allJobs);
}
/**
@@ -94,13 +97,7 @@ public class JobStatisticsService {
Map<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);
}
}
mergeRevenueByCustomer(revenueByCustomer, allJobs);
return revenueByCustomer;
}
@@ -145,24 +142,7 @@ public class JobStatisticsService {
if (customer == null || customer.isBlank()) {
return List.of();
}
// Trim and escape regex special characters for MongoDB
String trimmedCustomer = customer.trim();
String escapedCustomer = trimmedCustomer.replaceAll("([.^$*+?()\\[\\]{}|\\\\])", "\\\\$1");
// First try exact match (with optional whitespace)
String exactRegex = "^\\s*" + escapedCustomer + "\\s*$";
List<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;
return filterJobsByCustomer(jobRepository.findAll(), customer);
}
/**
@@ -218,6 +198,72 @@ public class JobStatisticsService {
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 ====================
/**
@@ -228,19 +274,21 @@ public class JobStatisticsService {
.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.
*/
public Map<JobStatus, Long> getJobCountsByStatusForCustomer(String customer) {
List<Job> customerJobs = getJobsByCustomer(customer);
Map<JobStatus, Long> counts = new EnumMap<>(JobStatus.class);
for (JobStatus status : JobStatus.values()) {
counts.put(status, 0L);
return countJobsByStatus(customerJobs);
}
for (Job job : customerJobs) {
counts.computeIfPresent(job.getStatus(), (k, v) -> v + 1L);
}
return counts;
public Map<JobStatus, Long> getJobCountsByStatusForCustomerForUser(String createdBy, String customer) {
return countJobsByStatus(getJobsByCustomerForUser(createdBy, customer));
}
/**
@@ -250,49 +298,51 @@ public class JobStatisticsService {
return getJobsByCustomer(customer).size();
}
public long getTotalJobCountForCustomerForUser(String createdBy, String customer) {
return getJobsByCustomerForUser(createdBy, customer).size();
}
public long getTotalJobCountForCustomerForUserInRange(String createdBy, String customer, LocalDateTime start,
LocalDateTime end) {
return filterJobsByDateRange(getJobsByCustomerForUser(createdBy, customer), start, end).size();
}
/**
* Get total revenue for a customer.
*/
public BigDecimal getTotalRevenueForCustomer(String customer) {
return getJobsByCustomer(customer).stream().map(Job::getPrice).filter(price -> price != null)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return sumRevenue(getJobsByCustomer(customer));
}
public BigDecimal getTotalRevenueForCustomerForUser(String createdBy, String customer) {
return sumRevenue(getJobsByCustomerForUser(createdBy, customer));
}
public BigDecimal getTotalRevenueForCustomerForUserInRange(String createdBy, String customer, LocalDateTime start,
LocalDateTime end) {
return sumRevenue(filterJobsByDateRange(getJobsByCustomerForUser(createdBy, customer), start, end));
}
/**
* Get completion rate for a customer.
*/
public double getCompletionRateForCustomer(String customer) {
List<Job> customerJobs = getJobsByCustomer(customer);
if (customerJobs.isEmpty()) {
return 0.0;
return calculateCompletionRate(getJobsByCustomer(customer));
}
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.
*/
public Map<Month, Long> getMonthlyJobCountsForCustomer(int year, String customer) {
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> 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);
return buildMonthlyJobCounts(filterJobsByYear(getJobsByCustomer(customer), year));
}
// Count jobs per month
for (Job job : customerJobs) {
Month month = job.getCreatedAt().getMonth();
monthlyCounts.computeIfPresent(month, (k, v) -> v + 1L);
}
return monthlyCounts;
public Map<Month, Long> getMonthlyJobCountsForCustomerForUser(int year, String createdBy, String customer) {
return buildMonthlyJobCounts(filterJobsByYear(getJobsByCustomerForUser(createdBy, customer), year));
}
/**
@@ -306,6 +356,14 @@ public class JobStatisticsService {
return customerJobs.stream().filter(j -> j.getStatus() == status).toList();
}
public List<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).
*/
@@ -316,6 +374,20 @@ public class JobStatisticsService {
String lowerQuery = query.toLowerCase();
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);
// First: exact match (case insensitive)
@@ -370,6 +442,113 @@ public class JobStatisticsService {
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
* "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.prompt=Wie hoch ist mein Umsatz diesen Monat?
statistics.quick.trend=Trends
statistics.quick.trend.prompt=Zeige mir Trends in den letzten 3 Monaten
statistics.quick.trend.prompt=Zeige mir Trends in den letzten 3 Monaten als Balkendiagramm
statistics.ai.label=KI-Antwort
statistics.data.fetched=Daten wurden abgerufen
statistics.loading=Berechne...