Erweiterungen

This commit is contained in:
2026-02-19 12:08:56 +01:00
parent 8b7256232f
commit 0aced91206
7 changed files with 219 additions and 54 deletions

View File

@@ -749,7 +749,7 @@ public class AddJobView extends Main {
return service.getVatRate().multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP) + " %";
}
return "";
}).setHeader("MwSt").setSortable(true);
}).setHeader("USt").setSortable(true);
servicesGrid.addComponentColumn(service -> {
// Verbindliche Leistungen können nicht gelöscht werden
if (service.isMandatory()) {
@@ -913,6 +913,10 @@ public class AddJobView extends Main {
* route distance (for distance-based services).
*/
private BigDecimal calculateServicePrice(Service service, Double routeDistance) {
return calculateServicePrice(service, routeDistance, null);
}
private BigDecimal calculateServicePrice(Service service, Double routeDistance, Integer durationSeconds) {
if (service.getCalculationBasis() == null) {
return BigDecimal.ZERO;
}
@@ -928,9 +932,13 @@ public class AddJobView extends Main {
return BigDecimal.ZERO;
case TIME:
// For time-based services, we would need time units
// For now, return the price per 15 minutes as base value
return service.getPricePer15Minutes() != null ? service.getPricePer15Minutes() : BigDecimal.ZERO;
if (service.getPricePer15Minutes() != null && durationSeconds != null && durationSeconds > 0) {
// Dauer in 15-Minuten-Einheiten umrechnen (aufrunden)
int units = durationSeconds / 900; // 900 Sekunden = 15 Minuten
if (durationSeconds % 900 > 0) units++; // Aufrunden
return service.getPricePer15Minutes().multiply(BigDecimal.valueOf(units));
}
return BigDecimal.ZERO;
default:
return BigDecimal.ZERO;
@@ -1185,15 +1193,7 @@ public class AddJobView extends Main {
binder.forField(deliveryCity).asRequired("").bind(Job::getDeliveryCity, Job::setDeliveryCity);
// Price is now calculated from selected services - bind to job price for
// storage
binder.forField(new com.vaadin.flow.component.textfield.TextField()).withConverter((String str) -> {
// Calculate total from selected services
BigDecimal total = selectedServices.stream()
.map(svc -> svc.getEffectivePrice() != null ? svc.getEffectivePrice() : BigDecimal.ZERO)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return total;
}, (java.math.BigDecimal bd) -> bd == null ? "" : bd.toString()).bind(Job::getPrice, Job::setPrice);
// Price wird manuell in submit() berechnet und gesetzt - kein Binder notwendig
// Bind date picker fields with validation
binder.forField(pickupDate).asRequired("")
@@ -1501,30 +1501,31 @@ public class AddJobView extends Main {
if (remarkArea != null)
job.setRemark(remarkArea.getValue());
// Calculate price from selected services
BigDecimal totalPrice = selectedServices.stream()
.map(s -> s.getEffectivePrice() != null ? s.getEffectivePrice() : BigDecimal.ZERO)
.reduce(BigDecimal.ZERO, BigDecimal::add);
job.setPrice(totalPrice);
// Store selected service IDs in job for invoice creation
job.setServiceIds(selectedServices.stream().map(Service::getId).toList());
// Store route distance and duration in job for invoice creation
if (routeCalculationResult != null && routeCalculationResult.isValid()) {
// Berechnete Route verwenden
job.setRouteDistanceKm(routeCalculationResult.getDistanceKm());
job.setRouteDurationSeconds(routeCalculationResult.getDurationSeconds());
} else if (manualDistanceInput != null && manualDistanceInput.getValue() != null) {
// Manuelle Eingabe verwenden
job.setRouteDistanceKm(manualDistanceInput.getValue());
if (manualDurationInput != null && manualDurationInput.getValue() != null) {
job.setRouteDurationSeconds(manualDurationInput.getValue() * 60); // Minuten in Sekunden umrechnen
}
}
// Validate all required fields using the binder
if (binder.writeBeanIfValid(job)) {
// Preis nach dem Binder-Call berechnen (damit er nicht überschrieben wird)
Double routeDistance = getEffectiveRouteDistance();
Integer durationSeconds = getEffectiveRouteDuration();
BigDecimal netTotal = selectedServices.stream()
.map(s -> calculateServicePrice(s, routeDistance, durationSeconds))
.reduce(BigDecimal.ZERO, BigDecimal::add);
job.setPrice(netTotal);
// Store route distance and duration in job for invoice creation
if (routeCalculationResult != null && routeCalculationResult.isValid()) {
// Berechnete Route verwenden
job.setRouteDistanceKm(routeCalculationResult.getDistanceKm());
job.setRouteDurationSeconds(routeCalculationResult.getDurationSeconds());
} else if (manualDistanceInput != null && manualDistanceInput.getValue() != null) {
// Manuelle Eingabe verwenden
job.setRouteDistanceKm(manualDistanceInput.getValue());
if (manualDurationInput != null && manualDurationInput.getValue() != null) {
job.setRouteDurationSeconds(manualDurationInput.getValue() * 60); // Minuten in Sekunden umrechnen
}
}
// Additional validation: If digital processing is enabled, app user must be
// selected
if (digitalProcessing.getValue() && appUser.getValue() == null) {
@@ -3263,6 +3264,21 @@ public class AddJobView extends Main {
return null;
}
/**
* Gibt die effektive Fahrtzeit zurück (berechnet oder manuell eingegeben).
*
* @return Dauer in Sekunden oder null, wenn keine Dauer verfügbar
*/
private Integer getEffectiveRouteDuration() {
if (routeCalculationResult != null && routeCalculationResult.isValid()) {
return routeCalculationResult.getDurationSeconds();
}
if (manualDurationInput != null && manualDurationInput.getValue() != null) {
return manualDurationInput.getValue() * 60; // Minuten in Sekunden
}
return null;
}
/**
* Registriert ValueChangeListener für alle Adressfelder, um bei Änderungen die
* Streckeninformationen zurückzusetzen.

View File

@@ -519,6 +519,40 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
variables.put("job.distance_km", "-");
}
// Services data - add as JSON array for the template
List<Map<String, String>> servicesData = new ArrayList<>();
for (Service service : getSelectedServices()) {
Map<String, String> serviceData = new HashMap<>();
serviceData.put("name", service.getName());
// Calculate price based on calculation basis
BigDecimal price = calculateServicePrice(service);
if (price != null) {
serviceData.put("netAmount", price.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ","));
} else {
serviceData.put("netAmount", "0,00");
}
// VAT rate
if (service.getVatRate() != null) {
serviceData.put("vatRate", service.getVatRate().multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP) + "%");
} else {
serviceData.put("vatRate", "19%");
}
servicesData.add(serviceData);
}
// Serialize services data to JSON and store in variables
if (!servicesData.isEmpty()) {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
variables.put("services.json", mapper.writeValueAsString(servicesData));
variables.put("services.count", String.valueOf(servicesData.size()));
} else {
variables.put("services.json", "[]");
variables.put("services.count", "0");
}
// Generate PDF using CustomerInvoiceService
return customerInvoiceService.generatePdfFromCanvasTemplateWithData(templateData, variables, currentUser);
}

View File

@@ -39,6 +39,8 @@ import de.assecutor.votianlt.repository.SignatureRepository;
import de.assecutor.votianlt.repository.BarcodeRepository;
import de.assecutor.votianlt.repository.PhotoRepository;
import de.assecutor.votianlt.repository.CommentRepository;
import de.assecutor.votianlt.repository.ServiceRepository;
import de.assecutor.votianlt.model.Service;
import de.assecutor.votianlt.model.Signature;
import de.assecutor.votianlt.model.Barcode;
import de.assecutor.votianlt.model.Photo;
@@ -55,6 +57,7 @@ import org.bson.types.ObjectId;
import org.springframework.beans.factory.annotation.Value;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
@@ -76,6 +79,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
private final AppUserService appUserService;
private final JobHistoryService jobHistoryService;
private final LocationService locationService;
private final ServiceRepository serviceRepository;
@Value("${app.google.maps.api-key}")
private String googleMapsApiKey;
@@ -86,7 +90,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
public JobSummaryView(JobRepository jobRepository, CargoItemRepository cargoItemRepository,
TaskRepository taskRepository, SignatureRepository signatureRepository, BarcodeRepository barcodeRepository,
PhotoRepository photoRepository, CommentRepository commentRepository, AppUserService appUserService,
MessageService messageService, JobHistoryService jobHistoryService, LocationService locationService) {
MessageService messageService, JobHistoryService jobHistoryService, LocationService locationService,
ServiceRepository serviceRepository) {
this.jobRepository = jobRepository;
this.cargoItemRepository = cargoItemRepository;
this.taskRepository = taskRepository;
@@ -97,6 +102,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
this.appUserService = appUserService;
this.jobHistoryService = jobHistoryService;
this.locationService = locationService;
this.serviceRepository = serviceRepository;
setSizeFull();
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
@@ -260,7 +266,13 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
VerticalLayout infoBox = borderedBox();
infoBox.add(new H3("Weitere Informationen"));
infoBox.add(new Span("Preis: " + (job.getPrice() != null ? formatPrice(job.getPrice()) : "-")));
// Preis basierend auf den hinterlegten Leistungen berechnen
PriceCalculationResult priceResult = calculatePriceFromServices(job);
infoBox.add(new Span("Netto: " + formatPrice(priceResult.netAmount())));
infoBox.add(new Span("USt: " + formatPrice(priceResult.vatAmount())));
infoBox.add(new Span("Gesamt: " + formatPrice(priceResult.totalAmount())));
if (job.getRemark() != null && !job.getRemark().isBlank()) {
infoBox.add(new Span("Bemerkung: " + job.getRemark()));
}
@@ -1176,4 +1188,75 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
private String getGoogleMapsApiKey() {
return googleMapsApiKey != null ? googleMapsApiKey : "";
}
/**
* Berechnet den Preis basierend auf den hinterlegten Leistungen des Jobs.
*/
private PriceCalculationResult calculatePriceFromServices(Job job) {
BigDecimal netTotal = BigDecimal.ZERO;
BigDecimal vatTotal = BigDecimal.ZERO;
List<String> serviceIds = job.getServiceIds();
if (serviceIds == null || serviceIds.isEmpty()) {
// Fallback auf gespeicherten Preis
BigDecimal price = job.getPrice() != null ? job.getPrice() : BigDecimal.ZERO;
return new PriceCalculationResult(price, BigDecimal.ZERO, price);
}
// Routendaten für die Berechnung
Double routeDistance = job.getRouteDistanceKm();
Integer durationSeconds = job.getRouteDurationSeconds();
for (String serviceId : serviceIds) {
Service service = serviceRepository.findById(serviceId).orElse(null);
if (service == null) continue;
BigDecimal price = calculateServicePrice(service, routeDistance, durationSeconds);
BigDecimal vatRate = service.getVatRate() != null ? service.getVatRate() : new BigDecimal("0.19");
netTotal = netTotal.add(price);
vatTotal = vatTotal.add(price.multiply(vatRate));
}
BigDecimal totalAmount = netTotal.add(vatTotal);
return new PriceCalculationResult(netTotal, vatTotal, totalAmount);
}
/**
* Berechnet den Preis für eine einzelne Leistung basierend auf ihrer Berechnungsgrundlage.
*/
private BigDecimal calculateServicePrice(Service service, Double routeDistance, Integer durationSeconds) {
if (service.getCalculationBasis() == null) {
return BigDecimal.ZERO;
}
switch (service.getCalculationBasis()) {
case FLAT_RATE:
return service.getPrice() != null ? service.getPrice() : BigDecimal.ZERO;
case DISTANCE:
if (service.getPricePerKilometer() != null && routeDistance != null && routeDistance > 0) {
return service.getPricePerKilometer().multiply(BigDecimal.valueOf(routeDistance));
}
return BigDecimal.ZERO;
case TIME:
if (service.getPricePer15Minutes() != null && durationSeconds != null && durationSeconds > 0) {
// Dauer in 15-Minuten-Einheiten umrechnen
int units = durationSeconds / 900; // 900 Sekunden = 15 Minuten
if (durationSeconds % 900 > 0) units++; // Aufrunden
return service.getPricePer15Minutes().multiply(BigDecimal.valueOf(units));
}
return BigDecimal.ZERO;
default:
return BigDecimal.ZERO;
}
}
/**
* Record für die Preisberechnungsergebnisse.
*/
private record PriceCalculationResult(BigDecimal netAmount, BigDecimal vatAmount, BigDecimal totalAmount) {
}
}

View File

@@ -84,7 +84,7 @@ public class CustomerInvoiceService {
// Rechnungsposten
List<CustomerInvoiceItem> items = new ArrayList<>();
BigDecimal vatRate = new BigDecimal("0.19"); // 19% MwSt.
BigDecimal vatRate = new BigDecimal("0.19"); // 19% USt.
CustomerInvoiceItem item1 = new CustomerInvoiceItem(new BigDecimal("2"), "Std.", "Transportdienstleistung",
new BigDecimal("85.00"), vatRate);
@@ -739,13 +739,19 @@ public class CustomerInvoiceService {
String grossTotal = variables.getOrDefault("invoice.gross_total", "0,00 €");
String vatRate = variables.getOrDefault("invoice.vat_rate", "19%");
// Sample data for now - in the future this would come from actual job services
// Parse the net total to get individual service amounts (split evenly for demo)
String[][] serviceData = {
{"Leistung 1", vatRate, "450,00 €"},
{"Leistung 2", vatRate, "85,00 €"},
{"Leistung 3", vatRate, "120,00 €"}
};
// Parse services JSON from variables
java.util.List<java.util.Map<String, String>> servicesData = new java.util.ArrayList<>();
String servicesJson = variables.get("services.json");
if (servicesJson != null && !servicesJson.isEmpty() && !servicesJson.equals("[]")) {
try {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.core.type.TypeReference<java.util.List<java.util.Map<String, String>>> typeRef =
new com.fasterxml.jackson.core.type.TypeReference<>() {};
servicesData = mapper.readValue(servicesJson, typeRef);
} catch (Exception e) {
System.err.println("DEBUG: Failed to parse services JSON: " + e.getMessage());
}
}
// Wrapper div
html.append("<div style='width:100%;box-sizing:border-box;'>");
@@ -760,14 +766,26 @@ public class CustomerInvoiceService {
html.append("<th style='text-align:right;padding:4px 8px;font-weight:bold;width:25%;white-space:nowrap;'>Nettobetrag</th>");
html.append("</tr>");
// Data rows - use actual service data
for (int i = 0; i < serviceData.length; i++) {
String bgColor = (i % 2 == 1) ? "background-color:rgba(0,0,0,0.02);" : "";
html.append("<tr style='").append(bgColor).append("border-bottom:1px solid #eeeeee;'>");
html.append("<td style='text-align:left;padding:4px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;'>").append(serviceData[i][0]).append("</td>");
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;'>").append(serviceData[i][1]).append("</td>");
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;'>").append(serviceData[i][2]).append("</td>");
// Data rows - use actual service data from the job
if (servicesData.isEmpty()) {
// Fallback: show a single row with no data
html.append("<tr style='border-bottom:1px solid #eeeeee;'>");
html.append("<td colspan='3' style='text-align:center;padding:4px 8px;white-space:nowrap;'>Keine Leistungen vorhanden</td>");
html.append("</tr>");
} else {
for (int i = 0; i < servicesData.size(); i++) {
java.util.Map<String, String> service = servicesData.get(i);
String name = service.getOrDefault("name", "Unbekannte Leistung");
String serviceVatRate = service.getOrDefault("vatRate", vatRate);
String netAmount = service.getOrDefault("netAmount", "0,00");
String bgColor = (i % 2 == 1) ? "background-color:rgba(0,0,0,0.02);" : "";
html.append("<tr style='").append(bgColor).append("border-bottom:1px solid #eeeeee;'>");
html.append("<td style='text-align:left;padding:4px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;'>").append(escapeHtml(name)).append("</td>");
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;'>").append(serviceVatRate).append("</td>");
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;'>").append(netAmount).append(" €</td>");
html.append("</tr>");
}
}
html.append("</table>");
@@ -806,4 +824,18 @@ public class CustomerInvoiceService {
return html.toString();
}
/**
* Escape HTML special characters to prevent XSS.
*/
private String escapeHtml(String input) {
if (input == null) {
return "";
}
return input.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#x27;");
}
}

View File

@@ -423,7 +423,7 @@
<td class="amount-col">${invoiceData.netAmount}</td>
</tr>
<tr>
<td class="label-col">zzgl. ${invoiceData.vatRate} MwSt.:</td>
<td class="label-col">zzgl. ${invoiceData.vatRate} USt.:</td>
<td class="amount-col">${invoiceData.vatAmount}</td>
</tr>
<tr class="total-row">

View File

@@ -75,7 +75,7 @@
<td></td><td style="text-align:left">Nettobetrag</td><td></td><td>5.639,00 €</td>
</tr>
<tr>
<td></td><td style="text-align:left">+ 19% MwSt.</td><td></td><td>1.071,41 €</td>
<td></td><td style="text-align:left">+ 19% USt.</td><td></td><td>1.071,41 €</td>
</tr>
<tr class="total-row">
<td></td><td style="text-align:left">Endbetrag</td><td></td><td>6.710,41 €</td>