From 9f7e0af6e0af2e87fbc37eba2681720d6574bebb Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Mon, 9 Mar 2026 16:21:24 +0100 Subject: [PATCH] feat: adapt station pricing and route handling --- .../java/de/assecutor/votianlt/model/Job.java | 6 +- .../votianlt/model/JobServiceSelection.java | 20 + .../de/assecutor/votianlt/model/Service.java | 6 +- .../ui/component/DeliveryStationDialog.java | 24 +- .../ui/component/PickupStationDialog.java | 4 - .../votianlt/pages/view/AddJobView.java | 411 ++++++++++++++---- .../pages/view/CreateInvoiceView.java | 138 +++--- .../votianlt/pages/view/EditProfileView.java | 83 +--- .../votianlt/pages/view/JobSummaryView.java | 29 +- .../service/CustomerInvoiceService.java | 36 +- src/main/resources/messages.properties | 4 +- src/main/resources/messages_en.properties | 4 +- .../resources/templates/customer_invoice.html | 6 +- 13 files changed, 514 insertions(+), 257 deletions(-) create mode 100644 src/main/java/de/assecutor/votianlt/model/JobServiceSelection.java diff --git a/src/main/java/de/assecutor/votianlt/model/Job.java b/src/main/java/de/assecutor/votianlt/model/Job.java index e772c61..4767ac2 100644 --- a/src/main/java/de/assecutor/votianlt/model/Job.java +++ b/src/main/java/de/assecutor/votianlt/model/Job.java @@ -148,6 +148,10 @@ public class Job { @Field("service_ids") private List serviceIds; + // Ausgewählte Leistungen inkl. zugeordneter Lieferstation und Berechnungsbasis + @Field("selected_services") + private List selectedServices = new ArrayList<>(); + // Streckeninformation für die Rechnung (in km) @Field("route_distance_km") private Double routeDistanceKm; @@ -227,4 +231,4 @@ public class Job { this.deliveryTime = first.getDeliveryTime(); } } -} \ No newline at end of file +} diff --git a/src/main/java/de/assecutor/votianlt/model/JobServiceSelection.java b/src/main/java/de/assecutor/votianlt/model/JobServiceSelection.java new file mode 100644 index 0000000..397c47d --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/model/JobServiceSelection.java @@ -0,0 +1,20 @@ +package de.assecutor.votianlt.model; + +import lombok.Data; +import org.springframework.data.mongodb.core.mapping.Field; + +@Data +public class JobServiceSelection { + + @Field("service_id") + private String serviceId; + + @Field("delivery_station_order") + private Integer deliveryStationOrder; + + @Field("route_distance_km") + private Double routeDistanceKm; + + @Field("route_duration_seconds") + private Integer routeDurationSeconds; +} diff --git a/src/main/java/de/assecutor/votianlt/model/Service.java b/src/main/java/de/assecutor/votianlt/model/Service.java index 06f2dbc..94276a0 100644 --- a/src/main/java/de/assecutor/votianlt/model/Service.java +++ b/src/main/java/de/assecutor/votianlt/model/Service.java @@ -6,6 +6,7 @@ import java.math.BigDecimal; @Document(collection = "services") public class Service { + public static final BigDecimal FIXED_VAT_RATE = new BigDecimal("0.19"); @Id private String id; @@ -16,7 +17,6 @@ public class Service { private BigDecimal price; // For FLAT_RATE services private BigDecimal pricePerKilometer; // For DISTANCE services - price per kilometer private BigDecimal pricePer15Minutes; // For TIME services - price per 15 minutes - private BigDecimal vatRate; private boolean mandatory; public enum CalculationBasis { @@ -36,7 +36,6 @@ public class Service { this.userId = userId; this.name = name; this.calculationBasis = calculationBasis; - this.vatRate = vatRate; this.mandatory = mandatory; // Set the appropriate price field based on calculation basis @@ -86,11 +85,10 @@ public class Service { } public BigDecimal getVatRate() { - return vatRate; + return FIXED_VAT_RATE; } public void setVatRate(BigDecimal vatRate) { - this.vatRate = vatRate; } public BigDecimal getPrice() { diff --git a/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationDialog.java b/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationDialog.java index a57331b..4b8d97c 100644 --- a/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationDialog.java +++ b/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationDialog.java @@ -28,7 +28,6 @@ import de.assecutor.votianlt.pages.service.AddressValidationService; import java.util.ArrayList; import java.util.List; -import java.util.Optional; import java.util.concurrent.CompletableFuture; /** @@ -199,7 +198,6 @@ public class DeliveryStationDialog extends Dialog { private Span tasksTabError; private final DeliveryStationTile.TranslationHelper translationHelper; - private final AddressValidationService addressValidationService; private final java.util.Map companyAddressOptions = new java.util.LinkedHashMap<>(); public DeliveryStationDialog(String dialogTitle, List customers, @@ -208,8 +206,6 @@ public class DeliveryStationDialog extends Dialog { AddressValidationService addressValidationService) { this.translationHelper = translationHelper; - this.addressValidationService = addressValidationService; - setHeaderTitle(dialogTitle); setCloseOnOutsideClick(false); setWidth("960px"); @@ -419,6 +415,7 @@ public class DeliveryStationDialog extends Dialog { if (data == null) return; String companyOption = findCompanyOptionLabel(data); + boolean customerSelectedFromOptions = companyOption != null; if (companyOption != null) { company.setValue(companyOption); } else if (data.getCompany() != null) { @@ -442,7 +439,8 @@ public class DeliveryStationDialog extends Dialog { zip.setValue(data.getZip()); if (data.getCity() != null) city.setValue(data.getCity()); - saveAddress.setValue(data.isSaveAddress()); + saveAddress.setValue(customerSelectedFromOptions ? false : data.isSaveAddress()); + updateSaveAddressState(customerSelectedFromOptions); // Load tasks into dialog state if (data.getTasks() != null && !data.getTasks().isEmpty()) { @@ -562,6 +560,7 @@ public class DeliveryStationDialog extends Dialog { companyField.addValueChangeListener(event -> { Customer customer = companyAddressOptions.get(event.getValue()); + updateSaveAddressState(customer != null); if (customer == null) { return; } @@ -589,7 +588,20 @@ public class DeliveryStationDialog extends Dialog { city.setValue(customer.getCity()); }); - companyField.addCustomValueSetListener(event -> companyField.setValue(event.getDetail())); + companyField.addCustomValueSetListener(event -> { + companyField.setValue(event.getDetail()); + updateSaveAddressState(false); + }); + } + + private void updateSaveAddressState(boolean customerSelectedFromOptions) { + if (customerSelectedFromOptions) { + saveAddress.setValue(false); + saveAddress.setEnabled(false); + return; + } + + saveAddress.setEnabled(true); } private String buildCompanyAddressLabel(Customer customer) { diff --git a/src/main/java/de/assecutor/votianlt/pages/base/ui/component/PickupStationDialog.java b/src/main/java/de/assecutor/votianlt/pages/base/ui/component/PickupStationDialog.java index 663f459..d1da820 100644 --- a/src/main/java/de/assecutor/votianlt/pages/base/ui/component/PickupStationDialog.java +++ b/src/main/java/de/assecutor/votianlt/pages/base/ui/component/PickupStationDialog.java @@ -250,14 +250,10 @@ public class PickupStationDialog extends Dialog { private Span cargoTabError; private final DeliveryStationTile.TranslationHelper translationHelper; - private final AddressValidationService addressValidationService; - public PickupStationDialog(String dialogTitle, List customers, DeliveryStationTile.TranslationHelper translationHelper, SaveListener saveListener, List availableAppUsers, AddressValidationService addressValidationService) { - this.addressValidationService = addressValidationService; - this.translationHelper = translationHelper; setHeaderTitle(dialogTitle); 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 63a2711..f502fe9 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java @@ -32,6 +32,7 @@ import com.vaadin.flow.theme.lumo.LumoUtility; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import de.assecutor.votianlt.model.Job; +import de.assecutor.votianlt.model.JobServiceSelection; import de.assecutor.votianlt.model.task.BarcodeTask; import de.assecutor.votianlt.model.task.BaseTask; import de.assecutor.votianlt.model.task.CommentTask; @@ -81,6 +82,32 @@ public class AddJobView extends Main implements HasDynamicTitle { private static final DateTimeFormatter PICKUP_PREVIEW_DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy"); private static final DateTimeFormatter PICKUP_PREVIEW_TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm"); + private static final class SelectedServiceEntry { + private final Service service; + private Integer deliveryStationOrder; + + private SelectedServiceEntry(Service service, Integer deliveryStationOrder) { + this.service = service; + this.deliveryStationOrder = deliveryStationOrder; + } + + public Service getService() { + return service; + } + + public Integer getDeliveryStationOrder() { + return deliveryStationOrder; + } + + public void setDeliveryStationOrder(Integer deliveryStationOrder) { + this.deliveryStationOrder = deliveryStationOrder; + } + } + + private record RouteCalculationBundle(RouteCalculationResult totalRoute, + Map deliveryRoutes) { + } + @Override public String getPageTitle() { return getTranslation("page.title.job.create"); @@ -115,8 +142,12 @@ public class AddJobView extends Main implements HasDynamicTitle { private final List deliveryStationTilesList = new ArrayList<>(); private final List deliveryStationsState = new ArrayList<>(); private final List deliveryStationsSaveAddress = new ArrayList<>(); + private final List
deliveryStationSlotList = new ArrayList<>(); + private final List deliveryStationDistanceChips = new ArrayList<>(); private Div stationsGridContainer; private Div addStationButton; + private Div addStationButtonSlot; + private Div pickupStationSlot; private StationTile pickupTile; private static final int MAX_DELIVERY_STATIONS = 7; @@ -125,10 +156,9 @@ public class AddJobView extends Main implements HasDynamicTitle { private ComboBox appUser; // Services for the job - private Grid servicesGrid; - private final List selectedServices = new ArrayList<>(); + private Grid servicesGrid; + private final List selectedServices = new ArrayList<>(); private Span netTotalLabel; - private Span vatTotalLabel; private Span grossTotalLabel; // Route distance display @@ -173,6 +203,7 @@ public class AddJobView extends Main implements HasDynamicTitle { private RouteCalculationResult routeCalculationResult; private boolean pickupAddressValidatedByGoogle; private final List deliveryStationsValidatedByGoogle = new ArrayList<>(); + private final Map pickupToDeliveryRouteResults = new HashMap<>(); public AddJobView(AddJobService addJobService, AddCustomerService addCustomerService, CustomerService customerService, AppUserService appUserService, TaskTemplateService taskTemplateService, @@ -342,10 +373,12 @@ public class AddJobView extends Main implements HasDynamicTitle { // Pickup tile (always present) pickupTile = new StationTile(StationTile.StationType.PICKUP, 0, getTranslation("addjob.section.pickup"), false); pickupTile.setClickListener(tile -> openPickupDialog()); - stationsGridContainer.add(pickupTile); + pickupStationSlot = createStationSlot(pickupTile, null); + stationsGridContainer.add(pickupStationSlot); // "+" add station button tile (must be created before addDeliveryStationTile) addStationButton = createAddStationButton(); + addStationButtonSlot = createStationSlot(addStationButton, null); // Add first delivery station tile (this will also add the "+" button) addDeliveryStationTile(); @@ -454,6 +487,12 @@ public class AddJobView extends Main implements HasDynamicTitle { manualDurationInput.setMin(0); manualDurationInput.setStep(1); manualDurationInput.setClearButtonVisible(true); + manualDurationInput.addValueChangeListener(e -> { + updatePriceSummary(); + if (servicesGrid != null) { + servicesGrid.getDataProvider().refreshAll(); + } + }); manualInputRow.add(manualDistanceInput, manualDurationInput); @@ -476,8 +515,12 @@ public class AddJobView extends Main implements HasDynamicTitle { servicesGrid.setHeight("250px"); servicesGrid.setItems(selectedServices); - servicesGrid.addColumn(Service::getName).setHeader(getTranslation("common.service")).setSortable(true); - servicesGrid.addColumn(service -> { + servicesGrid.addColumn(entry -> entry.getService().getName()).setHeader(getTranslation("common.service")) + .setSortable(true); + servicesGrid.addColumn(this::formatSelectedServiceStationLabel) + .setHeader(getTranslation("addjob.services.deliverystation")).setSortable(false); + servicesGrid.addColumn(entry -> { + Service service = entry.getService(); if (service.getCalculationBasis() != null) { return switch (service.getCalculationBasis()) { case DISTANCE -> getTranslation("addjob.services.basis.distance"); @@ -487,29 +530,28 @@ public class AddJobView extends Main implements HasDynamicTitle { } return ""; }).setHeader(getTranslation("addjob.services.calculation")).setSortable(true); - servicesGrid.addColumn(service -> { - // Get route distance for distance-based calculations (berechnet oder manuell) - Double routeDistance = getEffectiveRouteDistance(); - BigDecimal price = calculateServicePrice(service, routeDistance); + servicesGrid.addColumn(entry -> { + Service service = entry.getService(); + Double routeDistance = getEffectiveRouteDistance(entry); + Integer durationSeconds = getEffectiveRouteDuration(entry); + BigDecimal price = calculateServicePrice(service, routeDistance, durationSeconds); if (price.compareTo(BigDecimal.ZERO) > 0) { return price.setScale(2, RoundingMode.HALF_UP) + " €"; } - // Show price info if no route calculated yet if (service.getCalculationBasis() == Service.CalculationBasis.DISTANCE && routeDistance == null) { return service.getPricePerKilometer().setScale(2, RoundingMode.HALF_UP) + " €/km (" + getTranslation("addjob.services.route.missing") + ")"; } + if (service.getCalculationBasis() == Service.CalculationBasis.TIME && durationSeconds == null) { + return service.getPricePer15Minutes().setScale(2, RoundingMode.HALF_UP) + " €/15 Min. (" + + getTranslation("addjob.services.route.missing") + ")"; + } return service.getEffectivePrice() != null ? service.getEffectivePrice().setScale(2, RoundingMode.HALF_UP) + " €" : ""; }).setHeader(getTranslation("common.price")).setSortable(false); - servicesGrid.addColumn(service -> { - if (service.getVatRate() != null) { - return service.getVatRate().multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP) + " %"; - } - return ""; - }).setHeader(getTranslation("addjob.services.vat")).setSortable(true); - servicesGrid.addComponentColumn(service -> { + servicesGrid.addComponentColumn(entry -> { + Service service = entry.getService(); // Verbindliche Leistungen können nicht gelöscht werden if (service.isMandatory()) { return new Span(""); // Leeres Element statt Löschen-Button @@ -518,7 +560,7 @@ public class AddJobView extends Main implements HasDynamicTitle { removeButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL); removeButton.addClickListener(e -> { - selectedServices.remove(service); + selectedServices.remove(entry); servicesGrid.getDataProvider().refreshAll(); updatePriceSummary(); triggerValidation(); @@ -561,16 +603,6 @@ public class AddJobView extends Main implements HasDynamicTitle { netRow.add(netLabelSpan, netTotalLabel); priceTable.add(netRow); - // VAT total row - Div vatRow = new Div(); - vatRow.getStyle().set("display", "flex").set("justify-content", "space-between").set("padding", "4px 0"); - Span vatLabelSpan = new Span(getTranslation("addjob.summary.vat") + ":"); - vatLabelSpan.getStyle().set("padding-right", "8px"); - vatTotalLabel = new Span("0,00 €"); - vatTotalLabel.getStyle().set("font-weight", "bold").set("white-space", "nowrap"); - vatRow.add(vatLabelSpan, vatTotalLabel); - priceTable.add(vatRow); - // Gross total row Div grossRow = new Div(); grossRow.getStyle().set("display", "flex").set("justify-content", "space-between").set("padding", "4px 0"); @@ -630,6 +662,41 @@ public class AddJobView extends Main implements HasDynamicTitle { return button; } + private Div createStationSlot(Component content, Span distanceChip) { + Div slot = new Div(); + slot.getStyle().set("display", "flex"); + slot.getStyle().set("flex-direction", "column"); + slot.getStyle().set("align-items", "center"); + slot.getStyle().set("gap", "var(--lumo-space-s)"); + slot.setWidthFull(); + + content.getElement().getStyle().set("width", "100%"); + slot.add(content); + + if (distanceChip != null) { + slot.add(distanceChip); + } + + return slot; + } + + private Span createDeliveryDistanceChip() { + Span chip = new Span(); + chip.getStyle().set("display", "inline-flex"); + chip.getStyle().set("align-items", "center"); + chip.getStyle().set("justify-content", "center"); + chip.getStyle().set("padding", "0.35rem 0.75rem"); + chip.getStyle().set("border-radius", "999px"); + chip.getStyle().set("background-color", "var(--lumo-primary-color-10pct)"); + chip.getStyle().set("border", "1px solid var(--lumo-primary-color-30pct)"); + chip.getStyle().set("color", "var(--lumo-primary-text-color)"); + chip.getStyle().set("font-size", "var(--lumo-font-size-s)"); + chip.getStyle().set("font-weight", "600"); + chip.getStyle().set("text-align", "center"); + chip.setVisible(false); + return chip; + } + private void addDeliveryStationTile() { int stationNumber = deliveryStationTilesList.size() + 1; boolean removable = deliveryStationTilesList.size() > 0; // First station is not removable @@ -646,15 +713,20 @@ public class AddJobView extends Main implements HasDynamicTitle { tile.setClickListener(t -> openDeliveryDialog(t, stationIndex)); tile.setDeleteListener(this::removeDeliveryStationTile); + Span distanceChip = createDeliveryDistanceChip(); + Div stationSlot = createStationSlot(tile, distanceChip); + deliveryStationTilesList.add(tile); + deliveryStationSlotList.add(stationSlot); + deliveryStationDistanceChips.add(distanceChip); // Rebuild grid: remove plus button, add tile, re-add plus button - stationsGridContainer.remove(addStationButton); - stationsGridContainer.add(tile); + stationsGridContainer.remove(addStationButtonSlot); + stationsGridContainer.add(stationSlot); // Hide "+" button if max reached if (deliveryStationTilesList.size() < MAX_DELIVERY_STATIONS) { - stationsGridContainer.add(addStationButton); + stationsGridContainer.add(addStationButtonSlot); } resetRouteInformation(); @@ -683,6 +755,9 @@ public class AddJobView extends Main implements HasDynamicTitle { deliveryStationsSaveAddress.remove(removeIdx); deliveryStationsValidatedByGoogle.remove(removeIdx); deliveryStationTasksState.remove(removeIdx); + Div removedSlot = deliveryStationSlotList.remove(removeIdx); + deliveryStationDistanceChips.remove(removeIdx); + pickupToDeliveryRouteResults.remove(removeIdx); // Re-index tasks state for remaining stations Map> reindexed = new HashMap<>(); for (Map.Entry> entry : deliveryStationTasksState.entrySet()) { @@ -692,7 +767,28 @@ public class AddJobView extends Main implements HasDynamicTitle { } deliveryStationTasksState.clear(); deliveryStationTasksState.putAll(reindexed); - stationsGridContainer.remove(tile); + + Map reindexedRoutes = new HashMap<>(); + for (Map.Entry entry : pickupToDeliveryRouteResults.entrySet()) { + int oldIdx = entry.getKey(); + int newIdx = oldIdx > removeIdx ? oldIdx - 1 : oldIdx; + reindexedRoutes.put(newIdx, entry.getValue()); + } + pickupToDeliveryRouteResults.clear(); + pickupToDeliveryRouteResults.putAll(reindexedRoutes); + + for (SelectedServiceEntry selectedService : selectedServices) { + Integer stationOrder = selectedService.getDeliveryStationOrder(); + if (stationOrder == null) { + continue; + } + if (stationOrder == removeIdx) { + selectedService.setDeliveryStationOrder(deliveryStationsState.isEmpty() ? null : 0); + } else if (stationOrder > removeIdx) { + selectedService.setDeliveryStationOrder(stationOrder - 1); + } + } + stationsGridContainer.remove(removedSlot); // Renumber remaining tiles and update click listeners for (int i = 0; i < deliveryStationTilesList.size(); i++) { @@ -710,12 +806,16 @@ public class AddJobView extends Main implements HasDynamicTitle { } // Ensure "+" button is visible if under max - if (deliveryStationTilesList.size() < MAX_DELIVERY_STATIONS && addStationButton.getParent().isEmpty()) { - stationsGridContainer.add(addStationButton); + if (deliveryStationTilesList.size() < MAX_DELIVERY_STATIONS && addStationButtonSlot.getParent().isEmpty()) { + stationsGridContainer.add(addStationButtonSlot); } resetRouteInformation(); resetStationsAppliedState(); + if (servicesGrid != null) { + servicesGrid.getDataProvider().refreshAll(); + } + updatePriceSummary(); triggerValidation(); updateTabLabels(); }); @@ -1075,7 +1175,7 @@ public class AddJobView extends Main implements HasDynamicTitle { private void openAddServiceDialog() { Dialog dialog = new Dialog(); dialog.setHeaderTitle(getTranslation("addjob.services.dialog.title")); - dialog.setWidth("500px"); + dialog.setWidth("560px"); VerticalLayout dialogContent = new VerticalLayout(); dialogContent.setPadding(true); @@ -1099,7 +1199,18 @@ public class AddJobView extends Main implements HasDynamicTitle { serviceCombo.setPlaceholder(getTranslation("addjob.services.dialog.placeholder")); serviceCombo.setRequired(true); - dialogContent.add(serviceCombo); + ComboBox deliveryStationCombo = new ComboBox<>(getTranslation("addjob.services.deliverystation")); + deliveryStationCombo.setWidthFull(); + deliveryStationCombo.setRequired(true); + deliveryStationCombo.setRequiredIndicatorVisible(true); + deliveryStationCombo.setItems(getAvailableDeliveryStationOrders()); + deliveryStationCombo.setItemLabelGenerator(this::buildDeliveryStationSelectionLabel); + deliveryStationCombo.setPlaceholder(getTranslation("addjob.services.dialog.station.placeholder")); + if (!deliveryStationsState.isEmpty()) { + deliveryStationCombo.setValue(0); + } + + dialogContent.add(serviceCombo, deliveryStationCombo); HorizontalLayout buttonLayout = new HorizontalLayout(); buttonLayout.setWidthFull(); @@ -1110,8 +1221,8 @@ public class AddJobView extends Main implements HasDynamicTitle { cancelButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); Button addButton = new Button(getTranslation("addjob.services.dialog.add"), e -> { - if (serviceCombo.getValue() != null) { - selectedServices.add(serviceCombo.getValue()); + if (serviceCombo.getValue() != null && deliveryStationCombo.getValue() != null) { + selectedServices.add(new SelectedServiceEntry(serviceCombo.getValue(), deliveryStationCombo.getValue())); servicesGrid.getDataProvider().refreshAll(); updatePriceSummary(); triggerValidation(); @@ -1130,33 +1241,61 @@ public class AddJobView extends Main implements HasDynamicTitle { private void updatePriceSummary() { BigDecimal netTotal = BigDecimal.ZERO; - BigDecimal vatTotal = BigDecimal.ZERO; BigDecimal grossTotal = BigDecimal.ZERO; - // Get route distance for distance-based calculations (berechnet oder manuell) - Double routeDistance = getEffectiveRouteDistance(); - - for (Service service : selectedServices) { - BigDecimal price = calculateServicePrice(service, routeDistance); - BigDecimal vatRate = service.getVatRate() != null ? service.getVatRate() : BigDecimal.ZERO; + for (SelectedServiceEntry entry : selectedServices) { + Service service = entry.getService(); + BigDecimal price = calculateServicePrice(service, getEffectiveRouteDistance(entry), + getEffectiveRouteDuration(entry)); + BigDecimal vatRate = Service.FIXED_VAT_RATE; netTotal = netTotal.add(price); BigDecimal vatAmount = price.multiply(vatRate); - vatTotal = vatTotal.add(vatAmount); grossTotal = grossTotal.add(price.add(vatAmount)); } netTotalLabel.setText(netTotal.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",") + " €"); - vatTotalLabel.setText(vatTotal.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",") + " €"); grossTotalLabel.setText(grossTotal.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",") + " €"); } - /** - * Calculates the actual price for a service based on its calculation basis and - * route distance (for distance-based services). - */ - private BigDecimal calculateServicePrice(Service service, Double routeDistance) { - return calculateServicePrice(service, routeDistance, null); + private List getAvailableDeliveryStationOrders() { + List stationOrders = new ArrayList<>(); + for (int i = 0; i < deliveryStationsState.size(); i++) { + stationOrders.add(i); + } + return stationOrders; + } + + private String formatSelectedServiceStationLabel(SelectedServiceEntry entry) { + return formatDeliveryStationLabel(entry.getDeliveryStationOrder()); + } + + private String buildDeliveryStationSelectionLabel(Integer stationOrder) { + if (stationOrder == null || stationOrder < 0 || stationOrder >= deliveryStationsState.size()) { + return "-"; + } + + DeliveryStation station = deliveryStationsState.get(stationOrder); + StringBuilder label = new StringBuilder(getTranslation("addjob.station.delivery", stationOrder + 1)); + + String city = trimToNull(station.getCity()); + if (city != null) { + label.append(" - ").append(city); + } else { + String company = trimToNull(station.getCompany()); + if (company != null) { + label.append(" - ").append(company); + } + } + + return label.toString(); + } + + private String formatDeliveryStationLabel(Integer stationOrder) { + if (stationOrder == null || stationOrder < 0) { + return "-"; + } + return "Lieferstation " + (stationOrder + 1); } private BigDecimal calculateServicePrice(Service service, Double routeDistance, Integer durationSeconds) { @@ -1189,6 +1328,44 @@ public class AddJobView extends Main implements HasDynamicTitle { } } + private Double getEffectiveRouteDistance(SelectedServiceEntry entry) { + RouteCalculationResult stationRoute = getSelectedServiceRoute(entry); + if (stationRoute != null && stationRoute.isValid()) { + return stationRoute.getDistanceKm(); + } + if (routeCalculationResult == null || !routeCalculationResult.isValid()) { + return getEffectiveRouteDistance(); + } + return null; + } + + private Integer getEffectiveRouteDuration(SelectedServiceEntry entry) { + RouteCalculationResult stationRoute = getSelectedServiceRoute(entry); + if (stationRoute != null && stationRoute.isValid()) { + return stationRoute.getDurationSeconds(); + } + if (routeCalculationResult == null || !routeCalculationResult.isValid()) { + return getEffectiveRouteDuration(); + } + return null; + } + + private RouteCalculationResult getSelectedServiceRoute(SelectedServiceEntry entry) { + if (entry == null || entry.getDeliveryStationOrder() == null) { + return null; + } + return pickupToDeliveryRouteResults.get(entry.getDeliveryStationOrder()); + } + + private JobServiceSelection toJobServiceSelection(SelectedServiceEntry entry) { + JobServiceSelection selection = new JobServiceSelection(); + selection.setServiceId(entry.getService().getId()); + selection.setDeliveryStationOrder(entry.getDeliveryStationOrder()); + selection.setRouteDistanceKm(getEffectiveRouteDistance(entry)); + selection.setRouteDurationSeconds(getEffectiveRouteDuration(entry)); + return selection; + } + // createPickupSection(), togglePickupCollapse(), updateAddStationButtonSize() // removed - replaced by StationTile + StationDialog @@ -1577,15 +1754,15 @@ public class AddJobView extends Main implements HasDynamicTitle { job.syncFlatDeliveryFieldsFromStations(); // Store selected service IDs in job for invoice creation - job.setServiceIds(selectedServices.stream().map(Service::getId).toList()); + job.setServiceIds(selectedServices.stream().map(entry -> entry.getService().getId()).toList()); + job.setSelectedServices(selectedServices.stream().map(this::toJobServiceSelection).toList()); // 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)) + .map(entry -> calculateServicePrice(entry.getService(), getEffectiveRouteDistance(entry), + getEffectiveRouteDuration(entry))) .reduce(BigDecimal.ZERO, BigDecimal::add); job.setPrice(netTotal); @@ -1706,7 +1883,8 @@ public class AddJobView extends Main implements HasDynamicTitle { List mandatoryServices = userServices.stream().filter(Service::isMandatory).toList(); if (!mandatoryServices.isEmpty()) { - selectedServices.addAll(mandatoryServices); + mandatoryServices.stream().map(service -> new SelectedServiceEntry(service, 0)) + .forEach(selectedServices::add); if (servicesGrid != null) { servicesGrid.getDataProvider().refreshAll(); } @@ -1908,7 +2086,7 @@ public class AddJobView extends Main implements HasDynamicTitle { UI ui = UI.getCurrent(); loadingDialog.open(); - CompletableFuture.supplyAsync(this::calculateRouteAcrossAllStations).whenComplete((routeResult, throwable) -> { + CompletableFuture.supplyAsync(this::calculateRouteBundle).whenComplete((routeBundle, throwable) -> { if (ui == null) { return; } @@ -1921,8 +2099,9 @@ public class AddJobView extends Main implements HasDynamicTitle { return; } + RouteCalculationResult routeResult = routeBundle != null ? routeBundle.totalRoute() : null; if (routeResult != null && routeResult.isValid()) { - applyCalculatedRoute(routeResult); + applyCalculatedRoutes(routeBundle); showRouteSummaryDialog(routeResult); } else { String message = routeResult != null && routeResult.getRouteMessage() != null @@ -1950,36 +2129,86 @@ public class AddJobView extends Main implements HasDynamicTitle { return deliveryStationsValidatedByGoogle.stream().allMatch(Boolean.TRUE::equals); } - private RouteCalculationResult calculateRouteAcrossAllStations() { + private RouteCalculationBundle calculateRouteBundle() { if (!pickupAddressValidatedByGoogle) { - return createInvalidRouteResult("Die Abholstation ist nicht validiert."); + return createInvalidRouteBundle("Die Abholstation ist nicht validiert."); } - List stationResults = new ArrayList<>(); AddressValidationResult pickupValidation = getOrValidatePickupAddressResult(); if (pickupValidation == null || !pickupValidation.isValid()) { - return createInvalidRouteResult("Die Abholstation konnte nicht validiert werden."); + return createInvalidRouteBundle("Die Abholstation konnte nicht validiert werden."); } - stationResults.add(pickupValidation); + + List deliveryValidations = new ArrayList<>(); for (int i = 0; i < deliveryStationsState.size(); i++) { DeliveryStation station = deliveryStationsState.get(i); if (hasDeliveryStationValidationErrors(station)) { - return createInvalidRouteResult("Nicht alle Lieferstationen sind vollständig ausgefüllt."); + return createInvalidRouteBundle("Nicht alle Lieferstationen sind vollständig ausgefüllt."); } - if (i >= deliveryStationsValidatedByGoogle.size() || !Boolean.TRUE.equals(deliveryStationsValidatedByGoogle.get(i))) { - return createInvalidRouteResult("Nicht alle Lieferstationen sind validiert."); + if (i >= deliveryStationsValidatedByGoogle.size() + || !Boolean.TRUE.equals(deliveryStationsValidatedByGoogle.get(i))) { + return createInvalidRouteBundle("Nicht alle Lieferstationen sind validiert."); } AddressValidationResult deliveryValidation = getOrValidateDeliveryAddressResult(i); if (deliveryValidation == null || !deliveryValidation.isValid()) { - return createInvalidRouteResult( + return createInvalidRouteBundle( String.format("Die Strecke konnte für Lieferstation %d nicht berechnet werden.", i + 1)); } - stationResults.add(deliveryValidation); + deliveryValidations.add(deliveryValidation); } - return addressValidationService.calculateRoute(stationResults); + Map deliveryRoutes = new HashMap<>(); + AddressValidationResult previousStation = pickupValidation; + for (int i = 0; i < deliveryValidations.size(); i++) { + AddressValidationResult currentStation = deliveryValidations.get(i); + RouteCalculationResult legRoute = addressValidationService.calculateRoute(previousStation, currentStation); + if (legRoute == null || !legRoute.isValid()) { + return createInvalidRouteBundle( + String.format("Die Strecke konnte für Lieferstation %d nicht berechnet werden.", i + 1)); + } + deliveryRoutes.put(i, legRoute); + previousStation = currentStation; + } + + RouteCalculationResult totalRoute = aggregateLegRoutes(deliveryRoutes, deliveryValidations.size()); + return new RouteCalculationBundle(totalRoute, deliveryRoutes); + } + + private RouteCalculationResult aggregateLegRoutes(Map deliveryRoutes, int legCount) { + if (deliveryRoutes == null || deliveryRoutes.size() != legCount || legCount == 0) { + return createInvalidRouteResult("Die Gesamtstrecke konnte nicht berechnet werden."); + } + + double totalDistanceKm = 0.0; + int totalDurationSeconds = 0; + + for (int i = 0; i < legCount; i++) { + RouteCalculationResult legRoute = deliveryRoutes.get(i); + if (legRoute == null || !legRoute.isValid()) { + return createInvalidRouteResult("Die Gesamtstrecke konnte nicht berechnet werden."); + } + totalDistanceKm += legRoute.getDistanceKm(); + totalDurationSeconds += legRoute.getDurationSeconds(); + } + + RouteCalculationResult totalRoute = new RouteCalculationResult(); + totalRoute.setValid(true); + totalRoute.setDistanceKm(totalDistanceKm); + totalRoute.setDurationSeconds(totalDurationSeconds); + totalRoute.setFormattedDistance(String.format(Locale.GERMANY, "%.1f km", totalDistanceKm)); + totalRoute.setFormattedDuration(totalRoute.getFormattedDurationLong()); + totalRoute.setRouteMessage( + String.format("Route: %s, Dauer: %s", totalRoute.getFormattedDistance(), + totalRoute.getFormattedDurationLong())); + return totalRoute; + } + + private RouteCalculationResult createInvalidRouteResult(String message) { + RouteCalculationResult routeResult = new RouteCalculationResult(); + routeResult.setRouteMessage(message); + return routeResult; } private AddressValidationResult getOrValidatePickupAddressResult() { @@ -2028,10 +2257,8 @@ public class AddJobView extends Main implements HasDynamicTitle { addressValidationResults.put(resultKey, validationResult); } - private RouteCalculationResult createInvalidRouteResult(String message) { - RouteCalculationResult routeResult = new RouteCalculationResult(); - routeResult.setRouteMessage(message); - return routeResult; + private RouteCalculationBundle createInvalidRouteBundle(String message) { + return new RouteCalculationBundle(createInvalidRouteResult(message), Map.of()); } private Dialog createRouteLoadingDialog() { @@ -2055,8 +2282,14 @@ public class AddJobView extends Main implements HasDynamicTitle { return dialog; } - private void applyCalculatedRoute(RouteCalculationResult routeResult) { + private void applyCalculatedRoutes(RouteCalculationBundle routeBundle) { + RouteCalculationResult routeResult = routeBundle.totalRoute(); routeCalculationResult = routeResult; + pickupToDeliveryRouteResults.clear(); + if (routeBundle.deliveryRoutes() != null) { + pickupToDeliveryRouteResults.putAll(routeBundle.deliveryRoutes()); + } + renderDeliveryStationDistanceChips(); routeDistanceLabel.setText(routeResult.getFormattedDistance()); routeDurationLabel.setText(routeResult.getFormattedDurationLong()); @@ -2103,6 +2336,25 @@ public class AddJobView extends Main implements HasDynamicTitle { return row; } + private void renderDeliveryStationDistanceChips() { + if (deliveryStationDistanceChips.isEmpty()) { + return; + } + + for (int i = 0; i < deliveryStationDistanceChips.size(); i++) { + Span chip = deliveryStationDistanceChips.get(i); + RouteCalculationResult stationRoute = pickupToDeliveryRouteResults.get(i); + if (stationRoute == null || !stationRoute.isValid()) { + chip.setText(""); + chip.setVisible(false); + continue; + } + + chip.setText(stationRoute.getFormattedDistance()); + chip.setVisible(true); + } + } + /** * Registriert ValueChangeListener für alle Adressfelder, um bei Änderungen die * Streckeninformationen zurückzusetzen. @@ -2179,6 +2431,7 @@ public class AddJobView extends Main implements HasDynamicTitle { private void resetRouteInformation() { // Routenberechnung zurücksetzen routeCalculationResult = null; + pickupToDeliveryRouteResults.clear(); // Validierungsergebnisse zurücksetzen addressValidationResults.clear(); @@ -2196,6 +2449,10 @@ public class AddJobView extends Main implements HasDynamicTitle { if (manualRouteInputBox != null) { manualRouteInputBox.setVisible(true); } + for (Span chip : deliveryStationDistanceChips) { + chip.setText(""); + chip.setVisible(false); + } log.debug("Streckeninformationen zurückgesetzt aufgrund von Adressänderungen"); } 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 90d2b23..11e0279 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java @@ -18,6 +18,7 @@ import com.vaadin.flow.router.BeforeEvent; import com.vaadin.flow.router.HasUrlParameter; import de.assecutor.votianlt.model.Customer; import de.assecutor.votianlt.model.Job; +import de.assecutor.votianlt.model.JobServiceSelection; import de.assecutor.votianlt.model.Service; import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.model.InvoiceTemplate; @@ -73,6 +74,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter */ public static class ServiceRow { private Service service; + private JobServiceSelection selection; public ServiceRow() { this.service = null; @@ -82,6 +84,11 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter this.service = service; } + public ServiceRow(Service service, JobServiceSelection selection) { + this.service = service; + this.selection = selection; + } + public Service getService() { return service; } @@ -90,6 +97,14 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter this.service = service; } + public JobServiceSelection getSelection() { + return selection; + } + + public void setSelection(JobServiceSelection selection) { + this.selection = selection; + } + public boolean isEmpty() { return service == null; } @@ -119,6 +134,19 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter * Lädt die Services, die beim Job-Erstellen ausgewählt wurden. */ private void loadSelectedServicesFromJob() { + if (currentJob.getSelectedServices() != null && !currentJob.getSelectedServices().isEmpty()) { + gridRows.clear(); + for (JobServiceSelection selection : currentJob.getSelectedServices()) { + if (selection.getServiceId() == null) { + continue; + } + serviceRepository.findById(selection.getServiceId()).ifPresent(service -> { + gridRows.add(new ServiceRow(service, selection)); + }); + } + return; + } + if (currentJob.getServiceIds() != null && !currentJob.getServiceIds().isEmpty()) { gridRows.clear(); for (String serviceId : currentJob.getServiceIds()) { @@ -267,6 +295,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter return ""; }).setHeader(getTranslation("createinvoice.column.service")).setAutoWidth(true).setFlexGrow(2); + servicesGrid.addColumn(this::getDeliveryStationLabel).setHeader(getTranslation("addjob.services.deliverystation")) + .setAutoWidth(true).setFlexGrow(1); + // Calculation basis column (read-only) servicesGrid.addColumn(row -> { if (row.getService() != null && row.getService().getCalculationBasis() != null) { @@ -282,7 +313,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter // Price column (read-only) servicesGrid.addColumn(row -> { if (row.getService() != null) { - BigDecimal price = calculateServicePrice(row.getService()); + BigDecimal price = calculateServicePrice(row); if (price != null) { return price.setScale(2, RoundingMode.HALF_UP) + " €"; } @@ -296,8 +327,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter return servicesSection; } - private List getSelectedServices() { - return gridRows.stream().filter(row -> row.getService() != null).map(ServiceRow::getService).toList(); + private List getSelectedServices() { + return gridRows.stream().filter(row -> row.getService() != null).toList(); } private Div createSummarySection() { @@ -311,7 +342,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter // Calculate totals BigDecimal netAmount = calculateNetAmount(); - BigDecimal vatRate = calculateAverageVatRate(); + BigDecimal vatRate = Service.FIXED_VAT_RATE; BigDecimal vatAmount = netAmount.multiply(vatRate); BigDecimal totalAmount = netAmount.add(vatAmount); @@ -320,10 +351,6 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter priceTable.add(createPriceRow(getTranslation("createinvoice.summary.net") + ":", netAmount.setScale(2, RoundingMode.HALF_UP) + " €", false)); - priceTable.add(createPriceRow( - getTranslation("createinvoice.summary.vat", - vatRate.multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP).toString()) + ":", - vatAmount.setScale(2, RoundingMode.HALF_UP) + " €", false)); priceTable.add(createPriceRow(getTranslation("createinvoice.summary.total") + ":", totalAmount.setScale(2, RoundingMode.HALF_UP) + " €", true)); @@ -349,7 +376,17 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter return row; } - private BigDecimal calculateServicePrice(Service service) { + private String getDeliveryStationLabel(ServiceRow row) { + JobServiceSelection selection = row.getSelection(); + if (selection == null || selection.getDeliveryStationOrder() == null) { + return "-"; + } + return "Lieferstation " + (selection.getDeliveryStationOrder() + 1); + } + + private BigDecimal calculateServicePrice(ServiceRow row) { + Service service = row.getService(); + JobServiceSelection selection = row.getSelection(); if (service.getCalculationBasis() == null) { return null; } @@ -357,32 +394,55 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter if (service.getCalculationBasis() == Service.CalculationBasis.FLAT_RATE && service.getPrice() != null) { return service.getPrice(); } else if (service.getCalculationBasis() == Service.CalculationBasis.DISTANCE - && service.getPricePerKilometer() != null && currentJob.getRouteDistanceKm() != null) { - BigDecimal kilometers = BigDecimal.valueOf(currentJob.getRouteDistanceKm()); + && service.getPricePerKilometer() != null && getRouteDistanceKm(selection) != null) { + BigDecimal kilometers = BigDecimal.valueOf(getRouteDistanceKm(selection)); return service.getPricePerKilometer().multiply(kilometers); } else if (service.getCalculationBasis() == Service.CalculationBasis.TIME - && service.getPricePer15Minutes() != null && currentJob.getTimeIn15MinUnits() != null) { - BigDecimal timeUnits = new BigDecimal(currentJob.getTimeIn15MinUnits()); + && service.getPricePer15Minutes() != null && getTimeIn15MinUnits(selection) != null) { + BigDecimal timeUnits = new BigDecimal(getTimeIn15MinUnits(selection)); return service.getPricePer15Minutes().multiply(timeUnits); } return null; } + private Double getRouteDistanceKm(JobServiceSelection selection) { + if (selection != null && selection.getRouteDistanceKm() != null) { + return selection.getRouteDistanceKm(); + } + return currentJob.getRouteDistanceKm(); + } + + private Integer getTimeIn15MinUnits(JobServiceSelection selection) { + Integer durationSeconds = selection != null && selection.getRouteDurationSeconds() != null + ? selection.getRouteDurationSeconds() + : currentJob.getRouteDurationSeconds(); + if (durationSeconds == null || durationSeconds <= 0) { + return currentJob.getTimeIn15MinUnits(); + } + + int units = durationSeconds / 900; + if (durationSeconds % 900 > 0) { + units++; + } + return units; + } + private BigDecimal calculateNetAmount() { BigDecimal total = BigDecimal.ZERO; - for (Service service : getSelectedServices()) { + for (ServiceRow row : getSelectedServices()) { + Service service = row.getService(); if (service.getCalculationBasis() == Service.CalculationBasis.FLAT_RATE && service.getPrice() != null) { total = total.add(service.getPrice()); } else if (service.getCalculationBasis() == Service.CalculationBasis.DISTANCE - && service.getPricePerKilometer() != null && currentJob.getRouteDistanceKm() != null) { - BigDecimal kilometers = BigDecimal.valueOf(currentJob.getRouteDistanceKm()); + && service.getPricePerKilometer() != null && getRouteDistanceKm(row.getSelection()) != null) { + BigDecimal kilometers = BigDecimal.valueOf(getRouteDistanceKm(row.getSelection())); BigDecimal serviceTotal = service.getPricePerKilometer().multiply(kilometers); total = total.add(serviceTotal); } else if (service.getCalculationBasis() == Service.CalculationBasis.TIME - && service.getPricePer15Minutes() != null && currentJob.getTimeIn15MinUnits() != null) { - BigDecimal timeUnits = new BigDecimal(currentJob.getTimeIn15MinUnits()); + && service.getPricePer15Minutes() != null && getTimeIn15MinUnits(row.getSelection()) != null) { + BigDecimal timeUnits = new BigDecimal(getTimeIn15MinUnits(row.getSelection())); BigDecimal serviceTotal = service.getPricePer15Minutes().multiply(timeUnits); total = total.add(serviceTotal); } @@ -391,29 +451,6 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter return total; } - private BigDecimal calculateAverageVatRate() { - List selectedServicesList = getSelectedServices(); - if (selectedServicesList.isEmpty()) { - return new BigDecimal("0.19"); // Default 19% VAT - } - - BigDecimal totalVat = BigDecimal.ZERO; - int count = 0; - - for (Service service : selectedServicesList) { - if (service.getVatRate() != null) { - totalVat = totalVat.add(service.getVatRate()); - count++; - } - } - - if (count > 0) { - return totalVat.divide(new BigDecimal(count), 4, RoundingMode.HALF_UP); - } - - return new BigDecimal("0.19"); // Default 19% VAT - } - private String extractCompanyName(String customerSelection) { if (customerSelection == null || customerSelection.isBlank()) { return ""; @@ -478,7 +515,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter String invoiceNumber = userInvoiceDataService.generateNextInvoiceNumber(user.getId()); BigDecimal netAmount = calculateNetAmount(); - BigDecimal vatRate = calculateAverageVatRate(); + BigDecimal vatRate = Service.FIXED_VAT_RATE; BigDecimal vatAmount = netAmount.multiply(vatRate); BigDecimal totalAmount = netAmount.add(vatAmount); @@ -517,7 +554,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter throws Exception { // Calculate totals BigDecimal netAmount = calculateNetAmount(); - BigDecimal vatRate = calculateAverageVatRate(); + BigDecimal vatRate = Service.FIXED_VAT_RATE; BigDecimal vatAmount = netAmount.multiply(vatRate); BigDecimal totalAmount = netAmount.add(vatAmount); @@ -586,26 +623,19 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter // Services data - add as JSON array for the template List> servicesData = new ArrayList<>(); - for (Service service : getSelectedServices()) { + for (ServiceRow row : getSelectedServices()) { + Service service = row.getService(); Map serviceData = new HashMap<>(); serviceData.put("name", service.getName()); // Calculate price based on calculation basis - BigDecimal price = calculateServicePrice(service); + BigDecimal price = calculateServicePrice(row); 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); } @@ -711,4 +741,4 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter public String getPageTitle() { return getTranslation("page.title.invoice.create"); } -} \ No newline at end of file +} diff --git a/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java b/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java index 1f72351..e9dcbdd 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java @@ -318,6 +318,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle bankNameField = new TextField(); ibanField = new TextField(); taxRateField = new TextField(); + taxRateField.setValue("19"); introTextArea = new TextArea(); termsTextArea = new TextArea(); pdfFrame = new IFrame(); @@ -646,8 +647,6 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle bankNameField.setEnabled(enabled); if (ibanField != null) ibanField.setEnabled(enabled); - if (taxRateField != null) - taxRateField.setEnabled(enabled); if (introTextArea != null) introTextArea.setEnabled(enabled); if (termsTextArea != null) @@ -710,7 +709,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle // Create sample invoice items for preview List items = new ArrayList<>(); - BigDecimal vatRate = parseVatRate(safe(taxRateField)); + BigDecimal vatRate = Service.FIXED_VAT_RATE; CustomerInvoiceItem item1 = new CustomerInvoiceItem(new BigDecimal("1"), "Stk.", "Beispiel-Dienstleistung 1", new BigDecimal("100.00"), vatRate); @@ -752,24 +751,6 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle } } - private BigDecimal parseVatRate(String taxRateStr) { - try { - if (taxRateStr == null || taxRateStr.isEmpty()) { - return new BigDecimal("0.19"); // Default 19% - } - // Remove % sign if present and convert to decimal - taxRateStr = taxRateStr.replace("%", "").trim(); - BigDecimal rate = new BigDecimal(taxRateStr); - // If value is greater than 1, assume it's a percentage and convert - if (rate.compareTo(BigDecimal.ONE) > 0) { - rate = rate.divide(new BigDecimal("100")); - } - return rate; - } catch (Exception e) { - return new BigDecimal("0.19"); // Default 19% on error - } - } - // Utility: safe getter für TextField/TextArea private String safe(TextField f) { return f != null && f.getValue() != null ? f.getValue() : ""; @@ -789,7 +770,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle taxNumberField.setValue(safe(currentInvoiceData.getTaxNumber())); bankNameField.setValue(safe(currentInvoiceData.getBankName())); ibanField.setValue(safe(currentInvoiceData.getIban())); - taxRateField.setValue(safe(currentInvoiceData.getTaxRate())); + taxRateField.setValue("19"); introTextArea.setValue(safe(currentInvoiceData.getIntroText())); termsTextArea.setValue(safe(currentInvoiceData.getPaymentTerms())); @@ -804,7 +785,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle private void saveInvoiceData() { currentInvoiceData = userInvoiceDataService.createOrUpdate(currentUser.getId(), billingEnabled.getValue(), prefixField.getValue(), ustIdField.getValue(), taxNumberField.getValue(), bankNameField.getValue(), - ibanField.getValue(), taxRateField.getValue(), introTextArea.getValue(), termsTextArea.getValue()); + ibanField.getValue(), "19", introTextArea.getValue(), termsTextArea.getValue()); } private String safe(String value) { @@ -979,8 +960,6 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle VaadinIcon.LIST, "services.list", "Artikel 1: 100,00 €\nArtikel 2: 50,00 €"); Div servicesNetBlock = createServicesVariableTemplate(getTranslation("profile.invoice.net"), VaadinIcon.COIN_PILES, "services.net_total", "150,00 €"); - Div servicesVatBlock = createServicesVariableTemplate(getTranslation("profile.invoice.vat"), - VaadinIcon.COIN_PILES, "services.vat_total", "28,50 €"); Div servicesGrossBlock = createServicesVariableTemplate(getTranslation("profile.invoice.gross"), VaadinIcon.MONEY, "services.gross_total", "178,50 €"); @@ -1027,8 +1006,8 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle "image"); panel.add(invoiceHeader, senderCompany, senderName, senderAddress, senderCity, senderEmail, senderPhone, - invoiceNumber, servicesHeader, servicesListBlock, servicesNetBlock, servicesVatBlock, - servicesGrossBlock, customerHeader, customerCompany, customerName, customerAddress, customerCity, + invoiceNumber, servicesHeader, servicesListBlock, servicesNetBlock, servicesGrossBlock, customerHeader, + customerCompany, customerName, customerAddress, customerCity, customerEmail, customerPhone, freeHeader, textBlock, headerBlock, dateBlock, customerBlock, companyBlock, amountBlock, lineBlock, imageBlock); @@ -1476,7 +1455,6 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle case "customer.phone" -> "Telefon des Kunden"; case "services.list" -> "Liste aller Leistungen auf der Rechnung"; case "services.net_total" -> "Nettosumme aller Leistungen"; - case "services.vat_total" -> "Umsatzsteuer aller Leistungen"; case "services.gross_total" -> "Bruttosumme aller Leistungen"; default -> "Variable: " + variable; }; @@ -1530,13 +1508,6 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle return getTranslation("profile.services.calculated"); }).setHeader(getTranslation("common.price")).setSortable(true); - servicesGrid.addColumn(service -> { - if (service.getVatRate() != null) { - return service.getVatRate().multiply(new BigDecimal("100")) + " %"; - } - return ""; - }).setHeader(getTranslation("profile.services.vatrate")).setSortable(true); - servicesGrid .addColumn( service -> service.isMandatory() ? getTranslation("common.yes") : getTranslation("common.no")) @@ -1626,16 +1597,6 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle calculationBasisCombo.setRequired(true); calculationBasisCombo.setRequiredIndicatorVisible(true); - // VAT rate field - NumberField vatRateField = new NumberField(getTranslation("profile.services.vatrate.percent")); - vatRateField.setWidthFull(); - vatRateField.setMin(0); - vatRateField.setMax(100); - vatRateField.setStep(0.1); - vatRateField.setValue(19.0); // Default 19% - vatRateField.setRequired(true); - vatRateField.setRequiredIndicatorVisible(true); - // Mandatory checkbox Checkbox mandatoryCheckbox = new Checkbox(getTranslation("profile.services.mandatory")); mandatoryCheckbox.setValue(false); @@ -1644,9 +1605,6 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle if (service != null) { nameField.setValue(service.getName()); calculationBasisCombo.setValue(service.getCalculationBasis()); - if (service.getVatRate() != null) { - vatRateField.setValue(service.getVatRate().multiply(new BigDecimal("100")).doubleValue()); - } mandatoryCheckbox.setValue(service.isMandatory()); } @@ -1679,9 +1637,6 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle if (service != null) { nameField.setValue(service.getName()); calculationBasisCombo.setValue(service.getCalculationBasis()); - if (service.getVatRate() != null) { - vatRateField.setValue(service.getVatRate().multiply(new BigDecimal("100")).doubleValue()); - } // Set the appropriate price field based on calculation basis if (service.getCalculationBasis() != null) { @@ -1721,7 +1676,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle timePriceField.setVisible(initialBasis == Service.CalculationBasis.TIME); formLayout.add(nameField, calculationBasisCombo, flatRatePriceField, distancePriceField, timePriceField, - vatRateField, mandatoryCheckbox); + mandatoryCheckbox); // Action buttons HorizontalLayout buttonLayout = new HorizontalLayout(); @@ -1734,7 +1689,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle Button saveButton = new Button(getTranslation("button.savechanges"), e -> { if (validateServiceForm(nameField, calculationBasisCombo, flatRatePriceField, distancePriceField, - timePriceField, vatRateField, mandatoryCheckbox)) { + timePriceField, mandatoryCheckbox)) { // Get the appropriate price based on calculation basis BigDecimal priceValue = BigDecimal.ZERO; Service.CalculationBasis selectedBasis = calculationBasisCombo.getValue(); @@ -1748,10 +1703,8 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle priceValue = new BigDecimal(timePriceField.getValue()); } - BigDecimal vatRate = new BigDecimal(vatRateField.getValue()).divide(new BigDecimal("100")); boolean mandatory = mandatoryCheckbox.getValue(); - saveService(service, nameField.getValue(), calculationBasisCombo.getValue(), priceValue, vatRate, - mandatory); + saveService(service, nameField.getValue(), calculationBasisCombo.getValue(), priceValue, mandatory); dialog.close(); } }); @@ -1769,7 +1722,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle */ private boolean validateServiceForm(TextField nameField, ComboBox calculationBasisCombo, NumberField flatRatePriceField, NumberField distancePriceField, NumberField timePriceField, - NumberField vatRateField, Checkbox mandatoryCheckbox) { + Checkbox mandatoryCheckbox) { boolean isValid = true; if (nameField.isEmpty()) { @@ -1816,14 +1769,6 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle } } - if (vatRateField.isEmpty() || vatRateField.getValue() == null) { - vatRateField.setInvalid(true); - vatRateField.setErrorMessage(getTranslation("profile.services.validation.vatrate")); - isValid = false; - } else { - vatRateField.setInvalid(false); - } - return isValid; } @@ -1831,14 +1776,14 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle * Save service to database */ private void saveService(Service existingService, String name, Service.CalculationBasis calculationBasis, - BigDecimal priceValue, BigDecimal vatRate, boolean mandatory) { + BigDecimal priceValue, boolean mandatory) { try { Service service; if (existingService != null) { service = existingService; service.setName(name); service.setCalculationBasis(calculationBasis); - service.setVatRate(vatRate); + service.setVatRate(Service.FIXED_VAT_RATE); service.setMandatory(mandatory); // Set the appropriate price field based on calculation basis @@ -1860,8 +1805,8 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle break; } } else { - service = new Service(currentUser.getId().toString(), name, calculationBasis, priceValue, vatRate, - mandatory); + service = new Service(currentUser.getId().toString(), name, calculationBasis, priceValue, + Service.FIXED_VAT_RATE, mandatory); } serviceRepository.save(service); 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 fb8966a..26bd4a6 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java @@ -22,6 +22,7 @@ import com.vaadin.flow.theme.lumo.LumoUtility; import de.assecutor.votianlt.model.CargoItem; import de.assecutor.votianlt.model.DeliveryStation; import de.assecutor.votianlt.model.Job; +import de.assecutor.votianlt.model.JobServiceSelection; import de.assecutor.votianlt.model.LocationPosition; import de.assecutor.votianlt.model.task.BaseTask; import de.assecutor.votianlt.model.task.TodoListTask; @@ -223,8 +224,6 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has priceTable.add(createPriceRow(getTranslation("jobsummary.info.netto") + ":", formatPrice(priceResult.netAmount()), false)); - priceTable.add(createPriceRow(getTranslation("jobsummary.info.ust") + ":", formatPrice(priceResult.vatAmount()), - false)); priceTable.add(createPriceRow(getTranslation("jobsummary.info.gesamt") + ":", formatPrice(priceResult.totalAmount()), true)); @@ -1414,6 +1413,30 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has BigDecimal netTotal = BigDecimal.ZERO; BigDecimal vatTotal = BigDecimal.ZERO; + List selectedServices = job.getSelectedServices(); + if (selectedServices != null && !selectedServices.isEmpty()) { + for (JobServiceSelection selectedService : selectedServices) { + if (selectedService.getServiceId() == null) { + continue; + } + + Service service = serviceRepository.findById(selectedService.getServiceId()).orElse(null); + if (service == null) { + continue; + } + + BigDecimal price = calculateServicePrice(service, selectedService.getRouteDistanceKm(), + selectedService.getRouteDurationSeconds()); + BigDecimal vatRate = Service.FIXED_VAT_RATE; + + netTotal = netTotal.add(price); + vatTotal = vatTotal.add(price.multiply(vatRate)); + } + + BigDecimal totalAmount = netTotal.add(vatTotal); + return new PriceCalculationResult(netTotal, vatTotal, totalAmount); + } + List serviceIds = job.getServiceIds(); if (serviceIds == null || serviceIds.isEmpty()) { // Fallback auf gespeicherten Preis @@ -1431,7 +1454,7 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has continue; BigDecimal price = calculateServicePrice(service, routeDistance, durationSeconds); - BigDecimal vatRate = service.getVatRate() != null ? service.getVatRate() : new BigDecimal("0.19"); + BigDecimal vatRate = Service.FIXED_VAT_RATE; netTotal = netTotal.add(price); vatTotal = vatTotal.add(price.multiply(vatRate)); diff --git a/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java b/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java index 09e0c7c..d4dec25 100644 --- a/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java +++ b/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java @@ -481,7 +481,6 @@ public class CustomerInvoiceService { // Calculate totals double netTotal = 655.00; - double vatTotal = 124.45; double grossTotal = 779.45; // Wrapper div @@ -530,14 +529,6 @@ public class CustomerInvoiceService { .append(String.format(java.util.Locale.GERMANY, "%,.2f €", netTotal)).append(""); html.append(""); - // Umsatzsteuer - label in col 2, value in col 3 - html.append(""); - html.append(""); - html.append("zzgl. 19% USt:"); - html.append("") - .append(String.format(java.util.Locale.GERMANY, "%,.2f €", vatTotal)).append(""); - html.append(""); - // Gesamtsumme - label in col 2, value in col 3 html.append(""); html.append(""); @@ -782,9 +773,7 @@ public class CustomerInvoiceService { // Get invoice data from variables String netTotal = variables.getOrDefault("invoice.net_total", "0,00 €"); - String vatTotal = variables.getOrDefault("invoice.vat_total", "0,00 €"); String grossTotal = variables.getOrDefault("invoice.gross_total", "0,00 €"); - String vatRate = variables.getOrDefault("invoice.vat_rate", "19%"); // Parse services JSON from variables java.util.List> servicesData = new java.util.ArrayList<>(); @@ -809,9 +798,7 @@ public class CustomerInvoiceService { // Header row html.append(""); html.append( - "Name"); - html.append( - "Steuersatz"); + "Name"); html.append( "Nettobetrag"); html.append(""); @@ -821,23 +808,20 @@ public class CustomerInvoiceService { // Fallback: show a single row with no data html.append(""); html.append( - "Keine Leistungen vorhanden"); + "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) + html.append("").append(netAmount) .append(" €"); html.append(""); } @@ -849,9 +833,6 @@ public class CustomerInvoiceService { html.append("
"); html.append(""); - // Extract numeric value from VAT rate for display - String vatPercent = vatRate.replace(" %", "").replace("%", ""); - // Nettosumme html.append(""); html.append(""); @@ -860,15 +841,6 @@ public class CustomerInvoiceService { .append(netTotal).append(""); html.append(""); - // Umsatzsteuer - html.append(""); - html.append(""); - html.append(""); - html.append(""); - html.append(""); - // Gesamtsumme html.append(""); html.append(""); diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 60db6d3..a891a90 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -531,7 +531,9 @@ addjob.services.vat=Mehrwertsteuer addjob.services.route.missing=Route fehlt addjob.services.dialog.title=Leistung auswählen addjob.services.dialog.placeholder=Leistung wählen +addjob.services.dialog.station.placeholder=Lieferstation wählen addjob.services.dialog.add=Hinzufügen +addjob.services.deliverystation=Lieferstation addjob.summary.title=Zusammenfassung addjob.summary.net=Netto addjob.summary.vat=Mehrwertsteuer @@ -937,4 +939,4 @@ adminpricetable.field.applicense=App-Nutzungslizenz adminpricetable.field.revenue=Umsatzbeteiligung adminpricetable.notification.saved=Preistabelle wurde gespeichert adminpricetable.notification.save.error=Fehler beim Speichern: {0} -adminpricetable.notification.load.error=Fehler beim Laden: {0} \ No newline at end of file +adminpricetable.notification.load.error=Fehler beim Laden: {0} diff --git a/src/main/resources/messages_en.properties b/src/main/resources/messages_en.properties index 315b567..6240a21 100644 --- a/src/main/resources/messages_en.properties +++ b/src/main/resources/messages_en.properties @@ -531,7 +531,9 @@ addjob.services.vat=VAT addjob.services.route.missing=Route missing addjob.services.dialog.title=Select Service addjob.services.dialog.placeholder=Select service +addjob.services.dialog.station.placeholder=Select delivery station addjob.services.dialog.add=Add +addjob.services.deliverystation=Delivery Station addjob.summary.title=Summary addjob.summary.net=Net addjob.summary.vat=VAT @@ -936,4 +938,4 @@ adminpricetable.field.applicense=App Usage License adminpricetable.field.revenue=Revenue Participation adminpricetable.notification.saved=Price table has been saved adminpricetable.notification.save.error=Error saving: {0} -adminpricetable.notification.load.error=Error loading: {0} \ No newline at end of file +adminpricetable.notification.load.error=Error loading: {0} diff --git a/src/main/resources/templates/customer_invoice.html b/src/main/resources/templates/customer_invoice.html index b43642e..e3d5d6b 100644 --- a/src/main/resources/templates/customer_invoice.html +++ b/src/main/resources/templates/customer_invoice.html @@ -422,10 +422,6 @@ - - - - @@ -465,4 +461,4 @@ - \ No newline at end of file +
zzgl. ") - .append(vatPercent).append("% USt:") - .append(vatTotal).append("
Nettobetrag: ${invoiceData.netAmount}
zzgl. ${invoiceData.vatRate} USt.:${invoiceData.vatAmount}
Rechnungsbetrag: ${invoiceData.totalAmount}