feat: adapt station pricing and route handling

This commit is contained in:
2026-03-09 16:21:24 +01:00
parent e7423259f3
commit 9f7e0af6e0
13 changed files with 514 additions and 257 deletions

View File

@@ -148,6 +148,10 @@ public class Job {
@Field("service_ids")
private List<String> serviceIds;
// Ausgewählte Leistungen inkl. zugeordneter Lieferstation und Berechnungsbasis
@Field("selected_services")
private List<JobServiceSelection> 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();
}
}
}
}

View File

@@ -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;
}

View File

@@ -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() {

View File

@@ -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<String, Customer> companyAddressOptions = new java.util.LinkedHashMap<>();
public DeliveryStationDialog(String dialogTitle, List<Customer> 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) {

View File

@@ -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<Customer> customers,
DeliveryStationTile.TranslationHelper translationHelper, SaveListener saveListener,
List<AppUser> availableAppUsers, AddressValidationService addressValidationService) {
this.addressValidationService = addressValidationService;
this.translationHelper = translationHelper;
setHeaderTitle(dialogTitle);

View File

@@ -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<Integer, RouteCalculationResult> 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<StationTile> deliveryStationTilesList = new ArrayList<>();
private final List<DeliveryStation> deliveryStationsState = new ArrayList<>();
private final List<Boolean> deliveryStationsSaveAddress = new ArrayList<>();
private final List<Div> deliveryStationSlotList = new ArrayList<>();
private final List<Span> 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> appUser;
// Services for the job
private Grid<Service> servicesGrid;
private final List<Service> selectedServices = new ArrayList<>();
private Grid<SelectedServiceEntry> servicesGrid;
private final List<SelectedServiceEntry> 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<Boolean> deliveryStationsValidatedByGoogle = new ArrayList<>();
private final Map<Integer, RouteCalculationResult> 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<Integer, List<BaseTask>> reindexed = new HashMap<>();
for (Map.Entry<Integer, List<BaseTask>> entry : deliveryStationTasksState.entrySet()) {
@@ -692,7 +767,28 @@ public class AddJobView extends Main implements HasDynamicTitle {
}
deliveryStationTasksState.clear();
deliveryStationTasksState.putAll(reindexed);
stationsGridContainer.remove(tile);
Map<Integer, RouteCalculationResult> reindexedRoutes = new HashMap<>();
for (Map.Entry<Integer, RouteCalculationResult> 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<Integer> 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<Integer> getAvailableDeliveryStationOrders() {
List<Integer> 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<Service> 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<AddressValidationResult> 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<AddressValidationResult> 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<Integer, RouteCalculationResult> 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<Integer, RouteCalculationResult> 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");
}

View File

@@ -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<Service> getSelectedServices() {
return gridRows.stream().filter(row -> row.getService() != null).map(ServiceRow::getService).toList();
private List<ServiceRow> 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<Service> 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<Map<String, String>> servicesData = new ArrayList<>();
for (Service service : getSelectedServices()) {
for (ServiceRow row : getSelectedServices()) {
Service service = row.getService();
Map<String, String> 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");
}
}
}

View File

@@ -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<CustomerInvoiceItem> 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<Service.CalculationBasis> 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);

View File

@@ -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<String>, 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<String>, Has
BigDecimal netTotal = BigDecimal.ZERO;
BigDecimal vatTotal = BigDecimal.ZERO;
List<JobServiceSelection> 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<String> serviceIds = job.getServiceIds();
if (serviceIds == null || serviceIds.isEmpty()) {
// Fallback auf gespeicherten Preis
@@ -1431,7 +1454,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, 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));

View File

@@ -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("</td>");
html.append("</tr>");
// Umsatzsteuer - label in col 2, value in col 3
html.append("<tr>");
html.append("<td style='width:55%;padding:2px 0;'></td>");
html.append("<td style='width:20%;text-align:left;padding:2px 8px;white-space:nowrap;'>zzgl. 19% USt:</td>");
html.append("<td style='width:25%;text-align:right;padding:2px 8px;white-space:nowrap;font-weight:bold;'>")
.append(String.format(java.util.Locale.GERMANY, "%,.2f €", vatTotal)).append("</td>");
html.append("</tr>");
// Gesamtsumme - label in col 2, value in col 3
html.append("<tr>");
html.append("<td style='width:55%;padding:2px 0;'></td>");
@@ -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<java.util.Map<String, String>> servicesData = new java.util.ArrayList<>();
@@ -809,9 +798,7 @@ public class CustomerInvoiceService {
// Header row
html.append("<tr style='background-color:#f5f5f5;border-bottom:1px solid #cccccc;'>");
html.append(
"<th style='text-align:left;padding:4px 8px;font-weight:bold;width:55%;white-space:nowrap;'>Name</th>");
html.append(
"<th style='text-align:right;padding:4px 8px;font-weight:bold;width:20%;white-space:nowrap;'>Steuersatz</th>");
"<th style='text-align:left;padding:4px 8px;font-weight:bold;width:75%;white-space:nowrap;'>Name</th>");
html.append(
"<th style='text-align:right;padding:4px 8px;font-weight:bold;width:25%;white-space:nowrap;'>Nettobetrag</th>");
html.append("</tr>");
@@ -821,23 +808,20 @@ public class CustomerInvoiceService {
// 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>");
"<td colspan='2' 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;'>")
"<td style='text-align:left;padding:4px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:75%;'>")
.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)
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:25%;'>").append(netAmount)
.append(" €</td>");
html.append("</tr>");
}
@@ -849,9 +833,6 @@ public class CustomerInvoiceService {
html.append("<div style='margin-top:8px;width:100%;'>");
html.append("<table style='width:100%;border-collapse:collapse;font-size:inherit;table-layout:fixed;'>");
// Extract numeric value from VAT rate for display
String vatPercent = vatRate.replace(" %", "").replace("%", "");
// Nettosumme
html.append("<tr>");
html.append("<td style='width:55%;padding:2px 0;'></td>");
@@ -860,15 +841,6 @@ public class CustomerInvoiceService {
.append(netTotal).append("</td>");
html.append("</tr>");
// Umsatzsteuer
html.append("<tr>");
html.append("<td style='width:55%;padding:2px 0;'></td>");
html.append("<td style='width:20%;text-align:left;padding:2px 8px;white-space:nowrap;'>zzgl. ")
.append(vatPercent).append("% USt:</td>");
html.append("<td style='width:25%;text-align:right;padding:2px 8px;white-space:nowrap;font-weight:bold;'>")
.append(vatTotal).append("</td>");
html.append("</tr>");
// Gesamtsumme
html.append("<tr>");
html.append("<td style='width:55%;padding:2px 0;'></td>");

View File

@@ -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}
adminpricetable.notification.load.error=Fehler beim Laden: {0}

View File

@@ -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}
adminpricetable.notification.load.error=Error loading: {0}

View File

@@ -422,10 +422,6 @@
<td class="label-col">Nettobetrag:</td>
<td class="amount-col">${invoiceData.netAmount}</td>
</tr>
<tr>
<td class="label-col">zzgl. ${invoiceData.vatRate} USt.:</td>
<td class="amount-col">${invoiceData.vatAmount}</td>
</tr>
<tr class="total-row">
<td class="label-col">Rechnungsbetrag:</td>
<td class="amount-col">${invoiceData.totalAmount}</td>
@@ -465,4 +461,4 @@
</div>
</div>
</body>
</html>
</html>