findByJobIdAndStationOrderOrderByTaskOrderAsc(ObjectId jobId, int stationOrder);
/**
diff --git a/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java b/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java
index d4dec25..53ca97d 100644
--- a/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java
+++ b/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java
@@ -821,8 +821,8 @@ public class CustomerInvoiceService {
html.append(
"| ")
.append(escapeHtml(name)).append(" | ");
- html.append("").append(netAmount)
- .append(" € | ");
+ html.append("")
+ .append(netAmount).append(" € | ");
html.append("");
}
}
diff --git a/src/main/java/de/assecutor/votianlt/service/EmailService.java b/src/main/java/de/assecutor/votianlt/service/EmailService.java
index 0f8c89b..432bdf0 100644
--- a/src/main/java/de/assecutor/votianlt/service/EmailService.java
+++ b/src/main/java/de/assecutor/votianlt/service/EmailService.java
@@ -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;
diff --git a/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java b/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java
index 72fca38..ad2a62e 100644
--- a/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java
+++ b/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java
@@ -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 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 revenueByCustomer = new HashMap<>();
List allJobs = jobRepository.findAll();
- for (Job job : allJobs) {
- String customer = job.getCustomerSelection();
- if (customer != null && job.getPrice() != null) {
- revenueByCustomer.merge(customer, job.getPrice(), BigDecimal::add);
- }
- }
-
+ 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 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 getJobCountsByStatusForUser(String createdBy) {
+ return countJobsByStatus(getJobsCreatedByUser(createdBy));
+ }
+
+ public long getTotalJobCountForUser(String createdBy) {
+ return getJobsCreatedByUser(createdBy).size();
+ }
+
+ public double getCompletionRateForUser(String createdBy) {
+ List 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> getTopCustomersByRevenueForUser(String createdBy, int limit) {
+ return getRevenueByCustomerForUser(createdBy).entrySet().stream()
+ .sorted((a, b) -> b.getValue().compareTo(a.getValue())).limit(limit).toList();
+ }
+
+ public List> getTopCustomersByRevenueForUserInRange(String createdBy,
+ LocalDateTime start, LocalDateTime end, int limit) {
+ Map 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 getRevenueByCustomerForUser(String createdBy) {
+ Map revenueByCustomer = new HashMap<>();
+ mergeRevenueByCustomer(revenueByCustomer, getJobsCreatedByUser(createdBy));
+ return revenueByCustomer;
+ }
+
+ public Map getMonthlyJobCountsForUser(int year, String createdBy) {
+ return buildMonthlyJobCounts(filterJobsByYear(getJobsCreatedByUser(createdBy), year));
+ }
+
+ public List getJobsByCustomerForUser(String createdBy, String customer) {
+ return filterJobsByCustomer(getJobsCreatedByUser(createdBy), customer);
+ }
+
+ public Map getTaskCompletionStatsForUser(String createdBy) {
+ return buildTaskCompletionStats(getJobsCreatedByUser(createdBy));
+ }
+
+ public List getJobsByStatusForUser(String createdBy, JobStatus status) {
+ return getJobsCreatedByUser(createdBy).stream().filter(job -> job.getStatus() == status).toList();
+ }
+
+ public List 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 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 getJobCountsByStatusForCustomer(String customer) {
List customerJobs = getJobsByCustomer(customer);
- Map counts = new EnumMap<>(JobStatus.class);
- for (JobStatus status : JobStatus.values()) {
- counts.put(status, 0L);
- }
- for (Job job : customerJobs) {
- counts.computeIfPresent(job.getStatus(), (k, v) -> v + 1L);
- }
- return counts;
+ return countJobsByStatus(customerJobs);
+ }
+
+ public Map 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 customerJobs = 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;
+ return calculateCompletionRate(getJobsByCustomer(customer));
+ }
+
+ 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 getMonthlyJobCountsForCustomer(int year, String customer) {
- Map monthlyCounts = new LinkedHashMap<>();
- LocalDateTime yearStart = LocalDateTime.of(year, 1, 1, 0, 0);
- LocalDateTime yearEnd = LocalDateTime.of(year, 12, 31, 23, 59, 59);
+ return buildMonthlyJobCounts(filterJobsByYear(getJobsByCustomer(customer), year));
+ }
- List 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
- for (Job job : customerJobs) {
- Month month = job.getCreatedAt().getMonth();
- monthlyCounts.computeIfPresent(month, (k, v) -> v + 1L);
- }
-
- return monthlyCounts;
+ public Map 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 getJobsByCustomerAndStatusForUser(String createdBy, String customer, JobStatus status) {
+ List 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 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 allCustomers = getAllCustomerNamesForUser(createdBy);
+ return findMatchingCustomer(query, lowerQuery, allCustomers);
+ }
+
+ private String findMatchingCustomer(String query, String lowerQuery, List 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 getJobsCreatedByUser(String createdBy) {
+ if (createdBy == null || createdBy.isBlank()) {
+ return List.of();
+ }
+ return jobRepository.findByCreatedBy(createdBy);
+ }
+
+ private Map countJobsByStatus(List jobs) {
+ Map 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 jobs) {
+ return jobs.stream().map(Job::getPrice).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
+ }
+
+ private void mergeRevenueByCustomer(Map revenueByCustomer, List 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 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 filterJobsByYear(List 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 buildMonthlyJobCounts(List jobs) {
+ Map 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 filterJobsByDateRange(List jobs, LocalDateTime start, LocalDateTime end) {
+ return jobs.stream().filter(job -> job.getCreatedAt() != null && !job.getCreatedAt().isBefore(start)
+ && !job.getCreatedAt().isAfter(end)).toList();
+ }
+
+ private List filterJobsByCustomer(List jobs, String customer) {
+ if (customer == null || customer.isBlank()) {
+ return List.of();
+ }
+
+ String normalizedCustomer = customer.trim();
+ List 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 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 buildTaskCompletionStats(List jobs) {
+ Map stats = new HashMap<>();
+ stats.put("completed", 0L);
+ stats.put("pending", 0L);
+ stats.put("total", 0L);
+
+ List jobIds = jobs.stream().map(Job::getId).filter(Objects::nonNull).toList();
+ if (jobIds.isEmpty()) {
+ return stats;
+ }
+
+ List 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.
diff --git a/src/main/java/de/assecutor/votianlt/service/TaskAssignmentService.java b/src/main/java/de/assecutor/votianlt/service/TaskAssignmentService.java
index ed4fbee..dbd856c 100644
--- a/src/main/java/de/assecutor/votianlt/service/TaskAssignmentService.java
+++ b/src/main/java/de/assecutor/votianlt/service/TaskAssignmentService.java
@@ -88,7 +88,7 @@ public class TaskAssignmentService {
}
return tasks.stream().filter(Objects::nonNull)
- .sorted(Comparator.comparingInt(task -> resolveStationOrder(task, stationOrderById))
+ .sorted(Comparator. comparingInt(task -> resolveStationOrder(task, stationOrderById))
.thenComparingInt(task -> task.getTaskOrder() != null ? task.getTaskOrder() : 0))
.toList();
}
diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties
index 7862a92..4eec798 100644
--- a/src/main/resources/messages.properties
+++ b/src/main/resources/messages.properties
@@ -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...