feat: refine statistics ai responses
This commit is contained in:
2
pom.xml
2
pom.xml
@@ -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>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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®ion=de", DIRECTIONS_API_URL,
|
String.format("%s?origin=%s&destination=%s&mode=driving&key=%s&language=de®ion=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());
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 -> {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ public class TaskAssignmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return tasks.stream().filter(Objects::nonNull)
|
return tasks.stream().filter(Objects::nonNull)
|
||||||
.sorted(Comparator.<BaseTask>comparingInt(task -> resolveStationOrder(task, stationOrderById))
|
.sorted(Comparator.<BaseTask> comparingInt(task -> resolveStationOrder(task, stationOrderById))
|
||||||
.thenComparingInt(task -> task.getTaskOrder() != null ? task.getTaskOrder() : 0))
|
.thenComparingInt(task -> task.getTaskOrder() != null ? task.getTaskOrder() : 0))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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...
|
||||||
|
|||||||
Reference in New Issue
Block a user