diff --git a/HANDBUCH.md b/HANDBUCH.md index 0d15159..1b3eff8 100644 --- a/HANDBUCH.md +++ b/HANDBUCH.md @@ -161,7 +161,7 @@ Aufgaben definieren, welche Schritte der App-Nutzer bei der Ausführung des Auft 1. **Pflichtleistungen** werden automatisch geladen. 2. Über die Dropdown-Liste können Sie weitere **Leistungen hinzufügen**. -3. Die Berechnung von **Netto**, **MwSt.** und **Brutto** erfolgt automatisch. +3. Die Berechnung von **Netto**, **USt.** und **Brutto** erfolgt automatisch. 4. Die **Routeninformationen** (Entfernung und Fahrtdauer) werden aus der Adressvalidierung angezeigt. 5. Im Feld **Bemerkung** können Sie zusätzliche Anmerkungen zum Auftrag hinterlegen. @@ -320,7 +320,7 @@ Jede Konversation zeigt eine Vorschau, den Zeitpunkt der letzten Nachricht und d - **Auftragsdetails** (Abhol- und Zustelladresse, Termine) - **Leistungsdaten**: Geben Sie Kilometer und Zeitaufwand ein - **Leistungen**: Wählen Sie die abzurechnenden Leistungen aus Ihrem Leistungskatalog - - **Zusammenfassung**: Automatische Berechnung von Netto, MwSt. und Brutto + - **Zusammenfassung**: Automatische Berechnung von Netto, USt. und Brutto 5. Klicken Sie auf **"Rechnung erstellen"**. ### 8.2 Meine Rechnungen @@ -386,7 +386,7 @@ Verwalten Sie Ihren **Leistungskatalog**, der bei der Auftragserstellung und Rec 1. Klicken Sie auf **"Leistung hinzufügen"**, um eine neue Leistung anzulegen. 2. Wählen Sie die **Leistungsbezeichnung** aus oder geben Sie eine neue ein. -3. Geben Sie den **Preis** und den **MwSt.-Satz** ein. +3. Geben Sie den **Preis** und den **USt.-Satz** ein. 4. Über das Papierkorb-Symbol können Sie einzelne Leistungen entfernen. Rechts neben dem Formular wird eine **Live-Vorschau** Ihrer Rechnungsvorlage angezeigt, die sich bei Änderungen automatisch aktualisiert. diff --git a/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java b/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java index bfb411e..58ea67e 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java @@ -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. diff --git a/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java b/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java index c358ca6..9a18c4c 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java @@ -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> servicesData = new ArrayList<>(); + for (Service service : getSelectedServices()) { + Map 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); } diff --git a/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java b/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java index 53170b7..f15f9a8 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java @@ -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 { 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 { 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 { 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 { 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 { 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 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) { + } } diff --git a/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java b/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java index 0c8b27e..e25c035 100644 --- a/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java +++ b/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java @@ -84,7 +84,7 @@ public class CustomerInvoiceService { // Rechnungsposten List 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> 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>> 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("
"); @@ -760,14 +766,26 @@ public class CustomerInvoiceService { html.append("Nettobetrag"); html.append(""); - // 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(""); - html.append("").append(serviceData[i][0]).append(""); - html.append("").append(serviceData[i][1]).append(""); - html.append("").append(serviceData[i][2]).append(""); + // Data rows - use actual service data from the job + if (servicesData.isEmpty()) { + // Fallback: show a single row with no data + html.append(""); + html.append("Keine Leistungen vorhanden"); html.append(""); + } else { + for (int i = 0; i < servicesData.size(); i++) { + java.util.Map 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(""); + html.append("").append(escapeHtml(name)).append(""); + html.append("").append(serviceVatRate).append(""); + html.append("").append(netAmount).append(" €"); + html.append(""); + } } html.append(""); @@ -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("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } } diff --git a/src/main/resources/templates/customer_invoice.html b/src/main/resources/templates/customer_invoice.html index 2d708e1..b43642e 100644 --- a/src/main/resources/templates/customer_invoice.html +++ b/src/main/resources/templates/customer_invoice.html @@ -423,7 +423,7 @@ ${invoiceData.netAmount} - zzgl. ${invoiceData.vatRate} MwSt.: + zzgl. ${invoiceData.vatRate} USt.: ${invoiceData.vatAmount} diff --git a/src/main/resources/templates/system_invoice.html b/src/main/resources/templates/system_invoice.html index 9c91e78..1433397 100644 --- a/src/main/resources/templates/system_invoice.html +++ b/src/main/resources/templates/system_invoice.html @@ -75,7 +75,7 @@ Nettobetrag5.639,00 € - + 19% MwSt.1.071,41 € + + 19% USt.1.071,41 € Endbetrag6.710,41 €