From d3e14fa8f0e691a1850d25aa71d96d12310e0a94 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 22 Dec 2025 00:01:22 +0000 Subject: [PATCH 01/81] Update dependency org.springframework.boot:spring-boot-starter-parent to v3.5.9 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index acbde50..1de9138 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 3.5.8 + 3.5.9 de.avatic -- 2.45.3 From 63e1574d2ffe95e0a14f8997c60120b6ca279494 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 4 Jan 2026 17:39:43 +0100 Subject: [PATCH 02/81] Enhance logging in `DistanceApiService` to include cached distance details; refactor and improve handling/multiplier logic in cost calculation services. --- .../avatic/lcc/service/api/DistanceApiService.java | 2 +- .../steps/HandlingCostCalculationService.java | 13 ++++++++++++- .../steps/InventoryCostCalculationService.java | 3 ++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/main/java/de/avatic/lcc/service/api/DistanceApiService.java b/src/main/java/de/avatic/lcc/service/api/DistanceApiService.java index 557f3a1..b40a16d 100644 --- a/src/main/java/de/avatic/lcc/service/api/DistanceApiService.java +++ b/src/main/java/de/avatic/lcc/service/api/DistanceApiService.java @@ -78,7 +78,7 @@ public class DistanceApiService { Optional cachedDistance = distanceMatrixRepository.getDistance(from, isUsrFrom, to, isUsrTo); if (cachedDistance.isPresent() && cachedDistance.get().getState() == DistanceMatrixState.VALID) { - logger.info("Found cached distance from node {} (user: {}) to node {} (user {})", from.getExternalMappingId(), isUsrFrom, to.getExternalMappingId(), isUsrTo); + logger.info("Found cached distance from node {} (user: {}) to node {} (user {}) - {} meters", from.getExternalMappingId(), isUsrFrom, to.getExternalMappingId(), isUsrTo, cachedDistance.get().getDistance().doubleValue()); return cachedDistance; } diff --git a/src/main/java/de/avatic/lcc/service/calculation/execution/steps/HandlingCostCalculationService.java b/src/main/java/de/avatic/lcc/service/calculation/execution/steps/HandlingCostCalculationService.java index 7c22837..43219b6 100644 --- a/src/main/java/de/avatic/lcc/service/calculation/execution/steps/HandlingCostCalculationService.java +++ b/src/main/java/de/avatic/lcc/service/calculation/execution/steps/HandlingCostCalculationService.java @@ -44,6 +44,10 @@ public class HandlingCostCalculationService { var destinationRepacking = destination.getRepackingCost(); BigDecimal huAnnualAmount = BigDecimal.valueOf(destination.getAnnualAmount()).divide(BigDecimal.valueOf(hu.getContentUnitCount()),4, RoundingMode.UP ); + BigDecimal shippingFreq = BigDecimal.valueOf(shippingFrequencyCalculationService.doCalculation(setId, huAnnualAmount.doubleValue(), containerCalculationResult.getHuPerContainer(), !premise.getHuMixable())); + + BigDecimal multiplier = shippingFreq.compareTo(huAnnualAmount) > 0 ? shippingFreq : huAnnualAmount; + BigDecimal handling = destinationHandling != null ? destinationHandling : BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.GLT_HANDLING, setId).orElseThrow().getCurrentValue())); BigDecimal release = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.GLT_RELEASE, setId).orElseThrow().getCurrentValue())); BigDecimal dispatch = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.GLT_DISPATCH, setId).orElseThrow().getCurrentValue())); @@ -80,21 +84,28 @@ public class HandlingCostCalculationService { private HandlingResult getLLCCost(Integer setId, Premise premise, Destination destination, PackagingDimension hu, LoadCarrierType type, boolean addRepackingAndDisposalCost, ContainerCalculationResult containerCalculationResult) { + var destinationHandling = destination.getHandlingCost(); var destinationDisposal = destination.getDisposalCost(); var destinationRepacking = destination.getRepackingCost(); BigDecimal huAnnualAmount = BigDecimal.valueOf(destination.getAnnualAmount()).divide(BigDecimal.valueOf(hu.getContentUnitCount()),4, RoundingMode.UP ); + BigDecimal shippingFreq = BigDecimal.valueOf(shippingFrequencyCalculationService.doCalculation(setId, huAnnualAmount.doubleValue(), containerCalculationResult.getHuPerContainer(), !premise.getHuMixable())); + + BigDecimal multiplier = shippingFreq.compareTo(huAnnualAmount) > 0 ? shippingFreq : huAnnualAmount; + BigDecimal handling = destinationHandling != null ? destinationHandling : BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.GLT_HANDLING, setId).orElseThrow().getCurrentValue())); BigDecimal release = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.GLT_RELEASE, setId).orElseThrow().getCurrentValue())); BigDecimal dispatch = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.GLT_DISPATCH, setId).orElseThrow().getCurrentValue())); BigDecimal disposal = destinationDisposal != null ? destinationDisposal : (addRepackingAndDisposalCost ? BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.DISPOSAL, setId).orElseThrow().getCurrentValue())) : BigDecimal.ZERO); + + BigDecimal wageFactor = BigDecimal.valueOf(Double.parseDouble(countryPropertyRepository.getByMappingIdAndCountryId(CountryPropertyMappingId.WAGE, setId, destination.getCountryId()).orElseThrow().getCurrentValue())); BigDecimal booking = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.BOOKING, setId).orElseThrow().getCurrentValue())); var annualRepacking = getRepackingCost(setId, hu, type, addRepackingAndDisposalCost, destinationRepacking).multiply(wageFactor).multiply( huAnnualAmount); - var annualHandling = ((handling.add(dispatch).add(release)).multiply(wageFactor).multiply(huAnnualAmount)).add(booking.multiply(BigDecimal.valueOf(shippingFrequencyCalculationService.doCalculation(setId, huAnnualAmount.doubleValue(), containerCalculationResult.getHuPerContainer(), !premise.getHuMixable())))); + var annualHandling = (((handling.multiply(multiplier)).add((dispatch.multiply(huAnnualAmount))).add((release.multiply(huAnnualAmount)))).add(booking.multiply(shippingFreq))).multiply(wageFactor); var annualDisposal = (disposal.multiply(huAnnualAmount)); return new HandlingResult(LoadCarrierType.LLC, annualRepacking, annualHandling, annualDisposal, annualRepacking.add(annualHandling).add(annualDisposal)); diff --git a/src/main/java/de/avatic/lcc/service/calculation/execution/steps/InventoryCostCalculationService.java b/src/main/java/de/avatic/lcc/service/calculation/execution/steps/InventoryCostCalculationService.java index dafb733..c7d722e 100644 --- a/src/main/java/de/avatic/lcc/service/calculation/execution/steps/InventoryCostCalculationService.java +++ b/src/main/java/de/avatic/lcc/service/calculation/execution/steps/InventoryCostCalculationService.java @@ -72,7 +72,8 @@ public class InventoryCostCalculationService { } private BigDecimal getSpaceCostPerHu(Integer setId, PackagingDimension hu) { - var spaceCost = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.SPACE_COST, setId).orElseThrow().getCurrentValue())); + var spaceCostStr = propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.SPACE_COST, setId).orElseThrow().getCurrentValue(); + var spaceCost = BigDecimal.valueOf(Double.parseDouble(spaceCostStr)); var spaceCostPerHu = BigDecimal.valueOf(hu.getFloorArea(DimensionUnit.M)*hu.getRoundedHeight(DimensionUnit.M)).multiply(spaceCost); return spaceCostPerHu; } -- 2.45.3 From b473e34809468088ef6b0d6d8f5c1065bc6450fa Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 4 Jan 2026 17:40:48 +0100 Subject: [PATCH 03/81] Add page objects and test framework scaffolding for Selenium-based test automation. Includes initial test suites, element locators, and configuration setup. --- test/conftest.py | 101 +++++++++++++++++++++ test/pages/assistant.py | 46 ++++++++++ test/pages/base_page.py | 147 ++++++++++++++++++++++++++++++ test/pages/calculation_page.py | 157 +++++++++++++++++++++++++++++++++ test/pages/dev_page.py | 53 +++++++++++ test/pages/navigation.py | 29 ++++++ test/pages/results_page.py | 35 ++++++++ test/pytest.ini | 18 ++++ test/requirements.txt | 5 ++ test/test_calculation.py | 70 +++++++++++++++ 10 files changed, 661 insertions(+) create mode 100644 test/conftest.py create mode 100644 test/pages/assistant.py create mode 100644 test/pages/base_page.py create mode 100644 test/pages/calculation_page.py create mode 100644 test/pages/dev_page.py create mode 100644 test/pages/navigation.py create mode 100644 test/pages/results_page.py create mode 100644 test/pytest.ini create mode 100644 test/requirements.txt create mode 100644 test/test_calculation.py diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..55cbf47 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,101 @@ +# conftest.py +import pytest +from selenium import webdriver +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.support.ui import WebDriverWait +from pathlib import Path +import openpyxl + + +@pytest.fixture(scope="session") +def browser_options(): + options = Options() + options.add_argument("--start-maximized") + # options.add_argument("--headless") # Optional für CI/CD + return options + + +@pytest.fixture(scope="function") +def driver(browser_options): + """Erstellt einen neuen Browser pro Test""" + driver = webdriver.Chrome(options=browser_options) + driver.implicitly_wait(10) + yield driver + driver.quit() + + +@pytest.fixture(scope="function") +def wait(driver): + """WebDriverWait Instanz""" + return WebDriverWait(driver, 20) + + +@pytest.fixture +def base_url(): + """Basis-URL deiner Anwendung""" + return "http://localhost:5173" + + +def pytest_generate_tests(metafunc): + """Parametrisiert Tests basierend auf Excel-Dateien im testdata Ordner""" + if "testcase_file" in metafunc.fixturenames: + testdata_dir = Path("testcases") + excel_files = list(testdata_dir.glob("*.xlsx")) + + # IDs für bessere Testausgabe + ids = [f.stem for f in excel_files] + + metafunc.parametrize("testcase_file", excel_files, ids=ids) + + +@pytest.fixture +def test_data(testcase_file): + """Lädt Testdaten aus Excel-Datei""" + wb = openpyxl.load_workbook(testcase_file) + + # Eingabedaten aus "in" Sheet + input_sheet = wb["in"] + input_data = {} + groups = {} + for row in input_sheet.iter_rows(values_only=True): + if row[0]: # Feldname in Spalte A + if '_' in row[0]: + [remainder, suffix] = row[0].rsplit('_',1) + if suffix.isdigit(): + [prefix, name] = remainder.split('_',1) + if prefix not in groups: + groups[prefix] = {} + if groups[prefix].get(suffix) is None: + groups[prefix][suffix] = {} + groups[prefix][suffix][name] = row[1] + else: + input_data[row[0]] = row[1] + else: + input_data[row[0]] = row[1] # Wert in Spalte B + input_data.update(groups) + + # Erwartete Ausgaben aus "out" Sheet + output_sheet = wb["out"] + expected_data = {} + expected_groups = {} + for row in input_sheet.iter_rows(values_only=True): + if row[0]: # Feldname in Spalte A + if '_' in row[0]: + [remainder, suffix] = row[0].rsplit('_',1) + if suffix.isdigit(): + [prefix, name] = remainder.split('_',1) + if prefix not in expected_groups: + expected_groups[prefix] = {} + if expected_groups[prefix].get(suffix) is None: + expected_groups[prefix][suffix] = {} + expected_groups[prefix][suffix][name] = row[1] + else: + expected_data[row[0]] = row[1] + else: + expected_data[row[0]] = row[1] # Wert in Spalte B + expected_data.update(expected_groups) + return { + "input": input_data, + "expected": expected_data, + "filename": testcase_file.name + } \ No newline at end of file diff --git a/test/pages/assistant.py b/test/pages/assistant.py new file mode 100644 index 0000000..e5d07ba --- /dev/null +++ b/test/pages/assistant.py @@ -0,0 +1,46 @@ +import time + +from selenium.webdriver.common.by import By + +from conftest import driver +from pages.base_page import BasePage + + +class Assistant(BasePage): + """Page Object für den Assistant""" + + # Mapping von Excel-Feldnamen zu Locators + FIELD_MAPPING = { + "PART_NUMBER": (By.CSS_SELECTOR, "textarea[name='partNumbers']"), + "ANALYZE_BUTTON": (By.CSS_SELECTOR, ".part-number-modal-action > .btn--primary"), + "SUPPLIER_NAME": (By.CSS_SELECTOR, ".supplier-headers-searchbar-container .search-input"), + "LOAD_FROM_PREVIOUS": (By.CSS_SELECTOR, ".start-calculation-footer-container .checkbox-item"), + "CREATE_CALCULATION_BUTTON": (By.CSS_SELECTOR, ".start-calculation-footer-container .btn--secondary"), + "DELETE_SUPPLIER_BUTTON": (By.CSS_SELECTOR, ".supplier-headers ~ .item-list .item-list-element .icon-btn-container .icon-btn"), + } + + def search_part_numbers(self, data_dict): + """Füllt das Formular mit Daten aus dem Excel""" + self.fill_input(*self.FIELD_MAPPING["PART_NUMBER"], data_dict["PART_NUMBER"]) + self.click_button(*self.FIELD_MAPPING["ANALYZE_BUTTON"]) + + def delete_preselected_suppliers(self): + while True: + try: + button = self.wait_for_clickable( + *self.FIELD_MAPPING["DELETE_SUPPLIER_BUTTON"], + timeout=1 + ) + button.click() + time.sleep(0.2) + except: + # Keine Buttons mehr vorhanden + break + + def select_supplier(self, data_dict): + self.search_and_select_autosuggest(*self.FIELD_MAPPING["SUPPLIER_NAME"], data_dict["SUPPLIER_NAME"]) + + + def create_calculation(self, data_dict): + self.set_checkbox(*self.FIELD_MAPPING["LOAD_FROM_PREVIOUS"], data_dict["LOAD_FROM_PREVIOUS"]) + self.click_button(*self.FIELD_MAPPING["CREATE_CALCULATION_BUTTON"]) diff --git a/test/pages/base_page.py b/test/pages/base_page.py new file mode 100644 index 0000000..4a19b18 --- /dev/null +++ b/test/pages/base_page.py @@ -0,0 +1,147 @@ +# pages/base_page.py +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.common.by import By +from selenium.common.exceptions import TimeoutException +import time +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +class BasePage: + """Basis-Klasse für alle Page Objects""" + + def __init__(self, driver, wait): + self.driver = driver + self.wait = wait + + def wait_for_spa_navigation(self, expected_route_part, timeout=2): + """Wartet bis SPA zur erwarteten Route navigiert hat""" + WebDriverWait(self.driver, timeout).until( + lambda d: expected_route_part in d.current_url + ) + # Zusätzlich auf Vue-Rendering warten + time.sleep(0.5) + + def wait_for_element(self, by, value, timeout=2): + """Wartet auf ein Element""" + start_time = time.time() + logger.info(f"Waiting for element: {by}={value}") + + result = WebDriverWait(self.driver, timeout).until( + EC.presence_of_element_located((by, value)) + ) + + elapsed = time.time() - start_time + logger.info(f"Found element after {elapsed:.2f}s") + return result + def wait_for_clickable(self, by, value, timeout=2): + """Wartet bis Element klickbar ist""" + return WebDriverWait(self.driver, timeout).until( + EC.element_to_be_clickable((by, value)) + ) + + def fill_input(self, by, value, text, check_existence=False, timeout=2): + if check_existence: + # Prüfe ob Element existiert + try: + element = WebDriverWait(self.driver, timeout).until( + EC.presence_of_element_located((by, value)) + ) + logger.info(f"Element exists, filling...") + except TimeoutException: + logger.warning(f"Element does not exist, skipping (check_existence=True)") + return False + else: + # Normaler Modus - erwarte dass Element existiert + element = self.wait_for_element(by, value, timeout) + + # Element existiert - jetzt füllen + element.clear() + element.send_keys(text) + logger.info(f"Filled input with: {text}") + return True + + def click_button(self, by, value): + """Klickt einen Button""" + start_time = time.time() + logger.info(f"Clicking button: {by}={value}") + + button = self.wait_for_clickable(by, value) + button.click() + + elapsed = time.time() - start_time + logger.info(f"Clicked after {elapsed:.2f}s") + + def set_checkbox(self, by, value, checked, timeout=2): + label = self.wait_for_clickable(by, value, timeout) + + checkbox_input = label.find_element(By.CSS_SELECTOR, "input[type='checkbox']") + + is_checked = checkbox_input.is_selected() + + if is_checked != checked: + label.click() + time.sleep(0.3) + + + def select_dropdown_option(self, by, value, option_text, timeout=10): + dropdown_button = self.wait_for_element(by, value, timeout=timeout) + + try: + current_value = dropdown_button.find_element( + By.CSS_SELECTOR, + "span.dropdown-trigger-text" + ).text + + if current_value == option_text: + logger.info(f"Dropdown already has value: {option_text}") + return + except: + pass # Falls kein Text gefunden wurde, öffne das Dropdown + + dropdown_button.click() + logger.info("Opened dropdown") + + menu = WebDriverWait(self.driver, timeout).until( + EC.visibility_of_element_located((By.CSS_SELECTOR, "ul.dropdown-menu")) + ) + logger.info("Dropdown menu visible") + + option_xpath = f"//li[contains(@class, 'dropdown-option')][normalize-space(text())='{option_text}']" + option = WebDriverWait(self.driver, timeout).until( + EC.element_to_be_clickable((By.XPATH, option_xpath)) + ) + + logger.info(f"Clicking option: {option_text}") + option.click() + + + time.sleep(0.2) + + def search_and_select_autosuggest(self, by_or_selector, value_or_search_text, + search_text=None, + suggestion_selector=".suggestion-item", + timeout=2): + + if search_text is not None: + # Fall: (By.CSS_SELECTOR, ".selector", "search_text") + search_input = self.wait_for_element(by_or_selector, value_or_search_text, timeout) + text_to_search = search_text + else: + # Fall: (".selector", "search_text") + search_input = self.wait_for_element(By.CSS_SELECTOR, by_or_selector, timeout) + text_to_search = value_or_search_text + + search_input.clear() + search_input.send_keys(text_to_search) + + time.sleep(1) + + suggestion = WebDriverWait(self.driver, timeout).until( + EC.element_to_be_clickable((By.CSS_SELECTOR, suggestion_selector)) + ) + suggestion.click() + + time.sleep(0.5) diff --git a/test/pages/calculation_page.py b/test/pages/calculation_page.py new file mode 100644 index 0000000..56b1fea --- /dev/null +++ b/test/pages/calculation_page.py @@ -0,0 +1,157 @@ +# pages/calculation_page.py +from selenium.webdriver.common.by import By +from pages.base_page import BasePage +import logging + +logger = logging.getLogger(__name__) + + +class CalculationPage(BasePage): + """Page Object für die Berechnungsformulare""" + + # WICHTIG: Verwende data-v-* Attribute NUR wenn sie WIRKLICH stabil sind + # Besser: Positionsbasierte Selektoren mit aussagekräftigen Parent-Elementen + + FIELD_MAPPING = { + # Material-Sektion (erste Box) + "HS_CODE": ( + By.XPATH, + "//div[contains(@class, 'master-data-item')][1]//div[contains(@class, 'caption-column')][text()='HS code']" + "/following-sibling::div//input[@class='input-field']" + ), + "TARIFF_RATE": ( + By.XPATH, + "//div[contains(@class, 'master-data-item')][1]//div[contains(@class, 'caption-column')][contains(text(), 'Tariff rate')]" + "/following-sibling::div//input[@class='input-field']" + ), + + # Price-Sektion (zweite Box) + "PRICE": ( + By.XPATH, + "//div[contains(@class, 'master-data-item')][2]//div[contains(@class, 'caption-column')][text()='MEK_A [EUR]']" + "/following-sibling::div//input[@class='input-field']" + ), + "OVERSEA_SHARE": ( + By.XPATH, + "//div[contains(@class, 'master-data-item')][2]//div[contains(@class, 'caption-column')][contains(text(), 'Oversea share')]" + "/following-sibling::div//input[@class='input-field']" + ), + # Handling Unit-Sektion (dritte Box) + "LENGTH": ( + By.XPATH, + "//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='HU length']" + "/following-sibling::div//input[@class='input-field']" + ), + "WIDTH": ( + By.XPATH, + "//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='HU width']" + "/following-sibling::div//input[@class='input-field']" + ), + "HEIGHT": ( + By.XPATH, + "//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='HU height']" + "/following-sibling::div//input[@class='input-field']" + ), + "WEIGHT": ( + By.XPATH, + "//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='HU weight']" + "/following-sibling::div//input[@class='input-field']" + ), + "PIECES_UNIT": ( + By.XPATH, + "//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='Pieces per HU']" + "/following-sibling::div//input[@class='input-field']" + ), + + # Dropdowns + "DIMENSION_UNIT": ( + By.XPATH, + "//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='Dimension unit']" + "/following-sibling::div//button[contains(@class, 'dropdown-trigger')]" + ), + "WEIGHT_UNIT": ( + By.XPATH, + "//div[contains(@class, 'master-data-item')][3]//div[contains(@class, 'caption-column')][text()='Weight unit']" + "/following-sibling::div//button[contains(@class, 'dropdown-trigger')]" + ), + + # Checkboxen + "FBA_FEE": ( + By.XPATH, + "//div[contains(@class, 'master-data-item')][2]" + "//label[contains(@class, 'checkbox-item')]" + "[.//span[contains(@class, 'checkbox-label')][normalize-space(text())='']]" + ), + "MIXED": ( + By.XPATH, + "//label[contains(@class, 'checkbox-item')]" + "[.//span[contains(@class, 'checkbox-label')][text()='Mixable']]" + ), + "STACKED": ( + By.XPATH, + "//label[contains(@class, 'checkbox-item')]" + "[.//span[contains(@class, 'checkbox-label')][text()='Stackable']]" + ), + } + + DEST_FIELD_MAPPING = { + "NAME": (By.XPATH, "//input[@placeholder='Add new Destination ...']"), + "QUANTITY": (By.XPATH, + "//div[contains(@class, 'destination-edit-column-caption') and contains(text(), 'Annual quantity')]/following-sibling::div[1]//input[@class='input-field']") + "ROUTING": (By.XPATH, + "//input[@type='radio' and @name='model' and @value='routing']"), + "D2D": (By.XPATH, + "//input[@type='radio' and @name='model' and @value='d2d']"), + "ROUTE": (By.XPATH, + "//div[@class='destination-route-container']//div[contains(@class, 'destination-route-inner-container')][.//span[contains(text(), 'Ireland Su')] and .//span[contains(text(), 'WH ULHA')] and .//span[contains(text(), 'AB')]]") + "HANDLING_TAB": (By.XPATH, "//button[@class='tab-header' and text()='Handling & Repackaging']"), + "CUSTOM_HANDLING": (By.XPATH, + "//div[@class='destination-edit-handling-cost']//label[@class='checkbox-item']/input[@type='checkbox']"), + "REPACKING": (By.XPATH, + "//div[@class='destination-edit-column-caption' and contains(text(), 'Repackaging cost')]/following-sibling::div[@class='destination-edit-column-data'][1]//input[@class='input-field']"), + "HANDLING": (By.XPATH, + "//div[@class='destination-edit-column-caption' and contains(text(), 'Handling cost')]/following-sibling::div[@class='destination-edit-column-data'][1]//input[@class='input-field']"), + "DISPOSAL": (By.XPATH, + "//div[@class='destination-edit-column-caption' and contains(text(), 'Disposal cost')]/following-sibling::div[@class='destination-edit-column-data'][1]//input[@class='input-field']"), + } + + # Buttons + CALCULATE_CLOSE_BUTTON = (By.XPATH, "//button[contains(., 'Calculate & close')]") + CLOSE_BUTTON = (By.XPATH, "//button[contains(., 'Close') and not(contains(., 'Calculate'))]") + + def fill_form(self, data_dict): + """Füllt das Formular mit Daten aus dem Excel""" + for field_name, locator in self.FIELD_MAPPING.items(): + + value = data_dict[field_name] + logger.info(f"Filling field: {field_name} = {value}") + + try: + if field_name in ["FBA_FEE", "STACKED", "MIXED"]: + self.set_checkbox(*locator, str(value) == 'True') + + elif field_name in ["DIMENSION_UNIT", "WEIGHT_UNIT"]: + self.select_dropdown_option(*locator, str(value)) + + else: + self.fill_input(*locator, str(value), check_existence=field_name in ["HS_CODE", "TARIFF_RATE"]) + + except Exception as e: + logger.error(f"Failed to fill field {field_name}: {e}") + self.driver.save_screenshot(f"failed_field_{field_name}.png") + raise Exception(f"Could not fill field '{field_name}': {e}") from e + + def add_destination(self, data_dict): + self.search_and_select_autosuggest(*self.DEST_FIELD_MAPPING["NAME"], data_dict["NAME"]) + + def fill_destination(self, data_dict): + self.wait_for_element(*self.DEST_FIELD_MAPPING["QUANTITY"]) + pass + + def click_calculate_and_close(self): + """Klickt auf 'Calculate & close' Button""" + self.click_button(*self.CALCULATE_CLOSE_BUTTON) + + def click_close(self): + """Klickt auf 'Close' Button""" + self.click_button(*self.CLOSE_BUTTON) diff --git a/test/pages/dev_page.py b/test/pages/dev_page.py new file mode 100644 index 0000000..66de67d --- /dev/null +++ b/test/pages/dev_page.py @@ -0,0 +1,53 @@ +# pages/results_page.py +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC +import time + +class DevPage: + MODAL_YES_BUTTON = (By.CSS_SELECTOR, "div.modal-dialog-actions button.btn--primary") + MODAL_NO_BUTTON = (By.CSS_SELECTOR, "div.modal-dialog-actions button.btn--secondary") + + def __init__(self, driver, wait): + self.driver = driver + self.wait = wait + + def dev_login(self, base_url, user_name="John"): + """ + Simuliert Login über /dev Seite + + Args: + base_url: Basis-URL der Anwendung + user_name: Vorname des Users (z.B. "John", "Sarah", "Mike") + """ + # Navigiere zur Dev-Seite + self.driver.get(f"{base_url}/dev") + + # Warte bis Tabelle geladen ist + self.wait.until( + EC.presence_of_element_located((By.CSS_SELECTOR, "table.data-table")) + ) + + # Finde die User-Row anhand des Vornamens + # Die Tabelle hat Spalten: First name | Last name | E-Mail | ... + rows = self.driver.find_elements(By.CSS_SELECTOR, "table.data-table tbody tr.table-row") + + for row in rows: + cells = row.find_elements(By.TAG_NAME, "td") + if cells and user_name in cells[0].text: # cells[0] ist "First name" + # Klicke auf die Row + self.wait.until(EC.element_to_be_clickable(row)) + row.click() + break + else: + raise Exception(f"User '{user_name}' nicht in der Dev-User-Tabelle gefunden") + + # Warte auf Modal und klicke "Yes" + yes_button = self.wait.until( + EC.element_to_be_clickable(self.MODAL_YES_BUTTON) + ) + yes_button.click() + + # Warte bis Modal geschlossen ist + self.wait.until( + EC.invisibility_of_element_located((By.CSS_SELECTOR, "div.modal-container")) + ) \ No newline at end of file diff --git a/test/pages/navigation.py b/test/pages/navigation.py new file mode 100644 index 0000000..7c8ea82 --- /dev/null +++ b/test/pages/navigation.py @@ -0,0 +1,29 @@ +# pages/navigation.py +from selenium.webdriver.common.by import By + +from pages.assistant import Assistant +from pages.base_page import BasePage +from pages.calculation_page import CalculationPage +from pages.results_page import ResultsPage + + +class Navigation(BasePage): + """Handhabt die SPA-Navigation""" + + # Locators für Navigationselemente + MENU_BUTTON = (By.CSS_SELECTOR, "button.menu-toggle") + NEW_CALCULATION_LINK = (By.CSS_SELECTOR, "a[href*='/assistant']") + RESULTS_LINK = (By.CSS_SELECTOR, "a[href*='/results']") + + def start_calculation(self, base_url): + """Navigiert zur Berechnungsseite""" + self.driver.get(base_url+"/assistant") + self.wait_for_spa_navigation("/assistant") + return Assistant(self.driver, self.wait) + + + def navigate_to_results(self): + """Navigiert zur Ergebnisseite""" + self.click_button(*self.RESULTS_LINK) + self.wait_for_spa_navigation("/results") + return ResultsPage(self.driver, self.wait) \ No newline at end of file diff --git a/test/pages/results_page.py b/test/pages/results_page.py new file mode 100644 index 0000000..9d76774 --- /dev/null +++ b/test/pages/results_page.py @@ -0,0 +1,35 @@ +# pages/results_page.py +from selenium.webdriver.common.by import By +from pages.base_page import BasePage + + +class ResultsPage(BasePage): + """Page Object für die Ergebnisseite""" + + # Mapping von Excel-Feldnamen zu Locators + RESULT_MAPPING = { + "gesamtkosten": (By.ID, "result-total-cost"), + "zollgebuehr": (By.ID, "result-customs-fee"), + "transportkosten": (By.ID, "result-transport-cost"), + "mehrwertsteuer": (By.ID, "result-vat"), + # Weitere Ergebnisfelder... + } + + def wait_for_results(self): + """Wartet bis Ergebnisse geladen sind""" + self.wait_for_element(By.CSS_SELECTOR, ".results-container.loaded") + + def read_results(self): + """Liest alle Ergebniswerte aus""" + self.wait_for_results() + + results = {} + for field_name, locator in self.RESULT_MAPPING.items(): + try: + element = self.wait_for_element(*locator) + results[field_name] = element.text + except Exception as e: + print(f"Fehler beim Lesen von '{field_name}': {e}") + results[field_name] = None + + return results \ No newline at end of file diff --git a/test/pytest.ini b/test/pytest.ini new file mode 100644 index 0000000..e74e39e --- /dev/null +++ b/test/pytest.ini @@ -0,0 +1,18 @@ +[pytest] +log_cli = true +log_cli_level = INFO +log_cli_format = %(asctime)s [%(levelname)s] %(message)s +log_cli_date_format = %H:%M:%S +testpaths = . +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + -v + --tb=short + --capture=no + --html=reports/report.html + --self-contained-html +markers = + smoke: Smoke tests + regression: Regression tests \ No newline at end of file diff --git a/test/requirements.txt b/test/requirements.txt new file mode 100644 index 0000000..323adac --- /dev/null +++ b/test/requirements.txt @@ -0,0 +1,5 @@ +pytest==9.0.2 +selenium==4.39.0 +openpyxl==3.1.5 +pytest-html==4.1.1 +webdriver-manager==4.0.2 \ No newline at end of file diff --git a/test/test_calculation.py b/test/test_calculation.py new file mode 100644 index 0000000..dd7d95a --- /dev/null +++ b/test/test_calculation.py @@ -0,0 +1,70 @@ +# tests/test_calculation.py +import pytest + +from pages.dev_page import DevPage +from pages.navigation import Navigation +from pages.calculation_page import CalculationPage +from pages.results_page import ResultsPage + + +class TestCalculation: + """Testklasse für Berechnungslogik""" + + def test_calculation_workflow(self, driver, wait, base_url, test_data): + """ + Haupttest: Führt einen kompletten Berechnungsdurchlauf durch + Wird automatisch für jede Excel-Datei parametrisiert + """ + + login_page = DevPage(driver, wait) + login_page.dev_login(base_url, user_name="John") + + + # Setup assistant + nav = Navigation(driver, wait) + assistant = nav.start_calculation(base_url) + assistant.search_part_numbers(test_data["input"]) + assistant.delete_preselected_suppliers() + assistant.select_supplier(test_data["input"]) + assistant.create_calculation(test_data["input"]) + + # Fill calculation page + calc_page = CalculationPage(driver, wait) + calc_page.fill_form(test_data["input"]) + + for destination in test_data["input"]["DESTINATION"].values(): + calc_page.add_destination(destination) + calc_page.fill_destination(destination) + + # Ergebnisse lesen + results_page = ResultsPage(driver, wait) + actual_results = results_page.read_results() + + # Assertions: Vergleich mit erwarteten Werten aus "out" Sheet + expected = test_data["expected"] + + for field_name, expected_value in expected.items(): + actual_value = actual_results.get(field_name) + + # Numerische Werte mit Toleranz vergleichen + if self._is_numeric(expected_value): + expected_num = float(expected_value) + actual_num = float(actual_value.replace("€", "").replace(",", ".").strip()) + + assert pytest.approx(expected_num, rel=0.01) == actual_num, \ + f"Feld '{field_name}': Erwartet {expected_num}, erhalten {actual_num}" + else: + # String-Vergleich + assert str(actual_value).strip() == str(expected_value).strip(), \ + f"Feld '{field_name}': Erwartet '{expected_value}', erhalten '{actual_value}'" + + print(f"✓ Test erfolgreich für {test_data['filename']}") + + @staticmethod + def _is_numeric(value): + """Prüft ob Wert numerisch ist""" + try: + float(str(value).replace(",", ".")) + return True + except (ValueError, AttributeError): + return False -- 2.45.3 From 3a203d1c7e29998cd95a7ba9a007c8f03de5d497 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 4 Jan 2026 18:59:53 +0100 Subject: [PATCH 04/81] Refactor handling cost calculation logic: simplify null checks using `Objects.requireNonNullElse`, adjust handling and disposal calculations, and improve clarity in annual cost computations. --- .../steps/HandlingCostCalculationService.java | 49 +++++++++++++------ 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/src/main/java/de/avatic/lcc/service/calculation/execution/steps/HandlingCostCalculationService.java b/src/main/java/de/avatic/lcc/service/calculation/execution/steps/HandlingCostCalculationService.java index 43219b6..5e6dee5 100644 --- a/src/main/java/de/avatic/lcc/service/calculation/execution/steps/HandlingCostCalculationService.java +++ b/src/main/java/de/avatic/lcc/service/calculation/execution/steps/HandlingCostCalculationService.java @@ -14,6 +14,7 @@ import org.springframework.stereotype.Service; import java.math.BigDecimal; import java.math.RoundingMode; +import java.util.Objects; @Service public class HandlingCostCalculationService { @@ -48,19 +49,31 @@ public class HandlingCostCalculationService { BigDecimal multiplier = shippingFreq.compareTo(huAnnualAmount) > 0 ? shippingFreq : huAnnualAmount; - BigDecimal handling = destinationHandling != null ? destinationHandling : BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.GLT_HANDLING, setId).orElseThrow().getCurrentValue())); - BigDecimal release = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.GLT_RELEASE, setId).orElseThrow().getCurrentValue())); - BigDecimal dispatch = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.GLT_DISPATCH, setId).orElseThrow().getCurrentValue())); - BigDecimal disposal = destinationDisposal != null ? destinationDisposal : (addRepackingAndDisposalCost ? BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.DISPOSAL, setId).orElseThrow().getCurrentValue())) : BigDecimal.ZERO); + BigDecimal handling = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.KLT_HANDLING, setId).orElseThrow().getCurrentValue())); + BigDecimal release = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.KLT_RELEASE, setId).orElseThrow().getCurrentValue())); + BigDecimal dispatch = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.KLT_DISPATCH, setId).orElseThrow().getCurrentValue())); + BigDecimal disposal = (addRepackingAndDisposalCost ? BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.DISPOSAL, setId).orElseThrow().getCurrentValue())) : BigDecimal.ZERO); BigDecimal wageFactor = BigDecimal.valueOf(Double.parseDouble(countryPropertyRepository.getByMappingIdAndCountryId(CountryPropertyMappingId.WAGE, setId, destination.getCountryId()).orElseThrow().getCurrentValue())); BigDecimal booking = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.BOOKING_KLT, setId).orElseThrow().getCurrentValue())); + BigDecimal annualRepacking = Objects.requireNonNullElse(destinationRepacking, getRepackingCost(setId, hu, loadCarrierType, addRepackingAndDisposalCost, destinationRepacking).multiply(wageFactor)).multiply(huAnnualAmount); + BigDecimal annualDisposal = Objects.requireNonNullElse(destinationDisposal.multiply(huAnnualAmount), BigDecimal.ZERO); +// BigDecimal annualHandling = Objects.requireNonNullElse(destinationHandling, handling.add(release).add(dispatch).add(disposal).multiply(wageFactor)).multiply(multiplier); + + BigDecimal annualHandling; + + if(destinationHandling != null) + annualHandling = destinationHandling.multiply(multiplier); + else + annualHandling = (((handling.multiply(multiplier)).add((dispatch.multiply(huAnnualAmount))).add((release.multiply(huAnnualAmount)))).add(booking.multiply(shippingFreq))).multiply(wageFactor); + + return new HandlingResult(LoadCarrierType.SLC, - getRepackingCost(setId, hu, loadCarrierType, addRepackingAndDisposalCost, destinationRepacking).multiply(huAnnualAmount), - handling.multiply(huAnnualAmount), - destinationDisposal == null ? BigDecimal.ZERO : (disposal.multiply(huAnnualAmount)), //TODO: disposal SLC, ignore? - huAnnualAmount.multiply((handling.add(booking).add(release).add(dispatch).add(getRepackingCost(setId, hu, loadCarrierType, addRepackingAndDisposalCost, destinationRepacking)))).multiply(wageFactor)); + annualRepacking, + annualHandling, + annualDisposal, + annualHandling.add(annualRepacking).add(annualDisposal)); } @@ -84,7 +97,6 @@ public class HandlingCostCalculationService { private HandlingResult getLLCCost(Integer setId, Premise premise, Destination destination, PackagingDimension hu, LoadCarrierType type, boolean addRepackingAndDisposalCost, ContainerCalculationResult containerCalculationResult) { - var destinationHandling = destination.getHandlingCost(); var destinationDisposal = destination.getDisposalCost(); var destinationRepacking = destination.getRepackingCost(); @@ -94,19 +106,24 @@ public class HandlingCostCalculationService { BigDecimal multiplier = shippingFreq.compareTo(huAnnualAmount) > 0 ? shippingFreq : huAnnualAmount; - BigDecimal handling = destinationHandling != null ? destinationHandling : BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.GLT_HANDLING, setId).orElseThrow().getCurrentValue())); + BigDecimal handling = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.GLT_HANDLING, setId).orElseThrow().getCurrentValue())); BigDecimal release = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.GLT_RELEASE, setId).orElseThrow().getCurrentValue())); BigDecimal dispatch = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.GLT_DISPATCH, setId).orElseThrow().getCurrentValue())); - BigDecimal disposal = destinationDisposal != null ? destinationDisposal : (addRepackingAndDisposalCost ? BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.DISPOSAL, setId).orElseThrow().getCurrentValue())) : BigDecimal.ZERO); - - + BigDecimal disposal = (addRepackingAndDisposalCost ? BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.DISPOSAL, setId).orElseThrow().getCurrentValue())) : BigDecimal.ZERO); BigDecimal wageFactor = BigDecimal.valueOf(Double.parseDouble(countryPropertyRepository.getByMappingIdAndCountryId(CountryPropertyMappingId.WAGE, setId, destination.getCountryId()).orElseThrow().getCurrentValue())); BigDecimal booking = BigDecimal.valueOf(Double.parseDouble(propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.BOOKING, setId).orElseThrow().getCurrentValue())); - var annualRepacking = getRepackingCost(setId, hu, type, addRepackingAndDisposalCost, destinationRepacking).multiply(wageFactor).multiply( huAnnualAmount); - var annualHandling = (((handling.multiply(multiplier)).add((dispatch.multiply(huAnnualAmount))).add((release.multiply(huAnnualAmount)))).add(booking.multiply(shippingFreq))).multiply(wageFactor); - var annualDisposal = (disposal.multiply(huAnnualAmount)); + BigDecimal annualRepacking = Objects.requireNonNullElse(destinationRepacking, getRepackingCost(setId, hu, type, addRepackingAndDisposalCost, destinationRepacking).multiply(wageFactor)).multiply(huAnnualAmount); + + BigDecimal annualHandling; + + if(destinationHandling != null) + annualHandling = destinationHandling.multiply(multiplier); + else + annualHandling = (((handling.multiply(multiplier)).add((dispatch.multiply(huAnnualAmount))).add((release.multiply(huAnnualAmount)))).add(booking.multiply(shippingFreq))).multiply(wageFactor); + + BigDecimal annualDisposal = Objects.requireNonNullElse(destinationDisposal, disposal).multiply(huAnnualAmount); return new HandlingResult(LoadCarrierType.LLC, annualRepacking, annualHandling, annualDisposal, annualRepacking.add(annualHandling).add(annualDisposal)); -- 2.45.3 From 1ceca3f2f1b13bcd5e6481dfe17bc8e533fd08b5 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 4 Jan 2026 19:20:34 +0100 Subject: [PATCH 05/81] Adjust FCA fees calculation to average per destination for consistency with other cost computations --- .../lcc/service/transformer/report/ReportTransformer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/de/avatic/lcc/service/transformer/report/ReportTransformer.java b/src/main/java/de/avatic/lcc/service/transformer/report/ReportTransformer.java index 08b45fc..1837917 100644 --- a/src/main/java/de/avatic/lcc/service/transformer/report/ReportTransformer.java +++ b/src/main/java/de/avatic/lcc/service/transformer/report/ReportTransformer.java @@ -329,7 +329,7 @@ public class ReportTransformer { var airfreightValue = destination.stream().map(CalculationJobDestination::getAnnualAirFreightCost).reduce(BigDecimal.ZERO, BigDecimal::add).divide(annualAmount, 4, RoundingMode.HALF_UP); var materialValue = destination.stream().map(CalculationJobDestination::getMaterialCost).reduce(BigDecimal.ZERO, BigDecimal::add).divide(BigDecimal.valueOf(destination.size()), 4, RoundingMode.HALF_UP); - var fcaFeesValues = destination.stream().map(CalculationJobDestination::getFcaCost).reduce(BigDecimal.ZERO, BigDecimal::add); + var fcaFeesValues = destination.stream().map(CalculationJobDestination::getFcaCost).reduce(BigDecimal.ZERO, BigDecimal::add).divide(BigDecimal.valueOf(destination.size()), 4, RoundingMode.HALF_UP); var repackingValues = annualAmount.compareTo(BigDecimal.ZERO) == 0 ? BigDecimal.ZERO : destination.stream().map(CalculationJobDestination::getAnnualRepackingCost).reduce(BigDecimal.ZERO, BigDecimal::add).divide(annualAmount, 4, RoundingMode.HALF_UP); var handlingValues = annualAmount.compareTo(BigDecimal.ZERO) == 0 ? BigDecimal.ZERO : destination.stream().map(CalculationJobDestination::getAnnualHandlingCost).reduce(BigDecimal.ZERO, BigDecimal::add).divide(annualAmount, 4, RoundingMode.HALF_UP); var storageValues = annualAmount.compareTo(BigDecimal.ZERO) == 0 ? BigDecimal.ZERO : destination.stream().map(CalculationJobDestination::getAnnualStorageCost).reduce(BigDecimal.ZERO, BigDecimal::add).divide(annualAmount, 4, RoundingMode.HALF_UP); -- 2.45.3 From d606e3e33a35d9eb8489060db7456b658face6f8 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 4 Jan 2026 19:30:34 +0100 Subject: [PATCH 06/81] Add modal for node details with map view and country flag rendering, enhance table interactions with row-click handling --- .../src/components/layout/config/Nodes.vue | 69 ++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/components/layout/config/Nodes.vue b/src/frontend/src/components/layout/config/Nodes.vue index b308524..e9dedd0 100644 --- a/src/frontend/src/components/layout/config/Nodes.vue +++ b/src/frontend/src/components/layout/config/Nodes.vue @@ -1,8 +1,26 @@