This file is a merged representation of the entire codebase, combined into a single document by Repomix.

================================================================
File Summary
================================================================

Purpose:
--------
This file contains a packed representation of the entire repository's contents.
It is designed to be easily consumable by AI systems for analysis, code review,
or other automated processes.

File Format:
------------
The content is organized as follows:
1. This summary section
2. Repository information
3. Directory structure
4. Multiple file entries, each consisting of:
  a. A separator line (================)
  b. The file path (File: path/to/file)
  c. Another separator line
  d. The full contents of the file
  e. A blank line

Usage Guidelines:
-----------------
- This file should be treated as read-only. Any changes should be made to the
  original repository files, not this packed version.
- When processing this file, use the file path to distinguish
  between different files in the repository.
- Be aware that this file may contain sensitive information. Handle it with
  the same level of security as you would the original repository.

Notes:
------
- Some files may have been excluded based on .gitignore rules and Repomix's configuration
- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files
- Files matching patterns in .gitignore are excluded
- Files matching default ignore patterns are excluded

Additional Info:
----------------

================================================================
Directory Structure
================================================================
__todo-v2__/
  topdata_product_relationships.sql
.github/
  workflows/
    update-demoshops.yaml
ai_docs/
  checklist/
    CHECKLIST__refactor_device_linking_v2.md
  done/
    PLAN__enhance_import_report_counters.md
    PLAN__import_command_locking.md
    PLAN__merge_mapping_strategies.md
    PLAN__new_DeviceMediaImportService.md
    PLAN__refactor_device_linking_v2.md
    refactor_brand_lookup_checklist.md
    refactor_brand_lookup_plan.md
  plans/
    CHECKLIST__integrated_caching_strategy.md
    CHECKLIST__optimize_product_relationship_performance.md
    CHECKLIST__verbose_category_override_logging.md
    PLAN__cache_ean_oem_mappings.md
    PLAN__cache_stats_display.md
    PLAN__fix_mapping_cache.md
    PLAN__integrated_caching_strategy.md
    PLAN__optimize_product_relationship_performance.md
    PLAN__refactor_mapping_strategy_cache.md
    PLAN__verbose_category_override_logging.md
  CONVENTIONS.md
  PROJECT_SUMMARY.md
manual/
  10-installation.de.md
  10-installation.en.md
  30-settings.de.md
  30-settings.en.md
  40-faq.de.md
  40-faq.en.md
  50-demo-setup.de.md
  50-demo-setup.en.md
  index.de.md
  index.en.md
src/
  Command/
    Command_Import.php
    Command_LastReport.php
    Command_TestConnection.php
  Constants/
    BatchSizeConstants.php
    DescriptionImportTypeConstant.php
    GlobalPluginConstants.php
    MappingTypeConstants.php
    MergedPluginConfigKeyConstants.php
    WebserviceFilterTypeConstants.php
  Controller/
    Admin/
      TopdataWebserviceConnectorAdminApiController.php
  Core/
    Content/
      Brand/
        BrandCollection.php
        BrandDefinition.php
        BrandEntity.php
      Category/
        TopdataCategoryExtension/
          TopdataCategoryExtensionDefinition.php
          TopdataCategoryExtensionEntity.php
        CategoryExtension.php
      Customer/
        CustomerExtension.php
      Device/
        Agregate/
          DeviceCustomer/
            DeviceCustomerCollection.php
            DeviceCustomerDefinition.php
            DeviceCustomerEntity.php
          DeviceProduct/
            DeviceProductDefinition.php
          DeviceSynonym/
            DeviceSynonymDefinition.php
        DeviceCollection.php
        DeviceDefinition.php
        DeviceEntity.php
      DeviceType/
        DeviceTypeCollection.php
        DeviceTypeDefinition.php
        DeviceTypeEntity.php
      Product/
        Agregate/
          Alternate/
            ProductAlternateDefinition.php
          Bundled/
            ProductBundledDefinition.php
          CapacityVariant/
            ProductCapacityVariantDefinition.php
          ColorVariant/
            ProductColorVariantDefinition.php
          ProductCrossSelling/
            ProductCrossSellingExtension.php
            TopdataProductCrossSellingExtensionDefinition.php
            TopdataProductCrossSellingExtensionEntity.php
          Related/
            ProductRelatedDefinition.php
          Similar/
            ProductSimilarDefinition.php
          Variant/
            ProductVariantDefinition.php
        ProductDefinition.php
        ProductEntity.php
        ProductExtension.php
      Series/
        SeriesCollection.php
        SeriesDefinition.php
        SeriesEntity.php
      TopdataToProduct/
        TopdataToProductCollection.php
        TopdataToProductDefinition.php
        TopdataToProductEntity.php
  DependencyInjection/
    setting.xml
  DTO/
    ImportConfig.php
  Enum/
    ProductRelationshipTypeEnumV1.php
    ProductRelationshipTypeEnumV2.php
  Exception/
    MissingPluginConfigurationException.php
    TopdataConnectorPluginInactiveException.php
    WebserviceRequestException.php
    WebserviceResponseException.php
  Helper/
    CurlHttpClient.php
  Migration/
    Migration1578907114UpdateTables.php
    Migration1617830396UpdateTables.php
    Migration1640004125UpdateTables.php
    Migration1699543200AddApiBaseUrlConfig.php
    Migration1731968633RenameConfigKeys.php
    Migration1746267946CreateMappingCacheTable.php
  Resources/
    config/
      config.xml
      routes.xml
      services.xml
  ScheduledTask/
    ConnectorImportTask.php
    ConnectorImportTaskHandler.php
  Service/
    Cache/
      MappingCacheService.php
    Checks/
      ConfigCheckerService.php
      ConnectionTestService.php
    Config/
      MergedPluginConfigHelperService.php
      ProductImportSettingsService.php
    DbHelper/
      README_AI.md
      TopdataBrandService.php
      TopdataDeviceService.php
      TopdataDeviceSynonymsService.php
      TopdataDeviceTypeService.php
      TopdataSeriesService.php
      TopdataToProductService.php
    Import/
      MappingStrategy/
        AbstractMappingStrategy.php
        MappingStrategy_Distributor.php
        MappingStrategy_EanOem.php
        MappingStrategy_ProductNumberAs.php
        MappingStrategy_Unified.php
      DeviceImportService.php
      DeviceMediaImportService.php
      MappingHelperService.php
      ProductMappingService.php
    Linking/
      ProductDeviceRelationshipServiceV1.php
      ProductDeviceRelationshipServiceV2.php
      ProductProductRelationshipServiceV1__ORIG.php
      ProductProductRelationshipServiceV1.php
      ProductProductRelationshipServiceV2.php
    Shopware/
      BreadcrumbService.php
      ShopwareLanguageService.php
      ShopwareProductPropertyService.php
      ShopwareProductService.php
      ShopwarePropertyService.php
    EntitiesHelperService.php
    ImportService.php
    MediaHelperService.php
    ProductInformationServiceV1Slow.php
    ProductInformationServiceV2.php
    ProgressLoggingService.php
    TopdataWebserviceClient.php
  Util/
    ImportReport.php
    UtilMappingHelper.php
    UtilProfiling.php
    UtilStringFormatting.php
  TopdataConnectorSW6.php
.gitignore
.php-cs-fixer.dist.php
.php-cs-fixer.php
CHANGELOG.md
composer.json
LICENSE
php-cs-fixer.md
README.md
VERSIONING.md

================================================================
Files
================================================================

================
File: __todo-v2__/topdata_product_relationships.sql
================
-- Complete migration script for topdata_product_relationships

-- Create the unified table (already done)
CREATE TABLE `topdata_product_relationships`
(
    -- The source product
    `product_id`                BINARY(16)  NOT NULL,
    `product_version_id`        BINARY(16)  NOT NULL,

    -- The destination/linked product (generic column names)
    `linked_product_id`         BINARY(16)  NOT NULL,
    `linked_product_version_id` BINARY(16)  NOT NULL,

    -- The "Discriminator" Column: This is the key to the whole design.
    -- It stores 'similar', 'alternate', 'related', etc.
    `relationship_type`         VARCHAR(50) NOT NULL,

    -- Timestamps
    `created_at`                DATETIME(3) NULL,
    `updated_at`                DATETIME(3) NULL,

    -- The new, powerful PRIMARY KEY ensures a product can't be linked
    -- to the same product with the same relationship type more than once.
    PRIMARY KEY (`product_id`, `linked_product_id`, `relationship_type`),

    -- Index for efficient reverse lookups ("Who links to me?")
    INDEX `idx_linked_product` (`linked_product_id`),

    -- Optional but recommended: Foreign Keys
    CONSTRAINT `fk.product_relationships.product` FOREIGN KEY (`product_id`, `product_version_id`)
        REFERENCES `product` (`id`, `version_id`) ON DELETE CASCADE ON UPDATE CASCADE,
    CONSTRAINT `fk.product_relationships.linked_product` FOREIGN KEY (`linked_product_id`, `linked_product_version_id`)
        REFERENCES `product` (`id`, `version_id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_unicode_ci;

-- Migrate data from all relationship tables
SET FOREIGN_KEY_CHECKS = 0;

-- For 'similar'
INSERT INTO topdata_product_relationships (product_id, product_version_id, linked_product_id, linked_product_version_id, relationship_type, created_at, updated_at)
SELECT product_id, product_version_id, similar_product_id, similar_product_version_id, 'similar', created_at, updated_at
FROM topdata_product_to_similar;

-- For 'alternate'
INSERT INTO topdata_product_relationships (product_id, product_version_id, linked_product_id, linked_product_version_id, relationship_type, created_at, updated_at)
SELECT product_id, product_version_id, alternate_product_id, alternate_product_version_id, 'alternate', created_at, updated_at
FROM topdata_product_to_alternate;

-- For 'related'
INSERT INTO topdata_product_relationships (product_id, product_version_id, linked_product_id, linked_product_version_id, relationship_type, created_at, updated_at)
SELECT product_id, product_version_id, related_product_id, related_product_version_id, 'related', created_at, updated_at
FROM topdata_product_to_related;

-- For 'bundled'
INSERT INTO topdata_product_relationships (product_id, product_version_id, linked_product_id, linked_product_version_id, relationship_type, created_at, updated_at)
SELECT product_id, product_version_id, bundled_product_id, bundled_product_version_id, 'bundled', created_at, updated_at
FROM topdata_product_to_bundled;

-- For 'color_variant'
INSERT INTO topdata_product_relationships (product_id, product_version_id, linked_product_id, linked_product_version_id, relationship_type, created_at, updated_at)
SELECT product_id, product_version_id, color_variant_product_id, color_variant_product_version_id, 'color_variant', created_at, updated_at
FROM topdata_product_to_color_variant;

-- For 'capacity_variant'
INSERT INTO topdata_product_relationships (product_id, product_version_id, linked_product_id, linked_product_version_id, relationship_type, created_at, updated_at)
SELECT product_id, product_version_id, capacity_variant_product_id, capacity_variant_product_version_id, 'capacity_variant', created_at, updated_at
FROM topdata_product_to_capacity_variant;

-- For 'variant'
INSERT INTO topdata_product_relationships (product_id, product_version_id, linked_product_id, linked_product_version_id, relationship_type, created_at, updated_at)
SELECT product_id, product_version_id, variant_product_id, variant_product_version_id, 'variant', created_at, updated_at
FROM topdata_product_to_variant;

SET FOREIGN_KEY_CHECKS = 1;




-- Verification queries to check the migration
-- Count records by relationship type
SELECT relationship_type, COUNT(*) as count
FROM topdata_product_relationships
GROUP BY relationship_type;

-- Check total record count matches sum of original tables
SELECT (SELECT COUNT(*) FROM topdata_product_to_similar) +
       (SELECT COUNT(*) FROM topdata_product_to_alternate) +
       (SELECT COUNT(*) FROM topdata_product_to_related) +
       (SELECT COUNT(*) FROM topdata_product_to_bundled) +
       (SELECT COUNT(*) FROM topdata_product_to_color_variant) +
       (SELECT COUNT(*) FROM topdata_product_to_capacity_variant) +
       (SELECT COUNT(*) FROM topdata_product_to_variant)    as original_total,
       (SELECT COUNT(*) FROM topdata_product_relationships) as migrated_total;

-- Optional: Drop the old tables after verifying the migration
-- Uncomment these lines after confirming the migration was successful

-- DROP TABLE topdata_product_to_similar;
-- DROP TABLE topdata_product_to_alternate;
-- DROP TABLE topdata_product_to_related;
-- DROP TABLE topdata_product_to_bundled;
-- DROP TABLE topdata_product_to_color_variant;
-- DROP TABLE topdata_product_to_capacity_variant;
-- DROP TABLE topdata_product_to_variant;

================
File: .github/workflows/update-demoshops.yaml
================
# 05/2024 created
# This workflow triggers a plugin update in the topdata shopware6 demoshops
# The demoshop needs the topdata-plugin-manager-sw6 plugin installed

name: Update Plugin

on: [push]

jobs:
    update-demoshops:
        runs-on: ubuntu-latest
        steps:
            - name: Checkout code
              uses: actions/checkout@v3

            - name: Install dependencies
              run: sudo apt-get update && sudo apt-get install -y jq curl

            - name: Get Composer Name
              run: |
                  COMPOSER_NAME=$(jq -r .name composer.json)
                  echo "COMPOSER_NAME=$COMPOSER_NAME" >> $GITHUB_ENV

            - name: Build urls.txt
              run: |
                  cat << EOF > urls.txt
                  url = "${{ secrets.UPDATE_URL_DEMOSHOP_SW64_STAGING }}/${{ env.COMPOSER_NAME }}"
                  url = "${{ secrets.UPDATE_URL_DEMOSHOP_SW65_STAGING }}/${{ env.COMPOSER_NAME }}"
                  url = "${{ secrets.UPDATE_URL_DEMOSHOP_SW66_STAGING }}/${{ env.COMPOSER_NAME }}"
                  EOF

            - name: Trigger Plugin Update on demoshops
              run: |
                  curl -f -sS -X GET --parallel --parallel-immediate --parallel-max 4 --config urls.txt

================
File: ai_docs/checklist/CHECKLIST__refactor_device_linking_v2.md
================
# Checklist: Refactoring Device-Product Linking (V2)

Based on: `ai_docs/plan/PLAN__refactor_device_linking_v2.md`

## Phase 1: Create New Service Structure

- [ ] 1. **Create New Service File:** `src/Service/Linking/ProductDeviceRelationshipServiceV2.php`
- [ ] 2. **Define Class & Dependencies:**
    - [ ] Define class `ProductDeviceRelationshipServiceV2`.
    - [ ] Copy `__construct` from `ProductDeviceRelationshipService`.
- [ ] 3. **Define Public Method Stub:** Create `public function syncDeviceProductRelationshipsV2(): void`.
- [ ] 4. **Register Service:** Add `ProductDeviceRelationshipServiceV2` to `src/Resources/config/services.xml`.

## Phase 2: Implement Differential Update Logic in New Service

- [ ] 1. **Implement `syncDeviceProductRelationshipsV2()`:**
    - [ ] Fetch mapped products (`getTopdataProductMappings`).
    - [ ] Chunk product IDs.
    - [ ] Initialize active entity ID sets (`$activeDeviceDbIds`, `$activeBrandDbIds`, `$activeSeriesDbIds`, `$activeTypeDbIds`).
    - [ ] Loop through product chunks:
        - [ ] Fetch product-device links from webservice.
        - [ ] Process response for linked device `ws_id`s.
        - [ ] Fetch local device details (`getDeviceArrayByWsIdArray`).
        - [ ] Add fetched DB IDs to active sets.
        - [ ] Identify Shopware Product DB IDs for the chunk.
        - [ ] **Delete** existing links for these specific Product DB IDs.
        - [ ] **Insert** new links for the chunk.
    - [ ] After loop:
        - [ ] **Enable Active Entities** (Brand, Series, Type, Device) using `UPDATE ... WHERE id IN (...)`.
        - [ ] **Disable Inactive Entities** (Brand, Series, Type, Device) using `UPDATE ... WHERE id NOT IN (...)`.
    - [ ] Integrate `CliLogger` and `UtilProfiling`.

## Phase 3: Integrate New Service

- [ ] 1. **Inject New Service:** Add `ProductDeviceRelationshipServiceV2` dependency to `ImportService` constructor (`src/Service/ImportService.php`).
- [ ] 2. **Conditional Logic in `ImportService`:**
    - [ ] Modify `_handleProductOperations` in `ImportService.php`.
    - [ ] Replace `syncDeviceProductRelationships()` call with conditional logic checking `--experimental-v2` flag.

================
File: ai_docs/done/PLAN__enhance_import_report_counters.md
================
# Plan: Enhance ImportReport Counters for ProductDeviceRelationshipServiceV2

**Objective:** Enhance the import process reporting in two phases:
1.  Integrate the `ImportReport` utility class into `ProductDeviceRelationshipServiceV2` and add detailed counters to track the device-product linking process (V2).
2.  Improve the CLI output of the import counters to include descriptions for better readability.

**Proposed Counters:**

*   `linking_v2.products.found`: Total unique Shopware product IDs identified for processing.
*   `linking_v2.products.chunks`: Number of chunks the product IDs were split into.
*   `linking_v2.chunks.processed`: Number of chunks successfully processed.
*   `linking_v2.webservice.calls`: Number of webservice calls made to fetch device links.
*   `linking_v2.webservice.device_ids_fetched`: Total unique device webservice IDs fetched from the webservice.
*   `linking_v2.database.devices_found`: Total corresponding devices found in the local database.
*   `linking_v2.links.deleted`: Total number of existing device-product links deleted across all chunks.
*   `linking_v2.links.inserted`: Total number of new device-product links inserted across all chunks.
*   `linking_v2.status.devices.enabled`: Total number of devices marked as enabled.
*   `linking_v2.status.devices.disabled`: Total number of devices marked as disabled.
*   `linking_v2.status.brands.enabled`: Total number of brands marked as enabled.
*   `linking_v2.status.brands.disabled`: Total number of brands marked as disabled.
*   `linking_v2.status.series.enabled`: Total number of series marked as enabled.
*   `linking_v2.status.series.disabled`: Total number of series marked as disabled.
*   `linking_v2.status.types.enabled`: Total number of device types marked as enabled.
*   `linking_v2.status.types.disabled`: Total number of device types marked as disabled.
*   `linking_v2.active.devices`: Final count of active devices at the end of the process.
*   `linking_v2.active.brands`: Final count of active brands at the end of the process.
*   `linking_v2.active.series`: Final count of active series at the end of the process.
*   `linking_v2.active.types`: Final count of active device types at the end of the process.

**Phase 1: Add Counters to Linking Service**

*   **Goal:** Implement the proposed counters within the `ProductDeviceRelationshipServiceV2` logic.
*   **Steps:**
    1.  **Integrate `ImportReport`:** Modify `src/Service/Linking/ProductDeviceRelationshipServiceV2.php` to use the `ImportReport` class (ensure `use Topdata\TopdataConnectorSW6\Util\ImportReport;` exists).
    2.  **Add Counters:** Insert calls to `ImportReport::incCounter()` or `ImportReport::setCounter()` at the appropriate points in the `syncDeviceProductRelationshipsV2` method to update the counters listed in the "Proposed Counters" section. Refer to the "Conceptual Flow Diagram" for guidance on placement.
    3.  **Remove Redundant Logging:** In `src/Service/Linking/ProductDeviceRelationshipServiceV2.php`, remove the `CliLogger::getCliStyle()->dumpDict()` call (previously lines 375-380) as this specific debug information will now be captured more systematically by `ImportReport`.
    4.  **Testing (Phase 1):** Manually run an import that triggers V2 linking and verify (e.g., via debugging or temporary dumps) that the `ImportReport::$counters` array is populated correctly according to the logic.

**Phase 2: Enhance CLI Counter Output**

*   **Goal:** Modify the `ImportService` to display the counters collected by `ImportReport` in a table format that includes descriptions.
*   **Steps:**
    1.  **Add `use` Statement:** In `src/Service/ImportService.php`, add `use Symfony\Component\Console\Helper\Table;`.
    2.  **Define Descriptions:** Create a static associative array within `ImportService` mapping counter keys (e.g., `'linking_v2.products.found'`) to their descriptions (e.g., `'Total unique Shopware product IDs identified for processing.'`). Use the descriptions from the "Proposed Counters" section.
    3.  **Replace `dumpCounters` Call:** In the `runImport` method of `src/Service/ImportService.php` (around line 85), replace the line `CliLogger::getCliStyle()->dumpCounters(ImportReport::getCountersSorted(), 'Counters Report');` with the following logic:
        *   Get the `SymfonyStyle` object: `$io = CliLogger::getCliStyle();`
        *   Get the sorted counters: `$counters = ImportReport::getCountersSorted();`
        *   Get the descriptions map: `$descriptions = self::$counterDescriptions; // Or however the map is stored`
        *   Create a `Table` instance: `$table = new Table($io);`
        *   Set headers: `$table->setHeaders(['Counter', 'Value', 'Description']);`
        *   Prepare rows: Iterate through `$counters`. For each `$key => $value`, look up `$descriptions[$key]` (handle cases where the description might be missing). Add the row `[$key, $value, $description]` using `$table->addRow([...]);`.
        *   Render the table: `$io->title('Counters Report'); $table->render();`
    4.  **Testing (Phase 2):** Run the import command and verify that the CLI output now shows the counters table with the 'Counter', 'Value', and 'Description' columns, correctly populated.

**Conceptual Flow Diagram:**

```mermaid
graph TD
    A[Start syncDeviceProductRelationshipsV2] --> B{Fetch Product Mappings};
    B --> C{Found Mappings?};
    C -- No --> X[End];
    C -- Yes --> D[Extract Unique Product IDs];
    D --> E[Chunk Product IDs];
    E --> F{Loop Through Chunks};
    F -- Next Chunk --> G[Fetch WS Links for Chunk];
    G --> H{Process WS Response};
    H --> I[Fetch Local Devices];
    I --> J[Update Active Sets (Brands, Series, Types)];
    J --> K[Delete Existing Links for Chunk];
    K --> L[Prepare New Links];
    L --> M[Insert New Links for Chunk];
    M --> F;
    F -- All Chunks Done --> N[Enable/Disable Entities Based on Active Sets];
    N --> O[Update ImportReport Counters];
    O --> X;

    subgraph Counters Updated
        D -- set --> linking_v2.products.found;
        E -- set --> linking_v2.products.chunks;
        F -- inc --> linking_v2.chunks.processed;
        G -- inc --> linking_v2.webservice.calls;
        H -- inc --> linking_v2.webservice.device_ids_fetched;
        I -- inc --> linking_v2.database.devices_found;
        K -- inc --> linking_v2.links.deleted;
        M -- inc --> linking_v2.links.inserted;
        N -- set --> linking_v2.status.devices.enabled;
        N -- set --> linking_v2.status.devices.disabled;
        N -- set --> linking_v2.status.brands.enabled;
        N -- set --> linking_v2.status.brands.disabled;
        N -- set --> linking_v2.status.series.enabled;
        N -- set --> linking_v2.status.series.disabled;
        N -- set --> linking_v2.status.types.enabled;
        N -- set --> linking_v2.status.types.disabled;
        N -- set --> linking_v2.active.devices;
        N -- set --> linking_v2.active.brands;
        N -- set --> linking_v2.active.series;
        N -- set --> linking_v2.active.types;
    end

    style X fill:#f9f,stroke:#333,stroke-width:2px

================
File: ai_docs/done/PLAN__import_command_locking.md
================
# Plan: Integrate Symfony Lock Component into `topdata:connector:import` Command

**Objective:** Prevent concurrent execution of the `topdata:connector:import` command by utilizing the Symfony Lock component.

**Analysis:**
- The command `src/Command/Command_Import.php` is a standard Symfony command.
- The service definition in `src/Resources/config/services.xml` uses `autowire="true"`, simplifying dependency injection.

**Refined Plan:**

1.  **Modify `src/Command/Command_Import.php`:**
    *   Add `use Symfony\Component\Lock\LockFactory;` and `use Symfony\Component\Lock\LockInterface;`.
    *   Add a private property `$lock` of type `?LockInterface` to hold the lock object.
    *   Add a `LockFactory $lockFactory` parameter to the `__construct` method. Autowiring will handle the injection.
    *   In the `execute` method:
        *   Create the lock: `$this->lock = $this->lockFactory->createLock('topdata-connector-import', 3600);` (1-hour TTL).
        *   Attempt lock acquisition: `if (!$this->lock->acquire()) { ... }`. If it fails, log a message and exit gracefully.
        *   Wrap the core command logic (lines 95-126 in the original file) in a `try...finally` block.
        *   Release the lock in the `finally` block: `if ($this->lock) { $this->lock->release(); }`.

2.  **No changes needed in `src/Resources/config/services.xml`** due to autowiring.

**Mermaid Diagram of the `execute` flow:**

```mermaid
sequenceDiagram
    participant CLI as Command Line
    participant Command as Command_Import
    participant LockFactory
    participant Lock as LockInterface
    participant ImportService
    participant ReportService

    CLI->>Command: execute()
    Command->>LockFactory: createLock('topdata-connector-import', 3600)
    LockFactory-->>Command: returns Lock
    Command->>Lock: acquire()
    alt Lock Acquired (returns true)
        Lock-->>Command: true
        Command->>ReportService: newJobReport()
        Command->>ImportService: execute(importConfig)
        ImportService-->>Command: completes
        Command->>ReportService: markAsSucceeded()
        Command->>Lock: release() # In finally block
        Lock-->>Command: released
        Command-->>CLI: Command::SUCCESS
    else Lock Not Acquired (returns false)
        Lock-->>Command: false
        Command->>CLI: Log "Already running"
        Command-->>CLI: Command::SUCCESS (or specific code)
    else Exception during Import
        Lock-->>Command: true
        Command->>ReportService: newJobReport()
        Command->>ImportService: execute(importConfig)
        ImportService-->>Command: throws Exception
        Command->>ReportService: markAsFailed()
        Command->>Lock: release() # In finally block
        Lock-->>Command: released
        Command-->>CLI: throws Exception
    end

================
File: ai_docs/done/PLAN__merge_mapping_strategies.md
================
# Plan: Merging Mapping Strategy Classes

## Overview

This document outlines the plan for merging the `MappingStrategy_EanOem` and `MappingStrategy_Distributor` classes into a unified strategy. The main goal is to reduce code duplication and extend the caching mechanism (currently only used for EAN/OEM mappings) to distributor mappings as well.

## Current Situation Analysis

### Similarities between the strategies:
1. Both extend `AbstractMappingStrategy` and implement the `map(ImportConfig $importConfig): void` method
2. Both fetch mappings from the Topdata webservice
3. Both store mappings in the database via `TopdataToProductService`
4. Both use batch processing for efficiency
5. Both have similar constructor dependencies

### Key Differences:
1. **Caching**: `MappingStrategy_EanOem` uses `MappingCacheService` for caching, while `MappingStrategy_Distributor` doesn't
2. **Identifier Types**: 
   - `MappingStrategy_EanOem` handles EAN, OEM, and PCD identifiers
   - `MappingStrategy_Distributor` handles distributor SKUs
3. **Webservice Methods**:
   - `MappingStrategy_EanOem` calls `matchMyEANs`, `matchMyOems`, and `matchMyPcds`
   - `MappingStrategy_Distributor` calls `matchMyDistributor`
4. **Data Structure**: The response structure from the webservice is different for each strategy

### Benefits of Merging:
1. Code reuse and reduction of duplication
2. Consistent caching mechanism for all mapping types
3. Simplified maintenance and future enhancements
4. Potentially improved performance for distributor mappings

## Implementation Plan

### 1. Create the New Unified Strategy Class

```php
<?php

namespace Topdata\TopdataConnectorSW6\Service\Import\MappingStrategy;

use Exception;
use Override;
use Topdata\TopdataConnectorSW6\Constants\MappingTypeConstants;
use Topdata\TopdataConnectorSW6\Constants\MergedPluginConfigKeyConstants;
use Topdata\TopdataConnectorSW6\DTO\ImportConfig;
use Topdata\TopdataConnectorSW6\Service\Cache\MappingCacheService;
use Topdata\TopdataConnectorSW6\Service\Config\MergedPluginConfigHelperService;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataToProductService;
use Topdata\TopdataConnectorSW6\Service\Import\ShopwareProductPropertyService;
use Topdata\TopdataConnectorSW6\Service\Import\ShopwareProductService;
use Topdata\TopdataConnectorSW6\Service\TopdataWebserviceClient;
use Topdata\TopdataConnectorSW6\Util\ImportReport;
use Topdata\TopdataConnectorSW6\Util\UtilMappingHelper;
use Topdata\TopdataFoundationSW6\Util\CliLogger;
use Topdata\TopdataFoundationSW6\Util\UtilFormatter;

/**
 * Unified mapping strategy that handles EAN, OEM, PCD, and Distributor mappings.
 * Supports caching for all mapping types.
 * 
 * 05/2025 created (merged from MappingStrategy_EanOem and MappingStrategy_Distributor)
 */
final class MappingStrategy_Unified extends AbstractMappingStrategy
{
    const BATCH_SIZE = 500;
    
    /**
     * Tracks product IDs already mapped in a single run to avoid duplicates
     */
    private array $setted = [];
    
    public function __construct(
        private readonly MergedPluginConfigHelperService $mergedPluginConfigHelperService,
        private readonly TopdataToProductService         $topdataToProductService,
        private readonly TopdataWebserviceClient         $topdataWebserviceClient,
        private readonly ShopwareProductService          $shopwareProductService,
        private readonly MappingCacheService             $mappingCacheService,
        private readonly ShopwareProductPropertyService  $shopwareProductPropertyService,
    )
    {
    }
    
    // Implementation methods will follow...
}
```

### 2. Implement the Main `map()` Method

```php
/**
 * Maps products using the appropriate strategy based on the mapping type.
 *
 * @throws Exception if any critical error occurs during the mapping process
 */
#[Override]
public function map(ImportConfig $importConfig): void
{
    $mappingType = $this->mergedPluginConfigHelperService->getOption(MergedPluginConfigKeyConstants::MAPPING_TYPE);
    
    // Check if this is a distributor mapping type
    $isDistributorMapping = in_array($mappingType, [
        MappingTypeConstants::DISTRIBUTOR_DEFAULT,
        MappingTypeConstants::DISTRIBUTOR_CUSTOM,
        MappingTypeConstants::DISTRIBUTOR_CUSTOM_FIELD
    ]);
    
    if ($isDistributorMapping) {
        $this->mapDistributor($importConfig);
    } else {
        $this->mapEanOem($importConfig);
    }
}
```

### 3. Implement the EAN/OEM Mapping Method

```php
/**
 * Maps products using the EAN/OEM/PCD strategy.
 *
 * @param ImportConfig $importConfig The import configuration
 * @throws Exception if any critical error occurs during the mapping process
 */
private function mapEanOem(ImportConfig $importConfig): void
{
    CliLogger::section('Product Mapping Strategy: EAN/OEM/PCD');

    // 1. Check config
    $useExperimentalCacheV2 = TRUE; // (bool)$importConfig->getOptionExperimentalV2();
    CliLogger::info('Experimental V2 Cache Enabled: ' . ($useExperimentalCacheV2 ? 'Yes' : 'No'));

    // 2. Attempt to load from V2 cache (if enabled)
    if ($useExperimentalCacheV2 && $this->tryLoadFromCacheV2(MappingTypeConstants::EAN_OEM_GROUP)) {
        CliLogger::section('Finished mapping using cached data (V2).');
        return; // Cache was successfully loaded and used, skip fetch/save
    }

    // --- Cache was not used or failed, proceed with fetch and save ---

    // 3. Build identifier maps from Shopware
    [$oemMap, $eanMap] = $this->buildShopwareIdentifierMaps();

    // 4. Fetch corresponding mappings from Topdata Webservice
    $mappingsByType = $this->processWebserviceMappings($oemMap, $eanMap);
    unset($oemMap, $eanMap); // Free memory

    // 5. Save fetched mappings to V2 cache (if enabled)
    if ($useExperimentalCacheV2) {
        $this->saveToCacheV2($mappingsByType, MappingTypeConstants::EAN_OEM_GROUP);
    }

    // 6. Flatten mappings for database persistence
    $flatMappings = $this->flattenMappings($mappingsByType);

    // 7. Persist flattened mappings to the database
    $this->persistMappingsToDatabase($flatMappings);

    CliLogger::section('Finished product mapping (fetched from webservice).');
}
```

### 4. Implement the Distributor Mapping Method

```php
/**
 * Maps products using the distributor mapping strategy.
 *
 * @param ImportConfig $importConfig The import configuration
 * @throws Exception if any critical error occurs during the mapping process
 */
private function mapDistributor(ImportConfig $importConfig): void
{
    CliLogger::section('Product Mapping Strategy: Distributor');
    
    // 1. Check config
    $useExperimentalCacheV2 = TRUE; // (bool)$importConfig->getOptionExperimentalV2();
    CliLogger::info('Experimental V2 Cache Enabled: ' . ($useExperimentalCacheV2 ? 'Yes' : 'No'));
    
    // 2. Attempt to load from V2 cache (if enabled)
    if ($useExperimentalCacheV2 && $this->tryLoadFromCacheV2(MappingTypeConstants::DISTRIBUTOR)) {
        CliLogger::section('Finished mapping using cached data (V2).');
        return; // Cache was successfully loaded and used, skip fetch/save
    }
    
    // --- Cache was not used or failed, proceed with fetch and save ---
    
    // 3. Build article number map from Shopware
    $articleNumberMap = $this->getArticleNumbers();
    
    // 4. Fetch corresponding mappings from Topdata Webservice
    $mappingsByType = [
        MappingTypeConstants::DISTRIBUTOR => $this->processDistributorWebserviceMappings($articleNumberMap)
    ];
    unset($articleNumberMap); // Free memory
    
    // 5. Save fetched mappings to V2 cache (if enabled)
    if ($useExperimentalCacheV2) {
        $this->saveToCacheV2($mappingsByType, MappingTypeConstants::DISTRIBUTOR);
    }
    
    // 6. Flatten mappings for database persistence
    $flatMappings = $this->flattenMappings($mappingsByType);
    
    // 7. Persist flattened mappings to the database
    $this->persistMappingsToDatabase($flatMappings);
    
    CliLogger::section('Finished product mapping (fetched from webservice).');
}
```

### 5. Implement the Cache Loading Method

```php
/**
 * Attempts to load mappings directly from the V2 cache into the database.
 * This bypasses fetching from the webservice if the cache is valid and populated.
 *
 * @param string $mappingGroup The mapping group to load (EAN_OEM_GROUP or DISTRIBUTOR)
 * @return bool True if mappings were successfully loaded from cache, False otherwise.
 */
private function tryLoadFromCacheV2(string $mappingGroup): bool
{
    if (!$this->mappingCacheService->hasCachedMappings()) {
        CliLogger::info('No valid V2 cache found, proceeding with fetch.');
        return false;
    }

    CliLogger::info('Valid V2 cache found, attempting to load mappings...');
    
    // For EAN/OEM group, we load EAN, OEM, and PCD mappings
    // For Distributor group, we load only Distributor mappings
    $mappingTypes = ($mappingGroup === MappingTypeConstants::EAN_OEM_GROUP) 
        ? [MappingTypeConstants::EAN, MappingTypeConstants::OEM, MappingTypeConstants::PCD]
        : [MappingTypeConstants::DISTRIBUTOR];
    
    $totalLoaded = 0;
    foreach ($mappingTypes as $mappingType) {
        $loaded = $this->mappingCacheService->loadMappingsFromCache($mappingType);
        $totalLoaded += $loaded;
        CliLogger::info("Loaded $loaded $mappingType mappings from cache.");
    }

    if ($totalLoaded > 0) {
        CliLogger::info('Successfully loaded ' . UtilFormatter::formatInteger($totalLoaded) . ' total mappings from cache into database.');
        ImportReport::setCounter('Mappings Loaded from Cache', $totalLoaded);
        ImportReport::setCounter('Webservice Calls Skipped', 1); // Indicate that API fetch was skipped
        return true; // Signal success
    }

    CliLogger::warning('V2 cache exists but failed to load mappings (or was empty). Proceeding with fetch.');
    return false; // Signal failure
}
```

### 6. Implement the Cache Saving Method

```php
/**
 * Saves the fetched mappings to the V2 cache, if enabled.
 * This is done per mapping type (EAN, OEM, PCD, Distributor).
 *
 * @param array<string, array> $mappingsByType Mappings fetched from webservice, grouped by type.
 * @param string $mappingGroup The mapping group being saved (EAN_OEM_GROUP or DISTRIBUTOR)
 */
private function saveToCacheV2(array $mappingsByType, string $mappingGroup): void
{
    CliLogger::info('Saving fetched mappings to V2 cache...');
    $totalCached = 0;
    
    foreach ($mappingsByType as $mappingType => $typeMappings) {
        if (!empty($typeMappings)) {
            $count = count($typeMappings);
            CliLogger::info("-> Caching " . UtilFormatter::formatInteger($count) . " $mappingType mappings...");
            
            // Extract only the necessary fields for caching (topDataId and value)
            // This makes the cache independent of Shopware product IDs
            $cacheMappings = array_map(function($mapping) {
                return [
                    'topDataId' => $mapping['topDataId'],
                    'value'     => $mapping['value']
                ];
            }, $typeMappings);
            
            $this->mappingCacheService->saveMappingsToCache($cacheMappings, $mappingType);
            $totalCached += $count;
        }
    }
    
    // Display cache statistics after saving
    $cacheStats = $this->mappingCacheService->getCacheStats();
    CliLogger::info('--- Cache Statistics ---');
    CliLogger::info('Total cached mappings: ' . UtilFormatter::formatInteger($cacheStats['total']));
    if (isset($cacheStats['by_type'])) {
        CliLogger::info('Mappings by type:');
        foreach ($cacheStats['by_type'] as $type => $count) {
            CliLogger::info("  - {$type}: " . UtilFormatter::formatInteger($count));
        }
    }
    if (isset($cacheStats['oldest'])) {
        CliLogger::info('Oldest entry: ' . $cacheStats['oldest']);
    }
    if (isset($cacheStats['newest'])) {
        CliLogger::info('Newest entry: ' . $cacheStats['newest']);
    }
    CliLogger::info('------------------------');
    
    if ($totalCached > 0) {
        CliLogger::info('Finished saving ' . UtilFormatter::formatInteger($totalCached) . ' mappings to V2 cache.');
        ImportReport::setCounter('Mappings Saved to Cache', $totalCached);
    } else {
        CliLogger::info('No new mappings were fetched to save to V2 cache.');
    }
}
```

### 7. Implement the Distributor Webservice Processing Method

```php
/**
 * Processes distributor mappings from the webservice.
 *
 * @param array $articleNumberMap Mapping of article numbers to products from Shopware
 * @return array Distributor mappings data
 * @throws Exception if any error occurs during API communication
 */
private function processDistributorWebserviceMappings(array $articleNumberMap): array
{
    $this->setted = []; // Reset for this run
    $mappings = [];
    $stored = 0;
    
    CliLogger::info(UtilFormatter::formatInteger(count($articleNumberMap)) . ' products to check ...');
    
    try {
        // Iterate through the pages of distributor data from the web service
        for ($page = 1; ; $page++) {
            $response = $this->topdataWebserviceClient->matchMyDistributor(['page' => $page]);
            
            if (!isset($response->page->available_pages)) {
                throw new Exception('distributor webservice no pages');
            }
            
            $available_pages = (int)$response->page->available_pages;
            
            // Process each product in the current page
            foreach ($response->match as $prod) {
                $topDataId = $prod->products_id;
                
                foreach ($prod->distributors as $distri) {
                    foreach ($distri->artnrs as $artnr) {
                        $originalValue = (string)$artnr;
                        $key = $originalValue; // For distributor, we use the original value as the key
                        
                        if (isset($articleNumberMap[$key])) {
                            foreach ($articleNumberMap[$key] as $articleNumberValue) {
                                $shopwareProductKey = $articleNumberValue['id'] . '-' . $articleNumberValue['version_id'];
                                
                                // Check if this specific Shopware product (id+version) hasn't been mapped yet in this run
                                if (!isset($this->setted[$shopwareProductKey])) {
                                    $mappings[] = [
                                        'topDataId'        => $topDataId,
                                        'productId'        => $articleNumberValue['id'],
                                        'productVersionId' => $articleNumberValue['version_id'],
                                        'value'            => $originalValue, // Store original value for caching
                                    ];
                                    
                                    $this->setted[$shopwareProductKey] = true; // Mark as mapped for this run
                                    $stored++;
                                    
                                    if (($stored % 50) == 0) {
                                        CliLogger::activity();
                                    }
                                }
                            }
                        }
                    }
                }
            }
            
            CliLogger::progress($page, $available_pages, 'fetch distributor data');
            
            if ($page >= $available_pages) {
                break;
            }
        }
    } catch (Exception $e) {
        CliLogger::error('Error fetching distributor data from webservice: ' . $e->getMessage());
        throw $e; // Re-throw for now to indicate failure
    }
    
    CliLogger::writeln("\n" . UtilFormatter::formatInteger($stored) . ' - stored topdata products');
    ImportReport::setCounter('Fetched Distributor SKUs', $stored);
    
    return $mappings;
}
```

### 8. Implement the Article Numbers Method

```php
/**
 * Gets article numbers from Shopware based on the mapping type.
 *
 * @return array Map of article numbers to product data
 * @throws Exception if no products are found
 */
private function getArticleNumbers(): array
{
    // Determine the source of product numbers based on the mapping type
    $mappingType = $this->mergedPluginConfigHelperService->getOption(MergedPluginConfigKeyConstants::MAPPING_TYPE);
    $attributeArticleNumber = $this->mergedPluginConfigHelperService->getOption(MergedPluginConfigKeyConstants::ATTRIBUTE_ORDERNUMBER);

    if ($mappingType == MappingTypeConstants::DISTRIBUTOR_CUSTOM && $attributeArticleNumber != '') {
        // the distributor's SKU is a product property
        $artnos = UtilMappingHelper::convertMultiArrayBinaryIdsToHex(
            $this->shopwareProductPropertyService->getKeysByOptionValueUnique($attributeArticleNumber)
        );
    } elseif ($mappingType == MappingTypeConstants::DISTRIBUTOR_CUSTOM_FIELD && $attributeArticleNumber != '') {
        // the distributor's SKU is a product custom field
        $artnos = $this->shopwareProductService->getKeysByCustomFieldUnique($attributeArticleNumber);
    } else {
        // the distributor's SKU is the product number
        $artnos = UtilMappingHelper::convertMultiArrayBinaryIdsToHex(
            $this->shopwareProductService->getKeysByProductNumber()
        );
    }

    if (count($artnos) == 0) {
        throw new Exception('distributor mapping 0 products found');
    }

    return $artnos;
}
```

### 9. Update the MappingTypeConstants

Add a new constant to `MappingTypeConstants.php` for the distributor mapping type:

```php
<?php

namespace Topdata\TopdataConnectorSW6\Constants;

/**
 * Constants for mapping types.
 */
class MappingTypeConstants
{
    const DEFAULT = 'default';
    const CUSTOM = 'custom';
    const CUSTOM_FIELD = 'custom_field';
    const PRODUCT_NUMBER_AS_WS_ID = 'product_number_as_ws_id';
    const DISTRIBUTOR_DEFAULT = 'distributor_default';
    const DISTRIBUTOR_CUSTOM = 'distributor_custom';
    const DISTRIBUTOR_CUSTOM_FIELD = 'distributor_custom_field';
    
    // Mapping types for cache
    const EAN = 'ean';
    const OEM = 'oem';
    const PCD = 'pcd';
    const DISTRIBUTOR = 'distributor'; // New constant for distributor mappings
    
    // Mapping groups for cache loading/saving
    const EAN_OEM_GROUP = 'ean_oem_group';
}
```

### 10. Update the ProductMappingService

Update the `ProductMappingService` to use the new unified strategy:

```php
<?php

namespace Topdata\TopdataConnectorSW6\Service\Import;

use Doctrine\DBAL\Connection;
use Topdata\TopdataConnectorSW6\Constants\MappingTypeConstants;
use Topdata\TopdataConnectorSW6\Constants\MergedPluginConfigKeyConstants;
use Topdata\TopdataConnectorSW6\DTO\ImportConfig;
use Topdata\TopdataConnectorSW6\Service\Config\MergedPluginConfigHelperService;
use Topdata\TopdataConnectorSW6\Service\Import\MappingStrategy\AbstractMappingStrategy;
use Topdata\TopdataConnectorSW6\Service\Import\MappingStrategy\MappingStrategy_ProductNumberAs;
use Topdata\TopdataConnectorSW6\Service\Import\MappingStrategy\MappingStrategy_Unified;
use Topdata\TopdataConnectorSW6\Util\UtilProfiling;
use Topdata\TopdataFoundationSW6\Util\CliLogger;

/**
 * Service class for mapping products between Topdata and Shopware 6.
 * This service handles the process of mapping products from Topdata to Shopware 6,
 * utilizing different mapping strategies based on the configured mapping type.
 * 07/2024 created (extracted from MappingHelperService).
 * 05/2025 updated to use the unified mapping strategy.
 */
class ProductMappingService
{
    const BATCH_SIZE                    = 500;
    const BATCH_SIZE_TOPDATA_TO_PRODUCT = 99;

    /**
     * @var array already processed products
     */
    private array $setted;

    public function __construct(
        private readonly Connection                      $connection,
        private readonly MergedPluginConfigHelperService $mergedPluginConfigHelperService,
        private readonly MappingStrategy_ProductNumberAs $mappingStrategy_ProductNumberAs,
        private readonly MappingStrategy_Unified         $mappingStrategy_Unified,
    )
    {
    }

    /**
     * Maps products from Topdata to Shopware 6 based on the configured mapping type.
     * This method truncates the `topdata_to_product` table and then executes the appropriate
     * mapping strategy.
     */
    public function mapProducts(ImportConfig $importConfig): void
    {
        UtilProfiling::startTimer();
        CliLogger::info('ProductMappingService::mapProducts() - using mapping type: ' . $this->mergedPluginConfigHelperService->getOption(MergedPluginConfigKeyConstants::MAPPING_TYPE));

        // ---- Clear existing mappings
        $this->connection->executeStatement('TRUNCATE TABLE topdata_to_product');

        // ---- Create the appropriate strategy based on mapping type
        $strategy = $this->_createMappingStrategy();

        // ---- Execute the strategy
        $strategy->map($importConfig);
        UtilProfiling::stopTimer();
    }

    /**
     * Creates the appropriate mapping strategy based on the configured mapping type.
     *
     * @return AbstractMappingStrategy The mapping strategy to use.
     * @throws \Exception If an unknown mapping type is encountered.
     */
    private function _createMappingStrategy(): AbstractMappingStrategy
    {
        $mappingType = $this->mergedPluginConfigHelperService->getOption(MergedPluginConfigKeyConstants::MAPPING_TYPE);

        return match ($mappingType) {
            // ---- Product Number Mapping Strategy
            MappingTypeConstants::PRODUCT_NUMBER_AS_WS_ID => $this->mappingStrategy_ProductNumberAs,

            // ---- Unified Mapping Strategy (handles both EAN/OEM and Distributor)
            MappingTypeConstants::DEFAULT,
            MappingTypeConstants::CUSTOM,
            MappingTypeConstants::CUSTOM_FIELD,
            MappingTypeConstants::DISTRIBUTOR_DEFAULT,
            MappingTypeConstants::DISTRIBUTOR_CUSTOM,
            MappingTypeConstants::DISTRIBUTOR_CUSTOM_FIELD => $this->mappingStrategy_Unified,

            // ---- unknown mapping type --> throw exception
            default => throw new \Exception('Unknown mapping type: ' . $mappingType),
        };
    }
}
```

### 11. Update the services.xml Configuration

Update the `services.xml` file to register the new unified strategy:

```xml
<!-- Mapping Strategies -->
<service id="Topdata\TopdataConnectorSW6\Service\Import\MappingStrategy\MappingStrategy_ProductNumberAs">
    <argument type="service" id="Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataToProductService"/>
    <argument type="service" id="Topdata\TopdataConnectorSW6\Service\Import\ShopwareProductService"/>
</service>

<service id="Topdata\TopdataConnectorSW6\Service\Import\MappingStrategy\MappingStrategy_Unified">
    <argument type="service" id="Topdata\TopdataConnectorSW6\Service\Config\MergedPluginConfigHelperService"/>
    <argument type="service" id="Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataToProductService"/>
    <argument type="service" id="Topdata\TopdataConnectorSW6\Service\TopdataWebserviceClient"/>
    <argument type="service" id="Topdata\TopdataConnectorSW6\Service\Import\ShopwareProductService"/>
    <argument type="service" id="Topdata\TopdataConnectorSW6\Service\Cache\MappingCacheService"/>
    <argument type="service" id="Topdata\TopdataConnectorSW6\Service\Import\ShopwareProductPropertyService"/>
</service>

<!-- Product Mapping Service -->
<service id="Topdata\TopdataConnectorSW6\Service\Import\ProductMappingService">
    <argument type="service" id="Doctrine\DBAL\Connection"/>
    <argument type="service" id="Topdata\TopdataConnectorSW6\Service\Config\MergedPluginConfigHelperService"/>
    <argument type="service" id="Topdata\TopdataConnectorSW6\Service\Import\MappingStrategy\MappingStrategy_ProductNumberAs"/>
    <argument type="service" id="Topdata\TopdataConnectorSW6\Service\Import\MappingStrategy\MappingStrategy_Unified"/>
</service>
```

## Implementation of Common Methods

The unified strategy will also need to implement several common methods from the original strategies. These include:

1. `buildShopwareIdentifierMaps()` - From MappingStrategy_EanOem
2. `fetchAndMapIdentifiersFromWebservice()` - From MappingStrategy_EanOem
3. `processWebserviceMappings()` - From MappingStrategy_EanOem
4. `flattenMappings()` - From MappingStrategy_EanOem
5. `persistMappingsToDatabase()` - From MappingStrategy_EanOem

These methods can be copied directly from the original strategies with minimal modifications.

## Testing Plan

1. **Unit Tests**:
   - Test the `map()` method with different mapping types
   - Test the caching mechanism for both EAN/OEM and Distributor mappings
   - Test the webservice processing methods

2. **Integration Tests**:
   - Test the integration with the caching service
   - Test the integration with the webservice client
   - Test the integration with the database services

3. **End-to-End Tests**:
   - Test the complete import process with different mapping types
   - Test the caching behavior in real-world scenarios

4. **Performance Tests**:
   - Compare the performance of the unified strategy with the original strategies
   - Measure the impact of caching on performance

## Migration Plan

1. Create the new `MappingStrategy_Unified` class
2. Update the `ProductMappingService` to use the new unified strategy
3. Update the `services.xml` configuration
4. Add the new constant to `MappingTypeConstants.php`
5. Run tests to ensure everything works correctly
6. Deploy the changes
7. Monitor the system for any issues

## Conclusion

Merging the `MappingStrategy_EanOem` and `MappingStrategy_Distributor` classes into a unified strategy will reduce code duplication, extend caching to distributor mappings, and simplify maintenance. The implementation plan outlined in this document provides a clear path forward for this refactoring effort.

================
File: ai_docs/done/PLAN__new_DeviceMediaImportService.md
================
# Refactoring Plan: Extract DeviceMediaImportService

## Objective

Refactor the `Topdata\TopdataConnectorSW6\Service\Import\DeviceImportService` by extracting the media handling logic (`setDeviceMedia` method) into a new, dedicated service `Topdata\TopdataConnectorSW6\Service\Import\DeviceMediaImportService`.

## Rationale

*   **Single Responsibility Principle (SRP):** Separates the concern of importing core device data from handling associated media.
*   **Separation of Concerns:** Media handling involves distinct logic (media system interaction, URL/file handling, date comparisons) from structured data import.
*   **Reduced Class Complexity:** Makes `DeviceImportService` smaller and more focused.
*   **Improved Testability:** Allows isolated testing of media linking logic.
*   **Maintainability:** Facilitates future changes specific to media handling without impacting core device import.

## Proposed Steps

1.  **Create New Service File:**
    *   Create `src/Service/Import/DeviceMediaImportService.php`.
2.  **Define New Service Class:**
    *   Define the `DeviceMediaImportService` class.
    *   Inject dependencies via constructor:
        *   `Psr\Log\LoggerInterface`
        *   `Shopware\Core\Framework\DataAbstractionLayer\EntityRepository` (for `topdata_device`)
        *   `Topdata\TopdataConnectorSW6\Service\MediaHelperService`
        *   `Topdata\TopdataConnectorSW6\Service\TopdataWebserviceClient`
        *   `Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataBrandService`
        *   `Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataDeviceService`
    *   Handle `Shopware\Core\Framework\Context`.
3.  **Move Logic:**
    *   Move the `setDeviceMedia` method implementation from `DeviceImportService` to `DeviceMediaImportService`.
    *   Move the `IMAGE_PREFIX` constant to `DeviceMediaImportService`.
4.  **Clean Up Original Service:**
    *   Remove the `setDeviceMedia` method from `DeviceImportService`.
    *   Remove the `IMAGE_PREFIX` constant from `DeviceImportService`.
5.  **Refine Dependencies:**
    *   Remove `MediaHelperService` dependency from `DeviceImportService` if it's no longer needed after the move.
6.  **Update Service Configuration (`src/Resources/config/services.xml`):**
    *   Add a `<service>` definition for `DeviceMediaImportService` with its arguments.
    *   Update the `<service>` definition for `DeviceImportService` if its dependencies changed.
7.  **Update Callers:**
    *   Identify where `DeviceImportService::setDeviceMedia()` was called (e.g., `ImportService`, Commands).
    *   Inject `DeviceMediaImportService` into the caller(s).
    *   Update the calls to use `DeviceMediaImportService::setDeviceMedia()`.

## Conceptual Flow Diagram

```mermaid
graph TD
    A[Import Process / Command] --> B(DeviceImportService);
    A --> C(DeviceMediaImportService);

    B --> D{setDeviceTypes};
    B --> E{setSeries};
    B --> F{setDevices};

    C --> G{setDeviceMedia};

    subgraph Dependencies for DeviceImportService
        H[Logger]
        I[Repositories (Type, Series, Device)]
        J[WebserviceClient]
        K[HelperServices (Brand, Series, Type, Device)]
    end

    subgraph Dependencies for DeviceMediaImportService
        L[Logger]
        M[Repositories (Device)]
        N[WebserviceClient]
        O[HelperServices (Media, Brand, Device)]
    end

    B --> H; B --> I; B --> J; B --> K;
    C --> L; C --> M; C --> N; C --> O;

    G --> O; # setDeviceMedia uses MediaHelperService heavily

================
File: ai_docs/done/PLAN__refactor_device_linking_v2.md
================
# Refactoring Plan: Differential Device-Product Linking (V2)

**Goal:** Implement a new, more robust method for synchronizing device-to-product relationships that avoids disabling all entities upfront, maintaining data consistency. This new logic will be activated by the existing `--experimental-v2` CLI flag.

**Current Problem:** The existing `ProductDeviceRelationshipService::syncDeviceProductRelationships` method disables all brands, devices, series, and types, and deletes all links at the beginning of the process. This causes temporary data inconsistency in related tables during the import.

**Proposed Solution:** Create a new service (`ProductDeviceRelationshipServiceV2`) implementing a differential update approach.

---

**Implementation Tracking:**

*   Before starting Phase 1, create a checklist file: `ai_docs/checklist/CHECKLIST__refactor_device_linking_v2.md`.
*   This checklist should mirror the steps outlined in this plan.
*   As each step is completed during implementation, update the checklist accordingly.

---

## Phase 1: Create New Service Structure

1.  **Create New Service File:**
    *   Path: `src/Service/Linking/ProductDeviceRelationshipServiceV2.php`
2.  **Define Class & Dependencies:**
    *   Define the class `ProductDeviceRelationshipServiceV2`.
    *   Copy the `__construct` method from the existing `ProductDeviceRelationshipService` to inject the same dependencies (Connection, Helper Services, Webservice Client).
3.  **Define Public Method Stub:**
    *   Create an empty public method signature within the new class: `public function syncDeviceProductRelationshipsV2(): void`.
4.  **Register Service:**
    *   Add the new `ProductDeviceRelationshipServiceV2` to the dependency injection container configuration.
    *   File: `src/Resources/config/services.xml`

## Phase 2: Implement Differential Update Logic in New Service

1.  **Implement `syncDeviceProductRelationshipsV2()`:**
    *   Populate the method created in Phase 1 with the differential update logic:
        *   Fetch mapped products (`getTopdataProductMappings`).
        *   Chunk product IDs.
        *   Initialize empty sets to store the database IDs of active entities (`$activeDeviceDbIds`, `$activeBrandDbIds`, `$activeSeriesDbIds`, `$activeTypeDbIds`).
        *   Loop through product chunks:
            *   Fetch product-device links from the webservice for the current chunk.
            *   Process the response to identify linked device Webservice IDs (`ws_id`).
            *   Fetch corresponding local device details (DB IDs, `brand_id`, `series_id`, `type_id`) using `getDeviceArrayByWsIdArray`.
            *   Add the fetched database IDs to the respective active sets (`$activeDeviceDbIds`, `$activeBrandDbIds`, etc.).
            *   Identify the Shopware Product database IDs corresponding to the current chunk's webservice product IDs.
            *   **Delete** existing links from `topdata_device_to_product` *only* for these specific Shopware Product DB IDs (`WHERE product_id IN (...)`).
            *   **Insert** the new links fetched from the webservice for the current chunk.
        *   After the loop completes:
            *   **Enable Active Entities:** For each entity type (brand, series, type, device), run an `UPDATE` query to set `is_enabled = 1` for all records whose database IDs are present in the corresponding active set (e.g., `UPDATE topdata_device SET is_enabled = 1 WHERE id IN ($activeDeviceDbIds)`). Check if the set is not empty before executing.
            *   **Disable Inactive Entities:** For each entity type, run an `UPDATE` query to set `is_enabled = 0` for all records whose database IDs are *not* present in the corresponding active set (e.g., `UPDATE topdata_device SET is_enabled = 0 WHERE id NOT IN ($activeDeviceDbIds)`). Check if the set is not empty before executing.
        *   Integrate appropriate `CliLogger` and `UtilProfiling` calls throughout the method.

## Phase 3: Integrate New Service

1.  **Inject New Service:**
    *   Add `ProductDeviceRelationshipServiceV2` as a dependency to the `ImportService` constructor.
    *   File: `src/Service/ImportService.php`
2.  **Conditional Logic in `ImportService`:**
    *   Modify the `_handleProductOperations` method in `ImportService.php`.
    *   Locate the call: `$this->productDeviceRelationshipService->syncDeviceProductRelationships();`
    *   Replace it with conditional logic to check the `--experimental-v2` flag (retrieved from the `ImportCommandImportConfig`):
        ```php
        if ($importConfig->getOptionExperimentalV2()) {
            CliLogger::getCliStyle()->caution('Using experimental V2 device linking logic!');
            $this->productDeviceRelationshipServiceV2->syncDeviceProductRelationshipsV2();
        } else {
            // Keep the original call as the default
            $this->productDeviceRelationshipService->syncDeviceProductRelationships();
        }
        ```

---

This plan ensures the new logic is isolated, the existing default behavior is preserved, and activation is controlled via the existing CLI flag.

================
File: ai_docs/done/refactor_brand_lookup_checklist.md
================
# Refactoring Brand Lookup Logic Checklist

- [x] **Step 1: Enhance `TopdataBrandService`**
    - [x] Add private property `$brandsByWsIdCache`.
    - [x] Create private helper method `_loadBrandsByWsId()`.
    - [x] Create public method `getBrandByWsId()`.
- [x] **Step 2: Refactor `DeviceImportService`**
    - [x] Add `TopdataBrandService` dependency injection.
    - [x] Remove `brandWsArray` property.
    - [x] Remove `_getBrandByWsIdArray` method.
    - [x] Update method calls in `setDeviceTypes`, `setSeries`, `setDevices`, `setDeviceMedia` to use `TopdataBrandService::getBrandByWsId()`.

================
File: ai_docs/done/refactor_brand_lookup_plan.md
================
# Plan: Refactor Brand Lookup Logic

**Goal:** Move the logic for looking up brands by their webservice ID (`ws_id`) from `DeviceImportService` to `TopdataBrandService` to improve code organization and reusability.

**Steps:**

1.  **Enhance `TopdataBrandService` (`src/Service/DbHelper/TopdataBrandService.php`):**
    *   Add a private property for caching brands indexed by `ws_id`:
        ```php
        private ?array $brandsByWsIdCache = null;
        ```
    *   Create a private helper method `_loadBrandsByWsId()`:
        *   Check if `$brandsByWsIdCache` is already populated.
        *   If not, query the `topdata_brand` table for `id`, `code`, `label`, and `ws_id`.
        *   Convert the binary `id` to hex for each brand.
        *   Populate `$brandsByWsIdCache` with the results, keyed by `ws_id`.
    *   Create a new public method `getBrandByWsId(int $brandWsId): array`:
        *   Call `_loadBrandsByWsId()` to ensure the cache is loaded.
        *   Return the brand array from `$brandsByWsIdCache` based on the provided `$brandWsId`.
        *   Return an empty array `[]` if the `$brandWsId` is not found in the cache.

2.  **Refactor `DeviceImportService` (`src/Service/Import/DeviceImportService.php`):**
    *   **Dependency Injection:**
        *   Add `TopdataBrandService` to the constructor parameters.
        *   Store the injected service in a private readonly property:
            ```php
            private readonly TopdataBrandService $topdataBrandService
            ```
    *   **Remove Redundancy:**
        *   Delete the `private ?array $brandWsArray = null;` property.
        *   Delete the `private function _getBrandByWsIdArray(int $brandWsId): array` method.
    *   **Update Method Calls:**
        *   In the methods `setDeviceTypes`, `setSeries`, `setDevices`, and `setDeviceMedia`, replace all calls like:
            ```php
            $brand = $this->_getBrandByWsIdArray($someWsId);
            ```
            with:
            ```php
            $brand = $this->topdataBrandService->getBrandByWsId($someWsId);
            ```

**Visualization:**

```mermaid
sequenceDiagram
    participant DIS as DeviceImportService
    participant TBS as TopdataBrandService
    participant DB as Database

    DIS->>TBS: getBrandByWsId(123)
    alt Cache Miss
        TBS->>DB: SELECT id, code, label, ws_id FROM topdata_brand
        DB-->>TBS: Brand Rows
        TBS->>TBS: Populate brandsByWsIdCache
    end
    TBS-->>DIS: Brand Array for ws_id 123 (or [])

    Note right of DIS: Subsequent calls for other ws_ids...

    DIS->>TBS: getBrandByWsId(456)
    alt Cache Hit
        TBS-->>DIS: Brand Array for ws_id 456 (or [])
    end
```

**Benefits:**

*   Improved code cohesion (`DeviceImportService` focuses on devices).
*   Centralized brand data logic (`TopdataBrandService`).
*   Increased reusability of brand lookup functionality.
*   Enhanced maintainability.

================
File: ai_docs/plans/CHECKLIST__integrated_caching_strategy.md
================
# Integrated Caching Strategy Implementation Checklist

## Code Refactoring
- [x] Add mapping type constants for cache types
- [x] Enhance MappingCacheService with profiling and better error handling
- [x] Add type-specific cache operations
- [x] Implement cache statistics functionality
- [x] Create processWebserviceMappings() method for API communication
- [x] Extract handleCacheOperations() for cache lifecycle management
- [x] Modify mapping methods to return raw mappings

## Cache Integration
- [x] Create MappingCacheService class
- [x] Implement hasCachedMappings() method
- [x] Implement saveMappingsToCache() method
- [x] Implement loadMappingsFromCache() method
- [x] Add cache purging functionality
- [x] Add cache validation checks

## Service Wiring
- [x] Register MappingCacheService in services.xml
- [x] Inject MappingCacheService into MappingStrategy_EanOem
- [x] Update Command_Import to support cache operations

## Database
- [x] Create migration for mapping cache table

## Validation
- [ ] Test cache miss scenario
- [ ] Test cache hit scenario
- [ ] Test cache expiration
- [ ] Test partial cache invalidation

## Monitoring
- [x] Add profiling to cache operations
- [x] Add cache hit/miss metrics
- [x] Add cache size metrics
- [x] Add webservice calls saved metrics

================
File: ai_docs/plans/CHECKLIST__optimize_product_relationship_performance.md
================
# Implementation Checklist: Product Relationship Performance Optimization

## ✅ Preparation
- [ ] Review current `ProductProductRelationshipServiceV1.php` implementation
- [ ] Create feature branch: `feature/optimize-product-relationships`
- [ ] Set up performance testing environment with large dataset

## ⚙️ Implementation Tasks
### 1. Relationship Collection Refactor
- [ ] Modify `linkProducts()` method to collect relationships in `$allRelationships` array
- [ ] Include all relationship types: similar, alternate, related, bundled, color_variant, capacity_variant, variant

### 2. Bulk Processing Implementation
- [ ] Create `_processBulkRelationships()` private method with parameters:
  ```php
  private function _processBulkRelationships(
      array $productId_versionId,
      array $allRelationships,
      string $dateTime
  ): void
  ```
- [ ] Implement transaction handling (`beginTransaction()`/`commit()`/`rollBack()`)
- [ ] Add bulk insert logic with parameter type mapping
- [ ] Handle empty relationship cases

### 3. Helper Methods
- [ ] Implement `getTableForType(string $type): string`
- [ ] Implement `getIdColumnPrefix(string $type): string`
- [ ] Add mapping arrays for table names and ID prefixes

### 4. Constant Updates
- [ ] Define `BULK_INSERT_SIZE` constant (value: 500)
- [ ] Define `USE_TRANSACTIONS` constant (value: true)

### 5. Cross-Selling Preservation
- [ ] Verify `_addProductCrossSelling()` remains unchanged
- [ ] Ensure cross-selling logic executes after bulk processing

## 🧪 Testing & Validation
- [ ] Unit tests for new helper methods (`getTableForType`, `getIdColumnPrefix`)
- [ ] Integration test for `_processBulkRelationships()` with:
  - [ ] Valid relationships
  - [ ] Empty relationships
  - [ ] Transaction rollback scenario
- [ ] Performance comparison tests (before/after):
  - [ ] 100 relationships
  - [ ] 1,000 relationships
  - [ ] 10,000 relationships
- [ ] Verify data integrity after bulk inserts

## 🚀 Deployment
- [ ] Update CHANGELOG.md with optimization details
- [ ] Create database migration for any schema changes (if needed)
- [ ] Deploy to staging environment
- [ ] Monitor production performance metrics after deployment

## ⚠️ Risk Mitigation
- [ ] Implement detailed error logging in catch block
- [ ] Add feature flag to toggle between old/new implementation
- [ ] Verify rollback procedure works correctly
- [ ] Document bulk processing limitations

## 🔚 Final Checks
- [ ] Code review focusing on transaction safety
- [ ] Update inline documentation for new methods
- [ ] Verify coding standards compliance
- [ ] Merge feature branch to main

================
File: ai_docs/plans/CHECKLIST__verbose_category_override_logging.md
================
# Checklist: Implement Verbose Category Override Logging

## Implementation
- [x] Add Breadcrumb Fetching Method
- [x] Add Default Language Helper
- [x] Add Logging in loadProductImportSettings()
- [x] Import CliLogger

## Testing
- [ ] Create test categories with overrides
- [ ] Verify breadcrumbs are logged correctly
- [ ] Test with categories missing translations
- [ ] Verify performance with large category trees

================
File: ai_docs/plans/PLAN__cache_ean_oem_mappings.md
================
# Plan: Cache EAN and OEM Mappings in Database

## Objective
Implement a database caching mechanism for EAN and OEM mappings to reduce API calls to the TopData webservice and improve import performance.

## Analysis
- The current implementation in `MappingStrategy_EanOem` fetches EAN and OEM data from the webservice on every import run
- These API calls are time-consuming and the data doesn't change frequently
- Caching this data would significantly improve performance for subsequent imports

## Implementation Plan

### Phase 1: Create Database Structure
1. **Create Migration File**
   - Create `src/Migration/Migration1715000000CreateMappingCacheTable.php`
   - Define table structure with columns:
     - `id` (BINARY(16), primary key)
     - `type` (VARCHAR(50), for distinguishing mapping types)
     - `top_data_id` (INT, the TopData product ID)
     - `product_id` (BINARY(16), Shopware product ID)
     - `product_version_id` (BINARY(16), Shopware product version ID)
     - `created_at` (DATETIME, for cache invalidation)
     - `updated_at` (DATETIME, nullable)
   - Add appropriate indexes for performance

2. **Leverage Existing CLI Option**
   - Use the existing `--experimental-v2` CLI option to enable the caching feature
   - This option is already defined in `Command_Import` and available in `ImportConfig`
   - No need to add a new configuration option to the plugin schema

3. **Add Cache Purge Option**
   - Add a new CLI option `--purge-cache` to the `Command_Import` class
   - Update `ImportConfig` to include this option
   - Implement a method in `MappingStrategy_EanOem` to purge the cache when this option is used

### Phase 2: Modify MappingStrategy_EanOem
1. **Add Cache Management Methods**
   - Add `Connection` dependency to constructor
   - Implement `hasCachedMappings()` to check if valid cache exists
   - Implement `saveMappingsToCache()` to store mappings
   - Implement `loadMappingsFromCache()` to retrieve mappings
   - Implement `purgeMappingsCache()` to clear the cache
   - Add cache expiry constant (e.g., 24 hours)

2. **Update Processing Methods**
   - Modify `_processEANs()`, `_processOEMs()`, and `_processPCDs()` to optionally return mappings instead of inserting them directly
   - Add parameter `bool $returnMappings = false` to these methods
   - When `$returnMappings` is true, collect mappings in an array instead of inserting them

3. **Update Main Map Method**
   - Modify `map()` to accept the `ImportConfig` object to check for the `--experimental-v2` flag
   - If the `--purge-cache` option is set, clear the cache before proceeding
   - If the `--experimental-v2` flag is enabled and valid cache exists, load and use cached mappings
   - Otherwise, proceed with normal mapping and save results to cache if the flag is enabled

4. **Update ProductMappingService**
   - Modify `mapProducts()` to pass the `ImportConfig` to the mapping strategy

### Phase 3: Testing and Validation
1. **Unit Tests**
   - Create tests for cache validation logic
   - Test cache expiration functionality
   - Verify correct mapping retrieval from cache
   - Test cache purging functionality

2. **Integration Tests**
   - Test full import process with `--experimental-v2` flag
   - Test cache purging with `--purge-cache` option
   - Verify performance improvement with cached mappings
   - Ensure data consistency between cached and non-cached imports

3. **Performance Benchmarking**
   - Measure and document performance improvement
   - Test with various dataset sizes

## Expected Benefits
- Reduced API calls to the TopData webservice
- Faster import process for subsequent runs
- Reduced server load and bandwidth usage
- Improved user experience due to faster imports
- Consistent with existing experimental features pattern
- Explicit control over cache through dedicated CLI option

## Potential Risks and Mitigations
- **Risk**: Cache becomes stale if product mappings change
  - **Mitigation**: Implement configurable cache expiration (default 24 hours)
  - **Mitigation**: Provide `--purge-cache` option to explicitly refresh the cache

- **Risk**: Increased database size
  - **Mitigation**: Implement cleanup for old cache entries
  - **Mitigation**: Monitor database size impact

- **Risk**: Compatibility issues with existing code
  - **Mitigation**: Only activate with explicit `--experimental-v2` flag
  - **Mitigation**: Comprehensive testing before deployment

================
File: ai_docs/plans/PLAN__cache_stats_display.md
================
# Plan: Add Cache Statistics Display

## Objective

Add a method to display cache statistics in `src/Service/Cache/MappingCacheService.php` and display these statistics after the cache is populated in `src/Service/Import/MappingStrategy/MappingStrategy_EanOem.php`.

## Plan

1.  **Modify `getCacheStats` method:** Update the `getCacheStats` method in `src/Service/Cache/MappingCacheService.php` to include the `ORDER BY count DESC` clause in the SQL query for retrieving counts by mapping type.
2.  **Add stats display after caching:** In `src/Service/Import/MappingStrategy/MappingStrategy_EanOem.php`, after the call to `saveMappingsToCache` (around line 300), add code to:
    *   Call the modified `getCacheStats` method of the `MappingCacheService`.
    *   Iterate through the returned statistics and display them using `CliLogger::info`.

## Visual Representation

```mermaid
graph TD
    A[User Request: Add stats method and display after cache population] --> B{Analyze MappingCacheService.php};
    B --> C[Identify existing getCacheStats method];
    C --> D[Identify where saveMappingsToCache is called];
    D --> E{Modify getCacheStats query};
    E --> F{Add code to display stats in MappingStrategy_EanOem.php};
    F --> G[Call getCacheStats];
    G --> H[Log stats using CliLogger];
    H --> I[Task Complete];

================
File: ai_docs/plans/PLAN__fix_mapping_cache.md
================
# Plan: Refactor Mapping Cache to Store Webservice Values

## Objective
Modify the mapping cache implementation to store the actual mapping values returned from the webservice instead of Shopware product IDs, enabling more flexible and accurate product matching when loading from cache.

## Current Implementation Analysis
- The current mapping cache stores Shopware product IDs directly
- This approach tightly couples the cache to specific Shopware products
- If product identifiers change in Shopware, the cache becomes invalid even though the webservice mappings are still valid
- The cache cannot be reused across different Shopware instances or after product data changes

## Proposed Changes

### 1. Update Database Schema
- Modify `Migration1746267946CreateMappingCacheTable.php` to:
  - Replace `product_id` and `product_version_id` columns with a `mapping_value` column
  - This column will store the actual identifier value (EAN, OEM, PCD) from the webservice
  - Ensure appropriate indexes for performance

### 2. Modify MappingCacheService
- Update `saveMappingsToCache()` method to:
  - Store `topDataId` and `mapping_value` pairs instead of product associations
  - Maintain mapping type information for proper categorization
- Refactor `loadMappingsFromCache()` method to:
  - Load cached mapping values
  - Dynamically find matching Shopware products at runtime
  - Implement product lookup logic based on mapping type
- Add helper methods:
  - `findMatchingProducts()` to match cached values to Shopware products
  - `getEanMap()`, `getOemMap()`, `getPcdMap()` to retrieve current product identifiers

### 3. Update MappingStrategy_EanOem
- Modify `_processIdentifiers()` method to:
  - Store original webservice values alongside normalized versions
  - Include these values when creating mapping records
- Update `saveToCacheV2()` method to:
  - Extract only `topDataId` and `value` for caching
  - Exclude Shopware-specific product IDs from cache storage
- Ensure `flattenMappings()` and `persistMappingsToDatabase()` methods handle the new structure

### 4. Testing Strategy
- Unit tests:
  - Verify cache storage correctly preserves webservice values
  - Confirm cache loading properly matches to current Shopware products
- Integration tests:
  - Test full import cycle with cache enabled
  - Verify cache hit/miss scenarios
  - Test with modified product data to ensure dynamic matching works

## Benefits
- **Decoupling**: Cache becomes independent of Shopware product IDs
- **Flexibility**: Cache remains valid even if Shopware product identifiers change
- **Reusability**: Cache could potentially be shared across environments
- **Accuracy**: Ensures mappings reflect the actual webservice data
- **Performance**: Still provides performance benefits by avoiding webservice calls

## Implementation Phases

### Phase 1: Schema Changes
1. Create migration to modify the `topdata_mapping_cache` table
2. Update database schema in development environment
3. Verify schema changes

### Phase 2: Service Implementation
1. Update `MappingCacheService` methods
2. Implement product lookup logic
3. Modify `MappingStrategy_EanOem` to work with the new structure

### Phase 3: Testing and Validation
1. Develop unit tests for the new functionality
2. Test cache population and retrieval
3. Verify performance metrics
4. Ensure backward compatibility with existing import flows

## Potential Risks and Mitigations
- **Risk**: Performance impact of dynamic product lookup
  - **Mitigation**: Optimize queries and use efficient indexing
- **Risk**: Data inconsistency during transition
  - **Mitigation**: Implement cache purge option for clean transition
- **Risk**: Compatibility with other components
  - **Mitigation**: Ensure interface consistency and thorough testing

================
File: ai_docs/plans/PLAN__integrated_caching_strategy.md
================
# Integrated Caching Strategy for EAN/OEM Mappings

## Objective
Combine cache infrastructure improvements with code refactoring to create a maintainable, high-performance mapping solution that reduces webservice calls while maintaining data freshness.

## Architecture Overview
```mermaid
graph TD
    A[Import Command] --> B{--purge-cache?}
    B -->|Yes| C[Purge Cache]
    B -->|No| D{Valid Cache Exists?}
    D -->|Yes| E[Load from Cache]
    D -->|No| F[Fetch from Webservice]
    F --> G[Process & Map Data]
    G --> H[Save to Cache]
    E & H --> I[Insert Mappings]
    I --> J[Complete Import]
```

## Implementation Phases

### 1. Code Refactoring (MappingStrategy_EanOem)
- **Separation of Concerns**
    - Create `processWebserviceMappings()` method handling API communication
    - Extract `handleCacheOperations()` for cache lifecycle management
- **Batch Processing**
    - Modify `_processEANs()`, `_processOEMs()`, `_processPCDs()` to return raw mappings
    - Add `$mappingType` parameter to track EAN/OEM/PCD sources

### 2. Cache Integration
- **Service Wiring**
    - Inject `MappingCacheService` into `MappingStrategy_EanOem`
    - Implement cache validation checks using existing `hasCachedMappings()`
- **Data Flow**
  ```php
  // Example cache-aware processing
  if ($config->experimentalV2) {
      $mappings = $this->collectMappingsFromWebservice();
      $this->mappingCacheService->saveMappingsToCache($mappings);
  }
  ```


## Validation Plan

| Test Case                  | Method                          | Success Criteria                     |
|----------------------------|---------------------------------|--------------------------------------|
| Cache Miss Scenario        | Run import without valid cache  | All mappings saved to cache table    |
| Cache Hit Scenario         | Run import with valid cache     | Zero webservice calls logged         |
| Cache Expiration           | Wait 24+ hours between imports  | Cache automatically refreshes        |
| Partial Cache Invalidation | Purge specific mapping types    | Only targeted mappings are cleared   |

3. **Monitoring**
    - Add Prometheus metrics:
        - `mappings_cache_hits_total`
        - `mappings_cache_size_bytes`
        - `webservice_calls_saved_total`

================
File: ai_docs/plans/PLAN__optimize_product_relationship_performance.md
================
# Optimization Plan: Product Relationship Processing

## Problem Statement
The current implementation of product relationship processing in `ProductProductRelationshipServiceV1` is inefficient due to:
- Individual INSERT statements for each relationship
- High number of database roundtrips
- Lack of transaction batching
- Small chunk size (30 records)

This results in slow performance when processing product relationships.

## Proposed Solution
Implement bulk INSERT operations with transaction handling to reduce database roundtrips and improve performance.

```mermaid
graph LR
    A[Current Implementation] --> B[Individual INSERTs]
    B --> C[High DB Roundtrips]
    C --> D[Slow Processing]
    
    E[Optimized Solution] --> F[Bulk INSERTs]
    F --> G[Transaction Handling]
    G --> H[Large Batches]
    H --> I[Faster Processing]
```

## Implementation Steps

### 1. Refactor Relationship Collection
Modify `linkProducts()` to collect all relationships before processing:
```php
public function linkProducts(array $productId_versionId, $remoteProductData): void
{
    $dateTime = date('Y-m-d H:i:s');
    $allRelationships = [
        'similar' => $this->_findSimilarProducts($remoteProductData),
        'alternate' => $this->_findAlternateProducts($remoteProductData),
        'related' => $this->_findRelatedProducts($remoteProductData),
        'bundled' => $this->findBundledProducts($remoteProductData),
        'color_variant' => $this->_findColorVariantProducts($remoteProductData),
        'capacity_variant' => $this->_findCapacityVariantProducts($remoteProductData),
        'variant' => $this->_findVariantProducts($remoteProductData),
    ];
    
    // ... rest of implementation ...
}
```

### 2. Implement Bulk Processing
Create new `_processBulkRelationships()` method:
```php
private function _processBulkRelationships(
    array $productId_versionId,
    array $allRelationships,
    string $dateTime
): void {
    $this->connection->beginTransaction();
    
    try {
        foreach ($allRelationships as $type => $products) {
            if (empty($products)) continue;
            
            $tableName = $this->getTableForType($type);
            $idColumnPrefix = $this->getIdColumnPrefix($type);
            
            $values = [];
            foreach ($products as $tempProd) {
                $values[] = [
                    hex2bin($productId_versionId['product_id']),
                    hex2bin($productId_versionId['product_version_id']),
                    hex2bin($tempProd['product_id']),
                    hex2bin($tempProd['product_version_id']),
                    $dateTime
                ];
            }
            
            $this->connection->insert(
                $tableName,
                $values,
                [
                    'product_id' => Types::BINARY,
                    'product_version_id' => Types::BINARY,
                    "{$idColumnPrefix}_product_id" => Types::BINARY,
                    "{$idColumnPrefix}_product_version_id" => Types::BINARY,
                    'created_at' => Types::STRING
                ]
            );
        }
        
        $this->connection->commit();
    } catch (\Exception $e) {
        $this->connection->rollBack();
        throw $e;
    }
}
```

### 3. Add Helper Methods
```php
private function getTableForType(string $type): string
{
    $map = [
        'similar' => 'topdata_product_to_similar',
        'alternate' => 'topdata_product_to_alternate',
        'related' => 'topdata_product_to_related',
        'bundled' => 'topdata_product_to_bundled',
        'color_variant' => 'topdata_product_to_color_variant',
        'capacity_variant' => 'topdata_product_to_capacity_variant',
        'variant' => 'topdata_product_to_variant',
    ];
    return $map[$type] ?? '';
}

private function getIdColumnPrefix(string $type): string
{
    $map = [
        'similar' => 'similar_product',
        'alternate' => 'alternate_product',
        'related' => 'related_product',
        'bundled' => 'bundled_product',
        'color_variant' => 'color_variant_product',
        'capacity_variant' => 'capacity_variant_product',
        'variant' => 'variant_product',
    ];
    return $map[$type] ?? '';
}
```

### 4. Update Constants
```php
const BULK_INSERT_SIZE = 500; // Optimal batch size for MySQL
const USE_TRANSACTIONS = true;
```

### 5. Preserve Cross-Selling Logic
Keep the existing `_addProductCrossSelling()` method unchanged.

## Expected Benefits
- **10-100x performance improvement** for relationship processing
- Reduced database load
- More efficient resource utilization
- Scalable solution for large product catalogs

## Risk Mitigation
1. Maintain existing functionality through interface preservation
2. Comprehensive error handling with transactions
3. Detailed logging for debugging
4. Fallback to original implementation through feature flag

## Next Steps
1. Implement the changes in `ProductProductRelationshipServiceV1.php`
2. Test with large product datasets
3. Monitor performance metrics
4. Deploy to staging environment for validation

================
File: ai_docs/plans/PLAN__refactor_mapping_strategy_cache.md
================
# Plan: Refactor MappingStrategy_EanOem for Clear Cache Separation

## Objective
Refactor the `MappingStrategy_EanOem` class to establish a clear separation between cache and non-cache paths, ensuring that all mappings are properly saved to the cache when enabled.

## Analysis
- The current implementation in `MappingStrategy_EanOem` has an issue where only the final batch of mappings is being saved to the cache
- The code mixes cache and non-cache logic, making it difficult to maintain
- The processing methods (`_processEANs`, `_processOEMs`, `_processPCDs`) are overloaded with both direct database insertion and cache collection responsibilities

## Implementation Plan

### Phase 1: Restructure the Map Method
1. **Refactor the `map()` method**
   - Keep the existing cache check logic
   - Extract the webservice mapping logic into a new private method `mapFromWebservice(bool $saveToCache)`
   - Call this method when cache is not available or needs to be refreshed

2. **Create Dedicated Processing Methods**
   - Create `processEANsFromWebservice(array $eanMap, bool $saveToCache)`
   - Create `processOEMsFromWebservice(array $oemMap, bool $saveToCache)`
   - Create `processPCDsFromWebservice(array $oemMap, bool $saveToCache)`
   - These methods will handle fetching from webservice, direct database insertion, and optional cache collection

### Phase 2: Implement Batch Processing for Cache
1. **Implement Batch Collection for Cache**
   - In each processing method, maintain a separate array for cache entries
   - Add mapping type information to cache entries
   - Collect all mappings for cache throughout the entire process
   - Save the collected mappings to cache at the end of each processing method

2. **Improve Logging and Reporting**
   - Add logging to show how many mappings are being saved to cache
   - Maintain existing progress indicators and counters

### Phase 3: Testing and Validation
1. **Test Cache Population**
   - Verify that all mappings are properly saved to the cache
   - Compare the number of mappings in the cache with the number of mappings inserted directly

2. **Test Cache Usage**
   - Verify that subsequent imports correctly use the cached mappings
   - Ensure performance improvement with cached mappings

3. **Test Cache Purging**
   - Verify that the `--purge-cache` option correctly clears the cache

## Expected Benefits
- Clear separation between cache and non-cache code paths
- Proper collection and storage of all mappings in the cache
- Improved code maintainability
- Better logging and reporting of cache operations
- Consistent behavior between cached and non-cached imports

## Potential Risks and Mitigations
- **Risk**: Increased memory usage when collecting all mappings for cache
  - **Mitigation**: Consider implementing incremental cache updates if memory becomes an issue

- **Risk**: Performance impact of additional processing
  - **Mitigation**: The performance benefit of using cache should outweigh the cost of populating it

- **Risk**: Compatibility issues with existing code
  - **Mitigation**: Maintain the same public interface and behavior
  - **Mitigation**: Comprehensive testing before deployment

================
File: ai_docs/plans/PLAN__verbose_category_override_logging.md
================
# Plan: Enhance ProductImportSettingsService to Log Category Breadcrumbs

## Objective
Modify the `ProductImportSettingsService` class to log the full breadcrumb path of categories that have configuration overrides set during product import.

## Current Behavior
The `loadProductImportSettings()` method currently:
1. Loads category paths for products
2. Fetches import settings for these categories
3. Applies the most specific category override to each product
4. Does not log which categories have overrides or their hierarchy

## Proposed Changes

### 1. Add Breadcrumb Fetching Method
```php:src/Service/Config/ProductImportSettingsService.php
private function getCategoryBreadcrumb(string $categoryId): string
{
    $path = $this->connection->fetchOne('
        SELECT GROUP_CONCAT(category_translation.name ORDER BY category.path SEPARATOR " > ")
        FROM category
        JOIN category_translation ON category.id = category_translation.category_id
        JOIN (
            SELECT id, path FROM category WHERE id = UNHEX(:categoryId)
        ) AS target ON category.path LIKE CONCAT(target.path, '%')
        WHERE category_translation.language_id = UNHEX(:languageId)
    ', [
        'categoryId' => str_replace('-', '', $categoryId),
        'languageId' => $this->getDefaultLanguageId()
    ]);
    
    return $path ?: $categoryId;
}
```

### 2. Add Default Language Helper
```php:src/Service/Config/ProductImportSettingsService.php
private function getDefaultLanguageId(): string
{
    return $this->connection->fetchOne(
        'SELECT LOWER(HEX(id)) FROM language WHERE is_default = 1 LIMIT 1'
    ) ?: '';
}
```

### 3. Add Logging in loadProductImportSettings()
```php:src/Service/Config/ProductImportSettingsService.php
// After loading category settings
foreach ($allCategories as $categoryId => $settings) {
    if ($settings !== false) {
        $breadcrumb = $this->getCategoryBreadcrumb($categoryId);
        CliLogger::info("Category with override: $breadcrumb");
    }
}
```

### 4. Import CliLogger
```php:src/Service/Config/ProductImportSettingsService.php
use Topdata\TopdataConnectorSW6\Util\CliLogger;
```

## Process Flow
```mermaid
sequenceDiagram
    participant P as ProductImportSettingsService
    participant DB as Database
    participant L as Logger
    
    P->>DB: Load product categories
    P->>DB: Load category settings
    loop For each category with settings
        P->>DB: Get category breadcrumb
        DB-->>P: Return breadcrumb path
        P->>L: Log "Category with override: [breadcrumb]"
    end
    P->>P: Apply settings to products
```

## Implementation Notes
1. The solution fetches breadcrumbs only for categories with overrides
2. Uses a single optimized SQL query to get the full breadcrumb path
3. Respects the system's default language for translations
4. Falls back to category ID if breadcrumb can't be determined
5. Logs at INFO level to avoid cluttering output during normal operation

## Testing Plan
1. Create test categories with overrides at different hierarchy levels
2. Verify breadcrumbs are correctly logged during import
3. Test with categories that have no name translations
4. Verify performance with large category trees

## Estimated Effort
~2 hours development time including testing

================
File: ai_docs/CONVENTIONS.md
================
# PHP code style conventions

- the names of a private method should start with an underscore, e.g. `_myPrivateMethod()`
- each class should have a docblock explaining what the class does
- each method should have a docblock explaining what the method does, except setters and getters
- Do NOT add redundant PHPDoc tags to docblocks, e.g. `@return void` or `@param string $foo` without any additional information
- the signature of a method should have type hints for all parameters and the return type

================
File: ai_docs/PROJECT_SUMMARY.md
================
# Project Summary: TopData Webservice Connector for Shopware 6

## Brief Description and Purpose

The TopData Webservice Connector for Shopware 6 is a plugin designed to integrate a Shopware 6 online store with the TopData Webservice. Its primary purpose is to enable the import of device information from the TopData Webservice into the Shopware store. This plugin serves as a foundational component for other TopData plugins that extend its functionality.

## Key Aspects

### Architecture

The plugin follows a standard Shopware 6 plugin architecture, utilizing:
-   **Core Content Extensions:** Extending Shopware's core entities like Brand, Category, Customer, Device, Product, and Series to store TopData-specific information.
-   **Services:** A comprehensive set of services handle various aspects of the integration, including configuration checking, connection testing, data import (devices, media, products, synonyms), data mapping, database interactions, and product linking.
-   **Console Commands:** Provides CLI commands for initiating and managing import processes and testing the webservice connection.
-   **Scheduled Tasks:** Includes scheduled tasks for automated imports.
-   **API Controllers:** Provides an administrative API endpoint.
-   **Migrations:** Manages database schema changes required by the plugin.
-   **Dependency Injection:** Configuration is managed via XML service definitions.

### Implementation Details

-   **Webservice Communication:** Uses a dedicated webservice client (`TopdataWebserviceClient`) and an HTTP client (`CurlHttpClient`) to interact with the TopData API.
-   **Data Import:** Implements various import services (`DeviceImportService`, `DeviceMediaImportService`, etc.) to fetch and process data from the webservice.
-   **Data Mapping:** Includes mapping strategies (`MappingStrategy_Distributor`, `MappingStrategy_EanOem`, etc.) to link Shopware products with TopData devices based on different criteria.
-   **Product Linking:** Services like `ProductDeviceRelationshipServiceV2` handle the creation of relationships between Shopware products and imported devices.
-   **Error Handling:** Custom exceptions (`WebserviceRequestException`, `MissingPluginConfigurationException`, etc.) are used to manage specific error conditions.
-   **Configuration:** Plugin configuration is managed through the Shopware admin interface and accessed via a merged configuration helper service.

### Coding Standards and Conventions

The project adheres to specific coding standards and conventions, as indicated by the presence of:
-   `.php-cs-fixer.dist.php` and `.php-cs-fixer.php` for PHP code style fixing.
-   `CONVENTIONS.md` detailing project-specific conventions.

### Versioning

Versioning is managed through `VERSIONING.md` and changes are documented in `CHANGELOG.md`.

### Documentation

Includes user manuals in German and English (`manual/`) and internal AI-related documentation (`ai_docs/`).

## Minimal Requirements

-   Shopware 6.6.0 or higher
-   PHP 8.1 or higher

================
File: manual/10-installation.de.md
================
---
title: Installation
---


# Installation

## Einführung

In unserem Manual zeigen wir Ihnen Schritt für Schritt, wie Sie das Plugin installieren und einrichten können. In unterschiedlichen Shopware-Umgebungen können trotz umfangreicher Softwaretests immer wieder kleine Bugs auftreten. Solche Bugfixes erledigen wir schnell und unkompliziert, in der Regel noch am selben Tag.

## Support

Sollten bei der Einrichtung Ihres "TopCONNECTOR" Probleme oder Fragen auftauchen, kontaktieren Sie uns bitte unter: shopware@topdata.de.

## Wichtiger Hinweis

Bei jeder Installation bzw. jedem Update eines Plugins in Ihrem Shopware-Shop, handelt es sich um eine Änderung der Software. Trotz sämtlicher von uns oder Shopware durchgeführter Maßnahmen zur Qualitätssicherung können wir nicht ausschließen, dass es während der Installation oder des Updates in Ausnahmefällen zu Problemen kommen kann.

Wir empfehlen Ihnen daher grundsätzlich:
* Neue Plugins bzw. Updates von bereits installierten Plugins zunächst in einer geeigneten Testumgebung zu testen
* Diese nicht direkt in die Live-Umgebung einzuspielen
* Ein regelmäßiges Backup Ihrer Live-Umgebung wird ebenfalls dringend empfohlen

## Systemvoraussetzungen

* Linux-basiertes Betriebssystem mit Apache 2.2 oder 2.4
* Webserver mit mod_rewrite Modul und Möglichkeit auf .htaccess Zugriff
* PHP 7.2.x / 7.3.x (7.2.20 und 7.3.7 sind nicht kompatibel)
* MySQL >= 5.7 (außer 8.0.20 und 8.0.21)
* Möglichkeit Cronjobs einzurichten
* Mindestens 4 GB freier Speicherplatz
* Es wird ein Shell-Zugang benötigt, um den Import zu starten
* Empfohlen: memory_limit > 512M


## Die Einrichtung Ihres "TopCONNECTOR" Schritt für Schritt

1. Downloaden Sie "TopCONNECTOR" kostenlos im Shopware-Community Store
2. Bitte führen Sie den Bestellvorgang durch in dem Sie "TopCONNECTOR" in den Warenkorb legen und den Bestellprozess an der Kasse abschliessen
3. Laden Sie "TopCONNECTOR" im Adminbereich Ihres Shopware-Shops unter dem Menüpunkt "Einkäufe" und installieren Sie das Plugin anschließend unter dem Menüpunkt "Meine Plugins"
4. Im nächsten Schritt aktivieren Sie Ihren "TopCONNECTOR"
5. Abschließend konfigurieren Sie Ihren "TopCONNECTOR" ganz nach Wunsch



## Webservice Zugangsdaten
Nach der Installation des Plugins müssen Sie die API-Zugangsdaten eingeben, um eine Verbindung zum TopData Webservice herzustellen.

Einstellungen - System - Plugins - TopdataConnector Menü (... auf der rechten Seite) - Konfiguration

TopData stellt Ihnen folgende Daten zur Verfügung:

- API Benutzer-ID
- API Passwort
- API Security Key

### Demo Zugangsdaten

Wenn Sie das Plugin mit Demo-Zugangsdaten testen möchten, können Sie folgende Daten verwenden:

- API Benutzer-ID: 6
- API Passwort: nTI9kbsniVWT13Ns
- API Security Key: oateouq974fpby5t6ldf8glzo85mr9t6aebozrox

================
File: manual/10-installation.en.md
================
---
title: Installation
---
# Installation

## Introduction

In our manual, we show you step by step how to install and set up the plugin. Despite extensive software testing, minor bugs may still occur in different Shopware environments. We handle such bugfixes quickly and efficiently, usually on the same day.

## Support

If you encounter any problems or have questions while setting up your "TopCONNECTOR", please contact us at: shopware@topdata.de.

## Important Notice

Each installation or update of a plugin in your Shopware shop represents a change to the software. Despite all quality assurance measures carried out by us or Shopware, we cannot rule out the possibility that problems may occur during installation or updates in exceptional cases.

Therefore, we generally recommend:
* Testing new plugins or updates of already installed plugins in a suitable test environment first
* Not deploying them directly to the live environment
* Regular backup of your live environment is also strongly recommended

## System Requirements

* Linux-based operating system with Apache 2.2 or 2.4
* Web server with mod_rewrite module and ability to access .htaccess
* PHP 7.2.x / 7.3.x (7.2.20 and 7.3.7 are not compatible)
* MySQL >= 5.7 (except 8.0.20 and 8.0.21)
* Ability to set up cron jobs
* At least 4 GB of free disk space
* Shell access is required to start the import
* Recommended: memory_limit > 512M


## Setting up your "TopCONNECTOR" Step by Step

1. Download "TopCONNECTOR" for free from the Shopware Community Store
2. Please complete the ordering process by adding "TopCONNECTOR" to your cart and completing the checkout process
3. Download "TopCONNECTOR" in the admin area of your Shopware shop under the "Purchases" menu item and then install the plugin under "My Plugins"
4. In the next step, activate your "TopCONNECTOR"
5. Finally, configure your "TopCONNECTOR" according to your preferences


## Webservice Credentials
After installing the plugin, you need to fill in API credentials to connect to TopData Webservice.

Settings - System - Plugins - TopdataConnector menu (... on the right) - Config

TopData provides you with:

- API User-ID
- API Password
- API Security Key

### Demo Credentials

If you want to test the plugin with demo credentials, you can use the following:

- API User-ID: 6
- API Password: nTI9kbsniVWT13Ns
- API Security Key: oateouq974fpby5t6ldf8glzo85mr9t6aebozrox

================
File: manual/30-settings.de.md
================
---
title: Einstellungen
---

# Einstellungen Ihres "TopCONNECTOR"

Konfigurieren Sie "TopCONNECTOR" in Abhängigkeit Ihrer Bedürfnisse:

1. **Verkaufskanäle**  
   Wählen Sie, für welche Verkaufskanäle die Einstellungen gelten sollen

2. **API-User-ID**  
   Tragen Sie die von uns erhaltene API-User-ID ein

3. **API-Key**  
   Tragen Sie den von uns erhaltenen API-Key ein

4. **API-Salt**  
   Tragen Sie den von uns erhaltenen API-Salt ein

5. **API-Sprache**  
   Wählen Sie die gewünschte Sprache

6. **Verbindung prüfen**  
   Überprüfen der Verbindung zum Webservice

7. **Bitte wählen Sie Ihre Produkt-Mapping-Variante aus**  
   Entscheiden Sie, wie Ihre Artikel mit unserer Produktdatenbank verknüpft werden sollen

8. **OEM-Feld**  
   Geben Sie den Namen des von Ihnen festgelegten OEM Eigenschaften-/Zusatzfeldes an

9. **EAN-Feld**  
   Geben Sie den Namen des von Ihnen festgelegten EAN Eigenschaften-/Zusatzfeldes an

10. **Lieferanten-Artikelnummer-Feld**  
    Geben Sie den Namen des von Ihnen festgelegten Lieferanten Artikelnummer Eigenschaften-/Zusatzfeldes an

================
File: manual/30-settings.en.md
================
---
title: Settings
---

# Settings of your "TopCONNECTOR"

Configure "TopCONNECTOR" according to your needs:

1. **Sales Channels**  
   Select which sales channels the settings should apply to

2. **API User ID**  
   Enter the API User ID provided by us

3. **API Password**  
   Enter the API Password provided by us

4. **API Security Key**  
   Enter the API Security Key provided by us

5. **API Language**  
   Select your preferred language

6. **Check Connection**  
   Verify the connection to the web service

7. **Please select your Product Mapping Variant**  
   Decide how your articles should be linked to our product database

8. **OEM Field**  
   Enter the name of your designated OEM properties/additional field

9. **EAN Field**  
   Enter the name of your designated EAN properties/additional field

10. **Supplier Article Number Field**  
    Enter the name of your designated supplier article number properties/additional field

================
File: manual/40-faq.de.md
================
---
title: FAQ
---


## Wozu dient der "TopCONNECTOR"?

"TopCONNECTOR" ist die Kernkomponente unserer Daten-Plugins und sorgt für die Verbindung zwischen Ihrem Shopware-Shop und unserem Webservice. Für die Funktionalität unserer Daten-Plugins ist die vorherige Installation von "TopCONNECTOR" zwingend nötig.

## Ist für "TopCONNECTOR" eine Mitgliedschaft bei TopData nötig?

Nein, die Nutzung von "TopCONNECTOR" ist kostenfrei. Das Plugin steht im Shopware-Community-Store für Sie bereit.

================
File: manual/40-faq.en.md
================
---
title: FAQ
---


## What is the purpose of "TopCONNECTOR"?

"TopCONNECTOR" is the core component of our data plugins and ensures the connection between your Shopware shop and our web service. For the functionality of our data plugins, the prior installation of "TopCONNECTOR" is mandatory.

## Is a TopData membership required for "TopCONNECTOR"?

No, the use of "TopCONNECTOR" is free of charge. The plugin is available for you in the Shopware Community Store.

================
File: manual/50-demo-setup.de.md
================
---
title: Demo-Installation
---

## Demo-Installation von "TopCONNECTOR"

Sie können eine Demoversion von "TopCONNECTOR" installieren, um unsere Daten-Plugins zu testen.

Downloaden Sie die gewünschten Plugins sowie den "TopCONNECTOR" kostenlos im Shopware-Community Store

Installieren Sie "TopCONNECTOR" und die gewünschten Plugins gemäß unserer Installationsanleitung

Richten Sie "TopCONNECTOR" mit folgenden Demo-Zugangsdaten ein:
   * API-User-ID: 6
   * API-Key: nTI9kbsniVWT13Ns
   * API-Salt: oateouq974fpby5t6ldf8glzo85mr9t6aebozrox

Wählen Sie im "TopCONNECTOR" die Mapping-Option: "Standard OEM EAN"

Die Nutzung der Daten-Plugins setzt voraus, dass folgende Drucker und/oder deren Verbrauchsmaterialien mit hinterlegter EAN und/oder OEM Nummer in Ihrem Shop vorhanden sind:
   * HP Deskjet 1000 (CH340B)
   * HP Deskjet 1055
   * HP Deskjet 2000 (CH390A)
   * HP Deskjet 2544 AIO (D3A78B)
   * HP ENVY 4506
   * Samsung CLP 360 (CLP-360/SEE)
   * Samsung CLX 3300
   * Samsung Xpress SL-C 410 W
   * Samsung Xpress C 460 W (SL-C460W/SEE)
   * Canon Pixma IP 3100
   * Epson Stylus SX 438 W (C11CB21305)

================
File: manual/50-demo-setup.en.md
================
---
title: Demo Setup
---

## Demo Installation of "TopCONNECTOR"

You can install a demo version of "TopCONNECTOR" to test our data plugins.

 Download the desired plugins and "TopCONNECTOR" for free from the Shopware Community Store

Install "TopCONNECTOR" and the desired plugins according to our installation guide

Set up "TopCONNECTOR" with the following demo credentials:
   * API User ID: 6
   * API Password: nTI9kbsniVWT13Ns
   * API Security Key: oateouq974fpby5t6ldf8glzo85mr9t6aebozrox

Select the mapping option in "TopCONNECTOR": "Standard OEM EAN"

The use of the data plugins requires that the following printers and/or their consumables with stored EAN and/or OEM number are present in your shop:
   * HP Deskjet 1000 (CH340B)
   * HP Deskjet 1055
   * HP Deskjet 2000 (CH390A)
   * HP Deskjet 2544 AIO (D3A78B)
   * HP ENVY 4506
   * Samsung CLP 360 (CLP-360/SEE)
   * Samsung CLX 3300
   * Samsung Xpress SL-C 410 W
   * Samsung Xpress C 460 W (SL-C460W/SEE)
   * Canon Pixma IP 3100
   * Epson Stylus SX 438 W (C11CB21305)

================
File: manual/index.de.md
================
---
title: Einführung
---

## About

TopCONNECTOR - Dateninterface

"TopCONNECTOR" ermöglicht die bequeme Verbindung zu unserem Webservice und dient somit als Dreh und Angelpunkt für die Nutzung sämtlicher Plugins, die auf Grundlage unserer Produktdaten basieren.

================
File: manual/index.en.md
================
---
title: Introduction
---
## About

TopCONNECTOR - Data Interface

"TopCONNECTOR" enables convenient connection to our web service and thus serves as the central hub for using all plugins that are based on our product data.

================
File: src/Command/Command_Import.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Command;

use Shopware\Core\System\SystemConfig\SystemConfigService;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\LockInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Topdata\TopdataConnectorSW6\Constants\GlobalPluginConstants;
use Topdata\TopdataConnectorSW6\DTO\ImportConfig;
use Topdata\TopdataConnectorSW6\Exception\MissingPluginConfigurationException;
use Topdata\TopdataConnectorSW6\Service\ImportService;
use Topdata\TopdataConnectorSW6\Service\TopdataWebserviceClient;
use Topdata\TopdataConnectorSW6\Util\ImportReport;
use Topdata\TopdataConnectorSW6\Util\UtilProfiling;
use Topdata\TopdataFoundationSW6\Command\AbstractTopdataCommand;
use Topdata\TopdataFoundationSW6\Constants\TopdataJobTypeConstants;
use Topdata\TopdataFoundationSW6\Service\TopdataReportService;
use Topdata\TopdataFoundationSW6\Util\CliLogger;
use Topdata\TopdataFoundationSW6\Util\UtilThrowable;

/**
 * This command imports data from the TopData Webservice.
 * It provides various options to control the import process, such as importing all data,
 * mapping products, importing devices, and updating media and product information.
 */
class Command_Import extends AbstractTopdataCommand
{
    private ?LockInterface $lock = null;

    public function __construct(
        private readonly ImportService           $importService,
        private readonly TopdataReportService    $topdataReportService,
        private readonly SystemConfigService     $systemConfigService,
        private readonly LockFactory             $lockFactory,
        private readonly TopdataWebserviceClient $topdataWebserviceClient,
    )
    {
        parent::__construct();
    }

    /**
     * Retrieves basic report data for the import process.
     *
     * @return array An array containing counters and a user ID.
     */
    private function _getBasicReportData(ImportConfig $importConfig): array
    {
        $pluginConfig = $this->systemConfigService->get('TopdataConnectorSW6.config');

        return [
            'counters'  => ImportReport::getCountersSorted(),
            'profiling' => UtilProfiling::getProfiling(),
            'apiConfig' => [
                'uid'      => $pluginConfig['apiUid'],
                'baseUrl'  => $importConfig->getBaseUrl() ?? $pluginConfig['apiBaseUrl'],
                'language' => $pluginConfig['apiLanguage'],
            ],
        ];
    }

    /**
     * Configures the command with its name, description, and available options.
     */
    protected function configure(): void
    {
        $this->setName('topdata:connector:import');
        $this->setDescription('Import data from the TopData Webservice');
        $this->addOption('all', null, InputOption::VALUE_NONE, 'full update with webservice');
        $this->addOption('mapping', null, InputOption::VALUE_NONE, 'Mapping all existing products to webservice');
        $this->addOption('device', null, InputOption::VALUE_NONE, 'fetch devices from webservice');
        $this->addOption('device-only', null, InputOption::VALUE_NONE, 'fetch devices from webservice (no brands/series/types are fetched);'); // TODO: remove this option
        $this->addOption('product', null, InputOption::VALUE_NONE, 'link devices to products on the store');
        $this->addOption('device-media', null, InputOption::VALUE_NONE, 'update device media data');
        $this->addOption('device-synonyms', null, InputOption::VALUE_NONE, 'link active devices to synonyms');
        $this->addOption('product-info', null, InputOption::VALUE_NONE, 'update product information from webservice (TopFeed plugin needed);');
        $this->addOption('product-media-only', null, InputOption::VALUE_NONE, 'update only product media from webservice (TopFeed plugin needed);');
        $this->addOption('product-variated', null, InputOption::VALUE_NONE, 'Generate variated products based on color and capacity information (Import variants with other colors, Import variants with other capacities should be enabled in TopFeed plugin, product information should be already imported);');
        $this->addOption('experimental-v2', 'x', InputOption::VALUE_NONE, 'switch to use the faster v2 of the connector'); // 04/2025 added
        $this->addOption('product-device', '', InputOption::VALUE_NONE, 'fetch the product device relations from webservice'); // 04/2025 added
        $this->addOption('purge-cache', null, InputOption::VALUE_NONE, 'purge the mapping cache before import'); // 05/2025 added
        $this->addOption('base-url', null, InputOption::VALUE_REQUIRED, 'Override base URL for import operation'); // 06/2025 added
    }

    /**
     * Executes the import command.
     *
     * @param InputInterface $input The input interface.
     * @param OutputInterface $output The output interface.
     *
     * @return int 0 if everything went fine, or an error code.
     *
     * @throws \Throwable
     */
    public function execute(InputInterface $input, OutputInterface $output): int
    {
        // ---- Create lock
        $this->lock = $this->lockFactory->createLock('topdata-connector-import', 3600); // 1 hour TTL

        // ---- Attempt to acquire the lock
        if (!$this->lock->acquire()) {
            CliLogger::warning('The command topdata:connector:import is already running.');

            return Command::SUCCESS; // Exit gracefully
        }

        try {
            // ---- Get the command line (for the report)
            $commandLine = $_SERVER['argv'] ? implode(' ', $_SERVER['argv']) : 'topdata:connector:import';

            // ---- Start the import report
            $this->topdataReportService->newJobReport(TopdataJobTypeConstants::WEBSERVICE_IMPORT, $commandLine);

            // ---- print used credentials (TODO: a nice horizontal table and redact credentials)
//            $pluginConfig = $this->systemConfigService->get('TopdataConnectorSW6.config');
//            CliLogger::dump($pluginConfig);


            try {
                // ---- Create Input Config DTO from cli options
                $importConfig = ImportConfig::createFromCliInput($input);
                // ---- Set base URL if provided as CLI option
                if($importConfig->getBaseUrl()) {
                    $this->topdataWebserviceClient->setBaseUrl($importConfig->getBaseUrl());
                }

                CliLogger::info('Using base URL: ' . $this->topdataWebserviceClient->getBaseUrl());

                // ---- Execute the import service
                $this->importService->execute($importConfig);
                // ---- Mark as succeeded or failed based on the result
                $this->topdataReportService->markAsSucceeded($this->_getBasicReportData($importConfig));

                return Command::SUCCESS;
            } catch (\Throwable $e) {
                // ---- Handle exception and mark as failed
                if ($e instanceof MissingPluginConfigurationException) {
                    CliLogger::warning(GlobalPluginConstants::ERROR_MESSAGE_NO_WEBSERVICE_CREDENTIALS);
                }
                $reportData = $this->_getBasicReportData($importConfig);
                $reportData['error'] = UtilThrowable::toArray($e);
                $this->topdataReportService->markAsFailed($reportData);

                throw $e;
            }
        } finally {
            // ---- Release the lock
            if ($this->lock) {
                $this->lock->release();
                $this->lock = null;
            }
        }
    }

}

================
File: src/Command/Command_LastReport.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Command;

use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Sorting\FieldSorting;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Topdata\TopdataFoundationSW6\Command\AbstractTopdataCommand;
use Topdata\TopdataFoundationSW6\Core\Content\TopdataReport\TopdataReportEntity;
use Topdata\TopdataFoundationSW6\Util\CliLogger;

/**
 * TODO: move to Foundation plugin
 * 
 * Command to display statistics from the last import operation
 */
#[AsCommand(
    name: 'topdata:connector:last-report',
    description: 'Display statistics from the last import operation'
)]
class Command_LastReport extends AbstractTopdataCommand
{
    public function __construct(
        private readonly EntityRepository $topdataReportRepository
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        CliLogger::title('Last Report');

        $criteria = new Criteria();
        $criteria->setLimit(1);
        $criteria->addSorting(new FieldSorting('createdAt', FieldSorting::DESCENDING));

        $result = $this->topdataReportRepository->search($criteria, Context::createDefaultContext());

        if ($result->count() === 0) {
            CliLogger::error('No report found');
            return Command::FAILURE;
        }

        /** @var TopdataReportEntity $report */
        $report = $result->first();

        // Display general report information
        $table = CliLogger::getCliStyle()->createTable();
        $table->setHeaderTitle('Report Information');
        $table->setHeaders(['Property', 'Value']);

        $rows = [
            ['Report ID', $report->getId()],
            ['Job Type', $report->getJobType()],
            ['Job Status', $report->getJobStatus()],
            ['Command Line', $report->getCommandLine()],
            ['PID', $report->getPid() ?? 'N/A'],
            ['Started At', $report->getStartedAt()->format('Y-m-d H:i:s')],
            ['Finished At', $report->getFinishedAt() ? $report->getFinishedAt()->format('Y-m-d H:i:s') : 'Not finished'],
        ];

        $table->setRows($rows);
        $table->render();

        CliLogger::newLine();

        // Display report data as JSON
        CliLogger::title('Report Data');

        $reportData = $report->getReportData();
        if (empty($reportData)) {
            CliLogger::writeln('<yellow>No report data available</yellow>');
        } else {
            $jsonData = json_encode($reportData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
            CliLogger::writeln($jsonData);
        }

        $this->done();

        return Command::SUCCESS;
    }
}

================
File: src/Command/Command_TestConnection.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Command;

use Shopware\Core\System\SystemConfig\SystemConfigService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Topdata\TopdataConnectorSW6\Service\Checks\ConnectionTestService;
use Topdata\TopdataFoundationSW6\Command\AbstractTopdataCommand;
use Topdata\TopdataFoundationSW6\Service\PluginHelperService;
use Topdata\TopdataFoundationSW6\Service\TopConfigRegistry;
use Topdata\TopdataFoundationSW6\Util\CliLogger;
use Topdata\TopdataFoundationSW6\Util\Configuration\UtilToml;

/**
 * Test connection to the TopData webservice.
 */
#[AsCommand(
    name: 'topdata:connector:test-connection',
    description: 'Test connection to the TopData webservice',
)]
class Command_TestConnection extends AbstractTopdataCommand
{
    public function __construct(
        private readonly ConnectionTestService $connectionTestService,
        private readonly SystemConfigService   $systemConfigService, // legacy
        private readonly TopConfigRegistry     $topConfigRegistry, // new
        private readonly PluginHelperService   $pluginHelperService
    )
    {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this->addOption('print-config', 'p', InputOption::VALUE_NONE, 'Print the current configuration and exit');
    }

    /**
     * ==== MAIN ====
     *
     * 11/2024 created
     */
    public function execute(InputInterface $input, OutputInterface $output): int
    {

        // ---- print config and exit
        if ($input->getOption('print-config')) {
            $pluginSystemConfig = $this->systemConfigService->get('TopdataConnectorSW6.config');
            $topConfigToml = UtilToml::flatConfigToToml($this->topConfigRegistry->getTopConfig('TopdataConnectorSW6')->getFlatConfig());
            $topConfigFlat = $this->topConfigRegistry->getTopConfig('TopdataConnectorSW6')->getFlatConfig();
            CliLogger::writeln($topConfigToml);
//            \Topdata\TopdataFoundationSW6\Util\CliLogger::getCliStyle()->dumpDict($pluginSystemConfig, 'TopdataConnectorSW6.config');
//            \Topdata\TopdataFoundationSW6\Util\CliLogger::getCliStyle()->dumpDict($topConfigFlat, 'topConfigFlat(TopdataConnectorSW6)');
            $this->done();
            return Command::SUCCESS;
        }

        // ---- check connection to webservice
        CliLogger::section('Test connection to the TopData webservice');

        CliLogger::writeln('Check plugin is active...');
        if (!$this->pluginHelperService->isWebserviceConnectorPluginAvailable()) {
            CliLogger::error('The TopdataConnectorSW6 plugin is inactive!');
            CliLogger::writeln('Activate the TopdataConnectorSW6 plugin first. Abort.');
            return Command::FAILURE;
        }

        CliLogger::writeln('Testing connection...');
        $result = $this->connectionTestService->testConnection();

        if (!$result['success']) {
            CliLogger::error($result['message']);
            CliLogger::writeln('Abort.');
            return Command::FAILURE;
        }

        CliLogger::success($result['message']);
        $this->done();

        return Command::SUCCESS;
    }
}

================
File: src/Constants/BatchSizeConstants.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Constants;

class BatchSizeConstants
{
    const ENABLE_DEVICES      = 100;
    const ENABLE_BRANDS       = 200;
    const ENABLE_SERIES       = 200;
    const ENABLE_DEVICE_TYPES = 200;
}

================
File: src/Constants/DescriptionImportTypeConstant.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Constants;

/**
 * 03/2025 created
 */
class DescriptionImportTypeConstant
{
    const NO_IMPORT = 'NO_IMPORT';
    const REPLACE   = 'REPLACE';
    const APPEND    = 'APPEND';
    const PREPEND   = 'PREPEND';
    const INJECT    = 'INJECT';
}

================
File: src/Constants/GlobalPluginConstants.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Constants;

/**
 * 11/2024 created
 */
class GlobalPluginConstants
{

    /**
     * List of specifications to ignore during import.
     */
    const IGNORE_SPECS = [
        21  => 'Hersteller-Nr. (intern)',
        24  => 'Product Code (PCD) Intern',
        32  => 'Kurzbeschreibung',
        573 => 'Kurzbeschreibung (statisch)',
        583 => 'Beschreibung (statisch)',
        293 => 'Gattungsbegriff 1',
        294 => 'Gattungsbegriff 2',
        295 => 'Gattungsbegriff 3',
        299 => 'Originalprodukt (J/N)',
        307 => 'Hersteller-Nr. (alt)',
        308 => 'Hersteller-Nr. (Alternative)',
        311 => 'Fake Eintrag',
        340 => 'Automatisch gematched',
        341 => 'Security Code System',
        361 => 'Produktart (Überkompatibilität)',
        367 => 'Product Code (PCD) Alternative',
        368 => 'Produktcode (PCD) alt',
        371 => 'EAN/GTIN 08 (alt)',
        391 => 'MPS Ready',
        22  => 'EAN/GTIN-13 (intern)',
        23  => 'EAN/GTIN-08 (intern)',
        370 => 'EAN/GTIN 13 (alt)',
        372 => 'EAN/GTIN-13 (Alternative)',
        373 => 'EAN/GTIN-08 (Alternative)',
        26  => 'eCl@ss v6.1.0',
        28  => 'unspsc 111201',
        331 => 'eCl@ss v5.1.4',
        332 => 'eCl@ss v6.2.0',
        333 => 'eCl@ss v7.0.0',
        334 => 'eCl@ss v7.1.0',
        335 => 'eCl@ss v8.0.0',
        336 => 'eCl@ss v8.1.0',
        337 => 'eCl@ss v9.0.0',
        721 => 'eCl@ss v9.1.0',
        34  => 'Gruppe Pelikan',
        35  => 'Gruppe Carma',
        36  => 'Gruppe Reuter',
        37  => 'Gruppe Kores',
        38  => 'Gruppe DK',
        39  => 'Gruppe Pelikan (falsch)',
        40  => 'Gruppe USA (Druckwerk)',
        122 => 'Druckwerk',
        8   => 'Leergut',
        30  => 'Marketingtext',
    ];



    // TODO: add clone command:
    //    git clone https://github.com/topdata-software-gmbh/topdata-demo-data-importer-sw6.git
    //    git clone git@github.com:topdata-software-gmbh/topdata-demo-data-importer-sw6.git

    const ERROR_MESSAGE_NO_WEBSERVICE_CREDENTIALS =
        'Missing Webservice Credentials. ' . "\n" .
        'Please fill in the connection parameters in the shop administration: ' . "\n" .
        'Extensions > My Extensions > Topdata Connector > [...] > Configure' . "\n" .
        "\n" .
        'If you are using the Topdata Demo Data Plugin (https://github.com/topdata-software-gmbh/topdata-demo-data-importer-sw6.git), consider running the command `topdata:demo-data-importer:use-webservice-demo-credentials`';
}

================
File: src/Constants/MappingTypeConstants.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Constants;

/**
 * Constants for mapping types.
 *
 * 10/2024 created (extracted from MappingHelperService)
 * 05/2025 updated with cache mapping types
 */
class MappingTypeConstants
{
    // these are the options given to the user to choose from in the plugin config
    const PRODUCT_NUMBER_AS_WS_ID  = 'productNumberAsWsId';
    const DISTRIBUTOR_DEFAULT      = 'distributorDefault';
    const DISTRIBUTOR_CUSTOM       = 'distributorCustom';
    const DISTRIBUTOR_CUSTOM_FIELD = 'distributorCustomField';
    const DEFAULT                  = 'default'; // get ean and eom from product
    const CUSTOM                   = 'custom'; // get ean and eom from product property
    const CUSTOM_FIELD             = 'customField'; // get ean and oem from custom field
    
    // Cache mapping types
    const EAN            = 'EAN';
    const OEM            = 'OEM';
    const PCD            = 'PCD';
    const DISTRIBUTOR    = 'distributor'; // New constant for distributor mappings
    
    // Mapping groups for cache loading/saving
    const EAN_OEM_GROUP  = 'ean_oem_group';
}

================
File: src/Constants/MergedPluginConfigKeyConstants.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Constants;

/**
 * TODO? rename to TopfeedOptionConstants?
 *
 * Constants for option names.
 * // TODO: make it enum
 *
 * 10/2024 created (extracted from MappingHelperService)
 * 04/2025 renamed from OptionConstants to MergedPluginConfigKeyConstants
 */
class MergedPluginConfigKeyConstants
{
    // ---- keys from the connector plugin config ... they are mapping options how to map the webservice data to the shopware data
    const MAPPING_TYPE          = 'mappingType'; // aka MappingStrategy ("default" [EAN, OEM], "distributor", "product number")
    const ATTRIBUTE_OEM         = 'attributeOem'; // this is the Name of the OEM attribute in Shopware ... not sure if still needed in sw6
    const ATTRIBUTE_EAN         = 'attributeEan'; // this is the name of the EAN attribute in Shopware ... not sure if still needed in sw6
    const ATTRIBUTE_ORDERNUMBER = 'attributeOrdernumber'; // FIXME: this is not an ordernumber, but a product number


    // ---- keys from the Topfeed plugin config - used for linking/unlinking products to categories and products to products
    const PRODUCT_WAREGROUPS        = 'productWaregroups'; // allow to import product-category relations
    const PRODUCT_WAREGROUPS_DELETE = 'productWaregroupsDelete'; // allow to delete product-category relations
    const PRODUCT_WAREGROUPS_PARENT = 'productWaregroupsParent'; // something like id of the "root" category?
    const PRODUCT_COLOR_VARIANT     = 'productColorVariant';
    const PRODUCT_CAPACITY_VARIANT  = 'productCapacityVariant'; // unused?

    // moved from MergedPluginConfigHelperService to here:
    const OPTION_NAME_productName           = 'productName';
    const OPTION_NAME_productDescription    = 'productDescription';
    const OPTION_NAME_productBrand          = 'productBrand';
    const OPTION_NAME_productEan            = 'productEan';
    const OPTION_NAME_productOem            = 'productOem';
    const OPTION_NAME_productImages         = 'productImages';
    const OPTION_NAME_specReferencePCD      = 'specReferencePCD';
    const OPTION_NAME_specReferenceOEM      = 'specReferenceOEM';
    const OPTION_NAME_productSpecifications = 'productSpecifications';
    const OPTION_NAME_productImagesDelete   = 'productImagesDelete'; // not used?

    // ---- relationship options from the topfeed plugin config

    const RELATIONSHIP_OPTION_productSimilar         = 'productSimilar';
    const RELATIONSHIP_OPTION_productAlternate       = 'productAlternate';
    const RELATIONSHIO_OPTION_productRelated         = 'productRelated';
    const RELATIONSHIP_OPTION_productBundled         = 'productBundled';
    const RELATIONSHIP_OPTION_productColorVariant    = 'productColorVariant';
    const RELATIONSHIP_OPTION_productCapacityVariant = 'productCapacityVariant';
    const RELATIONSHIP_OPTION_productVariant         = 'productVariant';
    // ---- cross-selling options from the topfeed plugin config [whether cross-selling is enabled]
    const OPTION_NAME_productSimilarCross         = 'productSimilarCross';
    const OPTION_NAME_productAlternateCross       = 'productAlternateCross';
    const OPTION_NAME_productRelatedCross         = 'productRelatedCross';
    const OPTION_NAME_productBundledCross         = 'productBundledCross';
    const OPTION_NAME_productVariantColorCross    = 'productVariantColorCross';
    const OPTION_NAME_productVariantCapacityCross = 'productVariantCapacityCross';
    const OPTION_NAME_productVariantCross         = 'productVariantCross';

    // ---- display options from the topfeed plugin config
    const DISPLAY_OPTION_showAlternateProductsTab       = 'showAlternateProductsTab';
    const DISPLAY_OPTION_showBundledProductsTab         = 'showBundledProductsTab';
    const DISPLAY_OPTION_showBundlesTab                 = 'showBundlesTab';
    const DISPLAY_OPTION_showRelatedProductsTab         = 'showRelatedProductsTab';
    const DISPLAY_OPTION_showSimilarProductsTab         = 'showSimilarProductsTab';
    const DISPLAY_OPTION_showVariantProductsTab         = 'showVariantProductsTab';
    const DISPLAY_OPTION_showColorVariantProductsTab    = 'showColorVariantProductsTab';
    const DISPLAY_OPTION_showCapacityVariantProductsTab = 'showCapacityVariantProductsTab';

    // ---- list options from the topfeed plugin config
    const LIST_OPTION_listColorVariantProducts    = 'listColorVariantProducts';
    const LIST_OPTION_listCapacityVariantProducts = 'listCapacityVariantProducts';
    const LIST_OPTION_listAlternateProducts       = 'listAlternateProducts';
    const LIST_OPTION_listBundledProducts         = 'listBundledProducts';
    const LIST_OPTION_listBundles                 = 'listBundles';
    const LIST_OPTION_listRelatedProducts         = 'listRelatedProducts';
    const LIST_OPTION_listVariantProducts         = 'listVariantProducts';
}

================
File: src/Constants/WebserviceFilterTypeConstants.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Constants;

/**
 * 11/2024 created
 */
class WebserviceFilterTypeConstants
{
    const product_application_in = 'product_application_in';
    const all                    = 'all';
}

================
File: src/Controller/Admin/TopdataWebserviceConnectorAdminApiController.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Controller\Admin;

use Psr\Log\LoggerInterface;
use Shopware\Core\Framework\Context;
use Shopware\Core\System\SystemConfig\SystemConfigService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ParameterBag\ContainerBagInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Topdata\TopdataConnectorSW6\Constants\GlobalPluginConstants;
use Topdata\TopdataConnectorSW6\Service\Checks\ConfigCheckerService;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataBrandService;
use Topdata\TopdataConnectorSW6\Service\TopdataWebserviceClient;

/**
 * 10/2024 renamed TopdataConnectorController --> TopdataWebserviceConnectorAdminApiController
 */
#[Route(defaults: ['_routeScope' => ['administration']])]
class TopdataWebserviceConnectorAdminApiController extends AbstractController
{



    public function __construct(
        private readonly SystemConfigService     $systemConfigService,
        private readonly LoggerInterface         $logger,
        private readonly ContainerBagInterface   $containerBag,
        private readonly ConfigCheckerService    $configCheckerService,
        private readonly TopdataBrandService     $topdataBrandService,
        private readonly TopdataWebserviceClient $topdataWebserviceClient,
    )
    {
    }

    /**
     * Test the connector configuration.
     */
    #[Route(
        path: '/api/topdata/connector-test',
        name: 'api.action.topdata.connector-test',
        methods: ['GET']
    )]
    public function connectorTestAction(): JsonResponse
    {
        $additionalData = '';
        if ($this->configCheckerService->isConfigEmpty()) {
            $credentialsValid = 'no';
            $additionalData .= GlobalPluginConstants::ERROR_MESSAGE_NO_WEBSERVICE_CREDENTIALS;
        }

        try {
            $info = $this->topdataWebserviceClient->getUserInfo();

            if (isset($info->error)) {
                $credentialsValid = 'no';
                $additionalData .= $info->error[0]->error_message;
            } else {
                $credentialsValid = 'yes';
                $additionalData = $info;
            }
        } catch (\Throwable $e) {
            $errorMessage = $e->getMessage();
            $this->logger->error($errorMessage);
            $credentialsValid = 'no';
            $additionalData .= $errorMessage;
        }

        return new JsonResponse([
            'credentialsValid' => $credentialsValid,
            'additionalData'   => $additionalData,
        ]);
    }

    /**
     * Load all enabled brands.
     */
    #[Route(
        path: '/api/topdata/load-brands',
        name: 'api.action.topdata.load-brands',
        methods: ['GET']
    )]
    public function loadBrands(Request $request, Context $context): JsonResponse
    {
        $result = $this->topdataBrandService->getEnabledBrands();
        $result['additionalData'] = 'success';

        return new JsonResponse($result);
    }

    /**
     * Save primary brands.
     */
    #[Route(
        path: '/api/topdata/save-primary-brands',
        name: 'api.action.topdata.save-primary-brands',
        methods: ['POST']
    )]
    public function savePrimaryBrands(Request $request, Context $context): JsonResponse
    {
        $params = $request->request->all();
        $success = $this->topdataBrandService->savePrimaryBrands($params['primaryBrands'] ?? null);

        return new JsonResponse([
            'success' => $success ? 'true' : 'false',
        ]);
    }

    /**
     * Get active Topdata plugins.
     * TODO: move this into a service in the TopdataControlCenterSW6 plugin
     * TODO: remove additionalData (it is just an empty string)
     */
    #[Route(
        path: '/api/topdata/connector-plugins',
        name: 'api.action.topdata.connector-plugins',
        methods: ['GET']
    )]
    public function activeTopdataPlugins(Request $request, Context $context): JsonResponse
    {
        $activePlugins = [];
        $additionalData = '';

        $allActivePlugins = $this->containerBag->get('kernel.active_plugins');

        foreach ($allActivePlugins as $pluginClassName => $val) {
            $pluginClass = explode('\\', $pluginClassName);
            if ($pluginClass[0] == 'Topdata') {
                $activePlugins[] = array_pop($pluginClass);
            }
        }

        return new JsonResponse([
            'activePlugins'  => $activePlugins,
            'additionalData' => $additionalData,
        ]);
    }

    /**
     * Get the plugin's config.
     * TODO: rename .. it returns more than just the credentials
     */
    #[Route(
        path: '/api/_action/connector/connector-credentials-get',
        name: 'api.action.connector.connector.credentials.get',
        methods: ['GET']
    )]
    public function getCredentialsAction(): JsonResponse
    {
        $config = $this->systemConfigService->get('TopdataConnectorSW6.config');

        return new JsonResponse($config);
    }
}

================
File: src/Core/Content/Brand/BrandCollection.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Brand;

use Shopware\Core\Framework\DataAbstractionLayer\EntityCollection;

/**
 * @method void             add(BrandEntity $entity)
 * @method void             set(string $key, BrandEntity $entity)
 * @method BrandEntity[]    getIterator()
 * @method BrandEntity[]    getElements()
 * @method BrandEntity|null get(string $key)
 * @method BrandEntity|null first()
 * @method BrandEntity|null last()
 */
class BrandCollection extends EntityCollection
{
    protected function getExpectedClass(): string
    {
        return BrandEntity::class;
    }
}

================
File: src/Core/Content/Brand/BrandDefinition.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Brand;

use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\BoolField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\PrimaryKey;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FloatField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\IdField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\IntField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\StringField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;

class BrandDefinition extends EntityDefinition
{
    public const ENTITY_NAME = 'topdata_brand';

    public function getEntityName(): string
    {
        return self::ENTITY_NAME;
    }

    public function getEntityClass(): string
    {
        return BrandEntity::class;
    }

    public function getCollectionClass(): string
    {
        return BrandCollection::class;
    }

    protected function defineFields(): FieldCollection
    {
        return new FieldCollection([
            (new IdField('id', 'id'))->addFlags(new Required(), new PrimaryKey()),
            (new StringField('code', 'code'))->addFlags(new Required()),
            (new StringField('label', 'name'))->addFlags(new Required()),
            (new BoolField('is_enabled', 'enabled'))->addFlags(new Required()),
            (new IntField('sort', 'sort'))->addFlags(new Required()),
            (new IntField('ws_id', 'wsId'))->addFlags(new Required()),

        ]);
    }
}

================
File: src/Core/Content/Brand/BrandEntity.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Brand;

use Shopware\Core\Framework\DataAbstractionLayer\Entity;
use Shopware\Core\Framework\DataAbstractionLayer\EntityIdTrait;

class BrandEntity extends Entity
{
    use EntityIdTrait;

    /**
     * @var string
     */
    protected $code;

    /**
     * @var string
     */
    protected $name;

    /**
     * @var bool
     */
    protected $enabled = '0';

    /**
     * @var int
     */
    protected $sort = '0';

    /**
     * @var int
     */
    protected $wsId;

    public function getCode(): string
    {
        return $this->code;
    }

    public function setCode(string $code): void
    {
        $this->code = $code;
    }

    public function isEnabled(): bool
    {
        return $this->enabled;
    }

    public function setEnabled(bool $enabled): void
    {
        $this->enabled = $enabled;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function setName(string $name): void
    {
        $this->name = $name;
    }

    public function getSort(): int
    {
        return $this->sort;
    }

    public function setSort(int $sort): void
    {
        $this->sort = $sort;
    }

    public function getWsId(): int
    {
        return $this->wsId;
    }

    public function setWsId(int $wsId): void
    {
        $this->wsId = $wsId;
    }
}

================
File: src/Core/Content/Category/TopdataCategoryExtension/TopdataCategoryExtensionDefinition.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Category\TopdataCategoryExtension;

use Shopware\Core\Content\Category\CategoryDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\BoolField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\PrimaryKey;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required;
use Shopware\Core\Framework\DataAbstractionLayer\Field\IdField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\JsonField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;

class TopdataCategoryExtensionDefinition extends EntityDefinition
{
    public const ENTITY_NAME = 'topdata_category_extension';

    public function getEntityName(): string
    {
        return self::ENTITY_NAME;
    }

    public function getEntityClass(): string
    {
        return TopdataCategoryExtensionEntity::class;
    }

    protected function defineFields(): FieldCollection
    {
        return new FieldCollection([
            (new IdField('id', 'id'))->addFlags(new Required(), new PrimaryKey()),
            new FkField('category_id', 'categoryId', CategoryDefinition::class),
            new BoolField('plugin_settings', 'pluginSettings'),
            new JsonField('import_settings', 'importSettings'),

            new OneToOneAssociationField('category', 'category_id', 'id', CategoryDefinition::class, false),
        ]);
    }
}

================
File: src/Core/Content/Category/TopdataCategoryExtension/TopdataCategoryExtensionEntity.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Category\TopdataCategoryExtension;

use Shopware\Core\Framework\DataAbstractionLayer\Entity;

class TopdataCategoryExtensionEntity extends Entity
{
}

================
File: src/Core/Content/Category/CategoryExtension.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Category;

use Shopware\Core\Content\Category\CategoryDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\EntityExtension;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Inherited;
use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
use Topdata\TopdataConnectorSW6\Core\Content\Category\TopdataCategoryExtension\TopdataCategoryExtensionDefinition;

class CategoryExtension extends EntityExtension
{
    public function extendFields(FieldCollection $collection): void
    {
        $collection->add(
            (new OneToOneAssociationField(
                'topdataCategoryExtension',
                'id',
                'category_id',
                TopdataCategoryExtensionDefinition::class,
                false
            ))->addFlags(new Inherited())
        );
    }

    public function getDefinitionClass(): string
    {
        return CategoryDefinition::class;
    }
}

================
File: src/Core/Content/Customer/CustomerExtension.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Customer;

use Shopware\Core\Checkout\Customer\CustomerDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\EntityExtension;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Inherited;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToManyAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
use Topdata\TopdataConnectorSW6\Core\Content\Device\Agregate\DeviceCustomer\DeviceCustomerDefinition;
use Topdata\TopdataConnectorSW6\Core\Content\Device\DeviceDefinition;

class CustomerExtension extends EntityExtension
{
    public function extendFields(FieldCollection $collection): void
    {
        $collection->add(
            (new ManyToManyAssociationField(
                'devices',
                DeviceDefinition::class,
                DeviceCustomerDefinition::class,
                'customer_id',
                'device_id'
            ))->addFlags(new Inherited())
        );
    }

    public function getDefinitionClass(): string
    {
        return CustomerDefinition::class;
    }
}

================
File: src/Core/Content/Device/Agregate/DeviceCustomer/DeviceCustomerCollection.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Device\Agregate\DeviceCustomer;

use Shopware\Core\Framework\DataAbstractionLayer\EntityCollection;

/**
 * @method void              add(DeviceEntity $entity)
 * @method void              set(string $key, DeviceEntity $entity)
 * @method DeviceEntity[]    getIterator()
 * @method DeviceEntity[]    getElements()
 * @method DeviceEntity|null get(string $key)
 * @method DeviceEntity|null first()
 * @method DeviceEntity|null last()
 */
class DeviceCustomerCollection extends EntityCollection
{
    protected function getExpectedClass(): string
    {
        return DeviceCustomerEntity::class;
    }
}

================
File: src/Core/Content/Device/Agregate/DeviceCustomer/DeviceCustomerDefinition.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Device\Agregate\DeviceCustomer;

use Shopware\Core\Checkout\Customer\CustomerDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\BoolField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\CreatedAtField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\PrimaryKey;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required;
use Shopware\Core\Framework\DataAbstractionLayer\Field\IdField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\LongTextField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\StringField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
use Shopware\Core\Framework\DataAbstractionLayer\MappingEntityDefinition;
use Shopware\Core\System\User\UserDefinition;
use Topdata\TopdataConnectorSW6\Core\Content\Device\DeviceDefinition;

class DeviceCustomerDefinition extends EntityDefinition
{
    public const ENTITY_NAME = 'topdata_device_to_customer';

    public function getEntityName(): string
    {
        return self::ENTITY_NAME;
    }

    public function getEntityClass(): string
    {
        return DeviceCustomerEntity::class;
    }

    public function getCollectionClass(): string
    {
        return DeviceCustomerCollection::class;
    }

    protected function defineFields(): FieldCollection
    {
        return new FieldCollection([
            (new IdField('id', 'id'))->addFlags(new Required(), new PrimaryKey()),
            (new FkField('device_id', 'deviceId', DeviceDefinition::class))->addFlags(new Required()),
            new FkField('customer_id', 'customerId', CustomerDefinition::class),
            new IdField('customer_extra_id', 'customerExtraId'),
            (new LongTextField('extra_info', 'extraInfo')),
            new BoolField('is_dealer_managed', 'isDealerManaged'),

            new ManyToOneAssociationField('device', 'device_id', DeviceDefinition::class),
            new ManyToOneAssociationField('customer', 'customer_id', CustomerDefinition::class),
            new CreatedAtField(),
        ]);
    }
}

================
File: src/Core/Content/Device/Agregate/DeviceCustomer/DeviceCustomerEntity.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Device\Agregate\DeviceCustomer;

use Shopware\Core\Checkout\Customer\CustomerEntity;
use Shopware\Core\Content\Media\MediaEntity;
use Shopware\Core\Framework\DataAbstractionLayer\Entity;
use Shopware\Core\Framework\DataAbstractionLayer\EntityIdTrait;
use Topdata\TopdataConnectorSW6\Core\Content\Device\DeviceEntity;

class DeviceCustomerEntity extends Entity
{
    use EntityIdTrait;

    public const DEVICES         = 'devices';
    public const DEVICE_NAME     = 'name';
    public const DEVICE_NUMBER   = 'number';
    public const DEVICE_LOCATION = 'location';
    public const USER            = 'user';
    public const DEVICE_NOTES    = 'notes';
    public const DEVICE_TIME     = 'datetime';

    /**
     * @var string
     */
    protected $deviceId;

    /**
     * @var string
     */
    protected $customerId;

    /**
     * @var string|null
     */
    protected $extraInfo;

    protected $_extraInfo = null;

    /**
     * @var DeviceEntity
     */
    protected $device;

    /**
     * @var CustomerEntity
     */
    protected $customer;

    /**
     * @var array|null
     */
    protected $info = null;

    /**
     * @var string|null
     */
    protected $customerExtraId;

    public function getDeviceId(): string
    {
        return $this->deviceId;
    }

    public function setDeviceId(string $deviceId): void
    {
        $this->deviceId = $deviceId;
    }

    public function getCustomer(): CustomerEntity
    {
        return $this->customer;
    }

    public function setCustomer(CustomerEntity $customer): void
    {
        $this->customer = $customer;
    }

    public function getDevice(): DeviceEntity
    {
        return $this->device;
    }

    public function setDevice(DeviceEntity $device): void
    {
        $this->device = $device;
    }

    public function getExtraInfo(): array
    {
        if ($this->_extraInfo === null) {
            $this->_extraInfo = static::defaultExtraInfo();
            if ($this->extraInfo !== null) {
                $this->_extraInfo = json_decode($this->extraInfo, true);
            }
        }

        return $this->_extraInfo;
    }

    public function setExtraInfo(array $extraInfo): void
    {
        $this->_extraInfo = $extraInfo;
        $this->extraInfo = json_encode($extraInfo);
    }

    /**
     * Generates a default extra information array for devices.
     * this data is stored as json in the extra_info field.
     *
     * @param int $amount The number of devices to include in the array. Defaults to 0.
     *                    If a negative number is provided, it will be treated as 0.
     * @return array The default extra information array containing device details.
     */
    public static function defaultExtraInfo($amount = 0): array
    {
        $amount = (int)$amount;
        if ($amount < 0) {
            $amount = 0;
        }
        $return = [static::DEVICES => []];
        for ($i = 1; $i <= $amount; $i++) {
            $return[static::DEVICES][] = [
                static::DEVICE_NAME     => 'Device ' . $i,
                static::DEVICE_NUMBER   => '',
                static::DEVICE_LOCATION => '',
                static::USER            => '',
                static::DEVICE_NOTES    => '',
                static::DEVICE_TIME     => date('Y-m-d H:i:s'),
            ];
        }

        return $return;
    }

    public function getCustomerExtraId(): ?string
    {
        return $this->customerExtraId;
    }

    public function setCustomerExtraId(?string $id): void
    {
        $this->customerExtraId = $id;
    }
}

================
File: src/Core/Content/Device/Agregate/DeviceProduct/DeviceProductDefinition.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Device\Agregate\DeviceProduct;

use Shopware\Core\Content\Product\ProductDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\CreatedAtField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\PrimaryKey;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ReferenceVersionField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
use Shopware\Core\Framework\DataAbstractionLayer\MappingEntityDefinition;
use Topdata\TopdataConnectorSW6\Core\Content\Device\DeviceDefinition;

class DeviceProductDefinition extends MappingEntityDefinition
{
    public function getEntityName(): string
    {
        return 'topdata_device_to_product';
    }

    protected function defineFields(): FieldCollection
    {
        return new FieldCollection([
            (new FkField('device_id', 'deviceId', DeviceDefinition::class))->addFlags(new PrimaryKey(), new Required()),
            (new FkField('product_id', 'productId', ProductDefinition::class))->addFlags(new PrimaryKey(), new Required()),
            (new ReferenceVersionField(ProductDefinition::class, 'product_version_id'))->addFlags(new PrimaryKey(), new Required()),
            new ManyToOneAssociationField('device', 'device_id', DeviceDefinition::class),
            new ManyToOneAssociationField('product', 'product_id', ProductDefinition::class),
            new CreatedAtField(),
        ]);
    }
}

================
File: src/Core/Content/Device/Agregate/DeviceSynonym/DeviceSynonymDefinition.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Device\Agregate\DeviceSynonym;

use Shopware\Core\Framework\DataAbstractionLayer\Field\CreatedAtField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\PrimaryKey;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
use Shopware\Core\Framework\DataAbstractionLayer\MappingEntityDefinition;
use Topdata\TopdataConnectorSW6\Core\Content\Device\DeviceDefinition;

class DeviceSynonymDefinition extends MappingEntityDefinition
{
    public function getEntityName(): string
    {
        return 'topdata_device_to_synonym';
    }

    protected function defineFields(): FieldCollection
    {
        return new FieldCollection([
            (new FkField('device_id', 'deviceId', DeviceDefinition::class))->addFlags(new PrimaryKey(), new Required()),
            (new FkField('synonym_id', 'synonymId', DeviceDefinition::class))->addFlags(new PrimaryKey(), new Required()),
            new ManyToOneAssociationField('device', 'device_id', DeviceDefinition::class),
            new ManyToOneAssociationField('synonym', 'synonym_id', DeviceDefinition::class),
            new CreatedAtField(),
        ]);
    }
}

================
File: src/Core/Content/Device/DeviceCollection.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Device;

use Shopware\Core\Framework\DataAbstractionLayer\EntityCollection;

/**
 * @method void              add(DeviceEntity $entity)
 * @method void              set(string $key, DeviceEntity $entity)
 * @method DeviceEntity[]    getIterator()
 * @method DeviceEntity[]    getElements()
 * @method DeviceEntity|null get(string $key)
 * @method DeviceEntity|null first()
 * @method DeviceEntity|null last()
 */
class DeviceCollection extends EntityCollection
{
    protected function getExpectedClass(): string
    {
        return DeviceEntity::class;
    }
}

================
File: src/Core/Content/Device/DeviceDefinition.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Device;

use Shopware\Core\Checkout\Customer\CustomerDefinition;
use Shopware\Core\Content\Media\MediaDefinition;
use Shopware\Core\Content\Product\ProductDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\BoolField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\PrimaryKey;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FloatField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\IdField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\IntField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToManyAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToManyAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\StringField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
use Topdata\TopdataConnectorSW6\Core\Content\Brand\BrandDefinition;
use Topdata\TopdataConnectorSW6\Core\Content\Device\Agregate\DeviceCustomer\DeviceCustomerDefinition;
use Topdata\TopdataConnectorSW6\Core\Content\Device\Agregate\DeviceProduct\DeviceProductDefinition;
use Topdata\TopdataConnectorSW6\Core\Content\DeviceType\DeviceTypeDefinition;
use Topdata\TopdataConnectorSW6\Core\Content\Series\SeriesDefinition;

class DeviceDefinition extends EntityDefinition
{
    public const ENTITY_NAME = 'topdata_device';

    public function getEntityName(): string
    {
        return self::ENTITY_NAME;
    }

    public function getEntityClass(): string
    {
        return DeviceEntity::class;
    }

    public function getCollectionClass(): string
    {
        return DeviceCollection::class;
    }

    protected function defineFields(): FieldCollection
    {
        return new FieldCollection([
            (new IdField('id', 'id'))->addFlags(new Required(), new PrimaryKey()),
            (new FkField('brand_id', 'brandId', BrandDefinition::class)),
            (new FkField('type_id', 'typeId', DeviceTypeDefinition::class)),
            (new FkField('series_id', 'seriesId', SeriesDefinition::class)),
            (new BoolField('is_enabled', 'enabled'))->addFlags(new Required()),
            (new BoolField('has_synonyms', 'hasSynonyms')),
            (new StringField('code', 'code'))->addFlags(new Required()),
            (new StringField('model', 'model'))->addFlags(new Required()),
            (new StringField('keywords', 'keywords'))->addFlags(new Required()),
            (new IntField('sort', 'sort'))->addFlags(new Required()),
            (new FkField('media_id', 'mediaId', MediaDefinition::class)),
            //            (new IntField('media_id', 'mediaId')),
            (new IntField('ws_id', 'wsId'))->addFlags(new Required()),
            new ManyToOneAssociationField('brand', 'brand_id', BrandDefinition::class),
            new ManyToOneAssociationField('type', 'type_id', DeviceTypeDefinition::class),
            new ManyToOneAssociationField('series', 'series_id', SeriesDefinition::class),
            new ManyToManyAssociationField('products', ProductDefinition::class, DeviceProductDefinition::class, 'device_id', 'product_id'),
            new ManyToManyAssociationField('customers', CustomerDefinition::class, DeviceCustomerDefinition::class, 'device_id', 'customer_id'),
            new ManyToOneAssociationField('media', 'media_id', MediaDefinition::class),
        ]);
    }
}

================
File: src/Core/Content/Device/DeviceEntity.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Device;

use Shopware\Core\Checkout\Customer\CustomerCollection;
use Shopware\Core\Content\Media\MediaEntity;
use Shopware\Core\Content\Product\ProductCollection;
use Shopware\Core\Framework\DataAbstractionLayer\Entity;
use Shopware\Core\Framework\DataAbstractionLayer\EntityIdTrait;
use Topdata\TopdataConnectorSW6\Core\Content\Brand\BrandEntity;
use Topdata\TopdataConnectorSW6\Core\Content\DeviceType\DeviceTypeEntity;
use Topdata\TopdataConnectorSW6\Core\Content\Series\SeriesEntity;

class DeviceEntity extends Entity
{
    use EntityIdTrait;

    /**
     * @var bool
     */
    protected $enabled;

    /**
     * @var bool
     */
    protected $hasSynonyms;

    /**
     * @var bool
     */
    protected $inDeviceList;

    /**
     * @var BrandEntity
     */
    protected $brand;

    /**
     * @var string
     */
    protected $brandId;

    /**
     * @var string
     */
    protected $code;

    /**
     * @var DeviceTypeEntity
     */
    protected $type;

    /**
     * @var string
     */
    protected $typeId;

    /**
     * @var string
     */
    protected $model;

    /**
     * @var string
     */
    protected $keywords = '';

    /**
     * @var SeriesEntity|null
     */
    protected $series;

    /**
     * @var string
     */
    protected $seriesId;

    /**
     * @var bool
     */
    protected $sort = '0';

    /**
     * @var MediaEntity|null
     */
    protected $media;

    /**
     * @var int
     */
    protected $mediaId;

    /**
     * @var ProductCollection|null
     */
    protected $products;

    /**
     * @var CustomerCollection|null
     */
    protected $customers;

    /**
     * @var int
     */
    protected $wsId;

    public function getCode(): string
    {
        return $this->code;
    }

    public function setCode(string $code): void
    {
        $this->code = $code;
    }

    public function getModel(): string
    {
        return $this->model;
    }

    public function setModel(string $model): void
    {
        $this->model = $model;
    }

    public function getEnabled(): bool
    {
        return $this->enabled;
    }

    public function setEnabled(bool $enabled): void
    {
        $this->enabled = $enabled;
    }

    public function getHasSynonyms(): bool
    {
        return $this->hasSynonyms;
    }

    public function setHasSynonyms(bool $hasSynonyms): void
    {
        $this->hasSynonyms = $hasSynonyms;
    }

    public function getKeywords(): string
    {
        return $this->keywords;
    }

    public function setKeywords(string $keywords): void
    {
        $this->keywords = $keywords;
    }

    public function getSort(): int
    {
        return $this->sort;
    }

    public function setSort(int $sort): void
    {
        $this->sort = $sort;
    }

    public function getWsId(): int
    {
        return $this->wsId;
    }

    public function setWsId(int $wsId): void
    {
        $this->wsId = $wsId;
    }

    public function getBrandId(): ?string
    {
        return $this->brandId;
    }

    public function setBrandId(string $id): void
    {
        $this->brandId = $id;
    }

    public function getTypeId(): ?string
    {
        return $this->typeId;
    }

    public function setTypeId(string $id): void
    {
        $this->typeId = $id;
    }

    public function getSeriesId(): ?string
    {
        return $this->seriesId;
    }

    public function setSeriesId(string $id): void
    {
        $this->seriesId = $id;
    }

    public function getProducts(): ?ProductCollection
    {
        return $this->products;
    }

    public function setProducts(ProductCollection $products): void
    {
        $this->products = $products;
    }

    public function getCustomers(): ?CustomerCollection
    {
        return $this->customers;
    }

    public function setCustomers(CustomerCollection $customers): void
    {
        $this->customers = $customers;
    }

    public function getMedia(): ?MediaEntity
    {
        return $this->media;
    }

    public function setMedia(MediaEntity $media): void
    {
        $this->media = $media;
    }

    public function getBrand(): ?BrandEntity
    {
        return $this->brand;
    }

    public function setBrand(BrandEntity $brand): void
    {
        $this->brand = $brand;
    }

    public function getSeries(): ?SeriesEntity
    {
        return $this->series;
    }

    public function setSeries(SeriesEntity $series): void
    {
        $this->series = $series;
    }

    public function getType(): ?DeviceTypeEntity
    {
        return $this->type;
    }

    public function setType(DeviceTypeEntity $type): void
    {
        $this->type = $type;
    }

    public function getInDeviceList(): bool
    {
        return $this->inDeviceList;
    }

    public function setInDeviceList(bool $inDeviceList): void
    {
        $this->inDeviceList = $inDeviceList;
    }
}

================
File: src/Core/Content/DeviceType/DeviceTypeCollection.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\DeviceType;

use Shopware\Core\Framework\DataAbstractionLayer\EntityCollection;

/**
 * @method void                  add(DeviceTypeEntity $entity)
 * @method void                  set(string $key, DeviceTypeEntity $entity)
 * @method DeviceTypeEntity[]    getIterator()
 * @method DeviceTypeEntity[]    getElements()
 * @method DeviceTypeEntity|null get(string $key)
 * @method DeviceTypeEntity|null first()
 * @method DeviceTypeEntity|null last()
 */
class DeviceTypeCollection extends EntityCollection
{
    protected function getExpectedClass(): string
    {
        return DeviceTypeEntity::class;
    }
}

================
File: src/Core/Content/DeviceType/DeviceTypeDefinition.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\DeviceType;

use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\BoolField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\PrimaryKey;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FloatField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\IdField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\IntField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\StringField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
use Topdata\TopdataConnectorSW6\Core\Content\Brand\BrandDefinition;

class DeviceTypeDefinition extends EntityDefinition
{
    public const ENTITY_NAME = 'topdata_device_type';

    public function getEntityName(): string
    {
        return self::ENTITY_NAME;
    }

    public function getEntityClass(): string
    {
        return DeviceTypeEntity::class;
    }

    public function getCollectionClass(): string
    {
        return DeviceTypeCollection::class;
    }

    protected function defineFields(): FieldCollection
    {
        return new FieldCollection([
            (new IdField('id', 'id'))->addFlags(new Required(), new PrimaryKey()),
            (new FkField('brand_id', 'brandId', BrandDefinition::class)),
            (new StringField('code', 'code'))->addFlags(new Required()),
            (new BoolField('is_enabled', 'enabled'))->addFlags(new Required()),
            (new StringField('label', 'label'))->addFlags(new Required()),
            (new IntField('sort', 'sort'))->addFlags(new Required()),
            (new IntField('ws_id', 'wsId'))->addFlags(new Required()),
            new ManyToOneAssociationField('brand', 'brand_id', BrandDefinition::class),
        ]);
    }
}

================
File: src/Core/Content/DeviceType/DeviceTypeEntity.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\DeviceType;

use Shopware\Core\Framework\DataAbstractionLayer\Entity;
use Shopware\Core\Framework\DataAbstractionLayer\EntityIdTrait;
use Topdata\TopdataConnectorSW6\Core\Content\Brand\BrandEntity;

class DeviceTypeEntity extends Entity
{
    use EntityIdTrait;

    /**
     * @var string
     */
    protected $code;

    /**
     * @var BrandEntity
     */
    protected $brand;

    /**
     * @var string
     */
    protected $brandId;

    /**
     * @var bool
     */
    protected $enabled = '0';

    /**
     * @var string
     */
    protected $label;

    /**
     * @var bool
     */
    protected $sort = '0';

    /**
     * @var int
     */
    protected $wsId;

    public function getEnabled(): bool
    {
        return $this->enabled;
    }

    public function setEnabled(bool $enabled): void
    {
        $this->enabled = $enabled;
    }

    public function getSort(): int
    {
        return $this->sort;
    }

    public function setSort(int $sort): void
    {
        $this->sort = $sort;
    }

    public function getWsId(): int
    {
        return $this->wsId;
    }

    public function setWsId(int $wsId): void
    {
        $this->wsId = $wsId;
    }

    public function getLabel(): string
    {
        return $this->label;
    }

    public function setLabel(string $label): void
    {
        $this->label = $label;
    }

    public function getCode(): string
    {
        return $this->code;
    }

    public function setCode(string $code): void
    {
        $this->code = $code;
    }

    public function getBrand(): ?BrandEntity
    {
        return $this->brand;
    }

    public function setBrand(BrandEntity $brand): void
    {
        $this->brand = $brand;
    }

    public function getBrandId(): ?string
    {
        return $this->brandId;
    }

    public function setBrandId(string $id): void
    {
        $this->brandId = $id;
    }
}

================
File: src/Core/Content/Product/Agregate/Alternate/ProductAlternateDefinition.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\Alternate;

use Shopware\Core\Content\Product\ProductDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\CreatedAtField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\PrimaryKey;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ReferenceVersionField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
use Shopware\Core\Framework\DataAbstractionLayer\MappingEntityDefinition;

class ProductAlternateDefinition extends MappingEntityDefinition
{
    public function getEntityName(): string
    {
        return 'topdata_product_to_alternate';
    }

    protected function defineFields(): FieldCollection
    {
        return new FieldCollection([
            (new FkField('product_id', 'productId', ProductDefinition::class))->addFlags(new PrimaryKey(), new Required()),
            (new ReferenceVersionField(ProductDefinition::class, 'product_version_id'))->addFlags(new PrimaryKey(), new Required()),
            (new FkField('alternate_product_id', 'alternateProductId', ProductDefinition::class))->addFlags(new PrimaryKey(), new Required()),
            (new ReferenceVersionField(ProductDefinition::class, 'alternate_product_version_id'))->addFlags(new PrimaryKey(), new Required()),
            new ManyToOneAssociationField('product', 'product_id', ProductDefinition::class),
            new ManyToOneAssociationField('alternate_product', 'alternate_product_id', ProductDefinition::class),
            new CreatedAtField(),
        ]);
    }
}

================
File: src/Core/Content/Product/Agregate/Bundled/ProductBundledDefinition.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\Bundled;

use Shopware\Core\Content\Product\ProductDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\CreatedAtField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\PrimaryKey;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ReferenceVersionField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
use Shopware\Core\Framework\DataAbstractionLayer\MappingEntityDefinition;

class ProductBundledDefinition extends MappingEntityDefinition
{
    public function getEntityName(): string
    {
        return 'topdata_product_to_bundled';
    }

    protected function defineFields(): FieldCollection
    {
        return new FieldCollection([
            (new FkField('product_id', 'productId', ProductDefinition::class))->addFlags(new PrimaryKey(), new Required()),
            (new ReferenceVersionField(ProductDefinition::class, 'product_version_id'))->addFlags(new PrimaryKey(), new Required()),
            (new FkField('bundled_product_id', 'bundledProductId', ProductDefinition::class))->addFlags(new PrimaryKey(), new Required()),
            (new ReferenceVersionField(ProductDefinition::class, 'bundled_product_version_id'))->addFlags(new PrimaryKey(), new Required()),
            new ManyToOneAssociationField('product', 'product_id', ProductDefinition::class),
            new ManyToOneAssociationField('bundled_product', 'bundled_product_id', ProductDefinition::class),
            new CreatedAtField(),
        ]);
    }
}

================
File: src/Core/Content/Product/Agregate/CapacityVariant/ProductCapacityVariantDefinition.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\CapacityVariant;

use Shopware\Core\Content\Product\ProductDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\CreatedAtField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\PrimaryKey;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ReferenceVersionField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
use Shopware\Core\Framework\DataAbstractionLayer\MappingEntityDefinition;

class ProductCapacityVariantDefinition extends MappingEntityDefinition
{
    public function getEntityName(): string
    {
        return 'topdata_product_to_capacity_variant';
    }

    protected function defineFields(): FieldCollection
    {
        return new FieldCollection([
            (new FkField('product_id', 'productId', ProductDefinition::class))->addFlags(new PrimaryKey(), new Required()),
            (new ReferenceVersionField(ProductDefinition::class, 'product_version_id'))->addFlags(new PrimaryKey(), new Required()),
            (new FkField('capacity_variant_product_id', 'capacityVariantProductId', ProductDefinition::class))->addFlags(new PrimaryKey(), new Required()),
            (new ReferenceVersionField(ProductDefinition::class, 'capacity_variant_product_version_id'))->addFlags(new PrimaryKey(), new Required()),
            new ManyToOneAssociationField('product', 'product_id', ProductDefinition::class),
            new ManyToOneAssociationField('capacity_variant_product', 'capacity_variant_product_id', ProductDefinition::class),
            new CreatedAtField(),
        ]);
    }
}

================
File: src/Core/Content/Product/Agregate/ColorVariant/ProductColorVariantDefinition.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\ColorVariant;

use Shopware\Core\Content\Product\ProductDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\CreatedAtField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\PrimaryKey;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ReferenceVersionField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
use Shopware\Core\Framework\DataAbstractionLayer\MappingEntityDefinition;

class ProductColorVariantDefinition extends MappingEntityDefinition
{
    public function getEntityName(): string
    {
        return 'topdata_product_to_color_variant';
    }

    protected function defineFields(): FieldCollection
    {
        return new FieldCollection([
            (new FkField('product_id', 'productId', ProductDefinition::class))->addFlags(new PrimaryKey(), new Required()),
            (new ReferenceVersionField(ProductDefinition::class, 'product_version_id'))->addFlags(new PrimaryKey(), new Required()),
            (new FkField('color_variant_product_id', 'colorVariantProductId', ProductDefinition::class))->addFlags(new PrimaryKey(), new Required()),
            (new ReferenceVersionField(ProductDefinition::class, 'color_variant_product_version_id'))->addFlags(new PrimaryKey(), new Required()),
            new ManyToOneAssociationField('product', 'product_id', ProductDefinition::class),
            new ManyToOneAssociationField('color_variant_product', 'color_variant_product_id', ProductDefinition::class),
            new CreatedAtField(),
        ]);
    }
}

================
File: src/Core/Content/Product/Agregate/ProductCrossSelling/ProductCrossSellingExtension.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\ProductCrossSelling;

use Shopware\Core\Content\Product\Aggregate\ProductCrossSelling\ProductCrossSellingDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\EntityExtension;
use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;

class ProductCrossSellingExtension extends EntityExtension
{
    public function extendFields(FieldCollection $collection): void
    {
        $collection->add(
            new OneToOneAssociationField('topdataExtension', 'id', 'product_cross_selling_id', TopdataProductCrossSellingExtensionDefinition::class, false)
        );
    }

    public function getDefinitionClass(): string
    {
        return ProductCrossSellingDefinition::class;
    }
}

================
File: src/Core/Content/Product/Agregate/ProductCrossSelling/TopdataProductCrossSellingExtensionDefinition.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\ProductCrossSelling;

use Shopware\Core\Content\Product\Aggregate\ProductCrossSelling\ProductCrossSellingDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\PrimaryKey;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required;
use Shopware\Core\Framework\DataAbstractionLayer\Field\IdField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\StringField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;

class TopdataProductCrossSellingExtensionDefinition extends EntityDefinition
{
    public const ENTITY_NAME = 'topdata_product_cross_selling_extension';

    public function getEntityName(): string
    {
        return self::ENTITY_NAME;
    }

    public function getEntityClass(): string
    {
        return TopdataProductCrossSellingExtensionEntity::class;
    }

    protected function defineFields(): FieldCollection
    {
        return new FieldCollection([
            (new IdField('id', 'id'))->addFlags(new Required(), new PrimaryKey()),
            new FkField('product_cross_selling_id', 'productCrossSellingId', ProductCrossSellingDefinition::class),
            (new StringField('type', 'type')),

            new OneToOneAssociationField('productCrossSelling', 'product_cross_selling_id', 'id', ProductCrossSellingDefinition::class, false),
        ]);
    }
}

================
File: src/Core/Content/Product/Agregate/ProductCrossSelling/TopdataProductCrossSellingExtensionEntity.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\ProductCrossSelling;

use Shopware\Core\Framework\DataAbstractionLayer\Entity;

class TopdataProductCrossSellingExtensionEntity extends Entity
{
}

================
File: src/Core/Content/Product/Agregate/Related/ProductRelatedDefinition.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\Related;

use Shopware\Core\Content\Product\ProductDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\CreatedAtField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\PrimaryKey;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ReferenceVersionField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
use Shopware\Core\Framework\DataAbstractionLayer\MappingEntityDefinition;

class ProductRelatedDefinition extends MappingEntityDefinition
{
    public function getEntityName(): string
    {
        return 'topdata_product_to_related';
    }

    protected function defineFields(): FieldCollection
    {
        return new FieldCollection([
            (new FkField('product_id', 'productId', ProductDefinition::class))->addFlags(new PrimaryKey(), new Required()),
            (new ReferenceVersionField(ProductDefinition::class, 'product_version_id'))->addFlags(new PrimaryKey(), new Required()),
            (new FkField('related_product_id', 'relatedProductId', ProductDefinition::class))->addFlags(new PrimaryKey(), new Required()),
            (new ReferenceVersionField(ProductDefinition::class, 'related_product_version_id'))->addFlags(new PrimaryKey(), new Required()),
            new ManyToOneAssociationField('product', 'product_id', ProductDefinition::class),
            new ManyToOneAssociationField('related_product', 'related_product_id', ProductDefinition::class),
            new CreatedAtField(),
        ]);
    }
}

================
File: src/Core/Content/Product/Agregate/Similar/ProductSimilarDefinition.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\Similar;

use Shopware\Core\Content\Product\ProductDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\CreatedAtField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\PrimaryKey;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ReferenceVersionField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
use Shopware\Core\Framework\DataAbstractionLayer\MappingEntityDefinition;

class ProductSimilarDefinition extends MappingEntityDefinition
{
    public function getEntityName(): string
    {
        return 'topdata_product_to_similar';
    }

    protected function defineFields(): FieldCollection
    {
        return new FieldCollection([
            (new FkField('product_id', 'productId', ProductDefinition::class))->addFlags(new PrimaryKey(), new Required()),
            (new ReferenceVersionField(ProductDefinition::class, 'product_version_id'))->addFlags(new PrimaryKey(), new Required()),
            (new FkField('similar_product_id', 'similarProductId', ProductDefinition::class))->addFlags(new PrimaryKey(), new Required()),
            (new ReferenceVersionField(ProductDefinition::class, 'similar_product_version_id'))->addFlags(new PrimaryKey(), new Required()),
            new ManyToOneAssociationField('product', 'product_id', ProductDefinition::class),
            new ManyToOneAssociationField('similar_product', 'similar_product_id', ProductDefinition::class),
            new CreatedAtField(),
        ]);
    }
}

================
File: src/Core/Content/Product/Agregate/Variant/ProductVariantDefinition.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\Variant;

use Shopware\Core\Content\Product\ProductDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\CreatedAtField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\PrimaryKey;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ReferenceVersionField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
use Shopware\Core\Framework\DataAbstractionLayer\MappingEntityDefinition;

class ProductVariantDefinition extends MappingEntityDefinition
{
    public function getEntityName(): string
    {
        return 'topdata_product_to_variant';
    }

    protected function defineFields(): FieldCollection
    {
        return new FieldCollection([
            (new FkField('product_id', 'productId', ProductDefinition::class))->addFlags(new PrimaryKey(), new Required()),
            (new ReferenceVersionField(ProductDefinition::class, 'product_version_id'))->addFlags(new PrimaryKey(), new Required()),
            (new FkField('variant_product_id', 'variantProductId', ProductDefinition::class))->addFlags(new PrimaryKey(), new Required()),
            (new ReferenceVersionField(ProductDefinition::class, 'variant_product_version_id'))->addFlags(new PrimaryKey(), new Required()),
            new ManyToOneAssociationField('product', 'product_id', ProductDefinition::class),
            new ManyToOneAssociationField('variant_product', 'variant_product_id', ProductDefinition::class),
            new CreatedAtField(),
        ]);
    }
}

================
File: src/Core/Content/Product/ProductDefinition.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Product;

use Shopware\Core\Content\Product\ProductDefinition as parentProductDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\IntField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;

class ProductDefinition extends parentProductDefinition
{
    public function getEntityClass(): string
    {
        return ProductEntity::class;
    }

    protected function defineFields(): FieldCollection
    {
        $fieldCollection = parent::defineFields();
        $fieldCollection->add(
            new IntField('top_data_id', 'topDataId')
        );

        return $fieldCollection;
    }
}

================
File: src/Core/Content/Product/ProductEntity.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Product;

use Shopware\Core\Content\Product\ProductEntity as parentProductEntity;

class ProductEntity extends parentProductEntity
{
    /**
     * @var int
     */
    protected $topDataId;

    public function getTopDataId(): ?int
    {
        return $this->topDataId;
    }

    public function setTopDataId(int $topDataId): void
    {
        $this->topDataId = $topDataId;
    }
}

================
File: src/Core/Content/Product/ProductExtension.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Product;

use Shopware\Core\Content\Product\ProductDefinition;
use Shopware\Core\Content\Product\SalesChannel\SalesChannelProductDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\EntityExtension;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Inherited;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToManyAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
use Topdata\TopdataConnectorSW6\Core\Content\Device\Agregate\DeviceProduct\DeviceProductDefinition;
use Topdata\TopdataConnectorSW6\Core\Content\Device\DeviceDefinition;
use Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\Alternate\ProductAlternateDefinition;
use Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\Bundled\ProductBundledDefinition;
use Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\CapacityVariant\ProductCapacityVariantDefinition;
use Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\ColorVariant\ProductColorVariantDefinition;
use Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\Related\ProductRelatedDefinition;
use Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\Similar\ProductSimilarDefinition;
use Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\Variant\ProductVariantDefinition;
use Topdata\TopdataConnectorSW6\Core\Content\TopdataToProduct\TopdataToProductDefinition;

class ProductExtension extends EntityExtension
{
    public function extendFields(FieldCollection $collection): void
    {
        $collection->add(
            (new OneToOneAssociationField(
                'topdata',
                'id',
                'product_id',
                TopdataToProductDefinition::class,
                false
            ))->addFlags(new Inherited())
        );

        $collection->add(
            (new ManyToManyAssociationField(
                'alternate_products',
                SalesChannelProductDefinition::class,
                ProductAlternateDefinition::class,
                'product_id',
                'alternate_product_id'
            ))->addFlags(new Inherited())
        );

        $collection->add(
            (new ManyToManyAssociationField(
                'bundled_products',
                SalesChannelProductDefinition::class,
                ProductBundledDefinition::class,
                'product_id',
                'bundled_product_id'
            ))->addFlags(new Inherited())
        );

        $collection->add(
            (new ManyToManyAssociationField(
                'related_products',
                SalesChannelProductDefinition::class,
                ProductRelatedDefinition::class,
                'product_id',
                'related_product_id'
            ))->addFlags(new Inherited())
        );

        $collection->add(
            (new ManyToManyAssociationField(
                'similar_products',
                SalesChannelProductDefinition::class,
                ProductSimilarDefinition::class,
                'product_id',
                'similar_product_id'
            ))->addFlags(new Inherited())
        );

        $collection->add(
            (new ManyToManyAssociationField(
                'capacity_variant_products',
                SalesChannelProductDefinition::class,
                ProductCapacityVariantDefinition::class,
                'product_id',
                'capacity_variant_product_id'
            ))->addFlags(new Inherited())
        );

        $collection->add(
            (new ManyToManyAssociationField(
                'color_variant_products',
                SalesChannelProductDefinition::class,
                ProductColorVariantDefinition::class,
                'product_id',
                'color_variant_product_id'
            ))->addFlags(new Inherited())
        );

        $collection->add(
            (new ManyToManyAssociationField(
                'variant_products',
                SalesChannelProductDefinition::class,
                ProductVariantDefinition::class,
                'product_id',
                'variant_product_id'
            ))->addFlags(new Inherited())
        );

        $collection->add(
            (new ManyToManyAssociationField(
                'devices',
                DeviceDefinition::class,
                DeviceProductDefinition::class,
                'product_id',
                'device_id'
            ))->addFlags(new Inherited())
        );
    }

    public function getDefinitionClass(): string
    {
        return ProductDefinition::class;
    }
}

================
File: src/Core/Content/Series/SeriesCollection.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Series;

use Shopware\Core\Framework\DataAbstractionLayer\EntityCollection;

/**
 * @method void              add(SeriesEntity $entity)
 * @method void              set(string $key, SeriesEntity $entity)
 * @method SeriesEntity[]    getIterator()
 * @method SeriesEntity[]    getElements()
 * @method SeriesEntity|null get(string $key)
 * @method SeriesEntity|null first()
 * @method SeriesEntity|null last()
 */
class SeriesCollection extends EntityCollection
{
    protected function getExpectedClass(): string
    {
        return SeriesEntity::class;
    }
}

================
File: src/Core/Content/Series/SeriesDefinition.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Series;

use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\BoolField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\PrimaryKey;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FloatField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\IdField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\IntField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\StringField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
use Topdata\TopdataConnectorSW6\Core\Content\Brand\BrandDefinition;

class SeriesDefinition extends EntityDefinition
{
    public const ENTITY_NAME = 'topdata_series';

    public function getEntityName(): string
    {
        return self::ENTITY_NAME;
    }

    public function getEntityClass(): string
    {
        return SeriesEntity::class;
    }

    public function getCollectionClass(): string
    {
        return SeriesCollection::class;
    }

    protected function defineFields(): FieldCollection
    {
        return new FieldCollection([
            (new IdField('id', 'id'))->addFlags(new Required(), new PrimaryKey()),
            (new FkField('brand_id', 'brandId', BrandDefinition::class)),
            (new StringField('code', 'code'))->addFlags(new Required()),
            (new BoolField('is_enabled', 'enabled'))->addFlags(new Required()),
            (new StringField('label', 'label'))->addFlags(new Required()),
            (new IntField('sort', 'sort'))->addFlags(new Required()),
            (new IntField('ws_id', 'wsId'))->addFlags(new Required()),
            new ManyToOneAssociationField('brand', 'brand_id', BrandDefinition::class),

        ]);
    }
}

================
File: src/Core/Content/Series/SeriesEntity.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\Series;

use Shopware\Core\Framework\DataAbstractionLayer\Entity;
use Shopware\Core\Framework\DataAbstractionLayer\EntityIdTrait;
use Topdata\TopdataConnectorSW6\Core\Content\Brand\BrandEntity;

class SeriesEntity extends Entity
{
    use EntityIdTrait;

    /**
     * @var string
     */
    protected $code;

    /**
     * @var string
     */
    protected $label;

    /**
     * @var bool
     */
    protected $enabled = '0';

    /**
     * @var int
     */
    protected $sort = '0';

    /**
     * @var BrandEntity
     */
    protected $brand;

    /**
     * @var string
     */
    protected $brandId;

    protected $devices;

    /**
     * @var int
     */
    protected $wsId;

    public function getCode(): string
    {
        return $this->code;
    }

    public function setCode(string $code): void
    {
        $this->code = $code;
    }

    public function getEnabled(): bool
    {
        return $this->enabled;
    }

    public function setEnabled(bool $enabled): void
    {
        $this->enabled = $enabled;
    }

    public function getLabel(): string
    {
        return $this->label;
    }

    public function setLabel(string $label): void
    {
        $this->label = $label;
    }

    public function getSort(): int
    {
        return $this->sort;
    }

    public function setSort(int $sort): void
    {
        $this->sort = $sort;
    }

    public function getWsId(): int
    {
        return $this->wsId;
    }

    public function setWsId(int $wsId): void
    {
        $this->wsId = $wsId;
    }

    public function getBrand(): ?BrandEntity
    {
        return $this->brand;
    }

    public function setBrand(BrandEntity $brand): void
    {
        $this->brand = $brand;
    }

    public function getBrandId(): ?string
    {
        return $this->brandId;
    }

    public function setBrandId(string $id): void
    {
        $this->brandId = $id;
    }
}

================
File: src/Core/Content/TopdataToProduct/TopdataToProductCollection.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\TopdataToProduct;

use Shopware\Core\Framework\DataAbstractionLayer\EntityCollection;

class TopdataToProductCollection extends EntityCollection
{
    protected function getExpectedClass(): string
    {
        return TopdataToProductEntity::class;
    }
}

================
File: src/Core/Content/TopdataToProduct/TopdataToProductDefinition.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\TopdataToProduct;

use Shopware\Core\Content\Product\ProductDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\PrimaryKey;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required;
use Shopware\Core\Framework\DataAbstractionLayer\Field\IdField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\IntField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ReferenceVersionField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\StringField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;

/**
 * 06/2025 renamed TopdataToProductDefinition --> TopdataToProductDefinition
 */
class TopdataToProductDefinition extends EntityDefinition
{
    public const ENTITY_NAME = 'topdata_to_product';

    public function getEntityName(): string
    {
        return self::ENTITY_NAME;
    }

    public function getEntityClass(): string
    {
        return TopdataToProductEntity::class;
    }

    public function getCollectionClass(): string
    {
        return TopdataToProductCollection::class;
    }

    protected function defineFields(): FieldCollection
    {
        return new FieldCollection([
            (new IdField('id', 'id'))->addFlags(new Required(), new PrimaryKey()),
            (new IntField('top_data_id', 'topDataId'))->addFlags(new Required()),
            (new FkField('product_id', 'productId', ProductDefinition::class))->addFlags(new Required()),
            (new ReferenceVersionField(ProductDefinition::class, 'product_version_id'))->addFlags(new Required()),
            //            new ManyToOneAssociationField('product', 'product_id', ProductDefinition::class),
            new OneToOneAssociationField('product', 'product_id', 'id', ProductDefinition::class, false),
            //            (new StringField('product_id', 'productId'))->addFlags(new PrimaryKey(), new Required()),
            //            (new StringField('product_version_id', 'productVersionId'))->addFlags(new PrimaryKey(), new Required()),
            //
            //            (new FkField('product_id', 'productId', ProductDefinition::class))->addFlags(new PrimaryKey(), new Required()),
            //            (new ReferenceVersionField(ProductDefinition::class, 'product_version_id'))->addFlags(new PrimaryKey(), new Required()),
            //            new ManyToOneAssociationField('product', 'product_id', ProductDefinition::class),
        ]);
    }
}

================
File: src/Core/Content/TopdataToProduct/TopdataToProductEntity.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Core\Content\TopdataToProduct;

use Shopware\Core\Content\Product\ProductEntity;
use Shopware\Core\Framework\DataAbstractionLayer\Entity;
use Shopware\Core\Framework\DataAbstractionLayer\EntityIdTrait;

/**
 * 06/2025 rebamed TopdataToProductEntity --> TopdataToProductEntity
 */
class TopdataToProductEntity extends Entity
{
    use EntityIdTrait;

    /**
     * @var int
     */
    protected $topDataId;

    /**
     * @var string
     */
    protected $productId;

    /**
     * @var string
     */
    protected $productVersionId;

    /**
     * @var ?ProductEntity
     */
    protected $product;

    public function getTopDataId(): int
    {
        return $this->topDataId;
    }

    public function setTopDataId(int $topDataId): void
    {
        $this->topDataId = $topDataId;
    }

    public function getProductId(): string
    {
        return $this->productId;
    }

    public function setProductId(string $productId): void
    {
        $this->productId = $productId;
    }

    public function getProductVersionId(): string
    {
        return $this->productVersionId;
    }

    public function setProductVersionId(string $productVersionId): void
    {
        $this->productVersionId = $productVersionId;
    }

    public function getProduct(): ?ProductEntity
    {
        return $this->product;
    }

    public function setProduct(?ProductEntity $product): void
    {
        $this->product = $product;
    }
}

================
File: src/DependencyInjection/setting.xml
================
<?xml version="1.0" ?>

<container xmlns="http://symfony.com/schema/dic/services"
           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

    <services>
        <service id="Topdata\TopdataConnectorSW6\Setting\Service\SettingsService">
            <argument type="service" id="Shopware\Core\System\SystemConfig\SystemConfigService"/>
        </service>
    </services>

</container>

================
File: src/DTO/ImportConfig.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\DTO;

use Symfony\Component\Console\Input\InputInterface;

/**
 * it just holds the cli options for the `topdata:connector:import` command under different names
 * we use a dto for easy access to the cli options and easier code navigation
 *
 * 10/2024 created.
 * 05/2025 renamed from ImportCommandImportConfig to ImportConfig
 */
class ImportConfig
{
    private bool $optionAll; // --all
    private bool $optionMapping; // --mapping
    private bool $optionDevice; // --device
    private bool $optionDeviceOnly; // --device-only // todo: remove
    private bool $optionDeviceMedia; // --device-media
    private bool $optionDeviceSynonyms; // --device-synonyms
    private bool $optionProduct; // --product
    private bool $optionProductInformation; // --product-info
    private bool $optionProductMediaOnly; // --product-media-only // todo: remove
    private bool $optionProductVariations; // --product-variated
    private bool $optionExperimentalV2; // --experimental-v2, 04/2025 added
    private bool $optionProductDevice; // --product-device, 04/2025 added
    private bool $optionPurgeCache; // --purge-cache, 05/2025 added
    private ?string $baseUrl = null;


    /**
     * factory method
     *
     * 04/2025 created
     */
    public static function createFromCliInput(InputInterface $input): ImportConfig
    {
        $ret = new self();
        $ret->optionAll = (bool)$input->getOption('all'); // full update with webservice
        $ret->optionMapping = (bool)$input->getOption('mapping'); // Mapping all existing products to webservice
        $ret->optionDevice = (bool)$input->getOption('device'); // add devices from webservice
        $ret->optionDeviceOnly = (bool)$input->getOption('device-only'); // add devices from webservice (no brands/series/types are fetched)
        $ret->optionDeviceMedia = (bool)$input->getOption('device-media'); // update device media data
        $ret->optionDeviceSynonyms = (bool)$input->getOption('device-synonyms'); // link active devices to synonyms
        $ret->optionProduct = (bool)$input->getOption('product'); // link devices to products on the store
        $ret->optionProductInformation = (bool)$input->getOption('product-info'); // update product information from webservice (TopFeed plugin needed)
        $ret->optionProductMediaOnly = (bool)$input->getOption('product-media-only'); // update only product media from webservice (TopFeed plugin needed)
        $ret->optionProductVariations = (bool)$input->getOption('product-variated'); // Generate variated products based on color and capacity information
        $ret->optionExperimentalV2 = (bool)$input->getOption('experimental-v2');
        $ret->optionProductDevice = (bool)$input->getOption('product-device');
        $ret->optionPurgeCache = (bool)$input->getOption('purge-cache'); // purge the mapping cache before import
        $ret->baseUrl = $input->getOption('base-url');

        return $ret;
    }

    public function getOptionAll(): bool
    {
        return $this->optionAll;
    }

    public function getOptionMapping(): bool
    {
        return $this->optionMapping;
    }

    public function getOptionDevice(): bool
    {
        return $this->optionDevice;
    }

    public function getOptionDeviceOnly(): bool
    {
        return $this->optionDeviceOnly;
    }

    public function getOptionDeviceMedia(): bool
    {
        return $this->optionDeviceMedia;
    }

    public function getOptionDeviceSynonyms(): bool
    {
        return $this->optionDeviceSynonyms;
    }

    public function getOptionProduct(): bool
    {
        return $this->optionProduct;
    }

    public function getOptionProductInformation(): bool
    {
        return $this->optionProductInformation;
    }

    public function getOptionProductMediaOnly(): bool
    {
        return $this->optionProductMediaOnly;
    }

    public function getOptionProductVariations(): bool
    {
        return $this->optionProductVariations;
    }

    public function getOptionExperimentalV2(): bool
    {
        return $this->optionExperimentalV2;
    }

    public function getOptionProductDevice(): bool
    {
        return $this->optionProductDevice;
    }

    public function getOptionPurgeCache(): bool
    {
        return $this->optionPurgeCache;
    }

    public function getBaseUrl(): ?string
    {
        return $this->baseUrl;
    }



    public function toDict(): array
    {
        return [
            'optionAll'                => $this->optionAll,
            'optionMapping'            => $this->optionMapping,
            'optionDevice'             => $this->optionDevice,
            'optionDeviceOnly'         => $this->optionDeviceOnly,
            'optionDeviceMedia'        => $this->optionDeviceMedia,
            'optionDeviceSynonyms'     => $this->optionDeviceSynonyms,
            'optionProduct'            => $this->optionProduct,
            'optionProductDevice'      => $this->optionProductDevice,
            'optionProductInformation' => $this->optionProductInformation,
            'optionProductMedia'       => $this->optionProductMediaOnly,
            'optionProductVariations'  => $this->optionProductVariations,
            'optionExperimentalV2'     => $this->optionExperimentalV2,
            'optionPurgeCache'         => $this->optionPurgeCache,
            'baseUrl'                  => $this->baseUrl,
        ];
    }



}

================
File: src/Enum/ProductRelationshipTypeEnumV1.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Enum;

/**
 * 11/2024 created (extracted from MappingHelperService)
 *
 * Constants for cross-selling types.
 * 04/2025 changed from CrossSellingTypeConstant to ProductRelationshipTypeEnum
 * TODO: make the values uppercase
 */
enum ProductRelationshipTypeEnumV1: string
{
    case SIMILAR          = 'similar';
    case ALTERNATE        = 'alternate';
    case RELATED          = 'related';
    case BUNDLED          = 'bundled';
    case COLOR_VARIANT    = 'colorVariant';
    case CAPACITY_VARIANT = 'capacityVariant';
    case VARIANT          = 'variant';
}

================
File: src/Enum/ProductRelationshipTypeEnumV2.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Enum;

/**
 * 06/2025 created - it will replace the V1
 */
enum ProductRelationshipTypeEnumV2: string
{
    case ALTERNATE        = 'ALTERNATE';
    case BUNDLED          = 'BUNDLED';
    case RELATED          = 'RELATED';
    case SIMILAR          = 'SIMILAR';
    case CAPACITY_VARIANT = 'CAPACITY_VARIANT';
    case COLOR_VARIANT    = 'COLOR_VARIANT';
    case VARIANT          = 'VARIANT';
}

================
File: src/Exception/MissingPluginConfigurationException.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Exception;

use Exception;

/**
 *
 *
 * 04/2025 created
 */
class MissingPluginConfigurationException extends Exception
{

}

================
File: src/Exception/TopdataConnectorPluginInactiveException.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Exception;

use Exception;

/**
 * 04/2025 created
 */
class TopdataConnectorPluginInactiveException extends Exception
{

}

================
File: src/Exception/WebserviceRequestException.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Exception;

use Exception;

/**
 * something went wrong sending a request to the webservice
 *
 * 03/2025 created
 */
class WebserviceRequestException extends Exception
{

}

================
File: src/Exception/WebserviceResponseException.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Exception;

use Exception;

/**
 * something went wrong processing the response from the webservice
 *
 * 03/2025 created
 */
class WebserviceResponseException extends Exception
{

}

================
File: src/Helper/CurlHttpClient.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Helper;

use Exception;
use Topdata\TopdataConnectorSW6\Exception\WebserviceRequestException;
use Topdata\TopdataConnectorSW6\Exception\WebserviceResponseException;
use Topdata\TopdataFoundationSW6\Util\CliLogger;

/**
 * A simple cURL client for making HTTP requests with exponential backoff retry strategy.
 * This one is only used for the webservice calls, it validates the JSON response and
 * throws an exception if the response is invalid.
 *
 * TODO: use custom exception class/es
 *
 * 11/2024 created (extracted from TopdataWebserviceClient)
 */
class CurlHttpClient
{
    const CURL_TIMEOUT                         = 30;  // seconds
    const EXPONENTIAL_BACKOFF_INITIAL_DELAY_MS = 1000; // ms, for exponential backoff
    const EXPONENTIAL_BACKOFF_MAX_RETRIES      = 8; // 1s, 2s, 4s, 8s, ...
    const EXPONENTIAL_BACKOFF_MULTIPLIER       = 2.0;

    public function __construct(
        private int   $timeoutSeconds = self::CURL_TIMEOUT,
        private int   $initialDelayMs = self::EXPONENTIAL_BACKOFF_INITIAL_DELAY_MS,
        private int   $maxRetries = self::EXPONENTIAL_BACKOFF_MAX_RETRIES,
        private float $backoffMultiplier = self::EXPONENTIAL_BACKOFF_MULTIPLIER,
    )
    {
    }



    /**
     * Performs a cURL request to the specified URL and returns the decoded JSON response
     *
     * @param string $url The URL to send the request to
     * @param mixed|null $xml_data XML data to be sent (currently unused)
     * @param int $attempt Current retry attempt number, used for handling timeouts
     *
     * @return mixed Decoded JSON response from the API
     * @throws Exception When cURL errors occur or API returns an error response
     */
    public function get(string $url, $xml_data = null, $attempt = 1)
    {
        // ---- Initialize cURL request
        CliLogger::debug("$url");

        try {
            $ch = curl_init($url);
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
            curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->timeoutSeconds);
            curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeoutSeconds);
            $output = curl_exec($ch);

            // ---- Handle cURL errors
            if (curl_errno($ch)) {
                throw new WebserviceRequestException('cURL-ERROR: ' . curl_error($ch));
            }

            // ---- Handle HTTP status codes
            $header = curl_getinfo($ch);
            if ($header['http_code'] != 200) {
                throw new WebserviceRequestException('HTTP Error: ' . $header['http_code']);
            }

            // ---- Handle Bad Request responses by removing headers from output
            if ($header['http_code'] == 400) {
                $header_size = strpos($output, '{');
                $output = substr($output, $header_size);
            }

            // ---- Clean up and process response
            curl_close($ch);
            $ret = json_decode($output);

            // check if the response is valid JSON
            if (json_last_error() !== JSON_ERROR_NONE) {
                throw new WebserviceResponseException('Invalid JSON response: ' . json_last_error_msg());
            }

            // check if the webservice returned an error
            if (isset($ret->error)) {
                throw new WebserviceResponseException($ret->error[0]->error_message . ' @topdataconnector webservice error');
            }

            CliLogger::debug(substr($output, 0, 180) . '...');

            return $ret;
        } catch (Exception $e) {
            // ---- it failed ... try again until maxRetries is reached
            if ($attempt >= $this->maxRetries) {
                throw $e;
            }
            $delayMs = $this->initialDelayMs * pow($this->backoffMultiplier, $attempt - 1);
            CliLogger::warning("Request failed ... attempt " . ($attempt + 1) . "/{$this->maxRetries} in {$delayMs}ms ...");
            usleep($delayMs * 1000);

            return $this->get($url, $xml_data, $attempt + 1);
        }
    }

}

================
File: src/Migration/Migration1578907114UpdateTables.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Migration;

use Doctrine\DBAL\Connection;
use Shopware\Core\Framework\Migration\InheritanceUpdaterTrait;
use Shopware\Core\Framework\Migration\MigrationStep;

class Migration1578907114UpdateTables extends MigrationStep
{
    use InheritanceUpdaterTrait;

    public function getCreationTimestamp(): int
    {
        return 1578907114;
    }

    public function update(Connection $connection): void
    {
        $productFields = [];
        $temp          = $connection->fetchAllAssociative('SHOW COLUMNS from `product`');
        foreach ($temp as $field) {
            if (isset($field['Field'])) {
                $productFields[$field['Field']] = $field['Field'];
            }
        }

        $customerFields = [];
        $temp           = $connection->fetchAllAssociative('SHOW COLUMNS from `customer`');
        foreach ($temp as $field) {
            if (isset($field['Field'])) {
                $customerFields[$field['Field']] = $field['Field'];
            }
        }

        $connection->executeStatement('
            CREATE TABLE IF NOT EXISTS `topdata_to_product` (
              `id` binary(16) NOT NULL,
              `top_data_id` int(11) NOT NULL,
              `product_id` binary(16) NOT NULL,
              `product_version_id` binary(16) NOT NULL,
              `created_at` DATETIME(3) NULL,
              `updated_at` DATETIME(3) NULL,
              PRIMARY KEY (`id`),
              KEY `idx_ws_id` (`top_data_id`),
              KEY `idx_product` (`product_id`, `product_version_id`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
        ');

        if (!isset($productFields['topdata'])) {
            $this->updateInheritance($connection, 'product', 'topdata');
        }

        $connection->executeStatement('
            CREATE TABLE IF NOT EXISTS `topdata_brand` (
              `id` binary(16) NOT NULL,
              `code` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
              `label` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
              `is_enabled` tinyint(1) NOT NULL,
              `sort` int(11) NOT NULL,
              `ws_id` int(11) NOT NULL,
              `created_at` DATETIME(3) NULL,
              `updated_at` DATETIME(3) NULL,
              PRIMARY KEY (`id`),
              KEY `idx_label` (`label`),
              KEY `td_brand_code` (`code`),
              KEY `td_brand_ws_id` (`ws_id`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
        ');

        $connection->executeStatement('
            CREATE TABLE IF NOT EXISTS `topdata_device` (
              `id` binary(16) NOT NULL,
              `brand_id` binary(16) DEFAULT NULL,
              `type_id` binary(16) DEFAULT NULL,
              `series_id` binary(16) DEFAULT NULL,
              `is_enabled` tinyint(1) NOT NULL,
              `has_synonyms` TINYINT(1) NOT NULL DEFAULT 0,
              `code` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
              `model` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
              `keywords` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
              `sort` int(11) NOT NULL,
              `media_id` binary(16) DEFAULT NULL,
              `ws_id` int(11) NOT NULL,
              `created_at` DATETIME(3) NULL,
              `updated_at` DATETIME(3) NULL,
              PRIMARY KEY (`id`),
              UNIQUE KEY `UNIQ_4B8AD3A77153098` (`code`),
              KEY `IDX_4B8AD3A44F5D008` (`brand_id`),
              KEY `IDX_4B8AD3AC54C8C93` (`type_id`),
              KEY `IDX_4B8AD3A5278319C` (`series_id`),
              KEY `IDX_4B8AD3AEA9FDD75` (`media_id`),
              KEY `ws_id` (`ws_id`),
              KEY `idx_model` (`model`),
              KEY `idx_keywords` (`keywords`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
        ');

        $connection->executeStatement('
            CREATE TABLE IF NOT EXISTS `topdata_device_to_synonym` (
              `device_id` binary(16) NOT NULL,
              `synonym_id` binary(16) NOT NULL,
              `created_at` DATETIME(3) NULL,
              `updated_at` DATETIME(3) NULL,
              PRIMARY KEY (`device_id`, `synonym_id`)
            ) ENGINE=InnoDB;
        ');

        $connection->executeStatement('
            CREATE TABLE IF NOT EXISTS `topdata_device_to_product` (
              `device_id` binary(16) NOT NULL,
              `product_id` binary(16) NOT NULL,
              `product_version_id` binary(16) NOT NULL,
              `created_at` DATETIME(3) NULL,
              `updated_at` DATETIME(3) NULL,
              PRIMARY KEY (`device_id`,`product_id`,`product_version_id`),
              KEY `idx_product` (`product_id`,`product_version_id`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
        ');

        if (!isset($productFields['devices'])) {
            $this->updateInheritance($connection, 'product', 'devices');
        }

        $connection->executeStatement('
            CREATE TABLE IF NOT EXISTS `topdata_product_to_alternate` (
              `product_id` binary(16) NOT NULL,
              `product_version_id` binary(16) NOT NULL,
              `alternate_product_id` binary(16) NOT NULL,
              `alternate_product_version_id` binary(16) NOT NULL,
              `created_at` DATETIME(3) NULL,
              `updated_at` DATETIME(3) NULL,
              PRIMARY KEY (`product_id`, `product_version_id`, `alternate_product_id`, `alternate_product_version_id`),
              KEY `idx_alternate_id` (`alternate_product_id`, `alternate_product_version_id`)
            ) ENGINE=InnoDB;
        ');

        if (!isset($productFields['alternate_products'])) {
            $this->updateInheritance($connection, 'product', 'alternate_products');
        }

        $connection->executeStatement('
            CREATE TABLE IF NOT EXISTS `topdata_product_to_similar` (
              `product_id` binary(16) NOT NULL,
              `product_version_id` binary(16) NOT NULL,
              `similar_product_id` binary(16) NOT NULL,
              `similar_product_version_id` binary(16) NOT NULL,
              `created_at` DATETIME(3) NULL,
              `updated_at` DATETIME(3) NULL,
              PRIMARY KEY (`product_id`, `product_version_id`, `similar_product_id`, `similar_product_version_id`),
              KEY `idx_similar_id` (`similar_product_id`, `similar_product_version_id`)
            ) ENGINE=InnoDB;
        ');

        if (!isset($productFields['similar_products'])) {
            $this->updateInheritance($connection, 'product', 'similar_products');
        }

        $connection->executeStatement('
            CREATE TABLE IF NOT EXISTS `topdata_product_to_related` (
              `product_id` binary(16) NOT NULL,
              `product_version_id` binary(16) NOT NULL,
              `related_product_id` binary(16) NOT NULL,
              `related_product_version_id` binary(16) NOT NULL,
              `created_at` DATETIME(3) NULL,
              `updated_at` DATETIME(3) NULL,
              PRIMARY KEY (`product_id`, `product_version_id`, `related_product_id`, `related_product_version_id`),
              KEY `idx_related_id` (`related_product_id`, `related_product_version_id`)
            ) ENGINE=InnoDB;
        ');

        if (!isset($productFields['related_products'])) {
            $this->updateInheritance($connection, 'product', 'related_products');
        }

        $connection->executeStatement('
            CREATE TABLE IF NOT EXISTS `topdata_product_to_bundled` (
              `product_id` binary(16) NOT NULL,
              `product_version_id` binary(16) NOT NULL,
              `bundled_product_id` binary(16) NOT NULL,
              `bundled_product_version_id` binary(16) NOT NULL,
              `created_at` DATETIME(3) NULL,
              `updated_at` DATETIME(3) NULL,
              PRIMARY KEY (`product_id`, `product_version_id`, `bundled_product_id`, `bundled_product_version_id`),
              KEY `idx_bundled_id` (`bundled_product_id`, `bundled_product_version_id`)
            ) ENGINE=InnoDB;
        ');

        if (!isset($productFields['bundled_products'])) {
            $this->updateInheritance($connection, 'product', 'bundled_products');
        }

        $connection->executeStatement('
            CREATE TABLE IF NOT EXISTS `topdata_product_to_color_variant` (
              `product_id` binary(16) NOT NULL,
              `product_version_id` binary(16) NOT NULL,
              `color_variant_product_id` binary(16) NOT NULL,
              `color_variant_product_version_id` binary(16) NOT NULL,
              `created_at` DATETIME(3) NULL,
              `updated_at` DATETIME(3) NULL,
              PRIMARY KEY (`product_id`, `product_version_id`, `color_variant_product_id`, `color_variant_product_version_id`),
              KEY `idx_color_variant_id` (`color_variant_product_id`, `color_variant_product_version_id`)
            ) ENGINE=InnoDB;
        ');

        if (!isset($productFields['color_variant_products'])) {
            $this->updateInheritance($connection, 'product', 'color_variant_products');
        }

        $connection->executeStatement('
            CREATE TABLE IF NOT EXISTS `topdata_product_to_capacity_variant` (
              `product_id` binary(16) NOT NULL,
              `product_version_id` binary(16) NOT NULL,
              `capacity_variant_product_id` binary(16) NOT NULL,
              `capacity_variant_product_version_id` binary(16) NOT NULL,
              `created_at` DATETIME(3) NULL,
              `updated_at` DATETIME(3) NULL,
              PRIMARY KEY (`product_id`, `product_version_id`, `capacity_variant_product_id`, `capacity_variant_product_version_id`),
              KEY `idx_capacity_variant_id` (`capacity_variant_product_id`, `capacity_variant_product_version_id`)
            ) ENGINE=InnoDB;
        ');

        if (!isset($productFields['capacity_variant_products'])) {
            $this->updateInheritance($connection, 'product', 'capacity_variant_products');
        }

        $connection->executeStatement('
            CREATE TABLE IF NOT EXISTS `topdata_product_to_variant` (
              `product_id` binary(16) NOT NULL,
              `product_version_id` binary(16) NOT NULL,
              `variant_product_id` binary(16) NOT NULL,
              `variant_product_version_id` binary(16) NOT NULL,
              `created_at` DATETIME(3) NULL,
              `updated_at` DATETIME(3) NULL,
              PRIMARY KEY (`product_id`, `product_version_id`, `variant_product_id`, `variant_product_version_id`),
              KEY `idx_variant_id` (`variant_product_id`, `variant_product_version_id`)
            ) ENGINE=InnoDB;
        ');

        if (!isset($productFields['variant_products'])) {
            $this->updateInheritance($connection, 'product', 'variant_products');
        }

        $connection->executeStatement('
            CREATE TABLE IF NOT EXISTS `topdata_device_to_customer` (
              `id` binary(16) NOT NULL,
              `device_id` binary(16) NOT NULL,
              `customer_id` binary(16) DEFAULT NULL,
              `customer_extra_id` binary(16) DEFAULT NULL,
              `extra_info` text DEFAULT NULL,
              `is_dealer_managed` tinyint(1) DEFAULT 0 NOT NULL,
              `created_at` DATETIME(3) NULL,
              `updated_at` DATETIME(3) NULL,
              PRIMARY KEY (`id`),
              KEY `IDX_CUSTOMER` (`customer_id`),
              KEY `IDX_CUSTOMER_EXTRA` (`customer_extra_id`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
        ');

        if (!isset($customerFields['devices'])) {
            $this->updateInheritance($connection, 'customer', 'devices');
        }

        $connection->executeStatement('
            CREATE TABLE IF NOT EXISTS `topdata_device_type` (
              `id` binary(16) NOT NULL,
              `brand_id` binary(16) DEFAULT NULL,
              `code` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
              `is_enabled` tinyint(1) NOT NULL,
              `label` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
              `sort` int(11) NOT NULL,
              `ws_id` int(11) NOT NULL,
              `created_at` DATETIME(3) NULL,
              `updated_at` DATETIME(3) NULL,
              PRIMARY KEY (`id`),
              KEY `idx_brand_id` (`brand_id`),
              KEY `idx_code` (`code`),
              KEY `idx_label` (`label`),
              KEY `idx_ws_id` (`ws_id`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
        ');

        $connection->executeStatement('
            CREATE TABLE IF NOT EXISTS `topdata_series` (
              `id` binary(16) NOT NULL,
              `brand_id` binary(16) DEFAULT NULL,
              `code` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
              `is_enabled` tinyint(1) NOT NULL,
              `label` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
              `sort` int(11) NOT NULL,
              `ws_id` int(11) NOT NULL,
              `created_at` DATETIME(3) NULL,
              `updated_at` DATETIME(3) NULL,
              PRIMARY KEY (`id`),
              KEY `idx_code` (`code`),
              KEY `idx_label` (`label`),
              KEY `idx_wsid` (`ws_id`),
              KEY `idx_brandid` (`brand_id`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
        ');
    }

    public function updateDestructive(Connection $connection): void
    {
    }
}

================
File: src/Migration/Migration1617830396UpdateTables.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Migration;

use Doctrine\DBAL\Connection;
use Shopware\Core\Framework\Migration\InheritanceUpdaterTrait;
use Shopware\Core\Framework\Migration\MigrationStep;

class Migration1617830396UpdateTables extends MigrationStep
{
    use InheritanceUpdaterTrait;

    public function getCreationTimestamp(): int
    {
        return 1617830396;
    }

    public function update(Connection $connection): void
    {
        $sql = <<<'SQL'
CREATE TABLE IF NOT EXISTS `topdata_product_cross_selling_extension` (
    `id` BINARY(16) NOT NULL,
    `product_cross_selling_id` BINARY(16) NULL,
    `type` VARCHAR(255) NULL,
    `created_at` DATETIME(3) NOT NULL,
    `updated_at` DATETIME(3) NULL,
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SQL;
        $connection->executeStatement($sql);
    }

    public function updateDestructive(Connection $connection): void
    {
    }
}

================
File: src/Migration/Migration1640004125UpdateTables.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Migration;

use Doctrine\DBAL\Connection;
use Shopware\Core\Framework\Migration\InheritanceUpdaterTrait;
use Shopware\Core\Framework\Migration\MigrationStep;

class Migration1640004125UpdateTables extends MigrationStep
{
    use InheritanceUpdaterTrait;

    public function getCreationTimestamp(): int
    {
        return 1640004125;
    }

    public function update(Connection $connection): void
    {
        $sql = <<<'SQL'
CREATE TABLE IF NOT EXISTS `topdata_category_extension` (
              `id` binary(16) NOT NULL,
              `category_id` binary(16) DEFAULT NULL,
              `plugin_settings` int(1) DEFAULT 1 NOT NULL,
              `import_settings` longtext COLLATE utf8mb4_unicode_ci NOT NULL,
              `created_at` DATETIME(3) NULL,
              `updated_at` DATETIME(3) NULL,
              PRIMARY KEY (`id`),
              KEY `idx_category` (`category_id`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SQL;
        $connection->executeStatement($sql);
    }

    public function updateDestructive(Connection $connection): void
    {
    }
}

================
File: src/Migration/Migration1699543200AddApiBaseUrlConfig.php
================
<?php declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Migration;

use Doctrine\DBAL\Connection;
use Shopware\Core\Framework\Migration\MigrationStep;

class Migration1699543200AddApiBaseUrlConfig extends MigrationStep
{
    public function getCreationTimestamp(): int
    {
        return 1699543200;
    }

    public function update(Connection $connection): void
    {
        $connection->executeStatement('
            INSERT IGNORE INTO `system_config` (`id`, `configuration_key`, `configuration_value`, `created_at`)
            VALUES (
                   0xd5be1fba56a646659b8d6a2a1ccda7b2,
                "TopdataConnectorSW6.config.apiBaseUrl",
                \'{"_value": "https://ws.topdata.de"}\',
                NOW()
            )
        ');
    }

    public function updateDestructive(Connection $connection): void
    {
    }
}

================
File: src/Migration/Migration1731968633RenameConfigKeys.php
================
<?php declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Migration;

use Doctrine\DBAL\Connection;
use Shopware\Core\Framework\Migration\MigrationStep;

class Migration1731968633RenameConfigKeys extends MigrationStep
{
    public function getCreationTimestamp(): int
    {
        return 1731968633;
    }

    public function update(Connection $connection): void
    {
        $connection->executeStatement('
            UPDATE system_config 
            SET configuration_key = "TopdataConnectorSW6.config.apiUid" 
            WHERE configuration_key = "TopdataConnectorSW6.config.apiUsername"
        ');

        $connection->executeStatement('
            UPDATE system_config 
            SET configuration_key = "TopdataConnectorSW6.config.apiPassword" 
            WHERE configuration_key = "TopdataConnectorSW6.config.apiKey"
        ');

        $connection->executeStatement('
            UPDATE system_config 
            SET configuration_key = "TopdataConnectorSW6.config.apiSecurityKey" 
            WHERE configuration_key = "TopdataConnectorSW6.config.apiSalt"
        ');
    }

    public function updateDestructive(Connection $connection): void
    {
    }
}

================
File: src/Migration/Migration1746267946CreateMappingCacheTable.php
================
<?php declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Migration;

use Doctrine\DBAL\Connection;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\Migration\MigrationStep;

/**
 * @internal
  * Creates the mapping cache table for storing EAN/OEM/PCD mappings with actual mapping values
 */
#[Package('core')]
class Migration1746267946CreateMappingCacheTable extends MigrationStep
{
    public function getCreationTimestamp(): int
    {
        return 1746267946;
    }

    public function update(Connection $connection): void
    {
        echo "---- Create topdata_mapping_cache table\n";
        $connection->executeStatement('
            CREATE TABLE IF NOT EXISTS `topdata_mapping_cache` (
              `id` binary(16) NOT NULL,
              `mapping_type` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
              `top_data_id` int(11) NOT NULL,
              `mapping_value` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
              `created_at` DATETIME(3) NOT NULL,
              `updated_at` DATETIME(3) NULL,
              PRIMARY KEY (`id`),
              KEY `idx_mapping_type` (`mapping_type`),
              KEY `idx_top_data_id` (`top_data_id`),
              KEY `idx_mapping_value` (`mapping_value`),
              KEY `idx_mapping_type_value` (`mapping_type`, `mapping_value`),
              KEY `idx_created_at` (`created_at`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
        ');
    }

    public function updateDestructive(Connection $connection): void
    {
        // No destructive changes needed
    }
}

================
File: src/Resources/config/config.xml
================
<?xml version="1.0" encoding="UTF-8"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/shopware/platform/master/src/Core/System/SystemConfig/Schema/config.xsd">

    <card>
        <title>API Configuration</title>
        <title lang="de-DE">API Einstellungen</title>
        <input-field>
            <name>apiBaseUrl</name>
            <label>API Base URL</label>
            <label lang="de-DE">API Basis-URL</label>
            <defaultValue>https://ws.topdata.de</defaultValue>
            <helpText>Enter the base URL for the Topdata webservice (default: https://ws.topdata.de)</helpText>
            <helpText lang="de-DE">Geben Sie die Basis-URL für den Topdata-Webservice ein (Standard: https://ws.topdata.de)</helpText>
        </input-field>
        <input-field>
            <name>apiUid</name>
            <label>API User-ID | DEMO User-ID: 6</label>
            <label lang="de-DE">API User-ID | DEMO User-ID: 6</label>
            <helpText>Enter the user ID you received from us for the web service her.</helpText>
            <helpText lang="de-DE">Tragen Sie die von uns erhaltene API-User-ID ein.</helpText>
        </input-field>
        <input-field>
            <name>apiPassword</name>
            <label>API Password | DEMO API Password: nTI9kbsniVWT13Ns</label>
            <label lang="de-DE">API Password | DEMO API Password: nTI9kbsniVWT13Ns</label>
            <helpText>Enter the API Password you received from us for the web service here.</helpText>
            <helpText lang="de-DE">Tragen Sie das von uns erhaltene API-Passwort ein.</helpText>
        </input-field>
        <input-field>
            <name>apiSecurityKey</name>
            <label>API Security Key | DEMO API Security Key: oateouq974fpby5t6ldf8glzo85mr9t6aebozrox</label>
            <label lang="de-DE">API Security Key | DEMO API Security Key: oateouq974fpby5t6ldf8glzo85mr9t6aebozrox</label>
            <helpText>Enter the API Security Key you received from us for the web service here.</helpText>
            <helpText lang="de-DE">Tragen Sie den von uns erhaltenen API-Security Key ein.</helpText>
        </input-field>
        <input-field type="single-select">
            <name>apiLanguage</name>
            <options>
                <option>
                    <id>de</id>
                    <name>German</name>
                    <name lang="de-DE">Deutsch</name>
                    <name lang="nl-NL">Duits</name>
                </option>
                <option>
                    <id>en</id>
                    <name>English</name>
                    <name lang="de-DE">Englisch</name>
                    <name lang="nl-NL">Engels</name>
                </option>
                <option>
                    <id>nl</id>
                    <name>Dutch</name>
                    <name lang="de-DE">Niederländisch</name>
                    <name lang="nl-NL">Nederlands</name>
                </option>
            </options>
            <defaultValue>de</defaultValue>
            <label>API-Language</label>
            <label lang="de-DE">API-Sprache</label>
            <helpText>Choose the language for the webservice.</helpText>
            <helpText lang="de-DE">Wählen Sie die gewünschte Sprache für den Webservice.</helpText>
        </input-field>
        <component name="topdata-connector-test-connection">
            <name>configtestconnection</name>
        </component>
    </card>
    <card>
        <title>mapping options</title>
        <title lang="de-DE">Mapping Einstellungen</title>
        <input-field type="single-select">
            <name>mappingType</name>
            <options>
                <option>
                    <id>default</id>
                    <name>Standard mapping via MPN / EAN</name>
                    <name lang="de-DE">Standard-Mapping über MPN / EAN</name>
                </option>
                <option>
                    <id>distributorDefault</id>
                    <name>Mapping via article numbers of your distributor (article number field)</name>
                    <name lang="de-DE">Mapping über die Artikelnummern Ihres Lieferanten (Artikelnummerfeld)</name>
                </option>
                <option>
                    <id>custom</id>
                    <name>User defined mapping via MPN / EAN (Property group field)</name>
                    <name lang="de-DE">Benutzerdefiniertes Mapping über MPN / EAN (Eigenschaftenfeld)</name>
                </option>
                <option>
                    <id>distributorCustom</id>
                    <name>User defined mapping via article numbers of your distributor (Property group field)</name>
                    <name lang="de-DE">Benutzerdefiniertes Mapping über die Artikelnummern Ihres Lieferanten (Eigenschaftenfeld)</name>
                </option>
                <option>
                    <id>customField</id>
                    <name>User defined mapping via MPN / EAN (Customfield)</name>
                    <name lang="de-DE">Benutzerdefiniertes Mapping über MPN / EAN (Zusatzfeld)</name>
                </option>
                <option>
                    <id>distributorCustomField</id>
                    <name>User defined mapping via article numbers of your distributor (Customfield)</name>
                    <name lang="de-DE">Benutzerdefiniertes Mapping über die Artikelnummern Ihres Lieferanten (Zusatzfeld)</name>
                </option>
                <option>
                    <id>productNumberAsWsId</id>
                    <name>Mapping via Top-ID (article number field)</name>
                    <name lang="de-DE">Mapping über Top-ID (Artikelnummerfeld)</name>
                </option>
            </options>
            <defaultValue>default</defaultValue>
            <label>Please choose your mapping variant</label>
            <label lang="de-DE">Bitte wählen Sie Ihre produkt Mapping-Variante aus</label>
            <helpText>Select how you want our articles to be linked to our product database (mapping) *1</helpText>
            <helpText lang="de-DE">Legen Sie fest, wie Ihre Artikel mit unserer Produktdatenbank verknüpft werden sollen (Mapping)*1</helpText>
        </input-field>
        <input-field>
            <name>attributeOem</name>
            <label>MPN Field</label>
            <label lang="de-DE">MPN-Feld</label>
            <helpText>Enter the technical name of the MPN property group-/customfield you have defined</helpText>
            <helpText lang="de-DE">Geben Sie den technischen Namen des von Ihnen festgelegten MPN Eigenschaften-/Zusatzfeldes an</helpText>
        </input-field>
        <input-field>
            <name>attributeEan</name>
            <label>EAN Field</label>
            <label lang="de-DE">EAN-Feld</label>
            <helpText>Enter the technical name of the EAN property group-/customfield you have defined</helpText>
            <helpText lang="de-DE">Geben Sie den technischen Namen des von Ihnen festgelegten EAN Eigenschaften-/Zusatzfeldes an</helpText>
        </input-field>
        <input-field>
            <name>attributeOrdernumber</name>
            <label>Distributor product number field</label>
            <label lang="de-DE">Lieferanten- Artikelnummer-Feld</label>
            <helpText>Enter the technical name of the distributor articlenumber property group-/customfield you have defined</helpText>
            <helpText lang="de-DE">Geben Sie den technischen Namen des von Ihnen festgelegten Lieferanten Artikelnummer Eigenschaften-/Zusatzfeldes an</helpText>
        </input-field>
    </card>
</config>

================
File: src/Resources/config/routes.xml
================
<?xml version="1.0" encoding="UTF-8" ?>
<routes xmlns="http://symfony.com/schema/routing"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://symfony.com/schema/routing
        https://symfony.com/schema/routing/routing-1.0.xsd">

<!--    <import resource="../../Controller/TopdataConnectorController.php" type="annotation"/>-->
    <import resource="../../Controller/**/*Controller.php" type="attribute" />
</routes>

================
File: src/Resources/config/services.xml
================
<?xml version="1.0" ?>

<container xmlns="http://symfony.com/schema/dic/services"
           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

    <services>
        <!-- ENTITIES -->
        <service id="Topdata\TopdataConnectorSW6\Core\Content\Brand\BrandDefinition">
            <tag name="shopware.entity.definition" entity="topdata_brand" />
        </service>
        <service id="Topdata\TopdataConnectorSW6\Core\Content\Device\DeviceDefinition">
            <tag name="shopware.entity.definition" entity="topdata_device" />
        </service>
        <service id="Topdata\TopdataConnectorSW6\Core\Content\DeviceType\DeviceTypeDefinition">
            <tag name="shopware.entity.definition" entity="topdata_device_type" />
        </service>
        <service id="Topdata\TopdataConnectorSW6\Core\Content\Series\SeriesDefinition">
            <tag name="shopware.entity.definition" entity="topdata_series" />
        </service>
        <service id="Topdata\TopdataConnectorSW6\Core\Content\TopdataToProduct\TopdataToProductDefinition">
            <tag name="shopware.entity.definition" entity="topdata_to_product" />
        </service>
        
        <service id="Topdata\TopdataConnectorSW6\Core\Content\Device\Agregate\DeviceProduct\DeviceProductDefinition">
            <tag name="shopware.entity.definition" entity="topdata_device_to_product"/>
        </service>
        <service id="Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\Alternate\ProductAlternateDefinition">
            <tag name="shopware.entity.definition" entity="topdata_product_to_alternate"/>
        </service>
        <service id="Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\Bundled\ProductBundledDefinition">
            <tag name="shopware.entity.definition" entity="topdata_product_to_bundled"/>
        </service>
        <service id="Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\Related\ProductRelatedDefinition">
            <tag name="shopware.entity.definition" entity="topdata_product_to_related"/>
        </service>
        <service id="Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\Similar\ProductSimilarDefinition">
            <tag name="shopware.entity.definition" entity="topdata_product_to_similar"/>
        </service>
        <service id="Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\CapacityVariant\ProductCapacityVariantDefinition">
            <tag name="shopware.entity.definition" entity="topdata_product_to_capacity_variant"/>
        </service>
        <service id="Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\ColorVariant\ProductColorVariantDefinition">
            <tag name="shopware.entity.definition" entity="topdata_product_to_color_variant"/>
        </service>
        <service id="Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\Variant\ProductVariantDefinition">
            <tag name="shopware.entity.definition" entity="topdata_product_to_variant"/>
        </service>
        <service id="Topdata\TopdataConnectorSW6\Core\Content\Product\ProductExtension">
            <tag name="shopware.entity.extension"/>
        </service>
        <service id="Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\ProductCrossSelling\ProductCrossSellingExtension">
            <tag name="shopware.entity.extension"/>
        </service>
        <service id="Topdata\TopdataConnectorSW6\Core\Content\Product\Agregate\ProductCrossSelling\TopdataProductCrossSellingExtensionDefinition">
            <tag name="shopware.entity.definition" entity="topdata_product_cross_selling_extension"/>
        </service>
        
        <service id="Topdata\TopdataConnectorSW6\Core\Content\Device\Agregate\DeviceCustomer\DeviceCustomerDefinition">
            <tag name="shopware.entity.definition" entity="topdata_device_to_customer"/>
        </service>
        <service id="Topdata\TopdataConnectorSW6\Core\Content\Customer\CustomerExtension">
            <tag name="shopware.entity.extension"/>
        </service>
        
        <service id="Topdata\TopdataConnectorSW6\Core\Content\Category\TopdataCategoryExtension\TopdataCategoryExtensionDefinition">
            <tag name="shopware.entity.definition" entity="topdata_category_extension"/>
        </service>
        <service id="Topdata\TopdataConnectorSW6\Core\Content\Category\CategoryExtension">
            <tag name="shopware.entity.extension"/>
        </service>
        
        <!-- CONSOLE COMMANDS -->
        <service id="Topdata\TopdataConnectorSW6\Command\Command_TestConnection" autowire="true">
            <tag name="console.command"/>
        </service>
        <service id="Topdata\TopdataConnectorSW6\Command\Command_Import" autowire="true">
            <tag name="console.command"/>
        </service>
        <service id="Topdata\TopdataConnectorSW6\Command\Command_LastReport" autowire="true">
            <tag name="console.command"/>
        </service>



        <!-- SERVICES -->

        <!-- Shopware6 Core Table Helper Services -->
        <service id="Topdata\TopdataConnectorSW6\Service\Shopware\ShopwarePropertyService" autowire="true" />
        <service id="Topdata\TopdataConnectorSW6\Service\Shopware\ShopwareLanguageService" autowire="true" />
        <service id="Topdata\TopdataConnectorSW6\Service\Shopware\ShopwareProductService" autowire="true"/>
        <service id="Topdata\TopdataConnectorSW6\Service\Shopware\ShopwareProductPropertyService" autowire="true"/>
        <service id="Topdata\TopdataConnectorSW6\Service\Shopware\BreadcrumbService" autowire="true"/>

        <!-- Misc Services -->
        <service id="Topdata\TopdataConnectorSW6\Service\TopdataWebserviceClient" autowire="true"/>
        <service id="Topdata\TopdataConnectorSW6\Service\ImportService" autowire="true"/>
        <service id="Topdata\TopdataConnectorSW6\Service\EntitiesHelperService" autowire="true" />
        <service id="Topdata\TopdataConnectorSW6\Service\ProgressLoggingService" autowire="true"/>
        <service id="Topdata\TopdataConnectorSW6\Service\MediaHelperService" autowire="true" />
        <service id="Topdata\TopdataConnectorSW6\Service\Linking\ProductProductRelationshipServiceV1" autowire="true" />

        <!-- Config Services -->
        <service id="Topdata\TopdataConnectorSW6\Service\Config\ProductImportSettingsService" autowire="true" />
        <service id="Topdata\TopdataConnectorSW6\Service\Config\MergedPluginConfigHelperService" autowire="true"/>

        <!-- Checks Services -->
        <service id="Topdata\TopdataConnectorSW6\Service\Checks\ConfigCheckerService" autowire="true"/>
        <service id="Topdata\TopdataConnectorSW6\Service\Checks\ConnectionTestService" autowire="true"/>

        <!-- Cache Services -->
        <service id="Topdata\TopdataConnectorSW6\Service\Cache\MappingCacheService" autowire="true"/>

        <!-- DB Helper Services -->
        <service id="Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataDeviceSynonymsService" autowire="true" />
        <service id="Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataBrandService" autowire="true" />
        <service id="Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataSeriesService" autowire="true" />
        <service id="Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataDeviceTypeService" autowire="true" />
        <service id="Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataDeviceService" autowire="true" />
        <service id="Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataToProductService" autowire="true"/>

        <!-- Import Services -->
        <service id="Topdata\TopdataConnectorSW6\Service\Import\MappingHelperService" autowire="true" />
        <service id="Topdata\TopdataConnectorSW6\Service\Import\ProductMappingService" autowire="true" />
        <service id="Topdata\TopdataConnectorSW6\Service\Import\DeviceImportService" autowire="true"/>
        <service id="Topdata\TopdataConnectorSW6\Service\Import\DeviceMediaImportService" autowire="true"/>

        <!-- Mapping Strategies -->
        <service id="Topdata\TopdataConnectorSW6\Service\Import\MappingStrategy\MappingStrategy_ProductNumberAs" autowire="true"/>
        <service id="Topdata\TopdataConnectorSW6\Service\Import\MappingStrategy\MappingStrategy_Unified" autowire="true"/>

        <!-- WIP: experimental faster and more reliable syncing -->
        <service id="Topdata\TopdataConnectorSW6\Service\Linking\ProductDeviceRelationshipServiceV1" autowire="true" />
        <service id="Topdata\TopdataConnectorSW6\Service\Linking\ProductDeviceRelationshipServiceV2" autowire="true" />
        <service id="Topdata\TopdataConnectorSW6\Service\ProductInformationServiceV1Slow" autowire="true" />
        <service id="Topdata\TopdataConnectorSW6\Service\ProductInformationServiceV2" autowire="true" />

        <!-- Controllers -->
        <service id="Topdata\TopdataConnectorSW6\Controller\Admin\TopdataWebserviceConnectorAdminApiController" public="true" autowire="true">
            <call method="setContainer">
                <argument type="service" id="service_container"/>
            </call>
        </service>
        
        <!-- CRON JOBS -->
        <service id="Topdata\TopdataConnectorSW6\ScheduledTask\ConnectorImportTask">
            <tag name="shopware.scheduled.task" />
        </service>

        <service id="Topdata\TopdataConnectorSW6\ScheduledTask\ConnectorImportTaskHandler">
            <argument type="service" id="scheduled_task.repository" />
            <argument type="service" id="parameter_bag" />
            <tag name="messenger.message_handler" />
        </service>
        
    </services>
</container>

================
File: src/ScheduledTask/ConnectorImportTask.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\ScheduledTask;

use Shopware\Core\Framework\MessageQueue\ScheduledTask\ScheduledTask;

class ConnectorImportTask extends ScheduledTask
{
    public static function getTaskName(): string
    {
        return 'topdata.connector_import_task';
    }

    public static function getDefaultInterval(): int
    {
        return 3600 * 24; // once a day
    }
}

================
File: src/ScheduledTask/ConnectorImportTaskHandler.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\ScheduledTask;

use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\MessageQueue\ScheduledTask\ScheduledTaskHandler;
use Symfony\Component\DependencyInjection\ParameterBag\ContainerBagInterface;

class ConnectorImportTaskHandler extends ScheduledTaskHandler
{
    /**
     * @var string
     */
    protected $projectPath;

    public function __construct(EntityRepository $scheduledTaskRepository, ContainerBagInterface $ContainerBag)
    {
        $this->projectPath = $ContainerBag->get('kernel.project_dir');
        parent::__construct($scheduledTaskRepository);
    }

    public static function getHandledMessages(): iterable
    {
        return [ConnectorImportTask::class];
    }

    public function run(): void
    {
        /* @TODO:
         * uncomment???
         */
        //        exec("php " . $this->projectPath . '/bin/console topdata:connector:import --all --no-debug > /dev/null');
    }
}

================
File: src/Service/Cache/MappingCacheService.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\Cache;

use DateTime;
use Doctrine\DBAL\Connection;
use Shopware\Core\Framework\Uuid\Uuid;
use Topdata\TopdataConnectorSW6\Constants\MappingTypeConstants;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataToProductService;
use Topdata\TopdataConnectorSW6\Service\Shopware\ShopwareProductService;
use Topdata\TopdataConnectorSW6\Util\ImportReport;
use Topdata\TopdataConnectorSW6\Util\UtilProfiling;
use Topdata\TopdataFoundationSW6\Util\CliLogger;
use Topdata\TopdataFoundationSW6\Util\UtilFormatter;

/**
 * Service for caching mapping data to improve import performance.
 *
 * This service handles caching of EAN, OEM, and PCD mappings to reduce API calls
 * to the TopData webservice during imports. The cache stores the actual mapping values
 * from the webservice rather than Shopware product IDs, making it more flexible and
 * reusable across different Shopware instances or after product data changes.
 *
 * 05/2025 created
 * 05/2025 refactored to store webservice values instead of product IDs
 */
class MappingCacheService
{
    /**
     * Number of records to process in a single batch.
     */
    const BATCH_SIZE = 500;

    /**
     * Cache expiry time in hours.
     */
    const CACHE_EXPIRY_HOURS = 24;

    public function __construct(
        private readonly Connection              $connection,
        private readonly TopdataToProductService $topdataToProductService,
        private readonly ShopwareProductService  $shopwareProductService,
    )
    {
    }

    /**
     * Checks if valid cache exists for the mapping.
     *
     * @param string|null $mappingType Optional mapping type to check for specific cache
     * @return bool True if valid cache exists, false otherwise.
     */
    public function hasCachedMappings(): bool
    {
        UtilProfiling::startTimer();

        // Calculate the expiry date (current time minus cache expiry hours)
        $expiryDate = new DateTime();
        $expiryDate->modify('-' . self::CACHE_EXPIRY_HOURS . ' hours');
        $expiryDateStr = $expiryDate->format('Y-m-d H:i:s');

        // Build the query based on whether a specific mapping type was requested
        $query = 'SELECT 1 FROM topdata_mapping_cache WHERE created_at > :expiryDate LIMIT 1';
        $params = ['expiryDate' => $expiryDateStr];

        // Check if there is at least one cached mapping that is not expired
        $result = $this->connection->fetchOne($query, $params);

        UtilProfiling::stopTimer();

        return $result !== false;
    }

    /**
     * Saves mappings to the cache.
     *
     * @param array $mappings Array of mappings to save.
     * @param string $mappingType The type of mapping (EAN, OEM, PCD).
     */
    public function saveMappingsToCache(array $mappings, string $mappingType): void
    {
        UtilProfiling::startTimer();

        if (empty($mappings)) {
            return;
        }

        // Clear existing cache for this mapping type
        $this->purgeMappingsCacheByType($mappingType);

        $currentDateTime = date('Y-m-d H:i:s');
        $batchInsert = [];

        foreach ($mappings as $mapping) {
            // Store the actual mapping value from the webservice instead of product IDs
            $batchInsert[] = [
                'id'            => Uuid::randomBytes(),
                'mapping_type'  => $mappingType,
                'top_data_id'   => $mapping['topDataId'],
                'mapping_value' => $mapping['value'], // Store the actual identifier value
                'created_at'    => $currentDateTime,
            ];

            // Insert in batches to avoid memory issues
            if (count($batchInsert) >= self::BATCH_SIZE) {
                $this->connection->executeStatement(
                    $this->_buildBatchInsertQuery(count($batchInsert)),
                    $this->_flattenBatchInsertParams($batchInsert)
                );
                $batchInsert = [];
            }
        }

        // Insert any remaining mappings
        if (!empty($batchInsert)) {
            $this->connection->executeStatement(
                $this->_buildBatchInsertQuery(count($batchInsert)),
                $this->_flattenBatchInsertParams($batchInsert)
            );
        }

        ImportReport::setCounter('Cached ' . $mappingType . ' mappings', count($mappings));
        UtilProfiling::stopTimer();
    }

    /**
     * Builds a batch insert query for the cache table.
     *
     * @param int $batchSize The number of records to insert.
     * @return string The SQL query.
     */
    private function _buildBatchInsertQuery(int $batchSize): string
    {
        $placeholders = [];
        for ($i = 0; $i < $batchSize; $i++) {
            $placeholders[] = '(:id' . $i . ', :mapping_type' . $i . ', :top_data_id' . $i . ', :mapping_value' . $i . ', :created_at' . $i . ')';
        }

        return 'INSERT INTO topdata_mapping_cache (id, mapping_type, top_data_id, mapping_value, created_at) VALUES ' . implode(', ', $placeholders);
    }

    /**
     * Flattens batch insert parameters for use with the query.
     *
     * @param array $batch The batch of records to insert.
     * @return array The flattened parameters.
     */
    private function _flattenBatchInsertParams(array $batch): array
    {
        $params = [];
        foreach ($batch as $i => $record) {
            foreach ($record as $key => $value) {
                $params[$key . $i] = $value;
            }
        }
        return $params;
    }

    /**
     * Loads mappings from the cache (topdata_mapping_cache) and inserts them into the topdata_to_product table.
     * Dynamically finds matching Shopware products based on the cached mapping values.
     *
     * @param string|null $mappingType Optional mapping type to load specific cache
     * @return int Number of mappings loaded
     */
    public function loadMappingsFromCache(?string $mappingType = null): int
    {
        UtilProfiling::startTimer();
        CliLogger::info('Loading mappings from cache...');

        // Build the query based on whether a specific mapping type was requested
        $query = 'SELECT mapping_type, top_data_id, mapping_value FROM topdata_mapping_cache';
        $params = [];

        if ($mappingType !== null) {
            $query .= ' WHERE mapping_type = :mappingType';
            $params['mappingType'] = $mappingType;
        }

        // Get cached mappings
        $cachedMappings = $this->connection->fetchAllAssociative($query, $params);

        if (empty($cachedMappings)) {
            CliLogger::warning('No cached mappings found' . ($mappingType ? ' for type ' . $mappingType : '') . '.');
            UtilProfiling::stopTimer();
            return 0;
        }

        // Clear existing mappings before inserting new ones
        // $this->topdataToProductService->deleteAll('Clear existing mappings before inserting new ones');

        // Group mappings by type for efficient processing
        $mappingsByType = [];
        foreach ($cachedMappings as $mapping) {
            $type = $mapping['mapping_type'];
            if (!isset($mappingsByType[$type])) {
                $mappingsByType[$type] = [];
            }
            $mappingsByType[$type][] = [
                'topDataId' => (int)$mapping['top_data_id'],
                'value'     => $mapping['mapping_value']
            ];
        }

        // Process each mapping type
        $total = 0;
        foreach ($mappingsByType as $type => $typeMappings) {
            CliLogger::info('Processing ' . UtilFormatter::formatInteger(count($typeMappings)) . ' ' . $type . ' mappings...');

            // Get the appropriate product map based on mapping type
            $productMap = $this->getProductMapByType($type);
            if (empty($productMap)) {
                CliLogger::warning('No product matches found for ' . $type . ' mappings.');
                continue;
            }

            $batchInsert = [];
            $matchCount = 0;

            foreach ($typeMappings as $mapping) {
                $value = $mapping['value'];
                $topDataId = $mapping['topDataId'];

                // Find matching products for this mapping value
                if (isset($productMap[$value])) {
                    foreach ($productMap[$value] as $productData) {
                        $batchInsert[] = [
                            'topDataId'        => $topDataId,
                            'productId'        => bin2hex($productData['id']),
                            'productVersionId' => bin2hex($productData['version_id']),
                        ];

                        $matchCount++;

                        // Insert in batches to avoid memory issues
                        if (count($batchInsert) >= self::BATCH_SIZE) {
                            // dd($batchInsert);
                            $this->topdataToProductService->insertMany($batchInsert);
                            $batchInsert = [];
                        }
                    }
                }
            }

            // Insert any remaining mappings
            if (!empty($batchInsert)) {
                $this->topdataToProductService->insertMany($batchInsert);
            }

            $total += $matchCount;
            CliLogger::info('Matched ' . UtilFormatter::formatInteger($matchCount) . ' products for ' . $type . ' mappings.');
            ImportReport::setCounter('Matched ' . $type . ' mappings', $matchCount);
        }

        CliLogger::info('Loaded ' . UtilFormatter::formatInteger($total) . ' total mappings from cache' .
            ($mappingType ? ' for type ' . $mappingType : '') . '.');
        ImportReport::setCounter('Loaded mappings from cache' . ($mappingType ? ' (' . $mappingType . ')' : ''), $total);

        UtilProfiling::stopTimer();
        return $total;
    }

    /**
     * Gets the appropriate product map based on mapping type.
     *
     * @param string $mappingType The type of mapping (EAN, OEM, PCD)
     * @return array Map of normalized values to product data
     */
    private function getProductMapByType(string $mappingType): array
    {
        switch ($mappingType) {
            case MappingTypeConstants::EAN:
                return $this->getEanMap();
            case MappingTypeConstants::OEM:
            case MappingTypeConstants::PCD: // PCD uses same format as OEM
                return $this->getOemPcdMap();
            default:
                CliLogger::warning('Unknown mapping type: ' . $mappingType);
                return [];
        }
    }

    /**
     * Gets a map of EAN numbers to Shopware products.
     *
     * @return array Map of normalized EAN values to product data
     */
    private function getEanMap(): array
    {
        $eans = $this->shopwareProductService->getKeysByEan();
        $eanMap = [];

        foreach ($eans as $eanData) {
            if (empty($eanData['ean'])) continue;
            // Normalize: remove non-digits, trim, remove leading zeros
            $normalizedEan = ltrim(trim(preg_replace('/[^0-9]/', '', (string)$eanData['ean'])), '0');
            if (empty($normalizedEan)) continue;

            if (!isset($eanMap[$normalizedEan])) {
                $eanMap[$normalizedEan] = [];
            }

            $eanMap[$normalizedEan][] = [
                'id'         => $eanData['id'],
                'version_id' => $eanData['version_id'],
            ];
        }

        CliLogger::info('Found ' . UtilFormatter::formatInteger(count($eanMap)) . ' unique EANs in Shopware products.');
        return $eanMap;
    }

    /**
     * Gets a map of OEM/PCD numbers to Shopware products.
     *
     * @return array Map of normalized OEM/PCD values to product data
     */
    private function getOemPcdMap(): array
    {
        $oems = $this->shopwareProductService->getKeysByMpn();
        $oemMap = [];

        foreach ($oems as $oemData) {
            if (empty($oemData['manufacturer_number'])) continue;
            // Normalize: lowercase, trim, remove leading zeros
            $normalizedOem = strtolower(ltrim(trim((string)$oemData['manufacturer_number']), '0'));
            if (empty($normalizedOem)) continue;

            if (!isset($oemMap[$normalizedOem])) {
                $oemMap[$normalizedOem] = [];
            }

            $oemMap[$normalizedOem][] = [
                'id'         => $oemData['id'],
                'version_id' => $oemData['version_id'],
            ];
        }

        CliLogger::info('Found ' . UtilFormatter::formatInteger(count($oemMap)) . ' unique OEMs in Shopware products.');

        return $oemMap;
    }

    /**
     * Purges all mappings from the cache.
     */
    public function purgeMappingsCache(): void
    {
        UtilProfiling::startTimer();
        CliLogger::info('Purging mapping cache...');
        $this->connection->executeStatement('TRUNCATE TABLE topdata_mapping_cache');
        CliLogger::info('Mapping cache purged.');
        UtilProfiling::stopTimer();
    }

    /**
     * Purges mappings of a specific type from the cache.
     *
     * @param string $mappingType The type of mapping to purge.
     */
    public function purgeMappingsCacheByType(string $mappingType): void
    {
        UtilProfiling::startTimer();
        CliLogger::info('Purging ' . $mappingType . ' mapping cache...');
        $this->connection->executeStatement(
            'DELETE FROM topdata_mapping_cache WHERE mapping_type = :mappingType',
            ['mappingType' => $mappingType]
        );
        CliLogger::info($mappingType . ' mapping cache purged.');
        UtilProfiling::stopTimer();
    }

    /**
     * Gets statistics about the cache.
     *
     * @return array Cache statistics
     */
    public function getCacheStats(): array
    {
        $stats = [];

        // Get total count
        $stats['total'] = (int)$this->connection->fetchOne('SELECT COUNT(*) FROM topdata_mapping_cache');

        // Get counts by mapping type
        $typeStats = $this->connection->fetchAllAssociative(
            'SELECT mapping_type, COUNT(*) as count FROM topdata_mapping_cache GROUP BY mapping_type ORDER BY count DESC'
        );

        foreach ($typeStats as $typeStat) {
            $stats['by_type'][$typeStat['mapping_type']] = (int)$typeStat['count'];
        }

        // Get age of oldest and newest cache entries
        if ($stats['total'] > 0) {
            $stats['oldest'] = $this->connection->fetchOne('SELECT MIN(created_at) FROM topdata_mapping_cache');
            $stats['newest'] = $this->connection->fetchOne('SELECT MAX(created_at) FROM topdata_mapping_cache');
        }

        return $stats;
    }
}

================
File: src/Service/Checks/ConfigCheckerService.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\Checks;

use Shopware\Core\System\SystemConfig\SystemConfigService;

/**
 * a service which checks if the plugin configuration is valid.
 *
 * 04/2024 created
 */
class ConfigCheckerService
{
    public function __construct(
        private readonly SystemConfigService $systemConfigService)
    {
    }

    /**
     * 04/2024 created.
     */
    public function isConfigEmpty(): bool
    {
        $config = $this->systemConfigService->get('TopdataConnectorSW6.config');

        return empty($config['apiUid']) ||
            empty($config['apiPassword']) ||
            empty($config['apiSecurityKey']) ||
            empty($config['apiLanguage']);
    }


}

================
File: src/Service/Checks/ConnectionTestService.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Service\Checks;

use Shopware\Core\System\SystemConfig\SystemConfigService;
use Topdata\TopdataConnectorSW6\Constants\GlobalPluginConstants;
use Topdata\TopdataConnectorSW6\Service\TopdataWebserviceClient;

/**
 * 11/2024 created (extracted from TestConnectionCommand)
 */
class ConnectionTestService
{


    public function __construct(
        private readonly SystemConfigService     $systemConfigService,
        private readonly ConfigCheckerService    $configCheckerService,
        private readonly TopdataWebserviceClient $topdataWebserviceClient,
    )
    {
    }

    public function testConnection(): array
    {
        $config = $this->systemConfigService->get('TopdataConnectorSW6.config');

        if ($this->configCheckerService->isConfigEmpty()) {
            return [
                'success' => false,
                'message' => GlobalPluginConstants::ERROR_MESSAGE_NO_WEBSERVICE_CREDENTIALS
            ];
        }

        try {
            $info = $this->topdataWebserviceClient->getUserInfo();

            if (isset($info->error)) {
                return [
                    'success' => false,
                    'message' => "Connection error: {$info->error[0]->error_message}"
                ];
            }

            return [
                'success' => true,
                'message' => 'Connection success!'
            ];

        } catch (\Throwable $e) {
            return [
                'success' => false,
                'message' => "Connection error: {$e->getMessage()}"
            ];
        }
    }
}

================
File: src/Service/Config/MergedPluginConfigHelperService.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\Config;

use Shopware\Core\System\SystemConfig\SystemConfigService;
use Topdata\TopdataConnectorSW6\Constants\MergedPluginConfigKeyConstants;
use Topdata\TopdataFoundationSW6\Util\CliLogger;

/**
 * Service class to handle options for Topdata's Topfeed plugin.
 * as some of the import options are in the settings of the Topfeed plugin,
 * we need to load them here
 *
 * 03/2025 renamed from OptionsHelperService to TopfeedOptionsHelperService
 * 04/2025 renamed TopfeedOptionsHelperService to MergedPluginConfigHelperService
 */
class MergedPluginConfigHelperService
{
    private array $options = [];

    public function __construct(
        private readonly SystemConfigService $systemConfigService,
    )
    {
    }

    /**
     * Set an option.
     *
     * An "option" can be either something from command line or a plugin setting.
     *
     * @param string $name the option name
     * @param mixed $value the option value
     */
    private function _setOption($name, $value): void
    {
        CliLogger::getCliStyle()->blue("option: $name = $value");
        $this->options[$name] = $value;
    }

    /**
     * Set multiple options at once.
     *
     * @param array $keyValueArray an array of option name-value pairs
     */
    private function _setOptions(array $keyValueArray): void
    {
        foreach ($keyValueArray as $key => $value) {
            $this->_setOption($key, $value);
        }
    }

    /**
     * Get an option.
     *
     * @param string $name the option name
     * @return mixed  the option value or false if the option is not set
     */
    public function getOption(string $name): mixed
    {
        return $this->options[$name] ?? false;
    }


    /**
     * Load Topdata Topfeed plugin configuration.
     *
     * This method copies settings from Topdata's Topfeed plugin config to the options array.
     *
     *
     * 06/2024 created
     * 10/2024 moved from ImportCommand to OptionsHelperService
     */
    public function _loadOptionsFromTopFeedPluginConfig(): void
    {
        $topfeedPluginConfig = $this->systemConfigService->get('TopdataTopFeedSW6.config');
        if (!$topfeedPluginConfig) {
            CliLogger::warning('TopdataTopFeedSW6.config not found in system config');
            return;
        }

        // MergedPluginConfigHelperService::_setOptions(): Argument #1 ($keyValueArray) must be of type array, null given
        $this->_setOptions($topfeedPluginConfig ?? []);
        $this->_setOption(MergedPluginConfigKeyConstants::PRODUCT_COLOR_VARIANT, $topfeedPluginConfig['productVariantColor']); // FIXME? 'productColorVariant' != 'productVariantColor'
        $this->_setOption(MergedPluginConfigKeyConstants::PRODUCT_CAPACITY_VARIANT, $topfeedPluginConfig['productVariantCapacity']); // FIXME? 'productCapacityVariant' != 'productVariantCapacity'

    }

    /**
     * Initializes the options for the import process.
     *
     * This method retrieves configuration settings from the system configuration
     * and sets the corresponding options in the OptionsHelperService.
     *
     * 04/2025 moved from ImportService::_initOptions() to TopfeedOptionsHelperService::loadOptionsFromConnectorPluginConfig()
     */
    public function _loadOptionsFromConnectorPluginConfig(): void
    {
        $pluginConfig = $this->systemConfigService->get('TopdataConnectorSW6.config');

        $this->_setOptions([
            MergedPluginConfigKeyConstants::MAPPING_TYPE          => $pluginConfig['mappingType'],
            MergedPluginConfigKeyConstants::ATTRIBUTE_OEM         => $pluginConfig['attributeOem'] ?? '',
            MergedPluginConfigKeyConstants::ATTRIBUTE_EAN         => $pluginConfig['attributeEan'] ?? '',
            MergedPluginConfigKeyConstants::ATTRIBUTE_ORDERNUMBER => $pluginConfig['attributeOrdernumber'] ?? '',   // fixme: this is not an ordernumber, but a product number
        ]);
    }

    /**
     * 04/2025 created
     */
    public function init(): void
    {
        $this->_loadOptionsFromConnectorPluginConfig();
        $this->_loadOptionsFromTopFeedPluginConfig();
        CliLogger::dump($this->options, "OPTIONS");
    }


}

================
File: src/Service/Config/ProductImportSettingsService.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\Config;

use Doctrine\DBAL\Connection;
use Shopware\Core\Framework\Uuid\Uuid;
use Topdata\TopdataConnectorSW6\Constants\MergedPluginConfigKeyConstants;
use Topdata\TopdataConnectorSW6\Service\Shopware\BreadcrumbService;
use Topdata\TopdataFoundationSW6\Util\CliLogger;

/**
 * Service class responsible for managing product import settings in a hierarchical way ("hierarchical configuration override").
 *
 * This service allows retrieving and loading product-specific import settings,
 * overriding the global settings defined in OptionsHelperService.
 * It fetches settings based on the product's category and stores them for later use.
 */
class ProductImportSettingsService
{


    /**
     * @var array a map {productId => ...} containing product import settings.
     */
    private array $productImportSettings = [];

    /**
     * Constructor for the ProductImportSettingsService.
     *
     * @param MergedPluginConfigHelperService $optionsHelperService The service for retrieving merged plugin configurations.
     * @param Connection $connection The database connection.
     */
    public function __construct(
        private readonly MergedPluginConfigHelperService $optionsHelperService,
        private readonly Connection $connection,
        private readonly BreadcrumbService $breadcrumbService
    )
    {
    }

    /**
     * Logs all categories that have configuration overrides enabled.
     * The categories are displayed as a breadcrumb path.
     */
    public function logCategoryOverrides(): void
    {
        CliLogger::section('Categories with Configuration Overrides');

        $overriddenCategories = $this->connection->fetchFirstColumn('
            SELECT LOWER(HEX(category_id))
            FROM topdata_category_extension
            WHERE plugin_settings = 0
        ');

        if (empty($overriddenCategories)) {
            CliLogger::info('No category overrides found.');

            return;
        }

        $breadcrumbs = [];
        foreach ($overriddenCategories as $categoryId) {
            $breadcrumbs[] = $this->breadcrumbService->getCategoryBreadcrumb($categoryId);
        }

        CliLogger::getCliStyle()->list($breadcrumbs);
    }

    /**
     * Retrieves the value of a product option based on the provided option name and product ID.
     *
     * This method first checks if the product has specific import settings. If so, it retrieves the option value
     * from these settings. If not, it falls back to the global option settings.
     *
     * @param string $optionName The name of the option to retrieve, see MergedPluginConfigKeyConstants.
     * @param string $productId The ID of the product for which to retrieve the option.
     * @return bool Returns true if the option is enabled, false otherwise.
     */
    public function isProductOptionEnabled(string $optionName, string $productId): bool
    {
        if (isset($this->productImportSettings[$productId])) {
            // ---- get mapped option name
            $mappedOptionName = [
                MergedPluginConfigKeyConstants::OPTION_NAME_productName                    => 'name',
                MergedPluginConfigKeyConstants::OPTION_NAME_productDescription             => 'description',
                MergedPluginConfigKeyConstants::OPTION_NAME_productBrand                   => 'brand',
                MergedPluginConfigKeyConstants::OPTION_NAME_productEan                     => 'EANs',
                MergedPluginConfigKeyConstants::OPTION_NAME_productOem                     => 'MPNs',
                MergedPluginConfigKeyConstants::OPTION_NAME_productImages                  => 'pictures',
                MergedPluginConfigKeyConstants::OPTION_NAME_productImagesDelete            => 'unlinkOldPictures',
                MergedPluginConfigKeyConstants::OPTION_NAME_productSpecifications          => 'properties',
                MergedPluginConfigKeyConstants::OPTION_NAME_specReferencePCD               => 'PCDsProp',
                MergedPluginConfigKeyConstants::OPTION_NAME_specReferenceOEM               => 'MPNsProp',
                // ----
                MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productSimilar         => 'importSimilar',
                MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productAlternate       => 'importAlternates',
                MergedPluginConfigKeyConstants::RELATIONSHIO_OPTION_productRelated         => 'importAccessories',
                MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productBundled         => 'importBoundles',
                MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productVariant         => 'importVariants',
                MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productColorVariant    => 'importColorVariants',
                MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productCapacityVariant => 'importCapacityVariants',
                // ----
                MergedPluginConfigKeyConstants::OPTION_NAME_productSimilarCross            => 'crossSimilar',
                MergedPluginConfigKeyConstants::OPTION_NAME_productAlternateCross          => 'crossAlternates',
                MergedPluginConfigKeyConstants::OPTION_NAME_productRelatedCross            => 'crossAccessories',
                MergedPluginConfigKeyConstants::OPTION_NAME_productBundledCross            => 'crossBoundles',
                MergedPluginConfigKeyConstants::OPTION_NAME_productVariantCross            => 'crossVariants',
                MergedPluginConfigKeyConstants::OPTION_NAME_productVariantColorCross       => 'crossColorVariants',
                MergedPluginConfigKeyConstants::OPTION_NAME_productVariantCapacityCross    => 'crossCapacityVariants',
            ][$optionName] ?? '';

            return $this->productImportSettings[$productId][$mappedOptionName] ?? false;
        }

        // return option from topFEED config
        return $this->optionsHelperService->getOption($optionName) ? true : false;
    }


    /**
     * Loads product import settings for the given product IDs.
     *
     * This method fetches the category paths for the given product IDs and then loads the import settings
     * for each category. The settings are then mapped to the corresponding products.
     *
     * @param array $productIds An array of product IDs for which to load import settings.
     */
    public function loadProductImportSettings(array $productIds): void
    {
        // ---- Initialize the product import settings array
        $this->productImportSettings = [];

        // ---- Return early if no product IDs are provided
        if (!count($productIds)) {
            return;
        }

        // ---- Load each product category path
        $productCategories = [];
        $allCategories = [];
        $ids = '0x' . implode(',0x', $productIds);
        $temp = $this->connection->fetchAllAssociative('
        SELECT LOWER(HEX(id)) as id, category_tree
          FROM product 
          WHERE (category_tree is NOT NULL) 
            AND (id IN (' . $ids . '))
    ');

        // ---- Parse the category tree for each product
        foreach ($temp as $item) {
            $parsedIds = json_decode($item['category_tree'], true);
            foreach ($parsedIds as $id) {
                if (is_string($id) && Uuid::isValid($id)) {
                    $productCategories[$item['id']][] = $id;
                    $allCategories[$id] = false;
                }
            }
        }

        // ---- Return early if no categories are found
        if (!count($allCategories)) {
            return;
        }

        // ---- Load each category's settings
        $ids = '0x' . implode(',0x', array_keys($allCategories));
        $temp = $this->connection->fetchAllAssociative('
        SELECT LOWER(HEX(category_id)) as id, import_settings
          FROM topdata_category_extension 
          WHERE (plugin_settings=0) AND (category_id IN (' . $ids . '))
    ');

        // ---- Map the settings to the corresponding categories
        foreach ($temp as $item) {
            $allCategories[$item['id']] = json_decode($item['import_settings'], true);
        }

        // ---- Set product settings based on category
        foreach ($productCategories as $productId => $categoryTree) {
            for ($i = (count($categoryTree) - 1); $i >= 0; $i--) {
                if (isset($allCategories[$categoryTree[$i]])
                    &&
                    $allCategories[$categoryTree[$i]] !== false
                ) {
                    $this->productImportSettings[$productId] = $allCategories[$categoryTree[$i]];
                    break;
                }
            }
        }
    }


    /**
     * Filters product IDs based on a given configuration option.
     *
     * @param string $optionName The name of the configuration option to check., see MergedPluginConfigKeyConstants.
     * @param array $productIds An array of product IDs to filter.
     * @return array An array of product IDs that match the configuration option.
     *
     * 04/2025 moved from ProductInformationServiceV1Slow::_filterIdsByConfig() to ProductImportSettingsService::filterProductIdsByConfig()
     */
    public function filterProductIdsByConfig(string $optionName, array $productIds): array
    {
        $returnIds = [];
        // ---- Iterate over each product ID
        foreach ($productIds as $pid) {
            // ---- Check if the product option is enabled for the current product ID
            if ($this->isProductOptionEnabled($optionName, $pid)) {
                $returnIds[] = $pid;
            }
        }

        return $returnIds;
    }


}

================
File: src/Service/DbHelper/README_AI.md
================
for each table one service...

================
File: src/Service/DbHelper/TopdataBrandService.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Service\DbHelper;

use Doctrine\DBAL\Connection;

/**
 * Service class for managing Topdata brands.
 * Provides methods to retrieve enabled brands and save primary brands.
 */
class TopdataBrandService
{
    public function __construct(
        private readonly Connection $connection
    ) {
    }

    private ?array $brandsByWsIdCache = null;

    /**
     * Retrieves enabled brands from the database.
     *
     * @return array An array containing all enabled brands and primary brands.
     *               The array has the following structure:
     *               [
     *                   'brands' => [brandId => brandName, ...],
     *                   'primary' => [brandId => brandName, ...],
     *                   'brandsCount' => int,
     *                   'primaryCount' => int,
     *               ]
     */
    public function getEnabledBrands(): array
    {
        $allBrands = [];
        $primaryBrands = [];
        
        // ---- Fetch enabled brands from the database
        $brands = $this->connection->createQueryBuilder()
            ->select('LOWER(HEX(id)) as id, label as name, sort')
            ->from('topdata_brand')
            ->where('is_enabled = 1')
            ->orderBy('label')
            ->execute()
            ->fetchAllAssociative();

        // ---- Process the fetched brands
        foreach ($brands as $brand) {
            $allBrands[$brand['id']] = $brand['name'];
            if ($brand['sort'] == 1) {
                $primaryBrands[$brand['id']] = $brand['name'];
            }
        }

        return [
            'brands' => $allBrands,
            'primary' => $primaryBrands,
            'brandsCount' => count($allBrands),
            'primaryCount' => count($primaryBrands),
        ];
    }

    /**
     * Saves the provided brand IDs as primary brands in the database.
     *
     * @param array|null $brandIds An array of brand IDs to be set as primary brands.
     *                             If null, all brands will be set as non-primary.
     *
     * @return bool True if the operation was successful, false if the input was invalid.
     */
    public function savePrimaryBrands(?array $brandIds): bool
    {
        if ($brandIds === null) {
            return false;
        }

        // ---- Reset all brands to non-primary
        $this->connection->executeStatement('UPDATE topdata_brand SET sort = 0');

        if ($brandIds) {
            // ---- Prepare brand IDs for the SQL query
            foreach ($brandIds as $key => $brandId) {
                if (preg_match('/^[0-9a-f]{32}$/', $brandId)) {
                    $brandIds[$key] = '0x' . $brandId;
                }
            }

            // ---- Update the specified brands as primary
            $this->connection->executeStatement(
                'UPDATE topdata_brand SET sort = 1 WHERE id IN (' . implode(',', $brandIds) . ')'
            );
        }

        return true;
    }
    /**
     * Loads all brands from the database and populates the internal cache, keyed by ws_id.
     */
    private function _loadBrandsByWsId(): void
    {
        $brands = $this->connection->createQueryBuilder()
            ->select('LOWER(HEX(id)) as id, ws_id, code, label, is_enabled, sort')
            ->from('topdata_brand')
            ->where('ws_id IS NOT NULL') // Ensure we only get brands with a ws_id
            ->execute()
            ->fetchAllAssociative();

        $this->brandsByWsIdCache = [];
        foreach ($brands as $brand) {
            // Ensure ws_id is treated as an integer key
            $wsId = (int) $brand['ws_id'];
            $this->brandsByWsIdCache[$wsId] = $brand;
        }
    }

    /**
     * Retrieves a specific brand by its Webservice ID (ws_id).
     * Uses an internal cache for efficiency.
     *
     * @param int $brandWsId The Webservice ID of the brand to retrieve.
     * @return array The brand data as an associative array, or an empty array if not found.
     */
    public function getBrandByWsId(int $brandWsId): array
    {
        if ($this->brandsByWsIdCache === null) {
            $this->_loadBrandsByWsId();
        }

        return $this->brandsByWsIdCache[$brandWsId] ?? [];
    }


}

================
File: src/Service/DbHelper/TopdataDeviceService.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\DbHelper;

use Doctrine\DBAL\Connection;

/**
 * Service class for handling Topdata device related operations.
 * 11/2024 created (extracted from MappingHelperService)
 */
class TopdataDeviceService
{

    public function __construct(
        private readonly Connection $connection,
    )
    {
    }

    /**
     * Retrieves all enabled devices from the database.
     *
     * @return array An array of associative arrays representing the enabled devices.
     */
    public function _getEnabledDevices(): array
    {
        $query = $this->connection->createQueryBuilder();
        $query->select(['*'])
            ->from('topdata_device')
            ->where('is_enabled = 1');

        return $query->execute()->fetchAllAssociative();
    }

    /**
     * Retrieves an array of devices based on an array of WS IDs.
     * 03/2025 extracted from MappingHelperService
     *
     * @param array $wsIds An array of WS IDs to filter devices by.
     *
     * @return array An array of devices matching the provided WS IDs.
     */
    public function getDeviceArrayByWsIdArray(array $wsIds): array
    {
        if (!count($wsIds)) {
            return [];
        }
        $ret = []; // a list of devices

        // $this->brandWsArray = []; // FIXME: why is this here?
        $queryRez = $this->connection->createQueryBuilder()
            ->select('*')
            ->from('topdata_device')
            ->where('ws_id IN (' . implode(',', $wsIds) . ')')
            ->execute()
            ->fetchAllAssociative();

        // ---- Process the query results
        foreach ($queryRez as $device) {
            $device['id'] = bin2hex($device['id']);
            $device['brand_id'] = bin2hex($device['brand_id'] ?? '');
            $device['type_id'] = bin2hex($device['type_id'] ?? '');
            $device['series_id'] = bin2hex($device['series_id'] ?? '');
            $ret[] = $device;
        }

        return $ret;
    }

}

================
File: src/Service/DbHelper/TopdataDeviceSynonymsService.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\DbHelper;

use Doctrine\DBAL\Connection;
use Exception;
use Topdata\TopdataConnectorSW6\Constants\MergedPluginConfigKeyConstants;
use Topdata\TopdataConnectorSW6\Constants\WebserviceFilterTypeConstants;
use Topdata\TopdataConnectorSW6\Service\TopdataWebserviceClient;
use Topdata\TopdataConnectorSW6\Util\UtilProfiling;
use Topdata\TopdataFoundationSW6\Util\CliLogger;
use Topdata\TopdataFoundationSW6\Util\UtilFormatter;

/**
 * TODO: split: Db Helper Service + Import Service
 * Service to handle device synonyms.
 * 11/2024 created (extracted from MappingHelperService)
 */
class TopdataDeviceSynonymsService
{

    public function __construct(
        private readonly TopdataWebserviceClient     $topdataWebserviceClient,
        private readonly TopdataDeviceService        $topdataDeviceService,
        private readonly Connection                  $connection,
    )
    {
    }

    /**
     * it populates the topdata_device_to_synonym table
     *
     * Sets device synonyms by fetching data from the Topdata webservice and storing it in the database.
     *
     * @throws Exception
     */
    public function setDeviceSynonyms(): void
    {
        UtilProfiling::startTimer();
        CliLogger::section("Device synonyms");
        $enabledDevices = [];
        foreach ($this->topdataDeviceService->_getEnabledDevices() as $pr) {
            $enabledDevices[$pr['ws_id']] = bin2hex($pr['id']);
        }
        CliLogger::info(UtilFormatter::formatInteger(count($enabledDevices)) . " enabled devices found.");

        // ---- process chunks ----
        $chunkSize = 50;
        $chunks = array_chunk($enabledDevices, $chunkSize, true);
        CliLogger::lap(true);

        foreach ($chunks as $idxChunk => $chunk) {
//            if ($this->optionsHelperService->getOption(OptionConstants::START) && ($idxChunk + 1 < $this->optionsHelperService->getOption(OptionConstants::START))) {
//                continue;
//            }
//
//            if ($this->optionsHelperService->getOption(OptionConstants::END) && ($idxChunk + 1 > $this->optionsHelperService->getOption(OptionConstants::END))) {
//                break;
//            }

//            CliLogger::activity('xxx1 - Fetching data from remote server part ' . ($idxChunk + 1) . '/' . count($chunks) . '...');
            CliLogger::progress( ($idxChunk + 1), count($chunks), 'Fetching data from remote server [Device Synonyms]...');
            $response = $this->topdataWebserviceClient->myProductList([
                'products' => implode(',', array_keys($chunk)),
                'filter'   => WebserviceFilterTypeConstants::all,
            ]);
            CliLogger::activity(CliLogger::lap() . "sec\n");

            if (!isset($response->page->available_pages)) {
                throw new Exception($response->error[0]->error_message . ' device synonym webservice no pages');
            }
            //            \Topdata\TopdataFoundationSW6\Util\CliLogger::mem();
            CliLogger::activity("\nProcessing data...");

            // ---- Delete existing synonyms for the current chunk of devices
            $this->connection->executeStatement('DELETE FROM topdata_device_to_synonym WHERE device_id IN (0x' . implode(', 0x', $chunk) . ')');

            $variantsMap = [];
            foreach ($response->products as $product) {
                if (isset($product->product_variants->products)) {
                    foreach ($product->product_variants->products as $variant) {
                        if (($variant->type == 'synonym')
                            && isset($chunk[$product->products_id])
                            && isset($enabledDevices[$variant->id])
                        ) {
                            $prodId = $chunk[$product->products_id];
                            if (!isset($variantsMap[$prodId])) {
                                $variantsMap[$prodId] = [];
                            }
                            $variantsMap[$prodId][] = $enabledDevices[$variant->id];
                        }
                    }
                }
            }

            $dateTime = date('Y-m-d H:i:s');
            $dataInsert = [];
            foreach ($variantsMap as $deviceId => $synonymIds) {
                foreach ($synonymIds as $synonymId) {
                    $dataInsert[] = "(0x{$deviceId}, 0x{$synonymId}, '$dateTime')";
                }
            }

            if (count($dataInsert)) {
                // ---- Chunk the insert data to avoid exceeding database limits
                $insertDataChunks = array_chunk($dataInsert, 50);
                foreach ($insertDataChunks as $dataChunk) {
                    $this->connection->executeStatement(
                        'INSERT INTO topdata_device_to_synonym (device_id, synonym_id, created_at) VALUES ' . implode(',', $dataChunk)
                    );
                    CliLogger::activity();
                }
            }
            CliLogger::activity(CliLogger::lap() . 'sec ');
            CliLogger::mem();
            CliLogger::writeln('');
        }

        UtilProfiling::stopTimer();
    }

}

================
File: src/Service/DbHelper/TopdataDeviceTypeService.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Service\DbHelper;

use Doctrine\DBAL\Connection;

/**
 * Service to manage Topdata device types.
 * 03/2025 created (extracted from MappingHelperService)
 */
class TopdataDeviceTypeService
{
    private ?array $typesArray = null; // some cache

    public function __construct(
        private readonly Connection $connection
    ) {
    }

    /**
     * Retrieves an array of Topdata device types.
     *
     * @param bool $forceReload If true, forces a reload of the device types from the database.
     * @return array An array of device types, indexed by their hexadecimal ID.
     */
    public function getTypesArray($forceReload = false): array
    {
        // Check if the types array is already cached or if a reload is forced.
        if ($this->typesArray === null || $forceReload) {
            $this->typesArray = [];

            // ---- Fetch device types from the database.
            $results = $this
                ->connection
                ->createQueryBuilder()
                ->select('*')
//                ->select(['id','code', 'label', 'brand_id', 'ws_id'])
                ->from('topdata_device_type')
                ->execute()
                ->fetchAllAssociative();

            // ---- Process the database results and format the array.
            foreach ($results as $r) {
                $this->typesArray[bin2hex($r['id'])] = $r;
                $this->typesArray[bin2hex($r['id'])]['id'] = bin2hex($r['id']);
                $this->typesArray[bin2hex($r['id'])]['brand_id'] = bin2hex($r['brand_id']);
            }
        }

        return $this->typesArray;
    }

}

================
File: src/Service/DbHelper/TopdataSeriesService.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Service\DbHelper;

use Doctrine\DBAL\Connection;

/**
 * 03/2025 created (extracted from MappingHelperService)
 */
class TopdataSeriesService
{
    private ?array $seriesArray = null; // some cache

    public function __construct(
        private readonly Connection $connection
    ) {
    }

    public function getSeriesArray($forceReload = false): array
    {
        if ($this->seriesArray === null || $forceReload) {
            $this->seriesArray = [];
            $results = $this
                ->connection
                ->createQueryBuilder()
                ->select('*')
//                ->select(['id','code', 'label', 'brand_id', 'ws_id'])
                ->from('topdata_series')
                ->execute()
                ->fetchAllAssociative();
            foreach ($results as $r) {
                $this->seriesArray[bin2hex($r['id'])] = $r;
                $this->seriesArray[bin2hex($r['id'])]['id'] = bin2hex($r['id']);
                $this->seriesArray[bin2hex($r['id'])]['brand_id'] = bin2hex($r['brand_id']);
            }
        }

        return $this->seriesArray;
    }

}

================
File: src/Service/DbHelper/TopdataToProductService.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\DbHelper;

use Doctrine\DBAL\Connection;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Topdata\TopdataFoundationSW6\Util\CliLogger;
use Topdata\TopdataFoundationSW6\Util\UtilFormatter;

/**
 * service for handling the mapping between Topdata IDs and Shopware product IDs (in table `topdata_to_product`)
 * It handles fetching and storing product mappings in the database.
 *
 * 11/2024 created (extracted from MappingHelperService)
 */
class TopdataToProductService
{

    /**
     * @var array|null it is a map with format: [top_data_id => [product_id, product_version_id, parent_id]]
     * some kind of cache, returned by getTopdataProductMappings()
     */
    private ?array $topdataProductMappings = null;
    private Context $context; // default context


    public function __construct(
        private readonly Connection       $connection,
        private readonly EntityRepository $topdataToProductRepository
    )
    {
        $this->context = Context::createDefaultContext();
    }


    /**
     * Retrieves the mapping between Topdata IDs and product IDs.
     * The mapping is fetched from the database and cached in memory for subsequent calls.
     *
     * @param bool $forceReload If true, forces a reload of the mapping from the database, otherwise the cached version is returned if available.
     * @return array An array representing the mapping, where keys are Topdata IDs and values are arrays containing product_id, product_version_id, and parent_id.
     *
     * 04/2025 renamed from getTopidProducts() to getTopdataProductMappings()
     */
    public function getTopdataProductMappings(bool $forceReload = false): array
    {
        if ($this->topdataProductMappings === null || $forceReload) {
            // ---- fetch from db
            $rows = $this->connection->fetchAllAssociative('
                SELECT 
                    topdata_to_product.top_data_id, 
                    LOWER(HEX(topdata_to_product.product_id))          AS product_id, 
                    LOWER(HEX(topdata_to_product.product_version_id))  AS product_version_id, 
                    LOWER(HEX(product.parent_id))                      AS parent_id 
                FROM `topdata_to_product`
                INNER JOIN product ON 
                    topdata_to_product.product_id = product.id AND 
                    topdata_to_product.product_version_id = product.version_id 
                ORDER BY topdata_to_product.top_data_id
            ');

            // ---- log to console
            CliLogger::info('getTopdataProductMappings - fetched ' . UtilFormatter::formatInteger(count($rows)) . ' mappings from database [topdata_to_product]');
            if (empty($rows)) {
                CliLogger::warning('No mapped products found in database. Did you set the correct mapping in plugin config?');
            }

            // ---- build the map
            $this->topdataProductMappings = [];
            foreach ($rows as $row) {
                $this->topdataProductMappings[$row['top_data_id']][] = [
                    'product_id'         => $row['product_id'],
                    'product_version_id' => $row['product_version_id'],
                    'parent_id'          => $row['parent_id'],
                ];
            }
        }

        return $this->topdataProductMappings;
    }


    /**
     * Inserts multiple Topdata to product mappings into the database.
     * 11/2024 created
     *
     * @param array $dataInsert An array of data to insert into the topdata_to_product table.
     */
    public function insertMany(array $dataInsert): void
    {
        $this->topdataToProductRepository->create($dataInsert, $this->context);
    }

    /**
     * 06/2025 added logMessageHint
     */
    public function deleteAll(?string $logMessageHint = null)
    {
        CliLogger::warning("Clearing tbl `topdata_to_product`" . ($logMessageHint ? ' # ' . $logMessageHint : ''));
//        CliLogger::warning('FIXME: NOT Deleting all existing mappings tbl `topdata_to_product`');
//        return; // TODO: remove this

        $this->connection->executeStatement('TRUNCATE TABLE topdata_to_product');
    }


}

================
File: src/Service/Import/MappingStrategy/AbstractMappingStrategy.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\Import\MappingStrategy;

use Topdata\TopdataConnectorSW6\DTO\ImportConfig;

/**
 * 03/2025 created
 */
abstract  class AbstractMappingStrategy
{
    abstract public function map(ImportConfig $importConfig): void;
}

================
File: src/Service/Import/MappingStrategy/MappingStrategy_Distributor.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\Import\MappingStrategy;


use Exception;
use Override;
use Topdata\TopdataConnectorSW6\Constants\MappingTypeConstants;
use Topdata\TopdataConnectorSW6\Constants\MergedPluginConfigKeyConstants;
use Topdata\TopdataConnectorSW6\DTO\ImportConfig;
use Topdata\TopdataConnectorSW6\Service\Config\MergedPluginConfigHelperService;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataToProductService;
use Topdata\TopdataConnectorSW6\Service\Shopware\ShopwareProductPropertyService;
use Topdata\TopdataConnectorSW6\Service\Shopware\ShopwareProductService;
use Topdata\TopdataConnectorSW6\Service\TopdataWebserviceClient;
use Topdata\TopdataConnectorSW6\Util\UtilMappingHelper;
use Topdata\TopdataFoundationSW6\Util\CliLogger;
use Topdata\TopdataFoundationSW6\Util\UtilFormatter;

/**
 * 03/2025 created (extracted from ProductMappingService)
 */
final class MappingStrategy_Distributor extends AbstractMappingStrategy
{

    const BATCH_SIZE = 500;

    public function __construct(
        private readonly MergedPluginConfigHelperService $mergedPluginConfigHelperService,
        private readonly TopdataToProductService         $topdataToProductService,
        private readonly TopdataWebserviceClient         $topdataWebserviceClient,
        private readonly ShopwareProductService          $shopwareProductService,
        private readonly ShopwareProductPropertyService  $shopwareProductPropertyService,
    )
    {
    }


    /**
     * ==== MAIN ====
     *
     * Maps products using the distributor mapping strategy.
     *
     * This method handles the mapping of products based on distributor data. It fetches product data from the database,
     * processes it, and inserts the mapped data into the `topdata_to_product` repository. The mapping strategy is determined
     * by the options set in `OptionConstants`.
     *
     * @throws Exception if no products are found or if the web service does not return the expected number of pages
     */
    // private function _mapDistributor(): void
    #[Override]
    public function map(ImportConfig $importConfig): void
    {
        $dataInsert = [];
        $artnos = $this->_getArticleNumbers();

        $stored = 0;
        CliLogger::info(UtilFormatter::formatInteger(count($artnos)) . ' products to check ...');

        // ---- Iterate through the pages of distributor data from the web service
        for ($page = 1; ; $page++) {
            $all_artnr = $this->topdataWebserviceClient->matchMyDistributor(['page' => $page]);
            if (!isset($all_artnr->page->available_pages)) {
                throw new Exception('distributor webservice no pages');
            }
            $available_pages = $all_artnr->page->available_pages;

            // ---- Process each product in the current page
            foreach ($all_artnr->match as $prod) {
                foreach ($prod->distributors as $distri) {
                    //if((int)$s['distributor_id'] != (int)$distri->distributor_id)
                    //    continue;
                    foreach ($distri->artnrs as $artnr) {
                        $key = (string)$artnr;
                        if (isset($artnos[$key])) {
                            foreach ($artnos[$key] as $artnosValue) {
                                $stored++;
                                if (($stored % 50) == 0) {
                                    CliLogger::activity();
                                }
                                $dataInsert[] = [
                                    'topDataId'        => $prod->products_id,
                                    'productId'        => $artnosValue['id'],
                                    'productVersionId' => $artnosValue['version_id'],
                                ];
                                if (count($dataInsert) > self::BATCH_SIZE) {
                                    $this->topdataToProductService->insertMany($dataInsert);
                                    $dataInsert = [];
                                }
                            }
                        }
                    }
                }
            }

//            CliLogger::activity("\ndistributor $i/$available_pages");
//            CliLogger::mem();
//            CliLogger::writeln('');
            CliLogger::progress( $page , count($available_pages), 'fetch distributor data');
            if ($page >= $available_pages) {
                break;
            }
        }
        if (count($dataInsert) > 0) {
            $this->topdataToProductService->insertMany($dataInsert);
        }
        CliLogger::writeln("\n" . UtilFormatter::formatInteger($stored) . ' - stored topdata products');
        unset($artnos);
    }

    /**
     * 05/2025 created (extracted from MappingStrategy_Distributor::map())
     */
    public function _getArticleNumbers(): array
    {
        // ---- Determine the source of product numbers based on the mapping type
        $mappingType = $this->mergedPluginConfigHelperService->getOption(MergedPluginConfigKeyConstants::MAPPING_TYPE);
        $attributeArticleNumber = $this->mergedPluginConfigHelperService->getOption(MergedPluginConfigKeyConstants::ATTRIBUTE_ORDERNUMBER); // FIXME: it is not the order number, but product number

        if ($mappingType == MappingTypeConstants::DISTRIBUTOR_CUSTOM && $attributeArticleNumber != '') {
            // the distributor's SKU is a product property
            $artnos = UtilMappingHelper::convertMultiArrayBinaryIdsToHex($this->shopwareProductPropertyService->getKeysByOptionValueUnique($attributeArticleNumber));
        } elseif ($mappingType == MappingTypeConstants::DISTRIBUTOR_CUSTOM_FIELD && $attributeArticleNumber != '') {
            // the distributor's SKU is a product custom field
            $artnos = $this->shopwareProductService->getKeysByCustomFieldUnique($attributeArticleNumber);
        } else {
            // the distributor's SKU is the product number
            $artnos = UtilMappingHelper::convertMultiArrayBinaryIdsToHex($this->shopwareProductService->getKeysByProductNumber());
        }

        if (count($artnos) == 0) {
            throw new Exception('distributor mapping 0 products found');
        }

        return $artnos;
    }

}

================
File: src/Service/Import/MappingStrategy/MappingStrategy_EanOem.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\Import\MappingStrategy;

use Exception;
use Override;
use Topdata\TopdataConnectorSW6\Constants\MappingTypeConstants;
use Topdata\TopdataConnectorSW6\Constants\MergedPluginConfigKeyConstants;
use Topdata\TopdataConnectorSW6\DTO\ImportConfig;
use Topdata\TopdataConnectorSW6\Service\Cache\MappingCacheService;
use Topdata\TopdataConnectorSW6\Service\Config\MergedPluginConfigHelperService;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataToProductService;
use Topdata\TopdataConnectorSW6\Service\Shopware\ShopwareProductPropertyService;
use Topdata\TopdataConnectorSW6\Service\Shopware\ShopwareProductService;
use Topdata\TopdataConnectorSW6\Service\TopdataWebserviceClient;
use Topdata\TopdataConnectorSW6\Util\ImportReport;
use Topdata\TopdataConnectorSW6\Util\UtilMappingHelper;
use Topdata\TopdataFoundationSW6\Util\CliLogger;
use Topdata\TopdataFoundationSW6\Util\UtilFormatter;

/**
 * Implements the default mapping strategy for products, using OEM and EAN numbers.
 * 03/2025 created (extracted from ProductMappingService)
 * 04/2025 renamed from MappingStrategy_Default to MappingStrategy_EanOem
 * Refactored 05/2024 to improve cache handling logic and separation of concerns.
 */
final class MappingStrategy_EanOem extends AbstractMappingStrategy
{
    const BATCH_SIZE = 500;

    private array $setted = []; // Tracks product IDs already mapped in a single run to avoid duplicates


    public function __construct(
        private readonly MergedPluginConfigHelperService $mergedPluginConfigHelperService,
        private readonly TopdataToProductService         $topdataToProductService,
        private readonly TopdataWebserviceClient         $topdataWebserviceClient,
        private readonly ShopwareProductService          $shopwareProductService,
        private readonly MappingCacheService             $mappingCacheService,
        private readonly ShopwareProductPropertyService  $shopwareProductPropertyService,
    )
    {
    }

    /**
     * Builds mapping arrays for OEM and EAN numbers based on Shopware data.
     * (Implementation remains the same as before)
     *
     * @return array{0: array<string, array<string, array{id: string, version_id: string}>>, 1: array<string, array<string, array{id: string, version_id: string}>>}
     */
    private function buildShopwareIdentifierMaps(): array
    {
        CliLogger::info('Building Shopware identifier maps (OEM/EAN)...');
        $oems = [];
        $eans = [];
        $mappingType = $this->mergedPluginConfigHelperService->getOption(MergedPluginConfigKeyConstants::MAPPING_TYPE);
        $oemAttribute = $this->mergedPluginConfigHelperService->getOption(MergedPluginConfigKeyConstants::ATTRIBUTE_OEM);
        $eanAttribute = $this->mergedPluginConfigHelperService->getOption(MergedPluginConfigKeyConstants::ATTRIBUTE_EAN);

        // ---- Fetch product data based on mapping type configuration
        switch ($mappingType) {
            case MappingTypeConstants::CUSTOM:
                if ($oemAttribute) {
                    $oems = UtilMappingHelper::_fixArrayBinaryIds(
                        $this->shopwareProductPropertyService->getKeysByOptionValue($oemAttribute, 'manufacturer_number') // FIXME: hardcoded name
                    );
                }
                if ($eanAttribute) {
                    $eans = UtilMappingHelper::_fixArrayBinaryIds(
                        $this->shopwareProductPropertyService->getKeysByOptionValue($eanAttribute, 'ean')
                    );
                }
                break;

            case MappingTypeConstants::CUSTOM_FIELD:
                if ($oemAttribute) {
                    $oems = $this->shopwareProductService->getKeysByCustomFieldUnique($oemAttribute, 'manufacturer_number');
                }
                if ($eanAttribute) {
                    $eans = $this->shopwareProductService->getKeysByCustomFieldUnique($eanAttribute, 'ean');
                }
                break;

            default: // Default mapping
                $oems = UtilMappingHelper::_fixArrayBinaryIds($this->shopwareProductService->getKeysByMpn());
                $eans = UtilMappingHelper::_fixArrayBinaryIds($this->shopwareProductService->getKeysByEan());
                break;
        }

        CliLogger::info(UtilFormatter::formatInteger(count($oems)) . ' potential OEM sources found');
        CliLogger::info(UtilFormatter::formatInteger(count($eans)) . ' potential EAN sources found');

        // ---- Build OEM number mapping
        $oemMap = [];
        foreach ($oems as $oemData) {
            if (empty($oemData['manufacturer_number'])) continue;
            // Normalize: lowercase, trim, remove leading zeros
            $normalizedOem = strtolower(ltrim(trim((string)$oemData['manufacturer_number']), '0'));
            if (empty($normalizedOem)) continue;
            $key = $oemData['id'] . '-' . $oemData['version_id'];
            $oemMap[$normalizedOem][$key] = [
                'id'         => $oemData['id'],
                'version_id' => $oemData['version_id'],
            ];
        }
        unset($oems); // Free memory
        CliLogger::info(UtilFormatter::formatInteger(count($oemMap)) . ' unique normalized OEMs mapped');


        // ---- Build EAN number mapping
        $eanMap = [];
        foreach ($eans as $eanData) {
            if (empty($eanData['ean'])) continue;
            // Normalize: remove non-digits, trim, remove leading zeros
            $normalizedEan = ltrim(trim(preg_replace('/[^0-9]/', '', (string)$eanData['ean'])), '0');
            if (empty($normalizedEan)) continue;
            $key = $eanData['id'] . '-' . $eanData['version_id'];
            $eanMap[$normalizedEan][$key] = [
                'id'         => $eanData['id'],
                'version_id' => $eanData['version_id'],
            ];
        }
        unset($eans); // Free memory
        CliLogger::info(UtilFormatter::formatInteger(count($eanMap)) . ' unique normalized EANs mapped');

        return [$oemMap, $eanMap];
    }


    /**
     * Processes a specific type of identifier (EAN, OEM, PCD) from the webservice.
     * Modified to store original webservice values alongside product mappings.
     *
     * @param string $type (e.g., MappingTypeConstants::EAN, MappingTypeConstants::OEM, MappingTypeConstants::PCD)
     * @param string $webserviceMethod The method name on TopdataWebserviceClient (e.g., 'matchMyEANs')
     * @param array $identifierMap The map built from Shopware data (e.g., $eanMap or $oemMap)
     * @param string $logLabel A label for logging (e.g., 'EANs', 'OEMs', 'PCDs')
     * @return array Raw mappings data [{'topDataId': ..., 'productId': ..., 'productVersionId': ..., 'value': ...}]
     * @throws Exception If the webservice response is invalid
     */
    private function fetchAndMapIdentifiersFromWebservice(string $type, string $webserviceMethod, array $identifierMap, string $logLabel): array
    {
        $mappings = [];
        CliLogger::title("Fetching $logLabel from Webservice...");
        $totalFetched = 0;
        $matchedCount = 0;

        if (empty($identifierMap)) {
            CliLogger::warning("Skipping $logLabel fetch, no corresponding identifiers found in Shopware.");
            ImportReport::setCounter("Fetched $logLabel", 0);
            ImportReport::setCounter("$logLabel mappings collected", 0);
            return [];
        }

        try {
            for ($page = 1; ; $page++) {
                $response = $this->topdataWebserviceClient->$webserviceMethod(['page' => $page]);

                if (!isset($response->match, $response->page->available_pages)) {
                    throw new Exception("$type webservice response structure invalid on page $page.");
                }

                $totalFetched += count($response->match);
                $available_pages = (int)$response->page->available_pages;

                foreach ($response->match as $productData) {
                    $topDataId = $productData->products_id;
                    foreach ($productData->values as $identifier) {
                        // Store the original identifier value from the webservice
                        $originalIdentifier = (string)$identifier;
                        
                        // Normalize identifier from webservice similarly to how Shopware data was normalized
                        $normalizedIdentifier = $originalIdentifier;
                        if ($type === MappingTypeConstants::EAN) {
                            $normalizedIdentifier = ltrim(trim(preg_replace('/[^0-9]/', '', $normalizedIdentifier)), '0');
                        } else { // OEM, PCD
                            $normalizedIdentifier = strtolower(ltrim(trim($normalizedIdentifier), '0'));
                        }

                        if (empty($normalizedIdentifier)) continue;

                        // Check if this normalized identifier exists in our Shopware map
                        if (isset($identifierMap[$normalizedIdentifier])) {
                            foreach ($identifierMap[$normalizedIdentifier] as $shopwareProductKey => $shopwareProductData) {
                                // Check if this specific Shopware product (id+version) hasn't been mapped yet in this run
                                if (!isset($this->setted[$shopwareProductKey])) {
                                    $mappings[] = [
                                        'topDataId'        => $topDataId,
                                        'productId'        => $shopwareProductData['id'],
                                        'productVersionId' => $shopwareProductData['version_id'],
                                        'value'            => $originalIdentifier, // Store original value for caching
                                    ];
                                    $this->setted[$shopwareProductKey] = true; // Mark as mapped for this run
                                    $matchedCount++;
                                }
                            }
                        }
                    }
                }

                CliLogger::progress($page, $available_pages, "Fetched $logLabel page");

                if ($page >= $available_pages) {
                    break;
                }
            }
        } catch (Exception $e) {
            CliLogger::error("Error fetching $logLabel from webservice: " . $e->getMessage());
            // Depending on requirements, you might re-throw, return partial data, or return empty
            throw $e; // Re-throw for now to indicate failure
        }

        CliLogger::write("DONE. Fetched " . UtilFormatter::formatInteger($totalFetched) . " $logLabel records from Webservice. ");
        CliLogger::mem();
        ImportReport::setCounter("Fetched $logLabel", $totalFetched);
        ImportReport::setCounter("$logLabel mappings collected", $matchedCount);

        return $mappings;
    }


    /**
     * Processes webservice mappings by fetching data from the API for EAN, OEM, and PCD.
     * Resets and uses the `setted` property to avoid duplicate mappings within a single run.
     * (Implementation remains the same as before)
     *
     * @param array $oemMap Mapping of OEM numbers to products from Shopware
     * @param array $eanMap Mapping of EAN numbers to products from Shopware
     * @return array<string, array> Associative array of mapping data by type [type => [[mapping_data], ...]]
     * @throws Exception if any error occurs during API communication
     */
    private function processWebserviceMappings(array $oemMap, array $eanMap): array
    {
        $this->setted = []; // Reset for this run
        $allMappings = [];

        // Process EAN mappings
        $allMappings[MappingTypeConstants::EAN] = $this->fetchAndMapIdentifiersFromWebservice(
            MappingTypeConstants::EAN,
            'matchMyEANs',
            $eanMap,
            'EANs'
        );

        // Process OEM mappings
        $allMappings[MappingTypeConstants::OEM] = $this->fetchAndMapIdentifiersFromWebservice(
            MappingTypeConstants::OEM,
            'matchMyOems',
            $oemMap,
            'OEMs'
        );

        // Process PCD mappings (uses the same OEM map from Shopware)
        $allMappings[MappingTypeConstants::PCD] = $this->fetchAndMapIdentifiersFromWebservice(
            MappingTypeConstants::PCD,
            'matchMyPcds',
            $oemMap,
            'PCDs'
        );

        unset($this->setted); // Clean up instance variable after use

        return array_filter($allMappings); // Remove empty mapping types
    }

    /**
     * Attempts to load mappings directly from the V2 cache into the database.
     * This bypasses fetching from the webservice if the cache is valid and populated.
     *
     * @return bool True if mappings were successfully loaded from cache, False otherwise.
     */
    private function tryLoadFromCacheV2(): bool
    {
        if (!$this->mappingCacheService->hasCachedMappings()) {
            CliLogger::info('No valid V2 cache found, proceeding with fetch.');
            return false;
        }

        CliLogger::info('Valid V2 cache found, attempting to load mappings...');
        $totalLoaded = $this->mappingCacheService->loadMappingsFromCache();

        if ($totalLoaded > 0) {
            CliLogger::info('Successfully loaded ' . UtilFormatter::formatInteger($totalLoaded) . ' total mappings from cache into database.');
            ImportReport::setCounter('Mappings Loaded from Cache', $totalLoaded);
            ImportReport::setCounter('Webservice Calls Skipped', 1); // Indicate that API fetch was skipped
            return true; // Signal success
        }

        CliLogger::warning('V2 cache exists but failed to load mappings (or was empty). Proceeding with fetch.');
        // Invalidate or clear cache here if desired on load failure? Maybe not, let it be overwritten.
        return false; // Signal failure
    }

    /**
     * Saves the fetched mappings to the V2 cache, if enabled.
     * This is done per mapping type (EAN, OEM, PCD).
     * Modified to extract only topDataId and value for caching.
     *
     * @param array<string, array> $mappingsByType Mappings fetched from webservice, grouped by type.
     */
    private function saveToCacheV2(array $mappingsByType): void
    {
        CliLogger::info('Saving fetched mappings to V2 cache...');
        $totalCached = 0;
        foreach ($mappingsByType as $mappingType => $typeMappings) {
            if (!empty($typeMappings)) {
                $count = count($typeMappings);
                CliLogger::info("-> Caching " . UtilFormatter::formatInteger($count) . " $mappingType mappings...");
                
                // Extract only the necessary fields for caching (topDataId and value)
                // This makes the cache independent of Shopware product IDs
                $cacheMappings = array_map(function($mapping) {
                    return [
                        'topDataId' => $mapping['topDataId'],
                        'value'     => $mapping['value']
                    ];
                }, $typeMappings);
                
                $this->mappingCacheService->saveMappingsToCache($cacheMappings, $mappingType);
                $totalCached += $count;
            }
        }
        
        // Display cache statistics after saving
        $cacheStats = $this->mappingCacheService->getCacheStats();
        CliLogger::info('--- Cache Statistics ---');
        CliLogger::info('Total cached mappings: ' . UtilFormatter::formatInteger($cacheStats['total']));
        if (isset($cacheStats['by_type'])) {
            CliLogger::info('Mappings by type:');
            foreach ($cacheStats['by_type'] as $type => $count) {
                CliLogger::info("  - {$type}: " . UtilFormatter::formatInteger($count));
            }
        }
        if (isset($cacheStats['oldest'])) {
            CliLogger::info('Oldest entry: ' . $cacheStats['oldest']);
        }
        if (isset($cacheStats['newest'])) {
            CliLogger::info('Newest entry: ' . $cacheStats['newest']);
        }
        CliLogger::info('------------------------');
        if ($totalCached > 0) {
            CliLogger::info('Finished saving ' . UtilFormatter::formatInteger($totalCached) . ' mappings to V2 cache.');
            ImportReport::setCounter('Mappings Saved to Cache', $totalCached);
        } else {
            CliLogger::info('No new mappings were fetched to save to V2 cache.');
        }
    }

    /**
     * Inserts mappings into the database in batches.
     * (Implementation remains the same as before, but now has a single responsibility)
     *
     * @param array $mappings A flat list of mapping data arrays.
     */
    private function persistMappingsToDatabase(array $mappings): void
    {
        $totalToInsert = count($mappings);
        if ($totalToInsert === 0) {
            CliLogger::info('No mappings to insert into database.');
            ImportReport::setCounter('Mappings Inserted/Updated', 0);
            return;
        }

        // Clear existing mappings before inserting new ones (as done by loadMappingsFromCache implicitly)
        // NOTE: Consider if this blanket deletion is always desired. Maybe only delete if $mappings is not empty?
        // Or maybe the cache loading should *not* delete if it fails to load anything? This needs careful thought
        // based on exact requirements. Assuming the V2 cache load *replaces* DB content, we replicate that here.
//        CliLogger::info('Clearing existing mappings from database before insertion...');
//        $this->topdataToProductService->deleteAll();
//        CliLogger::info('Existing mappings cleared.');


        CliLogger::info('Inserting ' . UtilFormatter::formatInteger($totalToInsert) . ' total mappings into database...');
        $insertedCount = 0;
        foreach (array_chunk($mappings, self::BATCH_SIZE) as $batch) {
            $this->topdataToProductService->insertMany($batch);
            $insertedCount += count($batch);
            CliLogger::progress($insertedCount, $totalToInsert, 'Inserted mappings batch');
        }
        CliLogger::writeln('DONE. Finished inserting mappings.');
        ImportReport::setCounter('Mappings Inserted/Updated', $totalToInsert);
    }

    /**
     * Flattens the mappings grouped by type into a single list suitable for database insertion.
     * Removes the 'value' field which is only needed for caching.
     *
     * @param array<string, array> $mappingsByType
     * @return array
     */
    private function flattenMappings(array $mappingsByType): array
    {
        $allMappingsFlat = [];
        foreach ($mappingsByType as $typeMappings) {
            if (!empty($typeMappings)) {
                // Extract only the fields needed for database insertion (exclude 'value')
                $dbMappings = array_map(function($mapping) {
                    return [
                        'topDataId'        => $mapping['topDataId'],
                        'productId'        => $mapping['productId'],
                        'productVersionId' => $mapping['productVersionId']
                    ];
                }, $typeMappings);
                
                // array_merge is potentially slow for very large arrays repeatedly
                // consider alternative if performance is critical
                $allMappingsFlat = array_merge($allMappingsFlat, $dbMappings);
            }
        }
        // Optional: Add array_unique here if duplicates across types are possible AND undesirable
        // $allMappingsFlat = array_map("unserialize", array_unique(array_map("serialize", $allMappingsFlat)));
        // CliLogger::info('Flattened ' . count($allMappingsFlat) . ' unique mappings for persistence.');
        return $allMappingsFlat;
    }


    /**
     * ==== MAIN ====
     *
     * Maps products using the EAN/OEM/PCD strategy.
     *
     * Handles fetching identifiers from Shopware, checking/using cache (if V2 enabled),
     * fetching matches from the Topdata webservice, and persisting the results.
     *
     * @throws Exception if any critical error occurs during the mapping process
     */
    #[Override]
    public function map(ImportConfig $importConfig): void
    {
        CliLogger::section('Product Mapping Strategy: EAN/OEM/PCD');

        // 1. Check config
        $useExperimentalCacheV2 = TRUE; // (bool)$importConfig->getOptionExperimentalV2();
        CliLogger::info('Webservice Cache Enabled (Experimental): ' . ($useExperimentalCacheV2 ? 'Yes' : 'No'));

        // 2. Attempt to load from V2 cache (if enabled)
        //    This method now handles loading *and* populating the DB if successful.
        if ($useExperimentalCacheV2 && $this->tryLoadFromCacheV2()) {
            CliLogger::section('Finished mapping using cached data (V2).');
            return; // Cache was successfully loaded and used, skip fetch/save
        }

        // --- Cache was not used or failed, proceed with fetch and save ---

        // 3. Build identifier maps from Shopware
        [$oemMap, $eanMap] = $this->buildShopwareIdentifierMaps();

        // 4. Fetch corresponding mappings from Topdata Webservice
        $mappingsByType = $this->processWebserviceMappings($oemMap, $eanMap);
        unset($oemMap, $eanMap); // Free memory

        // 5. Save fetched mappings to V2 cache (if enabled)
        if ($useExperimentalCacheV2) {
            $this->saveToCacheV2($mappingsByType);
        }

        // 6. Flatten mappings for database persistence
        $flatMappings = $this->flattenMappings($mappingsByType);

        // 7. Persist flattened mappings to the database
        $this->persistMappingsToDatabase($flatMappings);

        CliLogger::section('Finished product mapping (fetched from webservice).');
    }
}

================
File: src/Service/Import/MappingStrategy/MappingStrategy_ProductNumberAs.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\Import\MappingStrategy;

use Doctrine\DBAL\Connection;
use Shopware\Core\Framework\Uuid\Uuid;
use Topdata\TopdataConnectorSW6\DTO\ImportConfig;
use Topdata\TopdataConnectorSW6\Service\Shopware\ShopwareProductService;
use Topdata\TopdataConnectorSW6\Util\UtilMappingHelper;
use Topdata\TopdataFoundationSW6\Util\CliLogger;

/**
 * This strategy does NOT fetch any mappings from the webservice, 
 * it simply uses the product number in the shop as the web service ID.
 *
 * 03/2025 created (extracted from ProductMappingService)
 */
final class MappingStrategy_ProductNumberAs extends AbstractMappingStrategy
{
    const BATCH_SIZE = 99;

    public function __construct(
        private readonly Connection             $connection,
        private readonly ShopwareProductService $shopwareProductService
    )
    {
    }

    /**
     * ==== MAIN ====
     *
     * Maps products using the product number as the web service ID.
     *
     * This method retrieves product numbers and their corresponding IDs from the database,
     * then inserts the mapped data into the `topdata_to_product` table.
     */
    // private function _mapProductNumberAsWsId(): void

    #[\Override]
    public function map(ImportConfig $importConfig): void
    {
        $dataInsert = [];

        $artnos = UtilMappingHelper::convertMultiArrayBinaryIdsToHex($this->shopwareProductService->getKeysByProductNumber());
        $currentDateTime = date('Y-m-d H:i:s');
        foreach ($artnos as $wsid => $prods) {
            foreach ($prods as $prodid) {
                if (ctype_digit((string)$wsid)) {
                    $dataInsert[] = '(' .
                        '0x' . Uuid::randomHex() . ',' .
                        "$wsid," .
                        "0x{$prodid['id']}," .
                        "0x{$prodid['version_id']}," .
                        "'$currentDateTime'" .
                        ')';
                }
                if (count($dataInsert) > self::BATCH_SIZE) {
                    $this->connection->executeStatement('
                    INSERT INTO topdata_to_product 
                    (id, top_data_id, product_id, product_version_id, created_at) 
                    VALUES ' . implode(',', $dataInsert) . '
                ');
                    $dataInsert = [];
                    CliLogger::activity();
                }
            }
        }
        if (count($dataInsert)) {
            $this->connection->executeStatement('
                INSERT INTO topdata_to_product 
                (id, top_data_id, product_id, product_version_id, created_at) 
                VALUES ' . implode(',', $dataInsert) . '
            ');
            $dataInsert = [];
            CliLogger::activity();
        }
    }
}

================
File: src/Service/Import/MappingStrategy/MappingStrategy_Unified.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\Import\MappingStrategy;

use Exception;
use Override;
use Topdata\TopdataConnectorSW6\Constants\MappingTypeConstants;
use Topdata\TopdataConnectorSW6\Constants\MergedPluginConfigKeyConstants;
use Topdata\TopdataConnectorSW6\DTO\ImportConfig;
use Topdata\TopdataConnectorSW6\Service\Cache\MappingCacheService;
use Topdata\TopdataConnectorSW6\Service\Config\MergedPluginConfigHelperService;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataToProductService;
use Topdata\TopdataConnectorSW6\Service\Shopware\ShopwareProductPropertyService;
use Topdata\TopdataConnectorSW6\Service\Shopware\ShopwareProductService;
use Topdata\TopdataConnectorSW6\Service\TopdataWebserviceClient;
use Topdata\TopdataConnectorSW6\Util\ImportReport;
use Topdata\TopdataConnectorSW6\Util\UtilMappingHelper;
use Topdata\TopdataFoundationSW6\Util\CliLogger;
use Topdata\TopdataFoundationSW6\Util\UtilFormatter;

/**
 * Unified mapping strategy that handles EAN, OEM, PCD, and Distributor mappings.
 * Supports caching for all mapping types.
 *
 * 05/2025 created (merged from MappingStrategy_EanOem and MappingStrategy_Distributor)
 */
final class MappingStrategy_Unified extends AbstractMappingStrategy
{
    const BATCH_SIZE = 500;

    /**
     * Tracks product IDs already mapped in a single run to avoid duplicates
     */
    private array $setted = [];

    public function __construct(
        private readonly MergedPluginConfigHelperService $mergedPluginConfigHelperService,
        private readonly TopdataToProductService         $topdataToProductService,
        private readonly TopdataWebserviceClient         $topdataWebserviceClient,
        private readonly ShopwareProductService          $shopwareProductService,
        private readonly MappingCacheService             $mappingCacheService,
        private readonly ShopwareProductPropertyService  $shopwareProductPropertyService,
    )
    {
    }

    /**
     * Maps products using the appropriate strategy based on the mapping type.
     *
     * @throws Exception if any critical error occurs during the mapping process
     */
    #[Override]
    public function map(ImportConfig $importConfig): void
    {
        $mappingType = $this->mergedPluginConfigHelperService->getOption(MergedPluginConfigKeyConstants::MAPPING_TYPE);

        // Check if this is a distributor mapping type
        $isDistributorMapping = in_array($mappingType, [
            MappingTypeConstants::DISTRIBUTOR_DEFAULT,
            MappingTypeConstants::DISTRIBUTOR_CUSTOM,
            MappingTypeConstants::DISTRIBUTOR_CUSTOM_FIELD
        ]);

        if ($isDistributorMapping) {
            $this->mapDistributor($importConfig);
        } else {
            $this->mapEanOem($importConfig);
        }
    }

    /**
     * Maps products using the EAN/OEM/PCD strategy.
     *
     * @param ImportConfig $importConfig The import configuration
     * @throws Exception if any critical error occurs during the mapping process
     */
    private function mapEanOem(ImportConfig $importConfig): void
    {
        CliLogger::section('Product Mapping Strategy: EAN/OEM/PCD');

        // 1. Check config
        $useExperimentalCacheV2 = TRUE; // (bool)$importConfig->getOptionExperimentalV2();
        CliLogger::info('Webservice Cache Enabled (Experimental): ' . ($useExperimentalCacheV2 ? 'Yes' : 'No'));

        // 2. Attempt to load from V2 cache (if enabled)
        if ($useExperimentalCacheV2 && $this->tryLoadFromCacheV2(MappingTypeConstants::EAN_OEM_GROUP)) {
            CliLogger::section('Finished mapping using cached data (V2).');
            return; // Cache was successfully loaded and used, skip fetch/save
        }

        // --- Cache was not used or failed, proceed with fetch and save ---

        // 3. Build identifier maps from Shopware
        [$oemMap, $eanMap] = $this->buildShopwareIdentifierMaps();

        // 4. Fetch corresponding mappings from Topdata Webservice
        $mappingsByType = $this->processWebserviceMappings($oemMap, $eanMap);
        unset($oemMap, $eanMap); // Free memory

        // 5. Save fetched mappings to V2 cache (if enabled)
        if ($useExperimentalCacheV2) {
            $this->saveToCacheV2($mappingsByType, MappingTypeConstants::EAN_OEM_GROUP);
        }

        // 6. Flatten mappings for database persistence
        $flatMappings = $this->flattenMappings($mappingsByType);

        // 7. Persist flattened mappings to the database
        $this->persistMappingsToDatabase($flatMappings);

        CliLogger::section('Finished product mapping (fetched from webservice).');
    }

    /**
     * Maps products using the distributor mapping strategy.
     *
     * @param ImportConfig $importConfig The import configuration
     * @throws Exception if any critical error occurs during the mapping process
     */
    private function mapDistributor(ImportConfig $importConfig): void
    {
        CliLogger::section('Product Mapping Strategy: Distributor');

        // 1. Check config
        $useExperimentalCacheV2 = TRUE; // (bool)$importConfig->getOptionExperimentalV2();
        CliLogger::info('Webservice Cache Enabled (Experimental): ' . ($useExperimentalCacheV2 ? 'Yes' : 'No'));

        // 2. Attempt to load from V2 cache (if enabled)
        if ($useExperimentalCacheV2 && $this->tryLoadFromCacheV2(MappingTypeConstants::DISTRIBUTOR)) {
            CliLogger::section('Finished mapping using cached data (V2).');
            return; // Cache was successfully loaded and used, skip fetch/save
        }

        // --- Cache was not used or failed, proceed with fetch and save ---

        // 3. Build article number map from Shopware
        $articleNumberMap = $this->getArticleNumbers();

        // 4. Fetch corresponding mappings from Topdata Webservice
        $mappingsByType = [
            MappingTypeConstants::DISTRIBUTOR => $this->processDistributorWebserviceMappings($articleNumberMap)
        ];
        unset($articleNumberMap); // Free memory

        // 5. Save fetched mappings to V2 cache (if enabled)
        if ($useExperimentalCacheV2) {
            $this->saveToCacheV2($mappingsByType, MappingTypeConstants::DISTRIBUTOR);
        }

        // 6. Flatten mappings for database persistence
        $flatMappings = $this->flattenMappings($mappingsByType);

        // 7. Persist flattened mappings to the database
        $this->persistMappingsToDatabase($flatMappings);

        CliLogger::section('Finished product mapping (fetched from webservice).');
    }

    /**
     * Attempts to load mappings directly from the V2 cache into the database.
     * This bypasses fetching from the webservice if the cache is valid and populated.
     *
     * @param string $mappingGroup The mapping group to load (EAN_OEM_GROUP or DISTRIBUTOR)
     * @return bool True if mappings were successfully loaded from cache, False otherwise.
     */
    private function tryLoadFromCacheV2(string $mappingGroup): bool
    {
        if (!$this->mappingCacheService->hasCachedMappings()) {
            CliLogger::info('No valid V2 cache found, proceeding with fetch.');
            return false;
        }

        CliLogger::info('Valid V2 cache found, attempting to load mappings...');

        // For EAN/OEM group, we load EAN, OEM, and PCD mappings
        // For Distributor group, we load only Distributor mappings
        $mappingTypes = ($mappingGroup === MappingTypeConstants::EAN_OEM_GROUP)
            ? [MappingTypeConstants::EAN, MappingTypeConstants::OEM, MappingTypeConstants::PCD]
            : [MappingTypeConstants::DISTRIBUTOR];

        $totalLoaded = 0;
        foreach ($mappingTypes as $mappingType) {
            $loaded = $this->mappingCacheService->loadMappingsFromCache($mappingType);
            $totalLoaded += $loaded;
            CliLogger::info("Loaded " . UtilFormatter::formatInteger($loaded) . " $mappingType mappings from cache.");
        }

        if ($totalLoaded > 0) {
            CliLogger::info('Successfully loaded ' . UtilFormatter::formatInteger($totalLoaded) . ' total mappings from cache into database.');
            ImportReport::setCounter('Mappings Loaded from Cache', $totalLoaded);
            ImportReport::setCounter('Webservice Calls Skipped', 1); // Indicate that API fetch was skipped
            return true; // Signal success
        }

        CliLogger::warning('V2 cache exists but failed to load mappings (or was empty). Proceeding with fetch.');
        return false; // Signal failure
    }

    /**
     * Saves the fetched mappings to the V2 cache, if enabled.
     * This is done per mapping type (EAN, OEM, PCD, Distributor).
     *
     * @param array<string, array> $mappingsByType Mappings fetched from webservice, grouped by type.
     * @param string $mappingGroup The mapping group being saved (EAN_OEM_GROUP or DISTRIBUTOR)
     */
    private function saveToCacheV2(array $mappingsByType, string $mappingGroup): void
    {
        CliLogger::info('Saving fetched mappings to V2 cache...');
        $totalCached = 0;

        foreach ($mappingsByType as $mappingType => $typeMappings) {
            if (!empty($typeMappings)) {
                $count = count($typeMappings);
                CliLogger::info("-> Caching " . UtilFormatter::formatInteger($count) . " $mappingType mappings...");

                // Extract only the necessary fields for caching (topDataId and value)
                // This makes the cache independent of Shopware product IDs
                $cacheMappings = array_map(function ($mapping) {
                    return [
                        'topDataId' => $mapping['topDataId'],
                        'value'     => $mapping['value']
                    ];
                }, $typeMappings);

                $this->mappingCacheService->saveMappingsToCache($cacheMappings, $mappingType);
                $totalCached += $count;
            }
        }

        // Display cache statistics after saving
        $cacheStats = $this->mappingCacheService->getCacheStats();
        CliLogger::info('--- Cache Statistics ---');
        CliLogger::info('Total cached mappings: ' . UtilFormatter::formatInteger($cacheStats['total']));
        if (isset($cacheStats['by_type'])) {
            CliLogger::info('Mappings by type:');
            foreach ($cacheStats['by_type'] as $type => $count) {
                CliLogger::info("  - {$type}: " . UtilFormatter::formatInteger($count));
            }
        }
        if (isset($cacheStats['oldest'])) {
            CliLogger::info('Oldest entry: ' . $cacheStats['oldest']);
        }
        if (isset($cacheStats['newest'])) {
            CliLogger::info('Newest entry: ' . $cacheStats['newest']);
        }
        CliLogger::info('------------------------');

        if ($totalCached > 0) {
            CliLogger::info('Finished saving ' . UtilFormatter::formatInteger($totalCached) . ' mappings to V2 cache.');
            ImportReport::setCounter('Mappings Saved to Cache', $totalCached);
        } else {
            CliLogger::info('No new mappings were fetched to save to V2 cache.');
        }
    }

    /**
     * Processes distributor mappings from the webservice.
     *
     * @param array $articleNumberMap Mapping of article numbers to products from Shopware
     * @return array Distributor mappings data
     * @throws Exception if any error occurs during API communication
     */
    private function processDistributorWebserviceMappings(array $articleNumberMap): array
    {
        $this->setted = []; // Reset for this run
        $mappings = [];
        $stored = 0;

        CliLogger::info(UtilFormatter::formatInteger(count($articleNumberMap)) . ' products to check ...');

        try {
            // Iterate through the pages of distributor data from the web service
            for ($page = 1; ; $page++) {
                $response = $this->topdataWebserviceClient->matchMyDistributor(['page' => $page]);

                if (!isset($response->page->available_pages)) {
                    throw new Exception('distributor webservice no pages');
                }

                $available_pages = (int)$response->page->available_pages;

                // Process each product in the current page
                foreach ($response->match as $prod) {
                    $topDataId = $prod->products_id;

                    foreach ($prod->distributors as $distri) {
                        foreach ($distri->artnrs as $artnr) {
                            $originalValue = (string)$artnr;
                            $key = $originalValue; // For distributor, we use the original value as the key

                            if (isset($articleNumberMap[$key])) {
                                foreach ($articleNumberMap[$key] as $articleNumberValue) {
                                    $shopwareProductKey = $articleNumberValue['id'] . '-' . $articleNumberValue['version_id'];

                                    // Check if this specific Shopware product (id+version) hasn't been mapped yet in this run
                                    if (!isset($this->setted[$shopwareProductKey])) {
                                        $mappings[] = [
                                            'topDataId'        => $topDataId,
                                            'productId'        => $articleNumberValue['id'],
                                            'productVersionId' => $articleNumberValue['version_id'],
                                            'value'            => $originalValue, // Store original value for caching
                                        ];

                                        $this->setted[$shopwareProductKey] = true; // Mark as mapped for this run
                                        $stored++;

                                        if (($stored % 50) == 0) {
                                            CliLogger::activity();
                                        }
                                    }
                                }
                            }
                        }
                    }
                }

                CliLogger::progress($page, $available_pages, 'fetch distributor data');

                if ($page >= $available_pages) {
                    break;
                }
            }
        } catch (Exception $e) {
            CliLogger::error('Error fetching distributor data from webservice: ' . $e->getMessage());
            throw $e; // Re-throw for now to indicate failure
        }

        CliLogger::writeln("\n" . UtilFormatter::formatInteger($stored) . ' - stored topdata products');
        ImportReport::setCounter('Fetched Distributor SKUs', $stored);

        return $mappings;
    }

    /**
     * Gets article numbers from Shopware based on the mapping type.
     *
     * @return array Map of article numbers to product data
     * @throws Exception if no products are found
     */
    private function getArticleNumbers(): array
    {
        // Determine the source of product numbers based on the mapping type
        $mappingType = $this->mergedPluginConfigHelperService->getOption(MergedPluginConfigKeyConstants::MAPPING_TYPE);
        $attributeArticleNumber = $this->mergedPluginConfigHelperService->getOption(MergedPluginConfigKeyConstants::ATTRIBUTE_ORDERNUMBER);

        if ($mappingType == MappingTypeConstants::DISTRIBUTOR_CUSTOM && $attributeArticleNumber != '') {
            // the distributor's SKU is a product property
            $artnos = UtilMappingHelper::convertMultiArrayBinaryIdsToHex(
                $this->shopwareProductPropertyService->getKeysByOptionValueUnique($attributeArticleNumber)
            );
        } elseif ($mappingType == MappingTypeConstants::DISTRIBUTOR_CUSTOM_FIELD && $attributeArticleNumber != '') {
            // the distributor's SKU is a product custom field
            $artnos = $this->shopwareProductService->getKeysByCustomFieldUnique($attributeArticleNumber);
        } else {
            // the distributor's SKU is the product number
            $artnos = UtilMappingHelper::convertMultiArrayBinaryIdsToHex(
                $this->shopwareProductService->getKeysByProductNumber()
            );
        }

        if (count($artnos) == 0) {
            throw new Exception('distributor mapping 0 products found');
        }

        return $artnos;
    }





    ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
    ///                                                                                                                    ///
    ///                                                                                                                    ///
    //                                                                                                                    //
    //                                                                                                                    //
    ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

    /**
     * Builds mapping arrays for OEM and EAN numbers based on Shopware data.
     * (Implementation remains the same as before)
     *
     * @return array{0: array<string, array<string, array{id: string, version_id: string}>>, 1: array<string, array<string, array{id: string, version_id: string}>>}
     */
    private function buildShopwareIdentifierMaps(): array
    {
        CliLogger::info('Building Shopware identifier maps (OEM/EAN)...');
        $oems = [];
        $eans = [];
        $mappingType = $this->mergedPluginConfigHelperService->getOption(MergedPluginConfigKeyConstants::MAPPING_TYPE);
        $oemAttribute = $this->mergedPluginConfigHelperService->getOption(MergedPluginConfigKeyConstants::ATTRIBUTE_OEM);
        $eanAttribute = $this->mergedPluginConfigHelperService->getOption(MergedPluginConfigKeyConstants::ATTRIBUTE_EAN);

        // ---- Fetch product data based on mapping type configuration
        switch ($mappingType) {
            case MappingTypeConstants::CUSTOM:
                if ($oemAttribute) {
                    $oems = UtilMappingHelper::_fixArrayBinaryIds(
                        $this->shopwareProductPropertyService->getKeysByOptionValue($oemAttribute, 'manufacturer_number') // FIXME: hardcoded name
                    );
                }
                if ($eanAttribute) {
                    $eans = UtilMappingHelper::_fixArrayBinaryIds(
                        $this->shopwareProductPropertyService->getKeysByOptionValue($eanAttribute, 'ean')
                    );
                }
                break;

            case MappingTypeConstants::CUSTOM_FIELD:
                if ($oemAttribute) {
                    $oems = $this->shopwareProductService->getKeysByCustomFieldUnique($oemAttribute, 'manufacturer_number');
                }
                if ($eanAttribute) {
                    $eans = $this->shopwareProductService->getKeysByCustomFieldUnique($eanAttribute, 'ean');
                }
                break;

            default: // Default mapping
                $oems = UtilMappingHelper::_fixArrayBinaryIds($this->shopwareProductService->getKeysByMpn());
                $eans = UtilMappingHelper::_fixArrayBinaryIds($this->shopwareProductService->getKeysByEan());
                break;
        }

        CliLogger::info(UtilFormatter::formatInteger(count($oems)) . ' potential OEM sources found');
        CliLogger::info(UtilFormatter::formatInteger(count($eans)) . ' potential EAN sources found');

        // ---- Build OEM number mapping
        $oemMap = [];
        foreach ($oems as $oemData) {
            if (empty($oemData['manufacturer_number'])) continue;
            // Normalize: lowercase, trim, remove leading zeros
            $normalizedOem = strtolower(ltrim(trim((string)$oemData['manufacturer_number']), '0'));
            if (empty($normalizedOem)) continue;
            $key = $oemData['id'] . '-' . $oemData['version_id'];
            $oemMap[$normalizedOem][$key] = [
                'id'         => $oemData['id'],
                'version_id' => $oemData['version_id'],
            ];
        }
        unset($oems); // Free memory
        CliLogger::info(UtilFormatter::formatInteger(count($oemMap)) . ' unique normalized OEMs mapped');


        // ---- Build EAN number mapping
        $eanMap = [];
        foreach ($eans as $eanData) {
            if (empty($eanData['ean'])) continue;
            // Normalize: remove non-digits, trim, remove leading zeros
            $normalizedEan = ltrim(trim(preg_replace('/[^0-9]/', '', (string)$eanData['ean'])), '0');
            if (empty($normalizedEan)) continue;
            $key = $eanData['id'] . '-' . $eanData['version_id'];
            $eanMap[$normalizedEan][$key] = [
                'id'         => $eanData['id'],
                'version_id' => $eanData['version_id'],
            ];
        }
        unset($eans); // Free memory
        CliLogger::info(UtilFormatter::formatInteger(count($eanMap)) . ' unique normalized EANs mapped');

        return [$oemMap, $eanMap];
    }


    /**
     * Processes a specific type of identifier (EAN, OEM, PCD) from the webservice.
     * Modified to store original webservice values alongside product mappings.
     *
     * @param string $type (e.g., MappingTypeConstants::EAN, MappingTypeConstants::OEM, MappingTypeConstants::PCD)
     * @param string $webserviceMethod The method name on TopdataWebserviceClient (e.g., 'matchMyEANs')
     * @param array $identifierMap The map built from Shopware data (e.g., $eanMap or $oemMap)
     * @param string $logLabel A label for logging (e.g., 'EANs', 'OEMs', 'PCDs')
     * @return array Raw mappings data [{'topDataId': ..., 'productId': ..., 'productVersionId': ..., 'value': ...}]
     * @throws Exception If the webservice response is invalid
     */
    private function fetchAndMapIdentifiersFromWebservice(string $type, string $webserviceMethod, array $identifierMap, string $logLabel): array
    {
        $mappings = [];
        CliLogger::title("Fetching $logLabel from Webservice...");
        $totalFetched = 0;
        $matchedCount = 0;

        if (empty($identifierMap)) {
            CliLogger::warning("Skipping $logLabel fetch, no corresponding identifiers found in Shopware.");
            ImportReport::setCounter("Fetched $logLabel", 0);
            ImportReport::setCounter("$logLabel mappings collected", 0);
            return [];
        }

        try {
            for ($page = 1; ; $page++) {
                $response = $this->topdataWebserviceClient->$webserviceMethod(['page' => $page]);

                if (!isset($response->match, $response->page->available_pages)) {
                    throw new Exception("$type webservice response structure invalid on page $page.");
                }

                $totalFetched += count($response->match);
                $available_pages = (int)$response->page->available_pages;

                foreach ($response->match as $productData) {
                    $topDataId = $productData->products_id;
                    foreach ($productData->values as $identifier) {
                        // Store the original identifier value from the webservice
                        $originalIdentifier = (string)$identifier;

                        // Normalize identifier from webservice similarly to how Shopware data was normalized
                        $normalizedIdentifier = $originalIdentifier;
                        if ($type === MappingTypeConstants::EAN) {
                            $normalizedIdentifier = ltrim(trim(preg_replace('/[^0-9]/', '', $normalizedIdentifier)), '0');
                        } else { // OEM, PCD
                            $normalizedIdentifier = strtolower(ltrim(trim($normalizedIdentifier), '0'));
                        }

                        if (empty($normalizedIdentifier)) continue;

                        // Check if this normalized identifier exists in our Shopware map
                        if (isset($identifierMap[$normalizedIdentifier])) {
                            foreach ($identifierMap[$normalizedIdentifier] as $shopwareProductKey => $shopwareProductData) {
                                // Check if this specific Shopware product (id+version) hasn't been mapped yet in this run
                                if (!isset($this->setted[$shopwareProductKey])) {
                                    $mappings[] = [
                                        'topDataId'        => $topDataId,
                                        'productId'        => $shopwareProductData['id'],
                                        'productVersionId' => $shopwareProductData['version_id'],
                                        'value'            => $originalIdentifier, // Store original value for caching
                                    ];
                                    $this->setted[$shopwareProductKey] = true; // Mark as mapped for this run
                                    $matchedCount++;
                                }
                            }
                        }
                    }
                }

                CliLogger::progress($page, $available_pages, "Fetched $logLabel page");

                if ($page >= $available_pages) {
                    break;
                }
            }
        } catch (Exception $e) {
            CliLogger::error("Error fetching $logLabel from webservice: " . $e->getMessage());
            // Depending on requirements, you might re-throw, return partial data, or return empty
            throw $e; // Re-throw for now to indicate failure
        }

        CliLogger::write("DONE. Fetched " . UtilFormatter::formatInteger($totalFetched) . " $logLabel records from Webservice. ");
        CliLogger::mem();
        ImportReport::setCounter("Fetched $logLabel", $totalFetched);
        ImportReport::setCounter("$logLabel mappings collected", $matchedCount);

        return $mappings;
    }


    /**
     * Processes webservice mappings by fetching data from the API for EAN, OEM, and PCD.
     * Resets and uses the `setted` property to avoid duplicate mappings within a single run.
     * (Implementation remains the same as before)
     *
     * @param array $oemMap Mapping of OEM numbers to products from Shopware
     * @param array $eanMap Mapping of EAN numbers to products from Shopware
     * @return array<string, array> Associative array of mapping data by type [type => [[mapping_data], ...]]
     * @throws Exception if any error occurs during API communication
     */
    private function processWebserviceMappings(array $oemMap, array $eanMap): array
    {
        $this->setted = []; // Reset for this run
        $allMappings = [];

        // Process EAN mappings
        $allMappings[MappingTypeConstants::EAN] = $this->fetchAndMapIdentifiersFromWebservice(
            MappingTypeConstants::EAN,
            'matchMyEANs',
            $eanMap,
            'EANs'
        );

        // Process OEM mappings
        $allMappings[MappingTypeConstants::OEM] = $this->fetchAndMapIdentifiersFromWebservice(
            MappingTypeConstants::OEM,
            'matchMyOems',
            $oemMap,
            'OEMs'
        );

        // Process PCD mappings (uses the same OEM map from Shopware)
        $allMappings[MappingTypeConstants::PCD] = $this->fetchAndMapIdentifiersFromWebservice(
            MappingTypeConstants::PCD,
            'matchMyPcds',
            $oemMap,
            'PCDs'
        );

        unset($this->setted); // Clean up instance variable after use

        return array_filter($allMappings); // Remove empty mapping types
    }


    /**
     * Inserts mappings into the database in batches.
     * (Implementation remains the same as before, but now has a single responsibility)
     *
     * @param array $mappings A flat list of mapping data arrays.
     */
    private function persistMappingsToDatabase(array $mappings): void
    {
        $totalToInsert = count($mappings);
        if ($totalToInsert === 0) {
            CliLogger::info('No mappings to insert into database.');
            ImportReport::setCounter('Mappings Inserted/Updated', 0);
            return;
        }

        // Clear existing mappings before inserting new ones (as done by loadMappingsFromCache implicitly)
        // NOTE: Consider if this blanket deletion is always desired. Maybe only delete if $mappings is not empty?
        // Or maybe the cache loading should *not* delete if it fails to load anything? This needs careful thought
        // based on exact requirements. Assuming the V2 cache load *replaces* DB content, we replicate that here.
//        CliLogger::info('Clearing existing mappings from database before insertion...');
//        $this->topdataToProductService->deleteAll();
//        CliLogger::info('Existing mappings cleared.');


        CliLogger::info('Inserting ' . UtilFormatter::formatInteger($totalToInsert) . ' total mappings into database...');
        $insertedCount = 0;
        foreach (array_chunk($mappings, self::BATCH_SIZE) as $batch) {
            $this->topdataToProductService->insertMany($batch);
            $insertedCount += count($batch);
            CliLogger::progress($insertedCount, $totalToInsert, 'Inserted mappings batch');
        }
        CliLogger::writeln('DONE. Finished inserting mappings.');
        ImportReport::setCounter('Mappings Inserted/Updated', $totalToInsert);
    }

    /**
     * Flattens the mappings grouped by type into a single list suitable for database insertion.
     * Removes the 'value' field which is only needed for caching.
     *
     * @param array<string, array> $mappingsByType
     * @return array
     */
    private function flattenMappings(array $mappingsByType): array
    {
        $allMappingsFlat = [];
        foreach ($mappingsByType as $typeMappings) {
            if (!empty($typeMappings)) {
                // Extract only the fields needed for database insertion (exclude 'value')
                $dbMappings = array_map(function ($mapping) {
                    return [
                        'topDataId'        => $mapping['topDataId'],
                        'productId'        => $mapping['productId'],
                        'productVersionId' => $mapping['productVersionId']
                    ];
                }, $typeMappings);

                // array_merge is potentially slow for very large arrays repeatedly
                // consider alternative if performance is critical
                $allMappingsFlat = array_merge($allMappingsFlat, $dbMappings);
            }
        }
        // Optional: Add array_unique here if duplicates across types are possible AND undesirable
        // $allMappingsFlat = array_map("unserialize", array_unique(array_map("serialize", $allMappingsFlat)));
        // CliLogger::info('Flattened ' . count($allMappingsFlat) . ' unique mappings for persistence.');
        return $allMappingsFlat;
    }


}

================
File: src/Service/Import/DeviceImportService.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\Import;

use Doctrine\DBAL\Connection;
use Exception;
use Psr\Log\LoggerInterface;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataBrandService;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataDeviceService;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataDeviceTypeService;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataSeriesService;
use Topdata\TopdataConnectorSW6\Service\MediaHelperService;
use Topdata\TopdataConnectorSW6\Service\TopdataWebserviceClient;
use Topdata\TopdataConnectorSW6\Util\ImportReport;
use Topdata\TopdataConnectorSW6\Util\UtilProfiling;
use Topdata\TopdataConnectorSW6\Util\UtilStringFormatting;
use Topdata\TopdataFoundationSW6\Util\CliLogger;
use Topdata\TopdataFoundationSW6\Util\UtilFormatter;

/**
 * 04/2025 created (extracted from MappingHelperService)
 */
class DeviceImportService
{

    const BATCH_SIZE   = 5000;

    private Context $context;

    public function __construct(
        private readonly Connection               $connection,
        private readonly EntityRepository         $topdataDeviceRepository,
        private readonly EntityRepository         $topdataSeriesRepository,
        private readonly EntityRepository         $topdataDeviceTypeRepository,
        private readonly TopdataWebserviceClient  $topdataWebserviceClient,
        private readonly TopdataSeriesService     $topdataSeriesService,
        private readonly TopdataDeviceTypeService $topdataDeviceTypeService,
        private readonly TopdataBrandService      $topdataBrandService
    )
    {
        $this->context = Context::createDefaultContext();
    }


    /**
     * Sets the device types by fetching data from the remote server and updating the local database.
     *
     * This method retrieves device types from the remote server, processes the data, and updates the local database
     * by creating new entries or updating existing ones. It uses the `TopdataWebserviceClient` to fetch the data and
     * the `EntityRepository` to perform database operations.
     *
     *
     * 04/2025 moved from MappingHelperService::setDeviceTypes() to DeviceImportService::setDeviceTypes()
     */
    public function setDeviceTypes(): void
    {
        UtilProfiling::startTimer();
        CliLogger::section("Device type");


        // Log the activity of getting data from the remote server
        CliLogger::writeln('Fetching data from remote server [DeviceType]...');
        CliLogger::lap(true);

        // Fetch device types from the remote server
        $types = $this->topdataWebserviceClient->getModelTypes();

        // Log the number of fetched device types
        ImportReport::setCounter('Fetched DeviceTypes', count($types->data));
        CliLogger::writeln('Fetched ' . count($types->data) . ' device types from remote server');

        // Initialize the repository and data arrays
        $topdataDeviceTypeRepository = $this->topdataDeviceTypeRepository;
        $dataCreate = [];
        $dataUpdate = [];

        // Log the activity of processing data
        CliLogger::activity('Processing data...');

        // Get all existing types from the local database
        $allTypes = $this->topdataDeviceTypeService->getTypesArray(true);

        // Process each fetched device type
        foreach ($types->data as $s) {
            foreach ($s->brandIds as $brandWsId) {
                ImportReport::incCounter('DeviceTypes Total Processed');
                ImportReport::incCounter('DeviceTypes Brand Lookups');

                // Get the brand by its web service ID
                $brand = $this->topdataBrandService->getBrandByWsId($brandWsId);
                if (!$brand) {
                    ImportReport::incCounter('DeviceTypes Brand Not Found');
                    continue;
                }

                // Check if the type already exists in the local database
                $type = false;
                foreach ($allTypes as $typeItem) {
                    if ($typeItem['ws_id'] == $s->id && $typeItem['brand_id'] == $brand['id']) {
                        $type = $typeItem;
                        break;
                    }
                }

                // Generate a unique code for the type
                $code = $brand['code'] . '_' . $s->id . '_' . UtilStringFormatting::formCode($s->val);

                // Prepare data for creating or updating the type
                if (!$type) {
                    $dataCreate[] = [
                        'code'    => $code,
                        'brandId' => $brand['id'],
                        'label'   => $s->val,
                        'sort'    => (int)$s->top,
                        'wsId'    => (int)$s->id,
                        'enabled' => false,
                    ];
                    ImportReport::incCounter('DeviceTypes Created');
                } elseif (
                    $type['label'] != $s->val
                    || $type['sort'] != (int)$s->top
                    || $type['brand_id'] != $brand['id']
                    || $type['code'] != $code
                ) {
                    $dataUpdate[] = [
                        'id'      => $type['id'],
                        'code'    => $code,
                        'brandId' => $brand['id'],
                        'label'   => $s->val,
                        'sort'    => (int)$s->top,
                    ];
                    ImportReport::incCounter('DeviceTypes Updated');
                } else {
                    ImportReport::incCounter('DeviceTypes Unchanged');
                }

                // Create new types in batches of 100
                if (count($dataCreate) > 100) {
                    $topdataDeviceTypeRepository->create($dataCreate, $this->context);
                    ImportReport::incCounter('DeviceTypes Create Batches');
                    $dataCreate = [];
                    CliLogger::activity();
                }

                // Update existing types in batches of 100
                if (count($dataUpdate) > 100) {
                    $topdataDeviceTypeRepository->update($dataUpdate, $this->context);
                    ImportReport::incCounter('DeviceTypes Update Batches');
                    $dataUpdate = [];
                    CliLogger::activity();
                }
            }
        }

        // Create any remaining new types
        if (count($dataCreate)) {
            $topdataDeviceTypeRepository->create($dataCreate, $this->context);
            ImportReport::incCounter('DeviceTypes Create Batches');
            CliLogger::activity();
        }

        // Update any remaining existing types
        if (count($dataUpdate)) {
            $topdataDeviceTypeRepository->update($dataUpdate, $this->context);
            ImportReport::incCounter('DeviceTypes Update Batches');
            CliLogger::activity();
        }

        // Clear the fetched types data
        $types = null;

        // Log summary
        CliLogger::writeln('');
        CliLogger::writeln('=== DeviceTypes Summary ===');
        CliLogger::writeln('Total processed: ' . ImportReport::getCounter('DeviceTypes Total Processed'));
        CliLogger::writeln('Brand lookups: ' . ImportReport::getCounter('DeviceTypes Brand Lookups'));
        CliLogger::writeln('Brand not found: ' . ImportReport::getCounter('DeviceTypes Brand Not Found'));
        CliLogger::writeln('Created: ' . ImportReport::getCounter('DeviceTypes Created'));
        CliLogger::writeln('Updated: ' . ImportReport::getCounter('DeviceTypes Updated'));
        CliLogger::writeln('Unchanged: ' . ImportReport::getCounter('DeviceTypes Unchanged'));
        CliLogger::writeln('Create batches: ' . ImportReport::getCounter('DeviceTypes Create Batches'));
        CliLogger::writeln('Update batches: ' . ImportReport::getCounter('DeviceTypes Update Batches'));

        // Log the completion of the device type processing
        CliLogger::writeln("\nDeviceType done " . CliLogger::lap() . 'sec');

        UtilProfiling::stopTimer();
    }


    /**
     * 04/2025 moved from MappingHelperService::setSeries() to DeviceImportService::setSeries()
     */
    public function setSeries(): void
    {
        UtilProfiling::startTimer();
        CliLogger::section("Series");

        CliLogger::writeln('Fetching data from remote server [Series]...');
        CliLogger::lap(true);
        $series = $this->topdataWebserviceClient->getModelSeries();
        CliLogger::activity('Got ' . UtilFormatter::formatInteger(count($series->data)) . " series records from remote server\n");
        ImportReport::setCounter('Fetched Series', count($series->data));

        $topdataSeriesRepository = $this->topdataSeriesRepository;
        $dataCreate = [];
        $dataUpdate = [];
        CliLogger::activity('Processing data');
        $allSeries = $this->topdataSeriesService->getSeriesArray(true);

        foreach ($series->data as $s) {
            foreach ($s->brandIds as $brandWsId) {
                ImportReport::incCounter('Series Total Processed');
                ImportReport::incCounter('Series Brand Lookups');

                $brand = $this->topdataBrandService->getBrandByWsId((int)$brandWsId);
                if (!$brand) {
                    ImportReport::incCounter('Series Brand Not Found');
                    continue;
                }

                $serie = false;
                foreach ($allSeries as $seriesItem) {
                    if ($seriesItem['ws_id'] == $s->id && $seriesItem['brand_id'] == $brand['id']) {
                        $serie = $seriesItem;
                        break;
                    }
                }
                $code = $brand['code'] . '_' . $s->id . '_' . UtilStringFormatting::formCode($s->val);

                if (!$serie) {
                    $dataCreate[] = [
                        'code'    => $code,
                        'brandId' => $brand['id'],
                        //or? 'brand' => $brand,
                        'label'   => $s->val,
                        'sort'    => (int)$s->top,
                        'wsId'    => (int)$s->id,
                        'enabled' => false,
                    ];
                    ImportReport::incCounter('Series Created');
                } elseif (
                    $serie['code'] != $code
                    || $serie['label'] != $s->val
                    || $serie['sort'] != (int)$s->top
                    || $serie['brand_id'] != $brand['id']
                ) {
                    $dataUpdate[] = [
                        'id'      => $serie['id'],
                        'code'    => $code,
                        'brandId' => $brand['id'],
                        'label'   => $s->val,
                        'sort'    => (int)$s->top,
                    ];
                    ImportReport::incCounter('Series Updated');
                } else {
                    ImportReport::incCounter('Series Unchanged');
                }

                if (count($dataCreate) > 100) {
                    $topdataSeriesRepository->create($dataCreate, $this->context);
                    ImportReport::incCounter('Series Create Batches');
                    $dataCreate = [];
                    CliLogger::activity();
                }

                if (count($dataUpdate) > 100) {
                    $topdataSeriesRepository->update($dataUpdate, $this->context);
                    ImportReport::incCounter('Series Update Batches');
                    $dataUpdate = [];
                    CliLogger::activity();
                }
            }
        }

        if (count($dataCreate)) {
            $topdataSeriesRepository->create($dataCreate, $this->context);
            ImportReport::incCounter('Series Create Batches');
            CliLogger::activity();
        }

        if (count($dataUpdate)) {
            $topdataSeriesRepository->update($dataUpdate, $this->context);
            ImportReport::incCounter('Series Update Batches');
            CliLogger::activity();
        }

        // Log summary
        CliLogger::writeln('');
        CliLogger::writeln('=== Series Summary ===');
        CliLogger::writeln('Total processed: ' . ImportReport::getCounter('Series Total Processed'));
        CliLogger::writeln('Brand lookups: ' . ImportReport::getCounter('Series Brand Lookups'));
        CliLogger::writeln('Brand not found: ' . ImportReport::getCounter('Series Brand Not Found'));
        CliLogger::writeln('Created: ' . ImportReport::getCounter('Series Created'));
        CliLogger::writeln('Updated: ' . ImportReport::getCounter('Series Updated'));
        CliLogger::writeln('Unchanged: ' . ImportReport::getCounter('Series Unchanged'));
        CliLogger::writeln('Create batches: ' . ImportReport::getCounter('Series Create Batches'));
        CliLogger::writeln('Update batches: ' . ImportReport::getCounter('Series Update Batches'));

        CliLogger::writeln("\nSeries done " . CliLogger::lap() . 'sec');
        $series = null;
        $topdataSeriesRepository = null;

        UtilProfiling::stopTimer();
    }


    /**
     * this is called when --device or --device-only CLI options are set.
     *
     * 04/2025 moved from MappingHelperService::setDevices() to DeviceImportService::setDevices()
     */
    public function setDevices(): void
    {
        UtilProfiling::startTimer();

        $duplicates = [];
        $dataCreate = [];
        $dataUpdate = [];
        $updated = 0;
        $created = 0;
        $start = 0;
        $limit = self::BATCH_SIZE;
        $SQLlogger = $this->connection->getConfiguration()->getSQLLogger();
        $this->connection->getConfiguration()->setSQLLogger(null);
        CliLogger::section('Devices');
        CliLogger::writeln("Devices begin (Chunk size is $limit devices)");
        CliLogger::mem();
        CliLogger::writeln('');
        $functionTimeStart = microtime(true);
        $chunkNumber = 0;
        $repeat = true;
        CliLogger::lap(true);
        $seriesArray = $this->topdataSeriesService->getSeriesArray(true);
        $typesArray = $this->topdataDeviceTypeService->getTypesArray(true);

        while ($repeat) {
            if ($start) {
                CliLogger::mem();
                CliLogger::activity(CliLogger::lap() . 'sec');
            }
            $chunkNumber++;
            CliLogger::activity("\nGetting device chunk $chunkNumber from remote server...");

            ImportReport::incCounter('Device Chunks');
            $response = $this->topdataWebserviceClient->getModels($limit, $start);
            CliLogger::activity(CliLogger::lap() . "sec\n");

            if (!isset($response->data) || count($response->data) == 0) {
                $repeat = false;
                break;
            }

            $recordsInChunk = count($response->data);
            ImportReport::incCounter('Devices Records Fetched', $recordsInChunk);
            CliLogger::activity("Processing Device Chunk $chunkNumber ($recordsInChunk records)");

            foreach ($response->data as $s) {
                ImportReport::incCounter('Devices Total Processed');

                $brandArr = $this->topdataBrandService->getBrandByWsId((int)$s->bId);

                if (!$brandArr) {
                    ImportReport::incCounter('Devices Brand Not Found');
                    continue;
                }

                $code = $brandArr['code'] . '_' . UtilStringFormatting::formCode($s->val);

                if (isset($duplicates[$code])) {
                    ImportReport::incCounter('Devices Duplicates Skipped');
                    continue;
                }
                $duplicates[$code] = true;

                $search_keywords = [];

                $search_keywords[] = $brandArr['label']
                    . ' '
                    . $s->val
                    . ' '
                    . $brandArr['label'];

                if (count(UtilStringFormatting::getWordsFromString($brandArr['label'])) > 1) {
                    $search_keywords[] = UtilStringFormatting::firstLetters($brandArr['label'])
                        . ' '
                        . $s->val
                        . ' '
                        . UtilStringFormatting::firstLetters($brandArr['label']);
                }

                $deviceArr = [];
                ImportReport::incCounter('Devices Database Lookups');
                $rez = $this
                    ->connection
                    ->createQueryBuilder()
                    ->select('*')
                    ->from('topdata_device')
                    ->where('code="' . $code . '"')
                    ->setMaxResults(1)
                    ->execute()
                    ->fetchAllAssociative();

                if (isset($rez[0])) {
                    $deviceArr = $rez[0];
                    $deviceArr['id'] = bin2hex($deviceArr['id']);
                    // brand
                    if (empty($deviceArr['brand_id'])) {
                        ImportReport::incCounter('Devices Without Brand Id');
                        $deviceArr['brand_id'] = 0x0; // or null?
                    } else {
                        ImportReport::incCounter('Devices With Brand Id');
                        $deviceArr['brand_id'] = bin2hex($deviceArr['brand_id']);
                    }
                    // type
                    if (empty($deviceArr['type_id'])) {
                        ImportReport::incCounter('Devices Without Type Id');
                        $deviceArr['type_id'] = 0x0; // or null?
                    } else {
                        ImportReport::incCounter('Devices With Type Id');
                        $deviceArr['type_id'] = bin2hex($deviceArr['type_id']);
                    }
                    // series
                    if (empty($deviceArr['series_id'])) {
                        ImportReport::incCounter('Devices Without Series Id');
                        $deviceArr['series_id'] = 0x0; // or null?
                    } else {
                        ImportReport::incCounter('Devices With Series Id');
                        $deviceArr['series_id'] = bin2hex($deviceArr['series_id']);
                    }
                }

                $serieId = null;
                $serie = [];
                if ($s->mId) {
                    ImportReport::incCounter('Devices Series Lookups');
                    foreach ($seriesArray as $serieItem) {
                        if ($serieItem['ws_id'] == (int)$s->mId && $serieItem['brand_id'] == $brandArr['id']) {
                            $serie = $serieItem;
                            break;
                        }
                    }
                }
                if ($serie) {
                    ImportReport::incCounter('Devices Series Found');
                    $serieId = $serie['id'];
                    $search_keywords[] = $serie['label'];
                }

                $typeId = null;
                $type = [];
                if ($s->dId) {
                    ImportReport::incCounter('Devices Type Lookups');
                    foreach ($typesArray as $typeItem) {
                        if ($typeItem['ws_id'] == (int)$s->dId && $typeItem['brand_id'] == $brandArr['id']) {
                            $type = $typeItem;
                            break;
                        }
                    }
                }

                if ($type) {
                    ImportReport::incCounter('Devices Type Found');
                    $typeId = $type['id'];
                    $search_keywords[] = $type['label'];
                }

                $keywords = $this->_formSearchKeywords($search_keywords);

                if (!$deviceArr) {
                    $dataCreate[] = [
                        'brandId'  => $brandArr['id'],
                        'typeId'   => $typeId,
                        'seriesId' => $serieId,
                        'code'     => $code,
                        'model'    => $s->val,
                        'keywords' => $keywords,
                        'sort'     => (int)$s->top,
                        'wsId'     => (int)$s->id,
                        'enabled'  => false,
                        'mediaId'  => null,
                    ];
                    ImportReport::incCounter('Devices Created');
                } elseif (
                    $deviceArr['brand_id'] != $brandArr['id']
                    || $deviceArr['type_id'] != $typeId
                    || $deviceArr['series_id'] != $serieId
                    || $deviceArr['model'] != $s->val
                    || $deviceArr['keywords'] != $keywords
                    || $deviceArr['ws_id'] != $s->id
                ) {
                    $dataUpdate[] = [
                        'id'       => $deviceArr['id'],
                        'brandId'  => $brandArr['id'],
                        'typeId'   => $typeId,
                        'seriesId' => $serieId,
                        'model'    => $s->val,
                        'keywords' => $keywords,
                        'wsId'     => (int)$s->id,
                    ];
                    ImportReport::incCounter('Devices Updated');
                } else {
                    ImportReport::incCounter('Devices Unchanged');
                }

                if (count($dataCreate) > 50) {
                    $created += count($dataCreate);
                    $this->topdataDeviceRepository->create($dataCreate, $this->context);
                    ImportReport::incCounter('Devices Create Batches');
                    $dataCreate = [];
                    CliLogger::activity('+');
                }

                if (count($dataUpdate) > 50) {
                    $updated += count($dataUpdate);
                    $this->topdataDeviceRepository->update($dataUpdate, $this->context);
                    ImportReport::incCounter('Devices Update Batches');
                    $dataUpdate = [];
                    CliLogger::activity('*');
                }
            }
            if (count($dataCreate)) {
                $created += count($dataCreate);
                $this->topdataDeviceRepository->create($dataCreate, $this->context);
                ImportReport::incCounter('Devices Create Batches');
                $dataCreate = [];
                CliLogger::activity('+');
            }
            if (count($dataUpdate)) {
                $updated += count($dataUpdate);
                $this->topdataDeviceRepository->update($dataUpdate, $this->context);
                ImportReport::incCounter('Devices Update Batches');
                $dataUpdate = [];
                CliLogger::activity('*');
            }

            $start += $limit;
            if (count($response->data) < $limit) {
                $repeat = false;
                break;
            }
        }

        $response = null;
        $duplicates = null;
        CliLogger::writeln('');
        $totalSecs = microtime(true) - $functionTimeStart;

        // Enhanced reporting with all counters
        CliLogger::writeln('');
        CliLogger::writeln('=== Devices Import Summary ===');
        CliLogger::writeln('Chunks processed: ' . ImportReport::getCounter('Device Chunks'));
        CliLogger::writeln('Total records fetched: ' . ImportReport::getCounter('Devices Records Fetched'));
        CliLogger::writeln('Total records processed: ' . ImportReport::getCounter('Devices Total Processed'));
        CliLogger::writeln('Brand not found: ' . ImportReport::getCounter('Devices Brand Not Found'));
        CliLogger::writeln('Duplicates skipped: ' . ImportReport::getCounter('Devices Duplicates Skipped'));
        CliLogger::writeln('Database lookups: ' . ImportReport::getCounter('Devices Database Lookups'));
        CliLogger::writeln('With brand ID: ' . ImportReport::getCounter('Devices With Brand Id'));
        CliLogger::writeln('Without brand ID: ' . ImportReport::getCounter('Devices Without Brand Id'));
        CliLogger::writeln('With type ID: ' . ImportReport::getCounter('Devices With Type Id'));
        CliLogger::writeln('Without type ID: ' . ImportReport::getCounter('Devices Without Type Id'));
        CliLogger::writeln('With series ID: ' . ImportReport::getCounter('Devices With Series Id'));
        CliLogger::writeln('Without series ID: ' . ImportReport::getCounter('Devices Without Series Id'));
        CliLogger::writeln('Series lookups: ' . ImportReport::getCounter('Devices Series Lookups'));
        CliLogger::writeln('Series found: ' . ImportReport::getCounter('Devices Series Found'));
        CliLogger::writeln('Type lookups: ' . ImportReport::getCounter('Devices Type Lookups'));
        CliLogger::writeln('Type found: ' . ImportReport::getCounter('Devices Type Found'));
        CliLogger::writeln('Created: ' . ImportReport::getCounter('Devices Created'));
        CliLogger::writeln('Updated: ' . ImportReport::getCounter('Devices Updated'));
        CliLogger::writeln('Unchanged: ' . ImportReport::getCounter('Devices Unchanged'));
        CliLogger::writeln('Create batches: ' . ImportReport::getCounter('Devices Create Batches'));
        CliLogger::writeln('Update batches: ' . ImportReport::getCounter('Devices Update Batches'));
        CliLogger::writeln('Total time: ' . $totalSecs . ' seconds');

        CliLogger::getCliStyle()->dumpDict([
            'created'    => $created,
            'updated'    => $updated,
            'total time' => $totalSecs,
        ], 'Devices Report');

        $this->connection->getConfiguration()->setSQLLogger($SQLlogger);

        UtilProfiling::stopTimer();
    }




    /**
     * 04/2025 moved from MappingHelperService::formSearchKeywords() to DeviceImportService::formSearchKeywords()
     */
    private function _formSearchKeywords(array $keywords): string
    {
        $result = [];
        foreach ($keywords as $keyword) {
            $temp = mb_strtolower(trim($keyword));
            $result[] = $temp;
            $result[] = str_replace(['_', '/', '-', ' ', '.'], '', $temp);
            $result[] = trim(preg_replace('/\s+/', ' ', str_replace(['_', '/', '-', '.'], ' ', $temp)));
        }

        return mb_substr(implode(' ', array_unique($result)), 0, 250);
    }


}

================
File: src/Service/Import/DeviceMediaImportService.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\Import;

use Exception;
use Psr\Log\LoggerInterface;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataBrandService;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataDeviceService;
use Topdata\TopdataConnectorSW6\Service\MediaHelperService;
use Topdata\TopdataConnectorSW6\Service\TopdataWebserviceClient;
use Topdata\TopdataConnectorSW6\Util\ImportReport;
use Topdata\TopdataConnectorSW6\Util\UtilProfiling;
use Topdata\TopdataConnectorSW6\Util\UtilStringFormatting;
use Topdata\TopdataFoundationSW6\Util\CliLogger;

/**
 * Handles the import and association of media (images) for devices.
 * This service fetches device media information from a remote source, processes it,
 * and updates the local database by associating media files with devices.
 * Extracted from DeviceImportService in 04/2025.
 */
class DeviceMediaImportService
{
    const IMAGE_PREFIX = 'td-'; // Moved from DeviceImportService
    const BATCH_SIZE   = 5000; // Keeping consistent batch size, might adjust if needed

    private Context $context;

    public function __construct(
        private readonly LoggerInterface         $logger,
        private readonly EntityRepository        $topdataDeviceRepository, // Assuming this is the correct one passed in services.xml
        private readonly MediaHelperService      $mediaHelperService,
        private readonly TopdataWebserviceClient $topdataWebserviceClient,
        private readonly TopdataBrandService     $topdataBrandService,
        private readonly TopdataDeviceService    $topdataDeviceService
        // private readonly Connection              $connection // Add if needed by moved logic
    )
    {
        $this->context = Context::createDefaultContext();
    }

    /**
     * Sets the device media by fetching data from the remote server and updating the local database.
     *
     * This method retrieves device media information from the remote server, processes the data, and updates the local database
     * by creating new entries or updating existing ones. It uses the `TopdataWebserviceClient` to fetch the data and
     * the `EntityRepository` to perform database operations.
     *
     * @throws Exception
     *
     * 04/2025 moved from MappingHelperService::setDeviceMedia() to DeviceImportService::setDeviceMedia()
     * 04/2025 moved from DeviceImportService::setDeviceMedia() to DeviceMediaImportService::setDeviceMedia()
     */
    public function setDeviceMedia(): void
    {
        UtilProfiling::startTimer();
        CliLogger::writeln('Devices Media start');

        // ---- Fetch enabled devices
        $available_Printers = [];
        foreach ($this->topdataDeviceService->_getEnabledDevices() as $pr) {
            $available_Printers[$pr['ws_id']] = true;
        }
        $numDevicesTotal = count($available_Printers);
        $numDevicesProcessed = 0;
        $chunkSize = self::BATCH_SIZE;
        CliLogger::writeln("Chunk size is $chunkSize devices");
        CliLogger::writeln("Available devices: $numDevicesTotal");
        $start = 0;
        $chunkNumber = 0;
        CliLogger::lap(true);

        // ---- Main loop to process devices in chunks
        while (true) {
            $chunkNumber++;
            CliLogger::activity("\nFetching media chunk $chunkNumber from remote server...");
            ImportReport::incCounter('Device Media Chunks');
            $models = $this->topdataWebserviceClient->getModels($chunkSize, $start);
            CliLogger::activity(CliLogger::lap() . 'sec. ');
            CliLogger::mem();
            CliLogger::writeln('');

            // ---- Check if there is no data, break the loop
            if (!isset($models->data) || count($models->data) == 0) {
                break;
            }

            $recordsInChunk = count($models->data);
            ImportReport::incCounter('Device Media Records Fetched', $recordsInChunk);
            CliLogger::activity("Processing data chunk $chunkNumber ($recordsInChunk records)");

            // ---- Iterate through each device model in the chunk
            foreach ($models->data as $s) {
                ImportReport::incCounter('Device Media Total Processed');

                // ---- Skip if the device is not available
                if (!isset($available_Printers[$s->id])) {
                    ImportReport::incCounter('Device Media Devices Skipped - Not Available');
                    continue;
                }

                if ($numDevicesProcessed++ % 4 == 0) {
                    CliLogger::progress($numDevicesProcessed, $numDevicesTotal);
                }

                // ---- Get the brand by its Webservice ID
                $brand = $this->topdataBrandService->getBrandByWsId((int)$s->bId);
                if (!$brand) {
                    ImportReport::incCounter('Device Media Devices Skipped - No Brand');
                    continue;
                }

                // ---- Construct the device code
                $code = $brand['code'] . '_' . UtilStringFormatting::formCode($s->val);
                $device = $this->topdataDeviceRepository->search(
                    (new Criteria())
                        ->addFilter(new EqualsFilter('code', $code))
                        ->addAssociation('media')
                        ->setLimit(1),
                    $this->context
                )
                    ->getEntities()
                    ->first();

                // ---- Skip if the device is not found
                if (!$device) {
                    ImportReport::incCounter('Device Media Devices Skipped - Device Not Found');
                    continue;
                }

                ImportReport::incCounter('Device Media Devices Found');
                $currentMedia = $device->getMedia();

                // ---- Delete media if the image is null
                if (is_null($s->img) && $currentMedia) {
                    $this->topdataDeviceRepository->update([
                        [
                            'id'      => $device->getId(),
                            'mediaId' => null,
                        ],
                    ], $this->context);

                    ImportReport::incCounter('Device Media Images Deleted');

                    /*
                     * @todo Use \Shopware\Core\Content\Media\DataAbstractionLayer\MediaRepositoryDecorator
                     * for deleting file physically?
                     */

                    continue;
                }

                // ---- Skip if the image is null
                if (is_null($s->img)) {
                    ImportReport::incCounter('Device Media Images Skipped - No Image');
                    continue;
                }

                // ---- Skip if the current media is newer than the fetched media
                if ($currentMedia && (date_timestamp_get($currentMedia->getCreatedAt()) > strtotime($s->img_date))) {
                    ImportReport::incCounter('Device Media Images Skipped - Current Newer');
                    continue;
                }

                $imageDate = strtotime(explode(' ', $s->img_date)[0]);

                // ---- Try to update the media
                try {
                    $mediaId = $this->mediaHelperService->getMediaId($s->img, $imageDate, self::IMAGE_PREFIX);
                    if ($mediaId) {
                        $this->topdataDeviceRepository->update([
                            [
                                'id'      => $device->getId(),
                                'mediaId' => $mediaId,
                            ],
                        ], $this->context);
                        ImportReport::incCounter('Device Media Images Updated');
                    }
                } catch (Exception $e) {
                    ImportReport::incCounter('Device Media Errors');
                    $this->logger->error($e->getMessage());
                    CliLogger::writeln('Exception: ' . $e->getMessage());
                }
            }
            CliLogger::writeln("processed $numDevicesProcessed of $numDevicesTotal devices " . CliLogger::lap() . 'sec. ');
            $start += $chunkSize;
            if (count($models->data) < $chunkSize) {
                break;
            }
        }

        // ---- Final summary with all counters
        CliLogger::writeln('');
        CliLogger::writeln('=== Device Media Import Summary ===');
        CliLogger::writeln('Chunks processed: ' . ImportReport::getCounter('Device Media Chunks'));
        CliLogger::writeln('Total records fetched: ' . ImportReport::getCounter('Device Media Records Fetched'));
        CliLogger::writeln('Total records processed: ' . ImportReport::getCounter('Device Media Total Processed'));
        CliLogger::writeln('Devices found: ' . ImportReport::getCounter('Device Media Devices Found'));
        CliLogger::writeln('Devices skipped (not available): ' . ImportReport::getCounter('Device Media Devices Skipped - Not Available'));
        CliLogger::writeln('Devices skipped (no brand): ' . ImportReport::getCounter('Device Media Devices Skipped - No Brand'));
        CliLogger::writeln('Devices skipped (device not found): ' . ImportReport::getCounter('Device Media Devices Skipped - Device Not Found'));
        CliLogger::writeln('Images updated: ' . ImportReport::getCounter('Device Media Images Updated'));
        CliLogger::writeln('Images deleted: ' . ImportReport::getCounter('Device Media Images Deleted'));
        CliLogger::writeln('Images skipped (no image): ' . ImportReport::getCounter('Device Media Images Skipped - No Image'));
        CliLogger::writeln('Images skipped (current newer): ' . ImportReport::getCounter('Device Media Images Skipped - Current Newer'));
        CliLogger::writeln('Errors encountered: ' . ImportReport::getCounter('Device Media Errors'));
        CliLogger::writeln('Devices Media done');

        UtilProfiling::stopTimer();
    }
}

================
File: src/Service/Import/MappingHelperService.php
================
<?php
/**
 * @author    Christoph Muskalla <muskalla@cm-s.eu>
 * @copyright 2019 CMS (http://www.cm-s.eu)
 * @license   http://opensource.org/licenses/osl-3.0.php  Open Software License (OSL 3.0)
 */

namespace Topdata\TopdataConnectorSW6\Service\Import;

use Doctrine\DBAL\Connection;
use PDO;
use Psr\Log\LoggerInterface;
use Shopware\Core\Content\Product\ProductEntity;
use Shopware\Core\Defaults;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\Uuid\Uuid;
use Topdata\TopdataConnectorSW6\Service\Config\MergedPluginConfigHelperService;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataDeviceService;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataDeviceTypeService;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataSeriesService;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataToProductService;
use Topdata\TopdataConnectorSW6\Service\MediaHelperService;
use Topdata\TopdataConnectorSW6\Service\TopdataWebserviceClient;
use Topdata\TopdataConnectorSW6\Util\ImportReport;
use Topdata\TopdataConnectorSW6\Util\UtilProfiling;
use Topdata\TopdataConnectorSW6\Util\UtilStringFormatting;
use Topdata\TopdataFoundationSW6\Service\LocaleHelperService;
use Topdata\TopdataFoundationSW6\Util\CliLogger;
use Topdata\TopdataFoundationSW6\Util\UtilFormatter;

/**
 * MappingHelperService class.
 *
 * This class is responsible for mapping and synchronizing data between Topdata and Shopware 6.
 * It handles various operations such as product mapping, device synchronization, and cross-selling setup.
 *
 * TODO: This class is quite large and should be refactored into smaller, more focused classes.
 * this class is quite large and has multiple responsibilities. Here are several suggestions for extracting functionality into separate classes:
 *
 * 1 ProductVariantService
 *
 * • Extract all variant-related methods like setProductColorCapacityVariants(), createVariatedProduct(), collectColorVariants(), collectCapacityVariants()
 * • This would handle all logic related to product variants and their creation
 *
 * 3 ProductImportSettingsService
 *
 * • Extract _loadProductImportSettings(), getProductOption(), _getProductExtraOption()
 * • Would handle all product import configuration and settings
 *
 * 5 ProductMediaService
 *
 * • Extract media-related functionality from prepareProduct() and setProductInformation()
 * • Would handle all product media operations
 *
 * 6 ProductPropertyService
 *
 * • Extract property-related functionality from prepareProduct()
 * • Would handle all product property operations
 *
 *
 * The main MappingHelperService would then orchestrate these services and maintain only the core mapping logic between Topdata and Shopware 6.
 *
 * This separation would:
 *
 * • Make the code more maintainable
 * • Make testing easier
 * • Follow the Single Responsibility Principle better
 * • Make the code more reusable
 * • Reduce the complexity of the main service
 *
 * 04/2024 Renamed from MappingHelper to MappingHelperService
 */
class MappingHelperService
{

    const CAPACITY_NAMES = [
        'Kapazität (Zusatz)',
        'Kapazität',
        'Capacity',
    ];

    const COLOR_NAMES = [
        'Farbe',
        'Color',
    ];

    /**
     * Array to store mapped products.
     *
     * Structure:
     * [
     *      ws_id1 => [
     *          'product_id' => hexid1,
     *          'product_version_id' => hexversionid1
     *      ],
     *      ws_id2 => [
     *          'product_id' => hexid2,
     *          'product_version_id' => hexversionid2
     *      ]
     *  ]
     */
    private Context $context;
    private string $systemDefaultLocaleCode;


    public function __construct(
        private readonly LoggerInterface                 $logger,
        private readonly Connection                      $connection,
        private readonly EntityRepository                $topdataBrandRepository,
        private readonly EntityRepository                $topdataDeviceRepository,
        private readonly EntityRepository                $topdataSeriesRepository,
        private readonly EntityRepository                $topdataDeviceTypeRepository,
        private readonly EntityRepository                $productRepository,
        private readonly ProductMappingService           $productMappingService,
        private readonly MergedPluginConfigHelperService $optionsHelperService,
        private readonly LocaleHelperService             $localeHelperService,
        private readonly TopdataToProductService         $topdataToProductHelperService,
        private readonly MediaHelperService              $mediaHelperService,
        private readonly TopdataDeviceService            $topdataDeviceService,
        private readonly TopdataWebserviceClient         $topdataWebserviceClient,
        private readonly TopdataSeriesService            $topdataSeriesService,
        private readonly TopdataDeviceTypeService        $topdataDeviceTypeService
    )
    {
        $this->systemDefaultLocaleCode = $this->localeHelperService->getLocaleCodeOfSystemLanguage();
        $this->context = Context::createDefaultContext();
    }



    //    /**
    //     * 10/2024 UNUSED --> commented out
    //     */
    //    private function getAlbumByNameAndParent($name, $parentID = null)
    //    {
    //        $query = $this->connection->createQueryBuilder();
    //        $query->select('*')
    //            ->from('s_media_album', 'alb')
    //            ->where('alb.name = :name')
    //            ->setParameter(':name', $name);
    //        if (is_null($parentID)) {
    //            $query->andWhere('alb.parentID is null');
    //        } else {
    //            $query->andWhere('alb.parentID = :parentID')
    //                ->setParameter(':parentID', ($parentID));
    //        }
    //
    //        $return = $query->execute()->fetchAllAssociative();
    //        if (isset($return[0])) {
    //            return $return[0];
    //        } else {
    //            return false;
    //        }
    //    }

    //    /**
    //     * 10/2024 UNUSED --> commented out
    //     */
    //    private function getKeysByCustomField(string $optionName, string $colName = 'name'): array
    //    {
    //        $query = $this->connection->createQueryBuilder();
    //
    //        //        $query->select(['val.value', 'det.id'])
    //        //            ->from('s_filter_articles', 'art')
    //        //            ->innerJoin('art', 's_articles_details','det', 'det.articleID = art.articleID')
    //        //            ->innerJoin('art', 's_filter_values','val', 'art.valueID = val.id')
    //        //            ->innerJoin('val', 's_filter_options', 'opt', 'opt.id = val.optionID')
    //        //            ->where('opt.name = :option')
    //        //            ->setParameter(':option', $optionName)
    //        //        ;
    //
    //        $query->select(['pgot.name ' . $colName, 'p.id', 'p.version_id'])
    //            ->from('product', 'p')
    //            ->innerJoin('p', 'product_property', 'pp', '(pp.product_id = p.id) AND (pp.product_version_id = p.version_id)')
    //            ->innerJoin('pp', 'property_group_option_translation', 'pgot', 'pgot.property_group_option_id = pp.property_group_option_id')
    //            ->innerJoin('pp', 'property_group_option', 'pgo', 'pgo.id = pp.property_group_option_id')
    //            ->innerJoin('pgo', 'property_group_translation', 'pgt', 'pgt.property_group_id = pgo.property_group_id')
    //            ->where('pgt.name = :option')
    //            ->setParameter(':option', $optionName);
    //        //print_r($query->getSQL());die();
    //        $returnArray = $query->execute()->fetchAllAssociative();
    //
    //        //        foreach ($returnArray as $key=>$val) {
    //        //            $returnArray[$key] = [
    //        //                $colName => $val[$colName],
    //        //                'id' => bin2hex($val['id']),
    //        //                'version_id' => bin2hex($val['version_id']),
    //        //            ];
    //        //        }
    //        return $returnArray;
    //    }


    private function getTopdataCategory()
    {
        $query = $this->connection->createQueryBuilder();
        $query->select(['categoryID', 'top_data_ws_id'])
            ->from('s_categories_attributes')
            ->where('top_data_ws_id != \'0\'')
            ->andWhere('top_data_ws_id != \'\'')
            ->andWhere('top_data_ws_id is not null');

        return $query->execute()->fetchAllAssociative(PDO::FETCH_KEY_PAIR);
    }

    /**
     * Sets the brands by fetching data from the remote server and updating the local database.
     *
     * This method retrieves brand data from the remote server, processes the data, and updates the local database
     * by creating new entries or updating existing ones. It uses the `TopdataWebserviceClient` to fetch the data and
     * the `EntityRepository` to perform database operations.
     */
    public function setBrands(): void
    {
        UtilProfiling::startTimer();
        CliLogger::section("Brands");

        // Log the start of the data fetching process
        CliLogger::writeln('Fetching data from remote server [Brand]...');
        CliLogger::lap(true);

        // Fetch the brands from the remote server
        $brands = $this->topdataWebserviceClient->getBrands();
        CliLogger::activity('Got ' . UtilFormatter::formatInteger(count($brands->data)) . " brands from remote server\n");
        ImportReport::setCounter('Fetched Brands', count($brands->data));

        $duplicates = [];
        $dataCreate = [];
        $dataUpdate = [];
        CliLogger::activity('Processing data');

        // Process each brand fetched from the remote server
        foreach ($brands->data as $b) {
            if ($b->main == 0) {
                continue;
            }

            $code = UtilStringFormatting::formCode($b->val);
            if (isset($duplicates[$code])) {
                continue;
            }
            $duplicates[$code] = true;

            // Search for existing brand in the local database
            $brand = $this->topdataBrandRepository->search(
                (new Criteria())->addFilter(new EqualsFilter('code', $code))->setLimit(1),
                $this->context
            )
                ->getEntities()
                ->first();

            // If the brand does not exist, prepare data for creation
            if (!$brand) {
                $dataCreate[] = [
                    'code'    => $code,
                    'name'    => $b->val,
                    'enabled' => false,
                    'sort'    => (int)$b->top,
                    'wsId'    => (int)$b->id,
                ];
                // If the brand exists but has different data, prepare data for update
            } elseif (
                $brand->getName() != $b->val ||
                $brand->getSort() != $b->top ||
                $brand->getWsId() != $b->id
            ) {
                $dataUpdate[] = [
                    'id'   => $brand->getId(),
                    'name' => $b->val,
                    // 'sort' => (int)$b->top,
                    'wsId' => (int)$b->id,
                ];
            }

            // Create new brands in batches of 100
            if (count($dataCreate) > 100) {
                $this->topdataBrandRepository->create($dataCreate, $this->context);
                $dataCreate = [];
                CliLogger::activity();
            }

            // Update existing brands in batches of 100
            if (count($dataUpdate) > 100) {
                $this->topdataBrandRepository->update($dataUpdate, $this->context);
                $dataUpdate = [];
                CliLogger::activity();
            }
        }

        // Create any remaining new brands
        if (count($dataCreate)) {
            $this->topdataBrandRepository->create($dataCreate, $this->context);
            CliLogger::activity();
        }

        // Update any remaining existing brands
        if (count($dataUpdate)) {
            $this->topdataBrandRepository->update($dataUpdate, $this->context);
            CliLogger::activity();
        }

        // Log the completion of the brands process
        CliLogger::writeln("\nBrands done " . CliLogger::lap() . 'sec');
        $duplicates = null;
        $brands = null;

        UtilProfiling::stopTimer();
    }


    private function addToGroup($groups, $ids, $variants): array
    {
        $colorVariants = ($variants == 'color');
        $capacityVariants = ($variants == 'capacity');
        $groupExists = false;
        foreach ($groups as $key => $group) {
            foreach ($ids as $id) {
                if (in_array($id, $group['ids'])) {
                    $groupExists = true;
                    break;
                }
            }

            if ($groupExists) {
                $groups[$key]['ids'] = array_unique(array_merge($group['ids'], $ids));
                if ($colorVariants) {
                    $groups[$key]['color'] = true;
                }
                if ($capacityVariants) {
                    $groups[$key]['capacity'] = true;
                }

                return $groups;
            }
        }

        $groups[] = [
            'ids'              => $ids,
            'color'            => $colorVariants,
            'capacity'         => $capacityVariants,
            'referenceProduct' => false,
        ];

        return $groups;
    }

    private function collectColorVariants($groups): array
    {
        $query = <<<'SQL'
        SELECT LOWER(HEX(product_id)) as id,
        GROUP_CONCAT(LOWER(HEX(color_variant_product_id)) SEPARATOR ',') as variant_ids
        FROM `topdata_product_to_color_variant` 
        GROUP BY product_id
SQL;
        $rez = $this->connection->fetchAllAssociative($query);
        foreach ($rez as $row) {
            $ids = array_merge([$row['id']], explode(',', $row['variant_ids']));
            $groups = $this->addToGroup($groups, $ids, 'color');
        }

        return $groups;
    }

    private function collectCapacityVariants($groups): array
    {
        $query = <<<'SQL'
        SELECT LOWER(HEX(product_id)) as id,
        GROUP_CONCAT(LOWER(HEX(capacity_variant_product_id)) SEPARATOR ',') as variant_ids
        FROM `topdata_product_to_capacity_variant` 
        GROUP BY product_id
SQL;
        $rez = $this->connection->fetchAllAssociative($query);
        foreach ($rez as $row) {
            $ids = array_merge([$row['id']], explode(',', $row['variant_ids']));
            $groups = $this->addToGroup($groups, $ids, 'capacity');
        }

        return $groups;
    }

    private function countProductGroupHits($groups): array
    {
        $return = [];
        $allIds = [];
        foreach ($groups as $group) {
            $allIds = array_merge($allIds, $group['ids']);
        }

        $allIds = array_unique($allIds);
        foreach ($allIds as $id) {
            foreach ($groups as $group) {
                if (in_array($id, $group['ids'])) {
                    if (!isset($return[$id])) {
                        $return[$id] = 0;
                    }
                    $return[$id]++;
                }
            }
        }

        return $return;
    }

    private function mergeIntersectedGroups($groups): array
    {
        $return = [];
        foreach ($groups as $group) {
            $added = false;
            foreach ($return as $key => $g) {
                if (count(array_intersect($group['ids'], $g['ids']))) {
                    $return[$key]['ids'] = array_unique(array_merge($group['ids'], $g['ids']));
                    $return[$key]['color'] = $group['color'] || $g['color'];
                    $return[$key]['capacity'] = $group['capacity'] || $g['capacity'];
                    $added = true;
                    break;
                }
            }
            if (!$added) {
                $return[] = $group;
            }
        }

        return $return;
    }

    public function setProductColorCapacityVariants(): void
    {
        UtilProfiling::startTimer();
        CliLogger::writeln("\nBegin generating variated products based on color and capacity information (Import variants with other colors, Import variants with other capacities should be enabled in TopFeed plugin, product information should be already imported)");
        $groups = [];
        CliLogger::lap(true);
        $groups = $this->collectColorVariants($groups);
        //        echo "\nColor groups:".count($groups)."\n";
        $groups = $this->collectCapacityVariants($groups);
        //        echo "\nColor+capacity groups:".count($groups)."\n";
        $groups = $this->mergeIntersectedGroups($groups);
        CliLogger::writeln('Found ' . count($groups) . ' groups to generate variated products');

        $invalidProd = true;
        for ($i = 0; $i < count($groups); $i++) {
//            if ($this->optionsHelperService->getOption(OptionConstants::START) && ($i + 1 < $this->optionsHelperService->getOption(OptionConstants::START))) {
//                continue;
//            }
//
//            if ($this->optionsHelperService->getOption(OptionConstants::END) && ($i + 1 > $this->optionsHelperService->getOption(OptionConstants::END))) {
//                break;
//            }

            CliLogger::activity('Group ' . ($i + 1) . '...');

            //            print_r($groups[$i]);
            //            echo "\n";

            $criteria = new Criteria($groups[$i]['ids']);
            $criteria->addAssociations(['properties.group', 'visibilities', 'categories']);
            $products = $this
                ->productRepository
                ->search($criteria, $this->context)
                ->getEntities();

            if (count($products)) {
                $invalidProd = false;
                $parentId = null;
                foreach ($groups[$i]['ids'] as $productId) {
                    $product = $products->get($productId);
                    if (!$product) {
                        $invalidProd = true;
                        CliLogger::writeln("\nProduct id=$productId not found!");
                        break;
                    }

                    if ($product->getParentId()) {
                        if (is_null($parentId)) {
                            $parentId = $product->getParentId();
                        }
                        if ($parentId != $product->getParentId()) {
                            $invalidProd = true;
                            CliLogger::writeln("\nMany parent products error (last checked product id=$productId)!");
                            break;
                        }
                    }

                    if ($product->getChildCount() > 0) {
                        CliLogger::writeln("\nProduct id=$productId has childs!");
                        $invalidProd = true;
                        break;
                    }

                    $prodOptions = [];

                    foreach ($product->getProperties() as $property) {
                        if ($groups[$i]['color'] && in_array($property->getGroup()->getName(), self::COLOR_NAMES)) {
                            $prodOptions['colorId'] = $property->getId();
                            $prodOptions['colorGroupId'] = $property->getGroup()->getId();
                        }
                        if ($groups[$i]['capacity'] && in_array($property->getGroup()->getName(), self::CAPACITY_NAMES)) {
                            $prodOptions['capacityId'] = $property->getId();
                            $prodOptions['capacityGroupId'] = $property->getGroup()->getId();
                        }
                    }

                    /*
                     * @todo: Add product property Color=none or Capacity=none if needed but not exist
                     */

                    if (count($prodOptions)) {
                        if (!isset($groups[$i]['options'])) {
                            $groups[$i]['options'] = [];
                        }
                        $prodOptions['skip'] = (bool)($product->getParentId());
                        $groups[$i]['options'][$productId] = $prodOptions;
                    } else {
                        CliLogger::writeln("\nProduct id=$productId has no valid properties!");
                        $invalidProd = true;
                        break;
                    }

                    if (!$groups[$i]['referenceProduct']
                        &&
                        (
                            isset($groups[$i]['options'][$productId]['colorId'])
                            ||
                            isset($groups[$i]['options'][$productId]['capacityId'])
                        )
                    ) {
                        $groups[$i]['referenceProduct'] = $product;
                    }
                }
            }

            if ($invalidProd) {
                CliLogger::activity('Variated product for group will be skip, product ids: ');
                CliLogger::writeln(implode(', ', $groups[$i]['ids']));
            }

            if ($groups[$i]['referenceProduct'] && !$invalidProd) {
                $this->createVariatedProduct($groups[$i], $parentId);
            }
            CliLogger::writeln('done');
        }

        CliLogger::activity(CliLogger::lap() . 'sec ');
        CliLogger::mem();
        CliLogger::writeln('Generating variated products done');

        UtilProfiling::stopTimer();
    }


    private function createVariatedProduct($group, $parentId = null)
    {
        if (is_null($parentId)) {
            /** @var ProductEntity $refProd */
            $refProd = $group['referenceProduct'];
            $parentId = Uuid::randomHex();

            $visibilities = [];
            foreach ($refProd->getVisibilities() as $visibility) {
                $visibilities[] = [
                    'salesChannelId' => $visibility->getSalesChannelId(),
                    'visibility'     => $visibility->getVisibility(),
                ];
            }

            $categories = [];
            foreach ($refProd->getCategories() as $category) {
                $categories[] = [
                    'id' => $category->getId(),
                ];
            }

            $prod = [
                'id'               => $parentId,
                'productNumber'    => 'VAR-' . $refProd->getProductNumber(),
                'active'           => true,
                'taxId'            => $refProd->getTaxId(),
                'stock'            => $refProd->getStock(),
                'shippingFree'     => $refProd->getShippingFree(),
                'purchasePrice'    => 0.0,
                'displayInListing' => true,
                'name'             => [
                    $this->systemDefaultLocaleCode => 'VAR ' . $refProd->getName(),
                ],
                'price'            => [[
                    'net'        => 0.0,
                    'gross'      => 0.0,
                    'linked'     => true,
                    'currencyId' => Defaults::CURRENCY,
                ]],
            ];

            if ($refProd->getManufacturerId()) {
                $prod['manufacturer'] = [
                    'id' => $refProd->getManufacturerId(),
                ];
            }

            if ($visibilities) {
                $prod['visibilities'] = $visibilities;
            }

            if ($categories) {
                $prod['categories'] = $categories;
            }

            $this->productRepository->create([$prod], $this->context);
        } else {
            //delete configurator settings
            $this->connection->executeStatement('
                DELETE FROM product_configurator_setting
                WHERE product_id=0x' . $parentId);
        }

        $configuratorSettings = [];
        $optionGroupIds = [];
        $confOptions = [];
        $data = [];
        $productIdsToClearCrosses = [];

        //        echo "\n";
        //        $group['referenceProduct'] = true;
        //        print_r($group);
        //        echo "\n";

        foreach ($group['options'] as $prodId => $item) {
            $options = [];
            if (isset($item['colorId'])) {
                if (!$item['skip']) {
                    $options[] = [
                        'id' => $item['colorId'],
                    ];
                }
                $add = true;
                foreach ($confOptions as $opt) {
                    if ($opt['id'] == $item['colorId']) {
                        $add = false;
                        break;
                    }
                }
                if ($add) {
                    $confOptions[] = [
                        'id'    => $item['colorId'],
                        'group' => [
                            'id' => $item['colorGroupId'],
                        ],
                    ];
                    $optionGroupIds[] = $item['colorGroupId'];
                }
            }

            if (isset($item['capacityId'])) {
                if (!$item['skip']) {
                    $options[] = [
                        'id' => $item['capacityId'],
                    ];
                }

                $add = true;
                foreach ($confOptions as $opt) {
                    if ($opt['id'] == $item['capacityId']) {
                        $add = false;
                        break;
                    }
                }
                if ($add) {
                    $confOptions[] = [
                        'id'    => $item['capacityId'],
                        'group' => [
                            'id' => $item['capacityGroupId'],
                        ],
                    ];
                    $optionGroupIds[] = $item['capacityGroupId'];
                }
            }

            if (count($options)) {
                $data[] = [
                    'id'       => $prodId,
                    'options'  => $options,
                    'parentId' => $parentId,
                ];
                $productIdsToClearCrosses[] = $prodId;
            }
        }

        $configuratorGroupConfig = [];
        $optionGroupIds = array_unique($optionGroupIds);
        //        echo "\n";
        //        print_r($parentId.'='.count($optionGroupIds));
        //        echo "\n";
        foreach ($optionGroupIds as $groupId) {
            $configuratorGroupConfig[] = [
                'id'                    => $groupId,
                'expressionForListings' => true,
                'representation'        => 'box',
            ];
        }

        foreach ($confOptions as $confOpt) {
            $configuratorSettings[] = [
                'option' => $confOpt,
            ];
        }

        if ($configuratorSettings) {
            $data[] = [
                'id'                      => $parentId,
                'configuratorGroupConfig' => $configuratorGroupConfig ?: null,
                'configuratorSettings'    => $configuratorSettings,
            ];
        }

        if (count($data)) {
            $this->productRepository->update($data, $this->context);

            if (count($productIdsToClearCrosses)) {
                //delete crosses for variant products:
                $ids = '0x' . implode(',0x', $productIdsToClearCrosses);
                $this->connection->executeStatement('
                    DELETE FROM product_cross_selling
                    WHERE product_id IN (' . $ids . ')
                ');
            }
        }
    }


}

================
File: src/Service/Import/ProductMappingService.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\Import;

use Doctrine\DBAL\Connection;
use Topdata\TopdataConnectorSW6\Constants\MappingTypeConstants;
use Topdata\TopdataConnectorSW6\Constants\MergedPluginConfigKeyConstants;
use Topdata\TopdataConnectorSW6\DTO\ImportConfig;
use Topdata\TopdataConnectorSW6\Service\Config\MergedPluginConfigHelperService;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataToProductService;
use Topdata\TopdataConnectorSW6\Service\Import\MappingStrategy\AbstractMappingStrategy;
use Topdata\TopdataConnectorSW6\Service\Import\MappingStrategy\MappingStrategy_ProductNumberAs;
use Topdata\TopdataConnectorSW6\Service\Import\MappingStrategy\MappingStrategy_Unified;
use Topdata\TopdataConnectorSW6\Util\UtilProfiling;
use Topdata\TopdataFoundationSW6\Util\CliLogger;

/**
 * Service class for mapping products between Topdata and Shopware 6.
 * This service handles the process of mapping products from Topdata to Shopware 6,
 * utilizing different mapping strategies based on the configured mapping type.
 * 07/2024 created (extracted from MappingHelperService).
 * 05/2025 updated to use the unified mapping strategy.
 */
class ProductMappingService
{
    const BATCH_SIZE                    = 500;
    const BATCH_SIZE_TOPDATA_TO_PRODUCT = 99;

    /**
     * @var array already processed products
     */
    private array $setted;

    public function __construct(
        private readonly MergedPluginConfigHelperService $mergedPluginConfigHelperService,
        private readonly MappingStrategy_ProductNumberAs $mappingStrategy_ProductNumberAs,
        private readonly MappingStrategy_Unified         $mappingStrategy_Unified,
        private readonly TopdataToProductService         $topdataToProductService,
    )
    {
    }

    /**
     * Maps products from Topdata to Shopware 6 based on the configured mapping type.
     * This method truncates the `topdata_to_product` table and then executes the appropriate
     * mapping strategy.
     */
    public function mapProducts(ImportConfig $importConfig): void
    {
        UtilProfiling::startTimer();
        CliLogger::info('ProductMappingService::mapProducts() - using mapping type: ' . $this->mergedPluginConfigHelperService->getOption(MergedPluginConfigKeyConstants::MAPPING_TYPE));

        // ---- Clear existing mappings
        $this->topdataToProductService->deleteAll('ProductMappingService::mapProducts - Clear existing mappings before mapping');

        // ---- Create the appropriate strategy based on mapping type
        $strategy = $this->_createMappingStrategy();

        // ---- Execute the strategy
        $strategy->map($importConfig);
        UtilProfiling::stopTimer();
    }

    /**
     * Creates the appropriate mapping strategy based on the configured mapping type.
     *
     * @return AbstractMappingStrategy The mapping strategy to use.
     * @throws \Exception If an unknown mapping type is encountered.
     */
    private function _createMappingStrategy(): AbstractMappingStrategy
    {
        $mappingType = $this->mergedPluginConfigHelperService->getOption(MergedPluginConfigKeyConstants::MAPPING_TYPE);

        return match ($mappingType) {
            // ---- Product Number Mapping Strategy
            MappingTypeConstants::PRODUCT_NUMBER_AS_WS_ID => $this->mappingStrategy_ProductNumberAs,

            // ---- Unified Mapping Strategy (handles both EAN/OEM and Distributor)
            MappingTypeConstants::DEFAULT,
            MappingTypeConstants::CUSTOM,
            MappingTypeConstants::CUSTOM_FIELD,
            MappingTypeConstants::DISTRIBUTOR_DEFAULT,
            MappingTypeConstants::DISTRIBUTOR_CUSTOM,
            MappingTypeConstants::DISTRIBUTOR_CUSTOM_FIELD => $this->mappingStrategy_Unified,

            // ---- unknown mapping type --> throw exception
            default => throw new \Exception('Unknown mapping type: ' . $mappingType),
        };
    }
}

================
File: src/Service/Linking/ProductDeviceRelationshipServiceV1.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\Linking;

use Doctrine\DBAL\Connection;
use Exception;
use Psr\Log\LoggerInterface;
use Topdata\TopdataConnectorSW6\Constants\BatchSizeConstants;
use Topdata\TopdataConnectorSW6\Constants\WebserviceFilterTypeConstants;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataDeviceService;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataToProductService;
use Topdata\TopdataConnectorSW6\Service\TopdataWebserviceClient;
use Topdata\TopdataConnectorSW6\Util\UtilProfiling;
use Topdata\TopdataFoundationSW6\Util\CliLogger;

/**
 * 04/2025 created (extracted from MappingHelperService)
 */
class ProductDeviceRelationshipServiceV1
{

    const CHUNK_SIZE = 100;

    public function __construct(
        private readonly Connection              $connection,
        private readonly TopdataToProductService $topdataToProductHelperService,
        private readonly TopdataDeviceService    $topdataDeviceService,
        private readonly TopdataWebserviceClient $topdataWebserviceClient,
    )
    {
    }


    /**
     * ==== MAIN ====
     *
     * The setProducts() method in the MappingHelperService class is responsible for linking devices to products. Here's a step-by-step breakdown of what it does:
     * It starts by disabling all devices, brands, series, and types in the database. This is done by setting the is_enabled field to 0 for each of these entities.
     * It unlinks all products by deleting all entries in the topdata_device_to_product table.
     * It retrieves all the product IDs from the topid_products array.
     * It then chunks these product IDs into groups of 100 and for each chunk, it does the following:
     * It makes a call to the remote server to get product data for the current chunk of product IDs.
     * It processes the returned product data. For each product, if it has associated devices, it adds these devices to the deviceWS array.
     * It then gets the device data for all the devices in the deviceWS array from the database.
     * For each device, it checks if the device's brand, series, and type are enabled. If not, it adds them to the respective arrays (enabledBrands, enabledSeries, enabledTypes).
     * It then checks if the device has associated products in the deviceWS array. If it does, it prepares data for inserting these associations into the topdata_device_to_product table.
     * It then inserts these associations into the topdata_device_to_product table in chunks of 30.
     * After all the associations have been inserted, it enables all the brands, series, and types that were added to the enabledBrands, enabledSeries, and enabledTypes arrays.
     * Finally, it returns true if everything went well, or false if an exception was thrown at any point.
     * This method is part of a larger process of syncing product and device data between a local database and a remote server. It ensures that the local database has up-to-date associations between products and the devices they are compatible with.
     *
     * 04/2025 moved from MappingHelperService::setProducts() to ProductDeviceRelationshipService::syncDeviceProductRelationships()
     */
    public function syncDeviceProductRelationshipsV1(): void
    {
        UtilProfiling::startTimer();

        CliLogger::getCliStyle()->yellow('Devices to products linking begin');
        CliLogger::getCliStyle()->yellow('Disabling all devices, brands, series and types, unlinking products, caching products...');
        CliLogger::lap(true);

        // ---- disable all brands
        $cntA = $this->connection->createQueryBuilder()
            ->update('topdata_brand')
            ->set('is_enabled', '0')
            ->executeStatement();

        // ---- disable all devices
        $cntB = $this->connection->createQueryBuilder()
            ->update('topdata_device')
            ->set('is_enabled', '0')
            ->executeStatement();

        // ---- disable all series
        $cntC = $this->connection->createQueryBuilder()
            ->update('topdata_series')
            ->set('is_enabled', '0')
            ->executeStatement();

        // ---- disable all device types
        $cntD = $this->connection->createQueryBuilder()
            ->update('topdata_device_type')
            ->set('is_enabled', '0')
            ->executeStatement();


        // ---- delete all device-to-product relations
        $cntE = $this->connection->createQueryBuilder()
            ->delete('topdata_device_to_product')
            ->executeStatement();

        // ---- just info
        CliLogger::getCliStyle()->dumpDict([
            'disabled brands '            => $cntA,
            'disabled devices '           => $cntB,
            'disabled series '            => $cntC,
            'disabled device types '      => $cntD,
            'unlinked device-to-product ' => $cntE,
        ]);

        $topidProducts = $this->topdataToProductHelperService->getTopdataProductMappings();

        CliLogger::activity(CliLogger::lap() . "sec\n");
        $enabledBrands = [];
        $enabledSeries = [];
        $enabledTypes = [];

        $topidsChunked = array_chunk(array_keys($topidProducts), self::CHUNK_SIZE);
        foreach ($topidsChunked as $idxChunk => $productIds) {

            // ---- fetch products from webservice
//            CliLogger::writeln("Fetching data from remote server part " . ($idxChunk + 1) . '/' . count($topidsChunked) . '...');
            CliLogger::progress( ($idxChunk + 1), count($topidsChunked), 'Fetching data from remote server [Product-Device-Relationship]...');
            $response = $this->topdataWebserviceClient->myProductList([
                'products' => implode(',', $productIds),
                'filter'   => WebserviceFilterTypeConstants::product_application_in,
            ]);
            CliLogger::activity(CliLogger::lap() . "sec\n");

            if (!isset($response->page->available_pages)) {
                throw new Exception($response->error[0]->error_message . 'webservice no pages');
            }
            CliLogger::mem();
            CliLogger::activity("\nProcessing data of " . count($response->products) . " products ...");
            $deviceWS = [];
            foreach ($response->products as $product) {
                if (!isset($topidProducts[$product->products_id])) {
                    continue;
                }
                if (isset($product->product_application_in->products) && count($product->product_application_in->products)) {
                    foreach ($product->product_application_in->products as $tid) {
                        foreach ($topidProducts[$product->products_id] as $tps) {
                            $deviceWS[$tid][] = $tps;
                        }
                    }
                }
            }

            //                $deviceWS = [
            //                    123 = [
            //                        ['product_id' = 00ffcc, 'product_version_id' = 00ffc2],
            //                        ['product_id' = 00ffcc, 'product_version_id' = 00ffc2]
            //                    ],
            //                    1138 = [
            //                        ['product_id' = 00afcc, 'product_version_id' = 00afc2],
            //                        ['product_id' = 00bfcc, 'product_version_id' = 00bfc2]
            //                    ]
            //                ]

            /*
             * Important!
             * There could be many devices with same ws_id!!!
             */

            $deviceIdsToEnable = array_keys($deviceWS);
            $devices = $this->topdataDeviceService->getDeviceArrayByWsIdArray($deviceIdsToEnable);
            CliLogger::activity();
            if (!count($devices)) {
                continue;
            }

            $chunkedDeviceIdsToEnable = array_chunk($deviceIdsToEnable, BatchSizeConstants::ENABLE_DEVICES);
            foreach ($chunkedDeviceIdsToEnable as $chunk) {
                $sql = 'UPDATE topdata_device SET is_enabled = 1 WHERE (is_enabled = 0) AND (ws_id IN (' . implode(',', $chunk) . '))';
                $cnt = $this->connection->executeStatement($sql);
                CliLogger::getCliStyle()->blue("Enabled $cnt devices");
                // \Topdata\TopdataFoundationSW6\Util\CliLogger::activity();
            }

            /* device_id, product_id, product_version_id, created_at */
            $insertData = [];
            $createdAt = date('Y-m-d H:i:s');

            foreach ($devices as $device) {
                if ($device['brand_id'] && !isset($enabledBrands[$device['brand_id']])) {
                    $enabledBrands[$device['brand_id']] = '0x' . $device['brand_id'];
                }

                if ($device['series_id'] && !isset($enabledSeries[$device['series_id']])) {
                    $enabledSeries[$device['series_id']] = '0x' . $device['series_id'];
                }

                if ($device['type_id'] && !isset($enabledTypes[$device['type_id']])) {
                    $enabledTypes[$device['type_id']] = '0x' . $device['type_id'];
                }

                if (isset($deviceWS[$device['ws_id']])) {
                    foreach ($deviceWS[$device['ws_id']] as $prod) {
                        $insertData[] = "(0x{$device['id']}, 0x{$prod['product_id']}, 0x{$prod['product_version_id']}, '$createdAt')";
                    }
                }
            }

            $insertDataChunks = array_chunk($insertData, 30);

            foreach ($insertDataChunks as $chunk) {
                // fixme: here it crashes: SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '\x01\x95`o\xDEkq\xC6\x9D\xAA(\xA3\xB1\xEE\xE6\xF4-\x85i\x85[F...' for key 'PRIMARY'
//                $this->connection->executeStatement('
//                        INSERT INTO topdata_device_to_product (device_id, product_id, product_version_id, created_at) VALUES ' . implode(',', $chunk) . '
//                    ');

                // crash workaround with "ON DUPLICATE KEY UPDATE"
                $sql = '
                    INSERT INTO topdata_device_to_product 
                        (device_id, product_id, product_version_id, created_at) 
                    VALUES ' . implode(',', $chunk) . '
                    ON DUPLICATE KEY UPDATE 
                        product_id = VALUES(product_id), 
                        product_version_id = VALUES(product_version_id)
                        -- updated_at = NOW() 
                ';
                $this->connection->executeStatement($sql);


                CliLogger::activity();
            }

            CliLogger::activity(CliLogger::lap() . "sec\n");
            CliLogger::mem();
        }

        CliLogger::getCliStyle()->yellow('Activating brands, series and device types...');
        CliLogger::getCliStyle()->dumpDict([
            'enabledBrands' => count($enabledBrands),
            'enabledSeries' => count($enabledSeries),
            'enabledTypes'  => count($enabledTypes),

        ]);

        // ---- enable brands
        $ArraybrandIds = array_chunk($enabledBrands, BatchSizeConstants::ENABLE_BRANDS);
        foreach ($ArraybrandIds as $brandIds) {
            $cnt = $this->connection->executeStatement('
                    UPDATE topdata_brand SET is_enabled = 1 WHERE id IN (' . implode(',', $brandIds) . ')
                ');
            CliLogger::getCliStyle()->blue("Enabled $cnt brands");
            CliLogger::activity();
        }

        // ---- enable series
        $ArraySeriesIds = array_chunk($enabledSeries, BatchSizeConstants::ENABLE_SERIES);
        foreach ($ArraySeriesIds as $seriesIds) {
            $cnt = $this->connection->executeStatement('
                    UPDATE topdata_series SET is_enabled = 1 WHERE id IN (' . implode(',', $seriesIds) . ')
                ');
            CliLogger::getCliStyle()->blue("Enabled $cnt series");
            CliLogger::activity();
        }

        // ---- enable device types
        $ArrayTypeIds = array_chunk($enabledTypes, BatchSizeConstants::ENABLE_DEVICE_TYPES);
        foreach ($ArrayTypeIds as $typeIds) {
            $cnt = $this->connection->executeStatement('
                    UPDATE topdata_device_type SET is_enabled = 1 WHERE id IN (' . implode(',', $typeIds) . ')
                ');
            CliLogger::getCliStyle()->blue("Enabled $cnt types");
            CliLogger::activity();
        }
        CliLogger::activity(CliLogger::lap() . "sec\n");
        CliLogger::writeln('Devices to products linking done.');
        UtilProfiling::stopTimer();
    }


}

================
File: src/Service/Linking/ProductDeviceRelationshipServiceV2.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\Linking;

use Doctrine\DBAL\Connection;
use Exception;
use Psr\Log\LoggerInterface;
use Topdata\TopdataConnectorSW6\Constants\BatchSizeConstants;
use Topdata\TopdataConnectorSW6\Constants\WebserviceFilterTypeConstants;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataDeviceService;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataToProductService;
use Topdata\TopdataConnectorSW6\Service\TopdataWebserviceClient;
use Topdata\TopdataConnectorSW6\Util\UtilProfiling;
use Topdata\TopdataFoundationSW6\Util\CliLogger;
use Topdata\TopdataConnectorSW6\Util\ImportReport;

/**
 * Implements a differential update approach for device-product relationships
 * that avoids disabling all entities upfront, maintaining data consistency.
 */
class ProductDeviceRelationshipServiceV2
{
    const CHUNK_SIZE = 100;

    public function __construct(
        private readonly Connection              $connection,
        private readonly TopdataToProductService $topdataToProductHelperService,
        private readonly TopdataDeviceService    $topdataDeviceService,
        private readonly TopdataWebserviceClient $topdataWebserviceClient,
    )
    {
    }

    /**
     * Synchronizes device-product relationships using a differential update approach.
     * 
     * This method implements a more robust approach for synchronizing device-to-product
     * relationships that avoids disabling all entities upfront, maintaining data consistency.
     * 
     * Unlike the original method, this implementation:
     * - Tracks active entities during processing
     * - Only deletes links for specific product IDs being processed
     * - Enables/disables entities based on their actual usage
     */
    public function syncDeviceProductRelationshipsV2(): void
    {
        UtilProfiling::startTimer();
        CliLogger::getCliStyle()->yellow('Devices to products linking begin (V2 differential approach)');
        CliLogger::lap(true);

        // Fetch mapped products
        CliLogger::getCliStyle()->info('Fetching product mappings...');
        $topidProducts = $this->topdataToProductHelperService->getTopdataProductMappings();
        if (empty($topidProducts)) {
            CliLogger::getCliStyle()->warning('No mapped products found. Skipping device-product relationship sync.');
            return;
        }

        // Extract Shopware product database IDs from the mappings
        $shopwareProductDbIds = [];
        foreach ($topidProducts as $wsId => $products) {
            foreach ($products as $product) {
                $shopwareProductDbIds[] = $product['product_id'];
            }
        }
        $shopwareProductDbIds = array_unique($shopwareProductDbIds);
        $productCount = count($shopwareProductDbIds);
        CliLogger::getCliStyle()->info(sprintf('Found %d unique Shopware product IDs to process', $productCount));
        ImportReport::setCounter('linking_v2.products.found', $productCount);

        // Chunk the product IDs for processing
        $chunkSize = BatchSizeConstants::ENABLE_DEVICES;
        $productIdChunks = array_chunk($shopwareProductDbIds, $chunkSize);
        $chunkCount = count($productIdChunks);
        CliLogger::getCliStyle()->info(sprintf('Split into %d chunks of max %d products each', $chunkCount, $chunkSize));
        ImportReport::setCounter('linking_v2.products.chunks', $chunkCount);

        // Initialize active sets to store the database IDs of entities that should remain active
        $activeDeviceDbIds = [];
        $activeBrandDbIds = [];
        $activeSeriesDbIds = [];
        $activeTypeDbIds = [];

        // Process each chunk
        foreach ($productIdChunks as $chunkIndex => $productIdsChunk) {
            CliLogger::getCliStyle()->info(sprintf('Processing chunk %d of %d...', $chunkIndex + 1, count($productIdChunks)));
            ImportReport::incCounter('linking_v2.chunks.processed');
            
            // Map back to webservice IDs for this chunk
            $wsProductIdsForChunk = [];
            foreach ($topidProducts as $wsId => $products) {
                foreach ($products as $product) {
                    if (in_array($product['product_id'], $productIdsChunk)) {
                        $wsProductIdsForChunk[] = $wsId;
                    }
                }
            }
            $wsProductIdsForChunk = array_unique($wsProductIdsForChunk);
            
            // Fetch webservice links for the current chunk
            ImportReport::incCounter('linking_v2.webservice.calls');
            $response = $this->topdataWebserviceClient->myProductList([
                'products' => implode(',', $wsProductIdsForChunk),
                'filter'   => WebserviceFilterTypeConstants::product_application_in,
            ]);
            
            if (!isset($response->page->available_pages)) {
                CliLogger::getCliStyle()->error('Webservice error: No pages available in response');
                continue; // Skip this chunk and continue with the next one
            }
            
            // Process response to extract linked device webservice IDs
            $deviceWsIds = [];
            foreach ($response->products as $product) {
                if (!isset($topidProducts[$product->products_id])) {
                    continue;
                }
                
                if (isset($product->product_application_in->products) && count($product->product_application_in->products)) {
                    foreach ($product->product_application_in->products as $deviceWsId) {
                        $deviceWsIds[] = $deviceWsId;
                    }
                }
            }
            $deviceWsIds = array_unique($deviceWsIds);
            ImportReport::incCounter('linking_v2.webservice.device_ids_fetched', count($deviceWsIds));
            
            if (empty($deviceWsIds)) {
                CliLogger::getCliStyle()->info('No device links found for this chunk. Continuing...');
                
                // Delete existing links for this chunk of products
                $placeholders = implode(',', array_fill(0, count($productIdsChunk), '?'));
                $hexProductIds = array_map(function($id) {
                    return hex2bin($id);
                }, $productIdsChunk);
                
                $this->connection->executeStatement(
                    "DELETE FROM topdata_device_to_product WHERE product_id IN ($placeholders)",
                    $hexProductIds
                );
                
                continue;
            }
            
            // Fetch local device details based on the webservice IDs
            $devices = $this->topdataDeviceService->getDeviceArrayByWsIdArray($deviceWsIds);
            
            $deviceCount = count($devices);
            ImportReport::incCounter('linking_v2.database.devices_found', $deviceCount);
            
            if (empty($devices)) {
                CliLogger::getCliStyle()->info('No matching devices found in database for this chunk. Continuing...');
                continue;
            }
            
            // Populate active sets with the fetched database IDs
            foreach ($devices as $device) {
                // Add device ID to active devices set
                if (!empty($device['id'])) {
                    $activeDeviceDbIds[$device['id']] = $device['id'];
                }
                
                // Add brand ID to active brands set
                if (!empty($device['brand_id'])) {
                    $activeBrandDbIds[$device['brand_id']] = $device['brand_id'];
                }
                
                // Add series ID to active series set
                if (!empty($device['series_id'])) {
                    $activeSeriesDbIds[$device['series_id']] = $device['series_id'];
                }
                
                // Add type ID to active types set
                if (!empty($device['type_id'])) {
                    $activeTypeDbIds[$device['type_id']] = $device['type_id'];
                }
            }
            
            // Delete existing links for this chunk of products
            $placeholders = implode(',', array_fill(0, count($productIdsChunk), '?'));
            $hexProductIds = array_map(function($id) {
                return hex2bin($id);
            }, $productIdsChunk);
            
            $deleteCount = $this->connection->executeStatement(
                "DELETE FROM topdata_device_to_product WHERE product_id IN ($placeholders)",
                $hexProductIds
            );
            ImportReport::incCounter('linking_v2.links.deleted', $deleteCount);
            CliLogger::getCliStyle()->info(sprintf('Deleted %d existing device-product links for this chunk', $deleteCount));
            
            // Prepare data for inserting new links
            $insertData = [];
            $createdAt = date('Y-m-d H:i:s');
            
            // Map devices to products
            $deviceProductMap = [];
            foreach ($response->products as $product) {
                if (!isset($topidProducts[$product->products_id])) {
                    continue;
                }
                
                if (isset($product->product_application_in->products) && count($product->product_application_in->products)) {
                    foreach ($product->product_application_in->products as $deviceWsId) {
                        foreach ($topidProducts[$product->products_id] as $shopwareProduct) {
                            $deviceProductMap[$deviceWsId][] = $shopwareProduct;
                        }
                    }
                }
            }
            
            // Create insert data
            foreach ($devices as $device) {
                if (isset($deviceProductMap[$device['ws_id']])) {
                    foreach ($deviceProductMap[$device['ws_id']] as $prod) {
                        $insertData[] = "(0x{$device['id']}, 0x{$prod['product_id']}, 0x{$prod['product_version_id']}, '$createdAt')";
                    }
                }
            }
            
            // Insert new links in chunks
            if (!empty($insertData)) {
                $insertDataChunks = array_chunk($insertData, 30);
                $totalInserted = 0;
                
                foreach ($insertDataChunks as $insertChunk) {
//                    $insertCount = $this->connection->executeStatement('
//                        INSERT INTO topdata_device_to_product (device_id, product_id, product_version_id, created_at)
//                        VALUES ' . implode(',', $insertChunk)
//                    );

                    // crash workaround with "ON DUPLICATE KEY UPDATE"
                    $sql = '
                        INSERT INTO topdata_device_to_product 
                            (device_id, product_id, product_version_id, created_at) 
                        VALUES ' . implode(',', $insertChunk) . '
                        ON DUPLICATE KEY UPDATE 
                            product_id = VALUES(product_id), 
                            product_version_id = VALUES(product_version_id)
                            -- updated_at = NOW() 
                    ';
                    $insertCount = $this->connection->executeStatement($sql);

                    $totalInserted += $insertCount;
                }
                
                ImportReport::incCounter('linking_v2.links.inserted', $totalInserted);
                CliLogger::getCliStyle()->info(sprintf('Inserted %d new device-product links for this chunk', $totalInserted));
            } else {
                CliLogger::getCliStyle()->info('No new device-product links to insert for this chunk');
            }
            
            CliLogger::activity(CliLogger::lap() . "sec\n");
        }
        
        // After processing all chunks, enable/disable entities based on the active sets
        CliLogger::getCliStyle()->yellow('Updating entity status (enable/disable)...');
        
        // Enable active devices
        if (!empty($activeDeviceDbIds)) {
            $deviceChunks = array_chunk(array_values($activeDeviceDbIds), BatchSizeConstants::ENABLE_DEVICES);
            foreach ($deviceChunks as $chunk) {
                $placeholders = implode(',', array_fill(0, count($chunk), '?'));
                $hexIds = array_map(function($id) {
                    return hex2bin($id);
                }, $chunk);
                
                $enableCount = $this->connection->executeStatement(
                    "UPDATE topdata_device SET is_enabled = 1 WHERE id IN ($placeholders)",
                    $hexIds
                );
                ImportReport::setCounter('linking_v2.status.devices.enabled', $enableCount);
                CliLogger::getCliStyle()->info(sprintf('Enabled %d devices', $enableCount));
            }
            
            // Disable inactive devices
            $disableCount = $this->connection->executeStatement(
                "UPDATE topdata_device SET is_enabled = 0 WHERE id NOT IN (?" . str_repeat(",?", count($activeDeviceDbIds) - 1) . ")",
                array_map(function($id) {
                    return hex2bin($id);
                }, array_values($activeDeviceDbIds))
            );
            ImportReport::setCounter('linking_v2.status.devices.disabled', $disableCount);
            ImportReport::setCounter('linking_v2.active.devices', count($activeDeviceDbIds));
            CliLogger::getCliStyle()->info(sprintf('Disabled %d devices', $disableCount));
        } else {
            // If no active devices, disable all
            $disableCount = $this->connection->executeStatement(
                "UPDATE topdata_device SET is_enabled = 0 WHERE 1=1"
            );
            CliLogger::getCliStyle()->info(sprintf('Disabled all %d devices (no active devices found)', $disableCount));
        }
        
        // Enable active brands
        if (!empty($activeBrandDbIds)) {
            $brandChunks = array_chunk(array_values($activeBrandDbIds), BatchSizeConstants::ENABLE_BRANDS);
            foreach ($brandChunks as $chunk) {
                $placeholders = implode(',', array_fill(0, count($chunk), '?'));
                $hexIds = array_map(function($id) {
                    return hex2bin($id);
                }, $chunk);
                
                $enableCount = $this->connection->executeStatement(
                    "UPDATE topdata_brand SET is_enabled = 1 WHERE id IN ($placeholders)",
                    $hexIds
                );
                ImportReport::setCounter('linking_v2.status.brands.enabled', $enableCount);
                CliLogger::getCliStyle()->info(sprintf('Enabled %d brands', $enableCount));
            }
            
            // Disable inactive brands
            $disableCount = $this->connection->executeStatement(
                "UPDATE topdata_brand SET is_enabled = 0 WHERE id NOT IN (?" . str_repeat(",?", count($activeBrandDbIds) - 1) . ")",
                array_map(function($id) {
                    return hex2bin($id);
                }, array_values($activeBrandDbIds))
            );
            ImportReport::setCounter('linking_v2.status.brands.disabled', $disableCount);
            ImportReport::setCounter('linking_v2.active.brands', count($activeBrandDbIds));
            CliLogger::getCliStyle()->info(sprintf('Disabled %d brands', $disableCount));
        } else {
            // If no active brands, disable all
            $disableCount = $this->connection->executeStatement(
                "UPDATE topdata_brand SET is_enabled = 0 WHERE 1=1"
            );
            CliLogger::getCliStyle()->info(sprintf('Disabled all %d brands (no active brands found)', $disableCount));
        }
        
        // Enable active series
        if (!empty($activeSeriesDbIds)) {
            $seriesChunks = array_chunk(array_values($activeSeriesDbIds), BatchSizeConstants::ENABLE_SERIES);
            foreach ($seriesChunks as $chunk) {
                $placeholders = implode(',', array_fill(0, count($chunk), '?'));
                $hexIds = array_map(function($id) {
                    return hex2bin($id);
                }, $chunk);
                
                $enableCount = $this->connection->executeStatement(
                    "UPDATE topdata_series SET is_enabled = 1 WHERE id IN ($placeholders)",
                    $hexIds
                );
                ImportReport::setCounter('linking_v2.status.series.enabled', $enableCount);
                CliLogger::getCliStyle()->info(sprintf('Enabled %d series', $enableCount));
            }
            
            // Disable inactive series
            $disableCount = $this->connection->executeStatement(
                "UPDATE topdata_series SET is_enabled = 0 WHERE id NOT IN (?" . str_repeat(",?", count($activeSeriesDbIds) - 1) . ")",
                array_map(function($id) {
                    return hex2bin($id);
                }, array_values($activeSeriesDbIds))
            );
            ImportReport::setCounter('linking_v2.status.series.disabled', $disableCount);
            ImportReport::setCounter('linking_v2.active.series', count($activeSeriesDbIds));
            CliLogger::getCliStyle()->info(sprintf('Disabled %d series', $disableCount));
        } else {
            // If no active series, disable all
            $disableCount = $this->connection->executeStatement(
                "UPDATE topdata_series SET is_enabled = 0 WHERE 1=1"
            );
            CliLogger::getCliStyle()->info(sprintf('Disabled all %d series (no active series found)', $disableCount));
        }
        
        // Enable active device types
        if (!empty($activeTypeDbIds)) {
            $typeChunks = array_chunk(array_values($activeTypeDbIds), BatchSizeConstants::ENABLE_DEVICE_TYPES);
            foreach ($typeChunks as $chunk) {
                $placeholders = implode(',', array_fill(0, count($chunk), '?'));
                $hexIds = array_map(function($id) {
                    return hex2bin($id);
                }, $chunk);
                
                $enableCount = $this->connection->executeStatement(
                    "UPDATE topdata_device_type SET is_enabled = 1 WHERE id IN ($placeholders)",
                    $hexIds
                );
                ImportReport::setCounter('linking_v2.status.types.enabled', $enableCount);
                CliLogger::getCliStyle()->info(sprintf('Enabled %d device types', $enableCount));
            }
            
            // Disable inactive device types
            $disableCount = $this->connection->executeStatement(
                "UPDATE topdata_device_type SET is_enabled = 0 WHERE id NOT IN (?" . str_repeat(",?", count($activeTypeDbIds) - 1) . ")",
                array_map(function($id) {
                    return hex2bin($id);
                }, array_values($activeTypeDbIds))
            );
            ImportReport::setCounter('linking_v2.status.types.disabled', $disableCount);
            ImportReport::setCounter('linking_v2.active.types', count($activeTypeDbIds));
            CliLogger::getCliStyle()->info(sprintf('Disabled %d device types', $disableCount));
        } else {
            // If no active device types, disable all
            $disableCount = $this->connection->executeStatement(
                "UPDATE topdata_device_type SET is_enabled = 0 WHERE 1=1"
            );
            CliLogger::getCliStyle()->info(sprintf('Disabled all %d device types (no active types found)', $disableCount));
        }
        
        CliLogger::getCliStyle()->success('Devices to products linking completed (V2 differential approach)');
        
        UtilProfiling::stopTimer();
    }
}

================
File: src/Service/Linking/ProductProductRelationshipServiceV1__ORIG.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\Linking;

use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use RuntimeException;
use Shopware\Core\Content\Product\Aggregate\ProductCrossSelling\ProductCrossSellingDefinition;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Sorting\FieldSorting;
use Shopware\Core\Framework\Uuid\Uuid;
use Topdata\TopdataConnectorSW6\Constants\MergedPluginConfigKeyConstants;
use Topdata\TopdataConnectorSW6\Enum\ProductRelationshipTypeEnumV1;
use Topdata\TopdataConnectorSW6\Service\Config\ProductImportSettingsService;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataToProductService;
use Topdata\TopdataConnectorSW6\Util\ImportReport;
use Topdata\TopdataConnectorSW6\Util\UtilProfiling;
use Topdata\TopdataFoundationSW6\Util\CliLogger;

/**
 * Service responsible for managing product linking relationships and cross-selling functionality.
 * Handles various types of product relationships such as similar products, alternates, variants,
 * color variants, capacity variants, related products, and bundled products.
 *
 * aka ProductCrossSellingService
 *
 * 11/2024 created (extracted from MappingHelperService)
 * 06/2025 deprecated
 * @deprecated - use ProductProductRelationshipServiceV2
 */
class ProductProductRelationshipServiceV1__ORIG
{
    const CHUNK_SIZE         = 30;
    const MAX_CROSS_SELLINGS = 24;

    private Context $context;


    public function __construct(
        private readonly ProductImportSettingsService $productImportSettingsService,
        private readonly Connection                   $connection,
        private readonly TopdataToProductService      $topdataToProductHelperService,
        private readonly EntityRepository             $productCrossSellingRepository,
        private readonly EntityRepository             $productCrossSellingAssignedProductsRepository,
    )
    {
        $this->context = Context::createDefaultContext();
    }

    /**
     * 04/2025 introduced to decouple the enum value from type in database... but maybe we can change the types in the database instead to the UPPERCASE enum values for consistency?
     */
    private static function _getCrossDbType(ProductRelationshipTypeEnumV1 $crossType)
    {
        return match ($crossType) {
            ProductRelationshipTypeEnumV1::SIMILAR          => 'similar',
            ProductRelationshipTypeEnumV1::ALTERNATE        => 'alternate',
            ProductRelationshipTypeEnumV1::RELATED          => 'related',
            ProductRelationshipTypeEnumV1::BUNDLED          => 'bundled',
            ProductRelationshipTypeEnumV1::COLOR_VARIANT    => 'colorVariant',
            ProductRelationshipTypeEnumV1::CAPACITY_VARIANT => 'capacityVariant',
            ProductRelationshipTypeEnumV1::VARIANT          => 'variant',
            default                                         => throw new RuntimeException("Unknown cross-selling type: {$crossType->value}"),
        };
    }


    private static function _getCrossNameTranslations(ProductRelationshipTypeEnumV1 $crossType): array
    {
        return match ($crossType) {
            ProductRelationshipTypeEnumV1::CAPACITY_VARIANT => [
                'de-DE' => 'Kapazitätsvarianten',
                'en-GB' => 'Capacity Variants',
                'nl-NL' => 'capaciteit varianten',
            ],
            ProductRelationshipTypeEnumV1::COLOR_VARIANT    => [
                'de-DE' => 'Farbvarianten',
                'en-GB' => 'Color Variants',
                'nl-NL' => 'kleur varianten',
            ],
            ProductRelationshipTypeEnumV1::ALTERNATE        => [
                'de-DE' => 'Alternative Produkte',
                'en-GB' => 'Alternate Products',
                'nl-NL' => 'alternatieve producten',
            ],
            ProductRelationshipTypeEnumV1::RELATED          => [
                'de-DE' => 'Zubehör',
                'en-GB' => 'Accessories',
                'nl-NL' => 'Accessoires',
            ],
            ProductRelationshipTypeEnumV1::VARIANT          => [
                'de-DE' => 'Varianten',
                'en-GB' => 'Variants',
                'nl-NL' => 'varianten',
            ],
            ProductRelationshipTypeEnumV1::BUNDLED          => [
                'de-DE' => 'Im Bundle',
                'en-GB' => 'In Bundle',
                'nl-NL' => 'In een bundel',
            ],
            ProductRelationshipTypeEnumV1::SIMILAR          => [
                'de-DE' => 'Ähnlich',
                'en-GB' => 'Similar',
                'nl-NL' => 'Vergelijkbaar',
            ],
            default                                => throw new RuntimeException("Unknown cross-selling type: {$crossType->value}"),
        };
    }


    /**
     * Finds similar products based on the remote product data
     *
     * @param object $remoteProductData The product data from remote source
     * @return array Array of similar products
     */
    private function _findSimilarProducts($remoteProductData): array
    {
        $similarProducts = [];
        $topid_products = $this->topdataToProductHelperService->getTopdataProductMappings();

        // ---- Check for products with same accessories
        if (isset($remoteProductData->product_same_accessories->products) && count($remoteProductData->product_same_accessories->products)) {
            foreach ($remoteProductData->product_same_accessories->products as $tid) {
                if (!isset($topid_products[$tid])) {
                    continue;
                }
                $similarProducts[$tid] = $topid_products[$tid][0];
            }
        }

        // ---- Check for products with same application
        if (isset($remoteProductData->product_same_application_in->products) && count($remoteProductData->product_same_application_in->products)) {
            foreach ($remoteProductData->product_same_application_in->products as $tid) {
                if (!isset($topid_products[$tid])) {
                    continue;
                }
                $similarProducts[$tid] = $topid_products[$tid][0];
            }
        }

        // ---- Check for product variants
        if (isset($remoteProductData->product_variants->products) && count($remoteProductData->product_variants->products)) {
            foreach ($remoteProductData->product_variants->products as $rprod) {
                if (!isset($topid_products[$rprod->id])) {
                    continue;
                }
                $similarProducts[$rprod->id] = $topid_products[$rprod->id][0];
            }
        }

        return $similarProducts;
    }


    /**
     * Finds color variant products based on the remote product data
     *
     * @param object $remoteProductData The product data from remote source
     * @return array Array of color variant products
     */
    private function _findColorVariantProducts($remoteProductData): array
    {
        $linkedProducts = [];
        $topid_products = $this->topdataToProductHelperService->getTopdataProductMappings();
        if (isset($remoteProductData->product_special_variants->color) && count($remoteProductData->product_special_variants->color)) {
            foreach ($remoteProductData->product_special_variants->color as $tid) {
                if (!isset($topid_products[$tid])) {
                    continue;
                }
                $linkedProducts[$tid] = $topid_products[$tid][0];
            }
        }

        return $linkedProducts;
    }

    /**
     * Finds capacity variant products based on the remote product data
     *
     * @param object $remoteProductData The product data from remote source
     * @return array Array of capacity variant products
     */
    private function _findCapacityVariantProducts($remoteProductData): array
    {
        $linkedProducts = [];
        $topid_products = $this->topdataToProductHelperService->getTopdataProductMappings();
        if (isset($remoteProductData->product_special_variants->capacity) && count($remoteProductData->product_special_variants->capacity)) {
            foreach ($remoteProductData->product_special_variants->capacity as $tid) {
                if (!isset($topid_products[$tid])) {
                    continue;
                }
                $linkedProducts[$tid] = $topid_products[$tid][0];
            }
        }

        return $linkedProducts;
    }

    /**
     * Finds general variant products based on the remote product data
     *
     * @param object $remoteProductData The product data from remote source
     * @return array Array of variant products
     */
    private function _findVariantProducts($remoteProductData): array
    {
        $products = [];
        $topid_products = $this->topdataToProductHelperService->getTopdataProductMappings();

        // ---- Process product variants that don't have a specific type
        if (isset($remoteProductData->product_variants->products) && count($remoteProductData->product_variants->products)) {
            foreach ($remoteProductData->product_variants->products as $rprod) {
                if ($rprod->type !== null) {
                    continue;
                }

                if (!isset($topid_products[$rprod->id])) {
                    continue;
                }
                $products[$rprod->id] = $topid_products[$rprod->id][0];
            }
        }

        return $products;
    }

    /**
     * Adds cross-selling relationships between products
     *
     * @param array $currentProduct The current product information
     * @param array $linkedProductIds Array of products to be linked
     * @param ProductRelationshipTypeEnumV1 $crossType The type of cross-selling relationship
     */
    private function _addProductCrossSelling(array $currentProduct, array $linkedProductIds, ProductRelationshipTypeEnumV1 $crossType): void
    {
        if ($currentProduct['parent_id']) {
            //don't create cross if product is variation!
            return;
        }

        // ---- Check if cross-selling already exists for this product and type

        $criteria = new Criteria();
        $criteria->addFilter(new EqualsFilter('productId', $currentProduct['product_id']));
        $criteria->addFilter(new EqualsFilter('topdataExtension.type', self::_getCrossDbType($crossType)));
        $productCrossSellingEntity = $this->productCrossSellingRepository->search($criteria, $this->context)->first();

        if ($productCrossSellingEntity) {
            // ---- Remove existing cross-selling product assignments
            $crossId = $productCrossSellingEntity->getId();
            $this->connection->executeStatement("
                    DELETE 
                    FROM product_cross_selling_assigned_products 
                    WHERE cross_selling_id = 0x$crossId
            ");
        } else {
            // ---- Create new cross-selling entity
            $crossId = Uuid::randomHex();
            $data = [
                'id'               => $crossId,
                'productId'        => $currentProduct['product_id'],
                'productVersionId' => $currentProduct['product_version_id'],
                'name'             => self::_getCrossNameTranslations($crossType),
                'position'         => self::_getCrossPosition($crossType),
                'type'             => ProductCrossSellingDefinition::TYPE_PRODUCT_LIST,
                'sortBy'           => ProductCrossSellingDefinition::SORT_BY_NAME,
                'sortDirection'    => FieldSorting::ASCENDING,
                'active'           => true,
                'limit'            => self::MAX_CROSS_SELLINGS,
                'topdataExtension' => [
                    'type' => self::_getCrossDbType($crossType)
                ],
            ];
            $this->productCrossSellingRepository->create([$data], $this->context);
            CliLogger::activity();
        }

        $i = 1;
        $data = [];
        foreach ($linkedProductIds as $prodId) {
            $data[] = [
                'crossSellingId'   => $crossId,
                'productId'        => $prodId['product_id'],
                'productVersionId' => $prodId['product_version_id'],
                'position'         => $i++,
            ];
        }

        $this->productCrossSellingAssignedProductsRepository->create($data, $this->context);
        CliLogger::activity();
    }


    /**
     * Finds alternate products based on the remote product data
     *
     * @param object $remoteProductData The product data from remote source
     * @return array Array of alternate products
     */
    private function _findAlternateProducts($remoteProductData): array
    {
        $alternateProducts = [];
        $topid_products = $this->topdataToProductHelperService->getTopdataProductMappings();
        if (isset($remoteProductData->product_alternates->products) && count($remoteProductData->product_alternates->products)) {
            foreach ($remoteProductData->product_alternates->products as $tid) {
                if (!isset($topid_products[$tid])) {
                    continue;
                }
                $alternateProducts[$tid] = $topid_products[$tid][0];
            }
        }

        return $alternateProducts;
    }

    /**
     * Finds related products (accessories) based on the remote product data
     *
     * @param object $remoteProductData The product data from remote source
     * @return array Array of related products
     */
    private function _findRelatedProducts($remoteProductData): array
    {
        $relatedProducts = [];
        $topid_products = $this->topdataToProductHelperService->getTopdataProductMappings();
        if (isset($remoteProductData->product_accessories->products) && count($remoteProductData->product_accessories->products)) {
            foreach ($remoteProductData->product_accessories->products as $tid) {
                if (!isset($topid_products[$tid])) {
                    continue;
                }
                $relatedProducts[$tid] = $topid_products[$tid][0];
            }
        }

        return $relatedProducts;
    }

    /**
     * Finds bundled products based on the remote product data
     *
     * @param object $remoteProductData The product data from remote source
     * @return array Array of bundled products
     */
    private function findBundledProducts($remoteProductData): array
    {
        $bundledProducts = [];
        $topid_products = $this->topdataToProductHelperService->getTopdataProductMappings();
        if (isset($remoteProductData->bundle_content->products) && count($remoteProductData->bundle_content->products)) {
            foreach ($remoteProductData->bundle_content->products as $tid) {
                if (!isset($topid_products[$tid->products_id])) {
                    continue;
                }
                $bundledProducts[$tid->products_id] = $topid_products[$tid->products_id][0];
            }
        }

        return $bundledProducts;
    }

    /**
     * Generic method to process product relationships
     * Handles database insertion and cross-selling for all relationship types
     *
     * @param array $productId_versionId The current product ID information
     * @param array $relatedProducts Array of products to be linked
     * @param string $tableName The database table to insert into
     * @param string $idColumnPrefix The prefix for the ID column in the table
     * @param ProductRelationshipTypeEnumV1 $crossType The type of cross-selling relationship
     * @param bool $enableCrossSelling Whether to enable cross-selling
     * @param string $dateTime The current date/time string
     */
    private function _processProductRelationship(
        array                         $productId_versionId,
        array                         $relatedProducts,
        string                        $tableName,
        string                        $idColumnPrefix,
        ProductRelationshipTypeEnumV1 $crossType,
        bool                          $enableCrossSelling,
        string                        $dateTime
    ): void
    {
        if (empty($relatedProducts)) {
            return;
        }

        $dataInsert = [];
        foreach ($relatedProducts as $tempProd) {
            $dataInsert[] = "(0x{$productId_versionId['product_id']}, 0x{$productId_versionId['product_version_id']}, 0x{$tempProd['product_id']}, 0x{$tempProd['product_version_id']}, '$dateTime')";
        }

        $insertDataChunks = array_chunk($dataInsert, self::CHUNK_SIZE);
        foreach ($insertDataChunks as $chunk) {
            $columns = implode(', ', [
                'product_id',
                'product_version_id',
                "{$idColumnPrefix}_product_id",
                "{$idColumnPrefix}_product_version_id",
                'created_at'
            ]);

            $SQL = "INSERT INTO $tableName ($columns) VALUES " . implode(',', $chunk);
            CliLogger::debug($SQL);
            $this->connection->executeStatement($SQL);
            CliLogger::activity();
        }

        if ($enableCrossSelling) {
            $this->_addProductCrossSelling($productId_versionId, $relatedProducts, $crossType);
        }
    }




    /**
     * Orchestrator method to unlink products from various relationships based on plugin configuration.
     * It filters the product IDs for each relationship type and delegates the deletion task.
     *
     * @param string[] $productIds Array of product IDs to unlink.
     * 04/2025 moved from MappingHelperService::unlinkProducts() to ProductRelationshipService::unlinkProducts()
     */
    public function unlinkProducts(array $productIds): void
    {
        if (empty($productIds)) {
            return;
        }

        // Validate that IDs are valid hex UUIDs before processing to prevent errors
        $validProductIds = array_filter($productIds, fn($id) => Uuid::isValid($id));
        if (empty($validProductIds)) {
            return;
        }

        // Map configuration keys to their respective unlinking method and table name
        // TODO: 7 tables which do basically the same thing .. why not just one and a column for the type? maybe for performance reasons?
        $relationshipMap = [
            MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productSimilar         => 'topdata_product_to_similar',
            MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productAlternate       => 'topdata_product_to_alternate',
            MergedPluginConfigKeyConstants::RELATIONSHIO_OPTION_productRelated         => 'topdata_product_to_related',
            MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productBundled         => 'topdata_product_to_bundled',
            MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productColorVariant    => 'topdata_product_to_color_variant',
            MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productCapacityVariant => 'topdata_product_to_capacity_variant',
            MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productVariant         => 'topdata_product_to_variant',
        ];

        foreach ($relationshipMap as $configKey => $tableName) {
            $idsToUnlink = $this->productImportSettingsService->filterProductIdsByConfig(
                $configKey,
                $validProductIds
            );

            if (!empty($idsToUnlink)) {
                $this->_deleteFromTableWhereProductIdsIn($tableName, $idsToUnlink);
            }
            ImportReport::incCounter('links deleted from ' . $tableName, count($idsToUnlink));
        }
    }

    /**
     * Executes a DELETE statement on a given table for a list of product IDs.
     * This method uses parameterized queries to prevent SQL injection.
     *
     * @param string   $tableName   The name of the database table.
     * @param string[] $productIds  The product IDs to delete.
     * @return int The number of deleted rows.
     */
    private function _deleteFromTableWhereProductIdsIn(string $tableName, array $productIds): int
    {
        CliLogger::debug("unlinking " . count($productIds) . " products from $tableName");
        // dump($productIds);

        // The product IDs are expected to be UUIDs in hex format (without 0x)
        $SQL = "DELETE FROM `{$tableName}` WHERE product_id IN (:ids)";
        CLiLogger::debug($SQL);
        $numDeleted = $this->connection->executeStatement(
            $SQL,
            [
                'ids' => array_map('hex2bin', $productIds),
            ],
            [
                'ids' => ArrayParameterType::BINARY,
            ]
        );
        CliLogger::debug("deleted $numDeleted rows from $tableName");

        return (int)$numDeleted;
    }


    /**
     * ==== MAIN ====
     *
     * Main method to link products with various relationships based on remote product data
     *
     * 11/2024 created
     *
     * @param array $productId_versionId Product ID and version information
     * @param object $remoteProductData The product data from remote source
     */
    public function linkProducts(array $productId_versionId, $remoteProductData): void
    {
        UtilProfiling::startTimer();
        $dateTime = date('Y-m-d H:i:s');
        $productId = $productId_versionId['product_id'];

        // ---- Process similar products
        if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productSimilar, $productId)) {
            CliLogger::debug("Processing similar products for product $productId");
            $this->_processProductRelationship(
                $productId_versionId,
                $this->_findSimilarProducts($remoteProductData),
                'topdata_product_to_similar',
                'similar',
                ProductRelationshipTypeEnumV1::SIMILAR,
                $this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productSimilarCross, $productId),
                $dateTime
            );
        }

        // ---- Process alternate products
        if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productAlternate, $productId)) {
            CliLogger::debug("Processing alternate products for product $productId");
            $this->_processProductRelationship(
                $productId_versionId,
                $this->_findAlternateProducts($remoteProductData),
                'topdata_product_to_alternate',
                'alternate',
                ProductRelationshipTypeEnumV1::ALTERNATE,
                $this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productAlternateCross, $productId),
                $dateTime
            );
        }

        // ---- Process related products (accessories)
        if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::RELATIONSHIO_OPTION_productRelated, $productId)) {
            CliLogger::debug("Processing related products for product $productId");
            $this->_processProductRelationship(
                $productId_versionId,
                $this->_findRelatedProducts($remoteProductData),
                'topdata_product_to_related',
                'related',
                ProductRelationshipTypeEnumV1::RELATED,
                $this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productRelatedCross, $productId),
                $dateTime
            );
        }

        // ---- Process bundled products
        if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productBundled, $productId)) {
            CliLogger::debug("Processing bundled products for product $productId");
            $this->_processProductRelationship(
                $productId_versionId,
                $this->findBundledProducts($remoteProductData),
                'topdata_product_to_bundled',
                'bundled',
                ProductRelationshipTypeEnumV1::BUNDLED,
                $this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productBundledCross, $productId),
                $dateTime
            );
        }

        // ---- Process color variant products
        if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productColorVariant, $productId)) {
            CliLogger::debug("Processing color variant products for product $productId");
            $this->_processProductRelationship(
                $productId_versionId,
                $this->_findColorVariantProducts($remoteProductData),
                'topdata_product_to_color_variant',
                'color_variant',
                ProductRelationshipTypeEnumV1::COLOR_VARIANT,
                $this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productVariantColorCross, $productId),
                $dateTime
            );
        }

        // ---- Process capacity variant products
        if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productCapacityVariant, $productId)) {
            CliLogger::debug("Processing capacity variant products for product $productId");
            $this->_processProductRelationship(
                $productId_versionId,
                $this->_findCapacityVariantProducts($remoteProductData),
                'topdata_product_to_capacity_variant',
                'capacity_variant',
                ProductRelationshipTypeEnumV1::CAPACITY_VARIANT,
                $this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productVariantCapacityCross, $productId),
                $dateTime
            );
        }

        // ---- Process general variant products
        if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productVariant, $productId)) {
            CliLogger::debug("Processing general variant products for product $productId");
            $this->_processProductRelationship(
                $productId_versionId,
                $this->_findVariantProducts($remoteProductData),
                'topdata_product_to_variant',
                'variant',
                ProductRelationshipTypeEnumV1::VARIANT,
                $this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productVariantCross, $productId),
                $dateTime
            );
        }

        UtilProfiling::stopTimer();
    }

    /**
     * 04/2025 created
     */
    private static function _getCrossPosition(ProductRelationshipTypeEnumV1 $crossType): int
    {
        return match ($crossType) {
            ProductRelationshipTypeEnumV1::CAPACITY_VARIANT => 1,
            ProductRelationshipTypeEnumV1::COLOR_VARIANT    => 2,
            ProductRelationshipTypeEnumV1::ALTERNATE        => 3,
            ProductRelationshipTypeEnumV1::RELATED          => 4,
            ProductRelationshipTypeEnumV1::VARIANT          => 5,
            ProductRelationshipTypeEnumV1::BUNDLED          => 6,
            ProductRelationshipTypeEnumV1::SIMILAR          => 7,
            default                                         => throw new RuntimeException("Unknown cross-selling type: {$crossType->value}"),
        };
    }

}

================
File: src/Service/Linking/ProductProductRelationshipServiceV1.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\Linking;

use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Types\Types;
use RuntimeException;
use Shopware\Core\Content\Product\Aggregate\ProductCrossSelling\ProductCrossSellingDefinition;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsAnyFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Sorting\FieldSorting;
use Shopware\Core\Framework\Uuid\Uuid;
use Topdata\TopdataConnectorSW6\Constants\MergedPluginConfigKeyConstants;
use Topdata\TopdataConnectorSW6\Enum\ProductRelationshipTypeEnumV1;
use Topdata\TopdataConnectorSW6\Service\Config\ProductImportSettingsService;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataToProductService;
use Topdata\TopdataConnectorSW6\Util\ImportReport;
use Topdata\TopdataConnectorSW6\Util\UtilProfiling;
use Topdata\TopdataFoundationSW6\Util\CliLogger;

/**
 * Service responsible for managing product linking relationships and cross-selling functionality.
 * Handles various types of product relationships such as similar products, alternates, variants,
 * color variants, capacity variants, related products, and bundled products.
 *
 * aka ProductCrossSellingService
 *
 * 11/2024 created (extracted from MappingHelperService)
 * 06/2025 deprecated
 * @deprecated - use ProductProductRelationshipServiceV2
 */
class ProductProductRelationshipServiceV1
{
    const CHUNK_SIZE         = 30;
    const MAX_CROSS_SELLINGS = 24;
    const BULK_INSERT_SIZE = 500;
    const USE_TRANSACTIONS = true;

    private Context $context;


    public function __construct(
        private readonly ProductImportSettingsService $productImportSettingsService,
        private readonly Connection                   $connection,
        private readonly TopdataToProductService      $topdataToProductHelperService,
        private readonly EntityRepository             $productCrossSellingRepository,
        private readonly EntityRepository             $productCrossSellingAssignedProductsRepository,
    )
    {
        $this->context = Context::createDefaultContext();
    }

    /**
     * 04/2025 introduced to decouple the enum value from type in database... but maybe we can change the types in the database instead to the UPPERCASE enum values for consistency?
     */
    private static function _getCrossDbType(ProductRelationshipTypeEnumV1 $crossType)
    {
        return match ($crossType) {
            ProductRelationshipTypeEnumV1::SIMILAR          => 'similar',
            ProductRelationshipTypeEnumV1::ALTERNATE        => 'alternate',
            ProductRelationshipTypeEnumV1::RELATED          => 'related',
            ProductRelationshipTypeEnumV1::BUNDLED          => 'bundled',
            ProductRelationshipTypeEnumV1::COLOR_VARIANT    => 'colorVariant',
            ProductRelationshipTypeEnumV1::CAPACITY_VARIANT => 'capacityVariant',
            ProductRelationshipTypeEnumV1::VARIANT          => 'variant',
            default                                         => throw new RuntimeException("Unknown cross-selling type: {$crossType->value}"),
        };
    }


    private static function _getCrossNameTranslations(ProductRelationshipTypeEnumV1 $crossType): array
    {
        return match ($crossType) {
            ProductRelationshipTypeEnumV1::CAPACITY_VARIANT => [
                'de-DE' => 'Kapazitätsvarianten',
                'en-GB' => 'Capacity Variants',
                'nl-NL' => 'capaciteit varianten',
            ],
            ProductRelationshipTypeEnumV1::COLOR_VARIANT    => [
                'de-DE' => 'Farbvarianten',
                'en-GB' => 'Color Variants',
                'nl-NL' => 'kleur varianten',
            ],
            ProductRelationshipTypeEnumV1::ALTERNATE        => [
                'de-DE' => 'Alternative Produkte',
                'en-GB' => 'Alternate Products',
                'nl-NL' => 'alternatieve producten',
            ],
            ProductRelationshipTypeEnumV1::RELATED          => [
                'de-DE' => 'Zubehör',
                'en-GB' => 'Accessories',
                'nl-NL' => 'Accessoires',
            ],
            ProductRelationshipTypeEnumV1::VARIANT          => [
                'de-DE' => 'Varianten',
                'en-GB' => 'Variants',
                'nl-NL' => 'varianten',
            ],
            ProductRelationshipTypeEnumV1::BUNDLED          => [
                'de-DE' => 'Im Bundle',
                'en-GB' => 'In Bundle',
                'nl-NL' => 'In een bundel',
            ],
            ProductRelationshipTypeEnumV1::SIMILAR          => [
                'de-DE' => 'Ähnlich',
                'en-GB' => 'Similar',
                'nl-NL' => 'Vergelijkbaar',
            ],
            default                                => throw new RuntimeException("Unknown cross-selling type: {$crossType->value}"),
        };
    }


    /**
     * Finds similar products based on the remote product data
     *
     * @param object $remoteProductData The product data from remote source
     * @return array Array of similar products
     */
    private function _findSimilarProducts($remoteProductData): array
    {
        $similarProducts = [];
        $topid_products = $this->topdataToProductHelperService->getTopdataProductMappings();

        // ---- Check for products with same accessories
        if (isset($remoteProductData->product_same_accessories->products) && count($remoteProductData->product_same_accessories->products)) {
            foreach ($remoteProductData->product_same_accessories->products as $tid) {
                if (!isset($topid_products[$tid])) {
                    continue;
                }
                $similarProducts[$tid] = $topid_products[$tid][0];
            }
        }

        // ---- Check for products with same application
        if (isset($remoteProductData->product_same_application_in->products) && count($remoteProductData->product_same_application_in->products)) {
            foreach ($remoteProductData->product_same_application_in->products as $tid) {
                if (!isset($topid_products[$tid])) {
                    continue;
                }
                $similarProducts[$tid] = $topid_products[$tid][0];
            }
        }

        // ---- Check for product variants
        if (isset($remoteProductData->product_variants->products) && count($remoteProductData->product_variants->products)) {
            foreach ($remoteProductData->product_variants->products as $rprod) {
                if (!isset($topid_products[$rprod->id])) {
                    continue;
                }
                $similarProducts[$rprod->id] = $topid_products[$rprod->id][0];
            }
        }

        return $similarProducts;
    }


    /**
     * Finds color variant products based on the remote product data
     *
     * @param object $remoteProductData The product data from remote source
     * @return array Array of color variant products
     */
    private function _findColorVariantProducts($remoteProductData): array
    {
        $linkedProducts = [];
        $topid_products = $this->topdataToProductHelperService->getTopdataProductMappings();
        if (isset($remoteProductData->product_special_variants->color) && count($remoteProductData->product_special_variants->color)) {
            foreach ($remoteProductData->product_special_variants->color as $tid) {
                if (!isset($topid_products[$tid])) {
                    continue;
                }
                $linkedProducts[$tid] = $topid_products[$tid][0];
            }
        }

        return $linkedProducts;
    }

    /**
     * Finds capacity variant products based on the remote product data
     *
     * @param object $remoteProductData The product data from remote source
     * @return array Array of capacity variant products
     */
    private function _findCapacityVariantProducts($remoteProductData): array
    {
        $linkedProducts = [];
        $topid_products = $this->topdataToProductHelperService->getTopdataProductMappings();
        if (isset($remoteProductData->product_special_variants->capacity) && count($remoteProductData->product_special_variants->capacity)) {
            foreach ($remoteProductData->product_special_variants->capacity as $tid) {
                if (!isset($topid_products[$tid])) {
                    continue;
                }
                $linkedProducts[$tid] = $topid_products[$tid][0];
            }
        }

        return $linkedProducts;
    }

    /**
     * Finds general variant products based on the remote product data
     *
     * @param object $remoteProductData The product data from remote source
     * @return array Array of variant products
     */
    private function _findVariantProducts($remoteProductData): array
    {
        $products = [];
        $topid_products = $this->topdataToProductHelperService->getTopdataProductMappings();

        // ---- Process product variants that don't have a specific type
        if (isset($remoteProductData->product_variants->products) && count($remoteProductData->product_variants->products)) {
            foreach ($remoteProductData->product_variants->products as $rprod) {
                if ($rprod->type !== null) {
                    continue;
                }

                if (!isset($topid_products[$rprod->id])) {
                    continue;
                }
                $products[$rprod->id] = $topid_products[$rprod->id][0];
            }
        }

        return $products;
    }

    /**
     * Adds cross-selling relationships between products
     *
     * @param array $currentProduct The current product information
     * @param array $linkedProductIds Array of products to be linked
     * @param ProductRelationshipTypeEnumV1 $crossType The type of cross-selling relationship
     */
    private function _addProductCrossSelling(array $currentProduct, array $linkedProductIds, ProductRelationshipTypeEnumV1 $crossType): void
    {
        if ($currentProduct['parent_id']) {
            //don't create cross if product is variation!
            return;
        }

        // ---- Check if cross-selling already exists for this product and type

        $criteria = new Criteria();
        $criteria->addFilter(new EqualsFilter('productId', $currentProduct['product_id']));
        $criteria->addFilter(new EqualsFilter('topdataExtension.type', self::_getCrossDbType($crossType)));
        $productCrossSellingEntity = $this->productCrossSellingRepository->search($criteria, $this->context)->first();

        if ($productCrossSellingEntity) {
            // ---- Remove existing cross-selling product assignments
            $crossId = $productCrossSellingEntity->getId();
            $this->connection->executeStatement("
                    DELETE
                    FROM product_cross_selling_assigned_products
                    WHERE cross_selling_id = 0x$crossId
            ");
        } else {
            // ---- Create new cross-selling entity
            $crossId = Uuid::randomHex();
            $data = [
                'id'               => $crossId,
                'productId'        => $currentProduct['product_id'],
                'productVersionId' => $currentProduct['product_version_id'],
                'name'             => self::_getCrossNameTranslations($crossType),
                'position'         => self::_getCrossPosition($crossType),
                'type'             => ProductCrossSellingDefinition::TYPE_PRODUCT_LIST,
                'sortBy'           => ProductCrossSellingDefinition::SORT_BY_NAME,
                'sortDirection'    => FieldSorting::ASCENDING,
                'active'           => true,
                'limit'            => self::MAX_CROSS_SELLINGS,
                'topdataExtension' => [
                    'type' => self::_getCrossDbType($crossType)
                ],
            ];
            $this->productCrossSellingRepository->create([$data], $this->context);
            CliLogger::activity();
        }

        $i = 1;
        $data = [];
        foreach ($linkedProductIds as $prodId) {
            $data[] = [
                'crossSellingId'   => $crossId,
                'productId'        => $prodId['product_id'],
                'productVersionId' => $prodId['product_version_id'],
                'position'         => $i++,
            ];
        }

        $this->productCrossSellingAssignedProductsRepository->create($data, $this->context);
        CliLogger::activity();
    }


    /**
     * Finds alternate products based on the remote product data
     *
     * @param object $remoteProductData The product data from remote source
     * @return array Array of alternate products
     */
    private function _findAlternateProducts($remoteProductData): array
    {
        $alternateProducts = [];
        $topid_products = $this->topdataToProductHelperService->getTopdataProductMappings();
        if (isset($remoteProductData->product_alternates->products) && count($remoteProductData->product_alternates->products)) {
            foreach ($remoteProductData->product_alternates->products as $tid) {
                if (!isset($topid_products[$tid])) {
                    continue;
                }
                $alternateProducts[$tid] = $topid_products[$tid][0];
            }
        }

        return $alternateProducts;
    }

    /**
     * Finds related products (accessories) based on the remote product data
     *
     * @param object $remoteProductData The product data from remote source
     * @return array Array of related products
     */
    private function _findRelatedProducts($remoteProductData): array
    {
        $relatedProducts = [];
        $topid_products = $this->topdataToProductHelperService->getTopdataProductMappings();
        if (isset($remoteProductData->product_accessories->products) && count($remoteProductData->product_accessories->products)) {
            foreach ($remoteProductData->product_accessories->products as $tid) {
                if (!isset($topid_products[$tid])) {
                    continue;
                }
                $relatedProducts[$tid] = $topid_products[$tid][0];
            }
        }

        return $relatedProducts;
    }

    /**
     * Finds bundled products based on the remote product data
     *
     * @param object $remoteProductData The product data from remote source
     * @return array Array of bundled products
     */
    private function findBundledProducts($remoteProductData): array
    {
        $bundledProducts = [];
        $topid_products = $this->topdataToProductHelperService->getTopdataProductMappings();
        if (isset($remoteProductData->bundle_content->products) && count($remoteProductData->bundle_content->products)) {
            foreach ($remoteProductData->bundle_content->products as $tid) {
                if (!isset($topid_products[$tid->products_id])) {
                    continue;
                }
                $bundledProducts[$tid->products_id] = $topid_products[$tid->products_id][0];
            }
        }

        return $bundledProducts;
    }

    /**
     * Generic method to process product relationships
     * Handles database insertion and cross-selling for all relationship types
     *
     * @param array $productId_versionId The current product ID information
     * @param array $relatedProducts Array of products to be linked
     * @param string $tableName The database table to insert into
     * @param string $idColumnPrefix The prefix for the ID column in the table
     * @param ProductRelationshipTypeEnumV1 $crossType The type of cross-selling relationship
     * @param bool $enableCrossSelling Whether to enable cross-selling
     * @param string $dateTime The current date/time string
     */
    private function _processProductRelationship__ORIG(
        array                         $productId_versionId,
        array                         $relatedProducts,
        string                        $tableName,
        string                        $idColumnPrefix,
        ProductRelationshipTypeEnumV1 $crossType,
        bool                          $enableCrossSelling,
        string                        $dateTime
    ): void
    {
        if (empty($relatedProducts)) {
            return;
        }

        $dataInsert = [];
        foreach ($relatedProducts as $tempProd) {
            $dataInsert[] = "(0x{$productId_versionId['product_id']}, 0x{$productId_versionId['product_version_id']}, 0x{$tempProd['product_id']}, 0x{$tempProd['product_version_id']}, '$dateTime')";
        }

        $insertDataChunks = array_chunk($dataInsert, self::CHUNK_SIZE);
        foreach ($insertDataChunks as $chunk) {
            $columns = implode(', ', [
                'product_id',
                'product_version_id',
                "{$idColumnPrefix}_product_id",
                "{$idColumnPrefix}_product_version_id",
                'created_at'
            ]);

            $SQL = "INSERT INTO $tableName ($columns) VALUES " . implode(',', $chunk);
            CliLogger::debug($SQL);
            $this->connection->executeStatement($SQL);
            CliLogger::activity();
        }

        if ($enableCrossSelling) {
            $this->_addProductCrossSelling($productId_versionId, $relatedProducts, $crossType);
        }
    }




    /**
     * Orchestrator method to unlink products from various relationships based on plugin configuration.
     * It filters the product IDs for each relationship type and delegates the deletion task.
     *
     * @param string[] $productIds Array of product IDs to unlink.
     * 04/2025 moved from MappingHelperService::unlinkProducts() to ProductRelationshipService::unlinkProducts()
     */
    public function unlinkProducts(array $productIds): void
    {
        if (empty($productIds)) {
            return;
        }

        // Validate that IDs are valid hex UUIDs before processing to prevent errors
        $validProductIds = array_filter($productIds, fn($id) => Uuid::isValid($id));
        if (empty($validProductIds)) {
            return;
        }

        // Map configuration keys to their respective unlinking method and table name
        // TODO: 7 tables which do basically the same thing .. why not just one and a column for the type? maybe for performance reasons?
        $relationshipMap = [
            MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productSimilar         => 'topdata_product_to_similar',
            MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productAlternate       => 'topdata_product_to_alternate',
            MergedPluginConfigKeyConstants::RELATIONSHIO_OPTION_productRelated         => 'topdata_product_to_related',
            MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productBundled         => 'topdata_product_to_bundled',
            MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productColorVariant    => 'topdata_product_to_color_variant',
            MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productCapacityVariant => 'topdata_product_to_capacity_variant',
            MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productVariant         => 'topdata_product_to_variant',
        ];

        foreach ($relationshipMap as $configKey => $tableName) {
            $idsToUnlink = $this->productImportSettingsService->filterProductIdsByConfig(
                $configKey,
                $validProductIds
            );

            if (!empty($idsToUnlink)) {
                $this->_deleteFromTableWhereProductIdsIn($tableName, $idsToUnlink);
            }
            ImportReport::incCounter('links deleted from ' . $tableName, count($idsToUnlink));
        }
    }

    /**
     * Executes a DELETE statement on a given table for a list of product IDs.
     * This method uses parameterized queries to prevent SQL injection.
     *
     * @param string   $tableName   The name of the database table.
     * @param string[] $productIds  The product IDs to delete.
     * @return int The number of deleted rows.
     */
    private function _deleteFromTableWhereProductIdsIn(string $tableName, array $productIds): int
    {
        CliLogger::debug("unlinking " . count($productIds) . " products from $tableName");
        // dump($productIds);

        // The product IDs are expected to be UUIDs in hex format (without 0x)
        $SQL = "DELETE FROM `{$tableName}` WHERE product_id IN (:ids)";
        CLiLogger::debug($SQL);
        $numDeleted = $this->connection->executeStatement(
            $SQL,
            [
                'ids' => array_map('hex2bin', $productIds),
            ],
            [
                'ids' => ArrayParameterType::BINARY,
            ]
        );
        CliLogger::debug("deleted $numDeleted rows from $tableName");

        return (int)$numDeleted;
    }


    /**
     * Maps relationship types to their corresponding database table names
     *
     * @param string $type The relationship type
     * @return string The database table name
     */
    private function getTableForType(string $type): string
    {
        $map = [
            'similar'          => 'topdata_product_to_similar',
            'alternate'        => 'topdata_product_to_alternate',
            'related'          => 'topdata_product_to_related',
            'bundled'          => 'topdata_product_to_bundled',
            'color_variant'    => 'topdata_product_to_color_variant',
            'capacity_variant' => 'topdata_product_to_capacity_variant',
            'variant'          => 'topdata_product_to_variant',
        ];
        return $map[$type] ?? '';
    }

    /**
     * Maps relationship types to their corresponding ID column prefixes
     *
     * @param string $type The relationship type
     * @return string The ID column prefix
     */
    private function getIdColumnPrefix(string $type): string
    {
        $map = [
            'similar'          => 'similar',
            'alternate'        => 'alternate',
            'related'          => 'related',
            'bundled'          => 'bundled',
            'color_variant'    => 'color_variant',
            'capacity_variant' => 'capacity_variant',
            'variant'          => 'variant',
        ];
        return $map[$type] ?? '';
    }

    /**
     * Processes all relationship types in bulk using database transactions
     *
     * @param array $productId_versionId Product ID and version information
     * @param array $allRelationships Array of all relationship types and their products
     * @param string $dateTime The current date/time string
     */
    private function _processBulkRelationships(
        array  $productId_versionId,
        array  $allRelationships,
        string $dateTime
    ): void
    {
        if (self::USE_TRANSACTIONS) {
            $this->connection->beginTransaction();
        }

        try {
            foreach ($allRelationships as $type => $products) {
                if (empty($products)) {
                    continue;
                }

                $tableName = $this->getTableForType($type);
                $idColumnPrefix = $this->getIdColumnPrefix($type);

                if (empty($tableName) || empty($idColumnPrefix)) {
                    continue;
                }

                // Process products in batches
                $productChunks = array_chunk($products, self::BULK_INSERT_SIZE);

                foreach ($productChunks as $chunk) {
                    $values = [];
                    foreach ($chunk as $tempProd) {
                        $values[] = [
                            'product_id'                       => hex2bin($productId_versionId['product_id']),
                            'product_version_id'               => hex2bin($productId_versionId['product_version_id']),
                            "{$idColumnPrefix}_product_id"         => hex2bin($tempProd['product_id']),
                            "{$idColumnPrefix}_product_version_id" => hex2bin($tempProd['product_version_id']),
                            'created_at'                       => $dateTime
                        ];
                    }

                    // Use bulk insert with proper type mapping
                    foreach ($values as $value) {
                        $this->connection->insert(
                            $tableName,
                            $value,
                            [
                                'product_id'                       => Types::BINARY,
                                'product_version_id'               => Types::BINARY,
                                "{$idColumnPrefix}_product_id"         => Types::BINARY,
                                "{$idColumnPrefix}_product_version_id" => Types::BINARY,
                                'created_at'                       => Types::STRING
                            ]
                        );
                    }

                    CliLogger::activity();
                }
            }

            if (self::USE_TRANSACTIONS) {
                $this->connection->commit();
            }
        } catch (\Exception $e) {
            if (self::USE_TRANSACTIONS) {
                $this->connection->rollBack();
            }
            throw $e;
        }
    }

    /**
     * ==== MAIN ====
     *
     * Main method to link products with various relationships based on remote product data
     *
     * 11/2024 created
     *
     * @param array $productId_versionId Product ID and version information
     * @param object $remoteProductData The product data from remote source
     */
    public function linkProducts(array $productId_versionId, $remoteProductData): void
    {
        UtilProfiling::startTimer();
        $dateTime = date('Y-m-d H:i:s');
        $productId = $productId_versionId['product_id'];

        // Collect all relationships first
        $allRelationships = [];
        $crossSellingData = [];

        // ---- Collect similar products
        if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productSimilar, $productId)) {
            CliLogger::debug("Collecting similar products for product $productId");
            $similarProducts = $this->_findSimilarProducts($remoteProductData);
            if (!empty($similarProducts)) {
                $allRelationships['similar'] = $similarProducts;
                if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productSimilarCross, $productId)) {
                    $crossSellingData[] = [
                        'products' => $similarProducts,
                        'type' => ProductRelationshipTypeEnumV1::SIMILAR
                    ];
                }
            }
        }

        // ---- Collect alternate products
        if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productAlternate, $productId)) {
            CliLogger::debug("Collecting alternate products for product $productId");
            $alternateProducts = $this->_findAlternateProducts($remoteProductData);
            if (!empty($alternateProducts)) {
                $allRelationships['alternate'] = $alternateProducts;
                if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productAlternateCross, $productId)) {
                    $crossSellingData[] = [
                        'products' => $alternateProducts,
                        'type' => ProductRelationshipTypeEnumV1::ALTERNATE
                    ];
                }
            }
        }

        // ---- Collect related products (accessories)
        if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::RELATIONSHIO_OPTION_productRelated, $productId)) {
            CliLogger::debug("Collecting related products for product $productId");
            $relatedProducts = $this->_findRelatedProducts($remoteProductData);
            if (!empty($relatedProducts)) {
                $allRelationships['related'] = $relatedProducts;
                if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productRelatedCross, $productId)) {
                    $crossSellingData[] = [
                        'products' => $relatedProducts,
                        'type' => ProductRelationshipTypeEnumV1::RELATED
                    ];
                }
            }
        }

        // ---- Collect bundled products
        if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productBundled, $productId)) {
            CliLogger::debug("Collecting bundled products for product $productId");
            $bundledProducts = $this->findBundledProducts($remoteProductData);
            if (!empty($bundledProducts)) {
                $allRelationships['bundled'] = $bundledProducts;
                if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productBundledCross, $productId)) {
                    $crossSellingData[] = [
                        'products' => $bundledProducts,
                        'type' => ProductRelationshipTypeEnumV1::BUNDLED
                    ];
                }
            }
        }

        // ---- Collect color variant products
        if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productColorVariant, $productId)) {
            CliLogger::debug("Collecting color variant products for product $productId");
            $colorVariantProducts = $this->_findColorVariantProducts($remoteProductData);
            if (!empty($colorVariantProducts)) {
                $allRelationships['color_variant'] = $colorVariantProducts;
                if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productVariantColorCross, $productId)) {
                    $crossSellingData[] = [
                        'products' => $colorVariantProducts,
                        'type' => ProductRelationshipTypeEnumV1::COLOR_VARIANT
                    ];
                }
            }
        }

        // ---- Collect capacity variant products
        if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productCapacityVariant, $productId)) {
            CliLogger::debug("Collecting capacity variant products for product $productId");
            $capacityVariantProducts = $this->_findCapacityVariantProducts($remoteProductData);
            if (!empty($capacityVariantProducts)) {
                $allRelationships['capacity_variant'] = $capacityVariantProducts;
                if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productVariantCapacityCross, $productId)) {
                    $crossSellingData[] = [
                        'products' => $capacityVariantProducts,
                        'type' => ProductRelationshipTypeEnumV1::CAPACITY_VARIANT
                    ];
                }
            }
        }

        // ---- Collect general variant products
        if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productVariant, $productId)) {
            CliLogger::debug("Collecting general variant products for product $productId");
            $variantProducts = $this->_findVariantProducts($remoteProductData);
            if (!empty($variantProducts)) {
                $allRelationships['variant'] = $variantProducts;
                if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productVariantCross, $productId)) {
                    $crossSellingData[] = [
                        'products' => $variantProducts,
                        'type' => ProductRelationshipTypeEnumV1::VARIANT
                    ];
                }
            }
        }

        // Process all relationships in bulk
        if (!empty($allRelationships)) {
            CliLogger::debug("Processing bulk relationships for product $productId");
            $this->_processBulkRelationships($productId_versionId, $allRelationships, $dateTime);
        }

        // Process cross-selling relationships
        foreach ($crossSellingData as $crossData) {
            $this->_addProductCrossSelling($productId_versionId, $crossData['products'], $crossData['type']);
        }

        UtilProfiling::stopTimer();
    }


    /**
     * ==== NEW BULK METHOD ====
     *
     * Main method to link MULTIPLE products with various relationships based on remote product data.
     * This method is optimized for performance by processing products in a single batch.
     *
     * @param array $productsToProcess Array of products to process, each element containing 'productId_versionId' and 'remoteProductData'
     */
    public function linkMultipleProducts(array $productsToProcess): void
    {
        if (empty($productsToProcess)) {
            return;
        }

        UtilProfiling::startTimer();
        $dateTime = date('Y-m-d H:i:s');
        CliLogger::debug("Starting bulk processing for " . count($productsToProcess) . " products.");

        $allRelationships = [];
        $allCrossSellingData = [];

        // 1. Collect all relationships and cross-selling data from all products
        foreach ($productsToProcess as $productData) {
            $productId_versionId = $productData['productId_versionId'];
            $remoteProductData = $productData['remoteProductData'];
            $productId = $productId_versionId['product_id'];

            $relationshipFinders = [
                'similar'          => ['config' => MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productSimilar, 'cross_config' => MergedPluginConfigKeyConstants::OPTION_NAME_productSimilarCross, 'finder' => [$this, '_findSimilarProducts'], 'cross_type' => ProductRelationshipTypeEnumV1::SIMILAR],
                'alternate'        => ['config' => MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productAlternate, 'cross_config' => MergedPluginConfigKeyConstants::OPTION_NAME_productAlternateCross, 'finder' => [$this, '_findAlternateProducts'], 'cross_type' => ProductRelationshipTypeEnumV1::ALTERNATE],
                'related'          => ['config' => MergedPluginConfigKeyConstants::RELATIONSHIO_OPTION_productRelated, 'cross_config' => MergedPluginConfigKeyConstants::OPTION_NAME_productRelatedCross, 'finder' => [$this, '_findRelatedProducts'], 'cross_type' => ProductRelationshipTypeEnumV1::RELATED],
                'bundled'          => ['config' => MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productBundled, 'cross_config' => MergedPluginConfigKeyConstants::OPTION_NAME_productBundledCross, 'finder' => [$this, 'findBundledProducts'], 'cross_type' => ProductRelationshipTypeEnumV1::BUNDLED],
                'color_variant'    => ['config' => MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productColorVariant, 'cross_config' => MergedPluginConfigKeyConstants::OPTION_NAME_productVariantColorCross, 'finder' => [$this, '_findColorVariantProducts'], 'cross_type' => ProductRelationshipTypeEnumV1::COLOR_VARIANT],
                'capacity_variant' => ['config' => MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productCapacityVariant, 'cross_config' => MergedPluginConfigKeyConstants::OPTION_NAME_productVariantCapacityCross, 'finder' => [$this, '_findCapacityVariantProducts'], 'cross_type' => ProductRelationshipTypeEnumV1::CAPACITY_VARIANT],
                'variant'          => ['config' => MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productVariant, 'cross_config' => MergedPluginConfigKeyConstants::OPTION_NAME_productVariantCross, 'finder' => [$this, '_findVariantProducts'], 'cross_type' => ProductRelationshipTypeEnumV1::VARIANT],
            ];

            foreach ($relationshipFinders as $type => $details) {
                if ($this->productImportSettingsService->isProductOptionEnabled($details['config'], $productId)) {
                    $foundProducts = call_user_func($details['finder'], $remoteProductData);
                    if (!empty($foundProducts)) {
                        if (!isset($allRelationships[$type])) {
                            $allRelationships[$type] = [];
                        }
                        foreach ($foundProducts as $targetProduct) {
                            $allRelationships[$type][] = ['source' => $productId_versionId, 'target' => $targetProduct];
                        }

                        if ($this->productImportSettingsService->isProductOptionEnabled($details['cross_config'], $productId)) {
                            $allCrossSellingData[] = ['source' => $productId_versionId, 'products' => $foundProducts, 'type' => $details['cross_type']];
                        }
                    }
                }
            }
        }

        // 2. Process all collected relationships in bulk
        if (!empty($allRelationships)) {
            CliLogger::debug("Processing bulk relationships...");
            $this->_processAllRelationshipsBulk($allRelationships, $dateTime);
        }

        // 3. Process all collected cross-selling data in bulk
        if (!empty($allCrossSellingData)) {
            CliLogger::debug("Processing bulk cross-selling...");
            $this->_processAllCrossSellingsBulk($allCrossSellingData);
        }

        UtilProfiling::stopTimer();
    }

    /**
     * Processes all relationships for multiple products in a true bulk fashion.
     */
    private function _processAllRelationshipsBulk(array $allRelationships, string $dateTime): void
    {
        if (self::USE_TRANSACTIONS) {
            $this->connection->beginTransaction();
        }

        try {
            foreach ($allRelationships as $type => $relations) {
                if (empty($relations)) {
                    continue;
                }

                $tableName = $this->getTableForType($type);
                $idColumnPrefix = $this->getIdColumnPrefix($type);

                if (empty($tableName) || empty($idColumnPrefix)) {
                    continue;
                }

                $valuesSql = [];
                foreach ($relations as $relation) {
                    $sourceProd = $relation['source'];
                    $targetProd = $relation['target'];
                    $valuesSql[] = "(0x{$sourceProd['product_id']}, 0x{$sourceProd['product_version_id']}, 0x{$targetProd['product_id']}, 0x{$targetProd['product_version_id']}, '$dateTime')";
                }

                $chunks = array_chunk($valuesSql, self::BULK_INSERT_SIZE);
                foreach ($chunks as $chunk) {
                    $columns = implode(', ', [
                        'product_id',
                        'product_version_id',
                        "{$idColumnPrefix}_product_id",
                        "{$idColumnPrefix}_product_version_id",
                        'created_at'
                    ]);

                    $sql = "INSERT INTO `$tableName` ($columns) VALUES " . implode(',', $chunk);
                    $this->connection->executeStatement($sql);
                    CliLogger::activity();
                }
            }

            if (self::USE_TRANSACTIONS) {
                $this->connection->commit();
            }
        } catch (\Exception $e) {
            if (self::USE_TRANSACTIONS) {
                $this->connection->rollBack();
            }
            throw $e;
        }
    }

    /**
     * Processes all cross-selling for multiple products in a true bulk fashion.
     */
    private function _processAllCrossSellingsBulk(array $allCrossSellingData): void
    {
        // 1. Filter out variants, which don't get cross-sellings
        $dataToProcess = array_filter($allCrossSellingData, fn($data) => empty($data['source']['parent_id']));
        if (empty($dataToProcess)) {
            return;
        }

        // 2. Fetch existing cross-selling entities for all products in the batch
        $productIds = array_unique(array_map(fn($data) => $data['source']['product_id'], $dataToProcess));
        $dbTypes = array_unique(array_map(fn($data) => self::_getCrossDbType($data['type']), $dataToProcess));

        $criteria = new Criteria();
        $criteria->addFilter(new EqualsAnyFilter('productId', $productIds));
        $criteria->addFilter(new EqualsAnyFilter('topdataExtension.type', $dbTypes));
        $criteria->addAssociation('topdataExtension');
        $existingCrossSells = $this->productCrossSellingRepository->search($criteria, $this->context)->getEntities();

        $existingMap = []; // [productId][dbType] => crossSellingEntity
        foreach ($existingCrossSells as $cs) {
            $ext = $cs->getExtension('topdataExtension');
            if ($ext && $ext->get('type')) {
                $existingMap[$cs->getProductId()][$ext->get('type')] = $cs;
            }
        }

        // 3. Prepare data for bulk create/update operations
        $crossSellsToCreate = [];
        $assignmentsToDeleteIds = [];
        $allAssignmentsToCreate = [];

        foreach ($dataToProcess as $data) {
            $sourceProduct = $data['source'];
            $linkedProducts = $data['products'];
            $crossType = $data['type'];
            $dbType = self::_getCrossDbType($crossType);
            $productId = $sourceProduct['product_id'];

            if (isset($existingMap[$productId][$dbType])) {
                // Exists: mark old assignments for deletion
                $crossId = $existingMap[$productId][$dbType]->getId();
                $assignmentsToDeleteIds[] = $crossId;
            } else {
                // Does not exist: prepare new cross-sell entity for creation
                $crossId = Uuid::randomHex();
                $crossSellsToCreate[] = [
                    'id'               => $crossId,
                    'productId'        => $productId,
                    'productVersionId' => $sourceProduct['product_version_id'],
                    'name'             => self::_getCrossNameTranslations($crossType),
                    'position'         => self::_getCrossPosition($crossType),
                    'type'             => ProductCrossSellingDefinition::TYPE_PRODUCT_LIST,
                    'sortBy'           => ProductCrossSellingDefinition::SORT_BY_NAME,
                    'sortDirection'    => FieldSorting::ASCENDING,
                    'active'           => true,
                    'limit'            => self::MAX_CROSS_SELLINGS,
                    'topdataExtension' => ['type' => $dbType],
                ];
            }

            // Prepare new assignments for creation
            $i = 1;
            foreach ($linkedProducts as $prodIdData) {
                $allAssignmentsToCreate[] = [
                    'crossSellingId'   => $crossId,
                    'productId'        => $prodIdData['product_id'],
                    'productVersionId' => $prodIdData['product_version_id'],
                    'position'         => $i++,
                ];
            }
        }

        // 4. Execute all DB operations in bulk
        if (!empty($assignmentsToDeleteIds)) {
            $this->connection->executeStatement(
                "DELETE FROM product_cross_selling_assigned_products WHERE cross_selling_id IN (:ids)",
                ['ids' => array_map('hex2bin', array_unique($assignmentsToDeleteIds))],
                ['ids' => ArrayParameterType::BINARY]
            );
            CliLogger::activity();
        }

        if (!empty($crossSellsToCreate)) {
            $this->productCrossSellingRepository->create($crossSellsToCreate, $this->context);
            CliLogger::activity();
        }

        if (!empty($allAssignmentsToCreate)) {
            foreach (array_chunk($allAssignmentsToCreate, self::BULK_INSERT_SIZE) as $chunk) {
                $this->productCrossSellingAssignedProductsRepository->create($chunk, $this->context);
                CliLogger::activity();
            }
        }
    }


    /**
     * 04/2025 created
     */
    private static function _getCrossPosition(ProductRelationshipTypeEnumV1 $crossType): int
    {
        return match ($crossType) {
            ProductRelationshipTypeEnumV1::CAPACITY_VARIANT => 1,
            ProductRelationshipTypeEnumV1::COLOR_VARIANT    => 2,
            ProductRelationshipTypeEnumV1::ALTERNATE        => 3,
            ProductRelationshipTypeEnumV1::RELATED          => 4,
            ProductRelationshipTypeEnumV1::VARIANT          => 5,
            ProductRelationshipTypeEnumV1::BUNDLED          => 6,
            ProductRelationshipTypeEnumV1::SIMILAR          => 7,
            default                                         => throw new RuntimeException("Unknown cross-selling type: {$crossType->value}"),
        };
    }

}

================
File: src/Service/Linking/ProductProductRelationshipServiceV2.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\Linking;

use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use RuntimeException;
use Shopware\Core\Content\Product\Aggregate\ProductCrossSelling\ProductCrossSellingDefinition;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Sorting\FieldSorting;
use Shopware\Core\Framework\Uuid\Uuid;
use Topdata\TopdataConnectorSW6\Constants\MergedPluginConfigKeyConstants;
use Topdata\TopdataConnectorSW6\Enum\ProductRelationshipTypeEnumV2;
use Topdata\TopdataConnectorSW6\Service\Config\ProductImportSettingsService;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataToProductService;
use Topdata\TopdataConnectorSW6\Util\ImportReport;
use Topdata\TopdataConnectorSW6\Util\UtilProfiling;
use Topdata\TopdataFoundationSW6\Util\CliLogger;

/**
 * Service responsible for managing product linking relationships and cross-selling functionality.
 * Handles various types of product relationships such as similar products, alternates, variants,
 * color variants, capacity variants, related products, and bundled products.
 *
 * Updated to use the unified topdata_product_relationships table instead of separate tables.
 *
 * aka ProductCrossSellingService
 *
 * 06/2026 created - this replaces the deprecated ProductProductRelationshipServiceV1, it uses the unified topdata_product_relationships table
 */
class ProductProductRelationshipServiceV2
{
    const CHUNK_SIZE         = 30;
    const MAX_CROSS_SELLINGS = 24;

    private Context $context;


    public function __construct(
        private readonly ProductImportSettingsService $productImportSettingsService,
        private readonly Connection                   $connection,
        private readonly TopdataToProductService      $topdataToProductHelperService,
        private readonly EntityRepository             $productCrossSellingRepository,
        private readonly EntityRepository             $productCrossSellingAssignedProductsRepository,
    )
    {
        $this->context = Context::createDefaultContext();
    }

    /**
     * Maps enum values to database relationship type values
     * Updated to match the new unified table structure
     */
    private static function _getCrossDbType(ProductRelationshipTypeEnumV2 $crossType): string
    {
        return match ($crossType) {
            ProductRelationshipTypeEnumV2::SIMILAR          => 'similar',
            ProductRelationshipTypeEnumV2::ALTERNATE        => 'alternate',
            ProductRelationshipTypeEnumV2::RELATED          => 'related',
            ProductRelationshipTypeEnumV2::BUNDLED          => 'bundled',
            ProductRelationshipTypeEnumV2::COLOR_VARIANT    => 'color_variant',
            ProductRelationshipTypeEnumV2::CAPACITY_VARIANT => 'capacity_variant',
            ProductRelationshipTypeEnumV2::VARIANT          => 'variant',
            default                                         => throw new RuntimeException("Unknown cross-selling type: {$crossType->value}"),
        };
    }


    private static function _getCrossNameTranslations(ProductRelationshipTypeEnumV2 $crossType): array
    {
        return match ($crossType) {
            ProductRelationshipTypeEnumV2::CAPACITY_VARIANT => [
                'de-DE' => 'Kapazitätsvarianten',
                'en-GB' => 'Capacity Variants',
                'nl-NL' => 'capaciteit varianten',
            ],
            ProductRelationshipTypeEnumV2::COLOR_VARIANT    => [
                'de-DE' => 'Farbvarianten',
                'en-GB' => 'Color Variants',
                'nl-NL' => 'kleur varianten',
            ],
            ProductRelationshipTypeEnumV2::ALTERNATE        => [
                'de-DE' => 'Alternative Produkte',
                'en-GB' => 'Alternate Products',
                'nl-NL' => 'alternatieve producten',
            ],
            ProductRelationshipTypeEnumV2::RELATED          => [
                'de-DE' => 'Zubehör',
                'en-GB' => 'Accessories',
                'nl-NL' => 'Accessoires',
            ],
            ProductRelationshipTypeEnumV2::VARIANT          => [
                'de-DE' => 'Varianten',
                'en-GB' => 'Variants',
                'nl-NL' => 'varianten',
            ],
            ProductRelationshipTypeEnumV2::BUNDLED          => [
                'de-DE' => 'Im Bundle',
                'en-GB' => 'In Bundle',
                'nl-NL' => 'In een bundel',
            ],
            ProductRelationshipTypeEnumV2::SIMILAR          => [
                'de-DE' => 'Ähnlich',
                'en-GB' => 'Similar',
                'nl-NL' => 'Vergelijkbaar',
            ],
            default                                         => throw new RuntimeException("Unknown cross-selling type: {$crossType->value}"),
        };
    }


    /**
     * Finds similar products based on the remote product data
     *
     * @param object $remoteProductData The product data from remote source
     * @return array Array of similar products
     */
    private function _findSimilarProducts($remoteProductData): array
    {
        $similarProducts = [];
        $topid_products = $this->topdataToProductHelperService->getTopdataProductMappings();

        // ---- Check for products with same accessories
        if (isset($remoteProductData->product_same_accessories->products) && count($remoteProductData->product_same_accessories->products)) {
            foreach ($remoteProductData->product_same_accessories->products as $tid) {
                if (!isset($topid_products[$tid])) {
                    continue;
                }
                $similarProducts[$tid] = $topid_products[$tid][0];
            }
        }

        // ---- Check for products with same application
        if (isset($remoteProductData->product_same_application_in->products) && count($remoteProductData->product_same_application_in->products)) {
            foreach ($remoteProductData->product_same_application_in->products as $tid) {
                if (!isset($topid_products[$tid])) {
                    continue;
                }
                $similarProducts[$tid] = $topid_products[$tid][0];
            }
        }

        // ---- Check for product variants
        if (isset($remoteProductData->product_variants->products) && count($remoteProductData->product_variants->products)) {
            foreach ($remoteProductData->product_variants->products as $rprod) {
                if (!isset($topid_products[$rprod->id])) {
                    continue;
                }
                $similarProducts[$rprod->id] = $topid_products[$rprod->id][0];
            }
        }

        return $similarProducts;
    }


    /**
     * Finds color variant products based on the remote product data
     *
     * @param object $remoteProductData The product data from remote source
     * @return array Array of color variant products
     */
    private function _findColorVariantProducts($remoteProductData): array
    {
        $linkedProducts = [];
        $topid_products = $this->topdataToProductHelperService->getTopdataProductMappings();
        if (isset($remoteProductData->product_special_variants->color) && count($remoteProductData->product_special_variants->color)) {
            foreach ($remoteProductData->product_special_variants->color as $tid) {
                if (!isset($topid_products[$tid])) {
                    continue;
                }
                $linkedProducts[$tid] = $topid_products[$tid][0];
            }
        }

        return $linkedProducts;
    }

    /**
     * Finds capacity variant products based on the remote product data
     *
     * @param object $remoteProductData The product data from remote source
     * @return array Array of capacity variant products
     */
    private function _findCapacityVariantProducts($remoteProductData): array
    {
        $linkedProducts = [];
        $topid_products = $this->topdataToProductHelperService->getTopdataProductMappings();
        if (isset($remoteProductData->product_special_variants->capacity) && count($remoteProductData->product_special_variants->capacity)) {
            foreach ($remoteProductData->product_special_variants->capacity as $tid) {
                if (!isset($topid_products[$tid])) {
                    continue;
                }
                $linkedProducts[$tid] = $topid_products[$tid][0];
            }
        }

        return $linkedProducts;
    }

    /**
     * Finds general variant products based on the remote product data
     *
     * @param object $remoteProductData The product data from remote source
     * @return array Array of variant products
     */
    private function _findVariantProducts($remoteProductData): array
    {
        $products = [];
        $topid_products = $this->topdataToProductHelperService->getTopdataProductMappings();

        // ---- Process product variants that don't have a specific type
        if (isset($remoteProductData->product_variants->products) && count($remoteProductData->product_variants->products)) {
            foreach ($remoteProductData->product_variants->products as $rprod) {
                if ($rprod->type !== null) {
                    continue;
                }

                if (!isset($topid_products[$rprod->id])) {
                    continue;
                }
                $products[$rprod->id] = $topid_products[$rprod->id][0];
            }
        }

        return $products;
    }

    /**
     * Adds cross-selling relationships between products
     *
     * @param array $currentProduct The current product information
     * @param array $linkedProductIds Array of products to be linked
     * @param ProductRelationshipTypeEnumV2 $crossType The type of cross-selling relationship
     */
    private function _addProductCrossSelling(array $currentProduct, array $linkedProductIds, ProductRelationshipTypeEnumV2 $crossType): void
    {
        if ($currentProduct['parent_id']) {
            //don't create cross if product is variation!
            return;
        }

        // ---- Check if cross-selling already exists for this product and type

        $criteria = new Criteria();
        $criteria->addFilter(new EqualsFilter('productId', $currentProduct['product_id']));
        $criteria->addFilter(new EqualsFilter('topdataExtension.type', self::_getCrossDbType($crossType)));
        $productCrossSellingEntity = $this->productCrossSellingRepository->search($criteria, $this->context)->first();

        if ($productCrossSellingEntity) {
            // ---- Remove existing cross-selling product assignments
            $crossId = $productCrossSellingEntity->getId();
            $this->connection->executeStatement("
                    DELETE 
                    FROM product_cross_selling_assigned_products 
                    WHERE cross_selling_id = 0x$crossId
            ");
        } else {
            // ---- Create new cross-selling entity
            $crossId = Uuid::randomHex();
            $data = [
                'id'               => $crossId,
                'productId'        => $currentProduct['product_id'],
                'productVersionId' => $currentProduct['product_version_id'],
                'name'             => self::_getCrossNameTranslations($crossType),
                'position'         => self::_getCrossPosition($crossType),
                'type'             => ProductCrossSellingDefinition::TYPE_PRODUCT_LIST,
                'sortBy'           => ProductCrossSellingDefinition::SORT_BY_NAME,
                'sortDirection'    => FieldSorting::ASCENDING,
                'active'           => true,
                'limit'            => self::MAX_CROSS_SELLINGS,
                'topdataExtension' => [
                    'type' => $crossType->value
                ],
            ];
            $this->productCrossSellingRepository->create([$data], $this->context);
            CliLogger::activity();
        }

        $i = 1;
        $data = [];
        foreach ($linkedProductIds as $prodId) {
            $data[] = [
                'crossSellingId'   => $crossId,
                'productId'        => $prodId['product_id'],
                'productVersionId' => $prodId['product_version_id'],
                'position'         => $i++,
            ];
        }

        $this->productCrossSellingAssignedProductsRepository->create($data, $this->context);
        CliLogger::activity();
    }


    /**
     * Finds alternate products based on the remote product data
     *
     * @param object $remoteProductData The product data from remote source
     * @return array Array of alternate products
     */
    private function _findAlternateProducts($remoteProductData): array
    {
        $alternateProducts = [];
        $topid_products = $this->topdataToProductHelperService->getTopdataProductMappings();
        if (isset($remoteProductData->product_alternates->products) && count($remoteProductData->product_alternates->products)) {
            foreach ($remoteProductData->product_alternates->products as $tid) {
                if (!isset($topid_products[$tid])) {
                    continue;
                }
                $alternateProducts[$tid] = $topid_products[$tid][0];
            }
        }

        return $alternateProducts;
    }

    /**
     * Finds related products (accessories) based on the remote product data
     *
     * @param object $remoteProductData The product data from remote source
     * @return array Array of related products
     */
    private function _findRelatedProducts($remoteProductData): array
    {
        $relatedProducts = [];
        $topid_products = $this->topdataToProductHelperService->getTopdataProductMappings();
        if (isset($remoteProductData->product_accessories->products) && count($remoteProductData->product_accessories->products)) {
            foreach ($remoteProductData->product_accessories->products as $tid) {
                if (!isset($topid_products[$tid])) {
                    continue;
                }
                $relatedProducts[$tid] = $topid_products[$tid][0];
            }
        }

        return $relatedProducts;
    }

    /**
     * Finds bundled products based on the remote product data
     *
     * @param object $remoteProductData The product data from remote source
     * @return array Array of bundled products
     */
    private function findBundledProducts($remoteProductData): array
    {
        $bundledProducts = [];
        $topid_products = $this->topdataToProductHelperService->getTopdataProductMappings();
        if (isset($remoteProductData->bundle_content->products) && count($remoteProductData->bundle_content->products)) {
            foreach ($remoteProductData->bundle_content->products as $tid) {
                if (!isset($topid_products[$tid->products_id])) {
                    continue;
                }
                $bundledProducts[$tid->products_id] = $topid_products[$tid->products_id][0];
            }
        }

        return $bundledProducts;
    }

    /**
     * Generic method to process product relationships using the unified table
     * Updated to use topdata_product_relationships instead of separate tables
     *
     * @param array $productId_versionId The current product ID information
     * @param array $relatedProducts Array of products to be linked
     * @param ProductRelationshipTypeEnumV2 $crossType The type of cross-selling relationship
     * @param bool $enableCrossSelling Whether to enable cross-selling
     * @param string $dateTime The current date/time string
     */
    private function _processProductRelationship(
        array                         $productId_versionId,
        array                         $relatedProducts,
        ProductRelationshipTypeEnumV2 $crossType,
        bool                          $enableCrossSelling,
        string                        $dateTime
    ): void
    {
        if (empty($relatedProducts)) {
            return;
        }

        $relationshipType = self::_getCrossDbType($crossType);
        $dataInsert = [];

        foreach ($relatedProducts as $tempProd) {
            $dataInsert[] = "(0x{$productId_versionId['product_id']}, 0x{$productId_versionId['product_version_id']}, 0x{$tempProd['product_id']}, 0x{$tempProd['product_version_id']}, '{$relationshipType}', '$dateTime', '$dateTime')";
        }

        $insertDataChunks = array_chunk($dataInsert, self::CHUNK_SIZE);
        foreach ($insertDataChunks as $chunk) {
            $columns = implode(', ', [
                'product_id',
                'product_version_id',
                'linked_product_id',
                'linked_product_version_id',
                'relationship_type',
                'created_at',
                'updated_at'
            ]);

            $this->connection->executeStatement(
                "INSERT INTO topdata_product_relationships ($columns) VALUES " . implode(',', $chunk)
            );
            CliLogger::activity();
        }

        if ($enableCrossSelling) {
            $this->_addProductCrossSelling($productId_versionId, $relatedProducts, $crossType);
        }
    }


    /**
     * Orchestrator method to unlink products from various relationships based on plugin configuration.
     * Updated to use the unified topdata_product_relationships table
     *
     * @param string[] $productIds Array of product IDs to unlink.
     */
    public function unlinkProducts(array $productIds): void
    {
        if (empty($productIds)) {
            return;
        }

        // Validate that IDs are valid hex UUIDs before processing to prevent errors
        $validProductIds = array_filter($productIds, fn($id) => Uuid::isValid($id));
        if (empty($validProductIds)) {
            return;
        }

        // Map configuration keys to their respective relationship types
        $relationshipMap = [
            MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productSimilar         => 'similar',
            MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productAlternate       => 'alternate',
            MergedPluginConfigKeyConstants::RELATIONSHIO_OPTION_productRelated         => 'related',
            MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productBundled         => 'bundled',
            MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productColorVariant    => 'color_variant',
            MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productCapacityVariant => 'capacity_variant',
            MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productVariant         => 'variant',
        ];

        foreach ($relationshipMap as $configKey => $relationshipType) {
            $idsToUnlink = $this->productImportSettingsService->filterProductIdsByConfig(
                $configKey,
                $validProductIds
            );

            if (!empty($idsToUnlink)) {
                $this->_deleteFromUnifiedTableWhereProductIdsIn($relationshipType, $idsToUnlink);
            }
            ImportReport::incCounter('links deleted for ' . $relationshipType, count($idsToUnlink));
        }
    }

    /**
     * Executes a DELETE statement on the unified table for a list of product IDs and relationship type.
     * Updated to work with the unified topdata_product_relationships table
     *
     * @param string $relationshipType The relationship type to delete
     * @param string[] $productIds The product IDs to delete. The product IDs are expected to be UUIDs in hex format (without 0x and without dashes)
     */
    private function _deleteFromUnifiedTableWhereProductIdsIn(string $relationshipType, array $productIds): int
    {
        return (int)$this->connection->executeStatement(
            "DELETE FROM `topdata_product_relationships` WHERE product_id IN (:ids) AND relationship_type = :type",
            [
                'ids'  => array_map('hex2bin', $productIds),
                'type' => $relationshipType,
            ],
            [
                'ids' => ArrayParameterType::BINARY,
            ]
        );
    }


    /**
     * ==== MAIN ====
     *
     * Main method to link products with various relationships based on remote product data
     * Updated to use the unified table structure
     *
     * @param array $productId_versionId Product ID and version information
     * @param object $remoteProductData The product data from remote source
     */
    public function linkProducts(array $productId_versionId, $remoteProductData): void
    {
        UtilProfiling::startTimer();
        $dateTime = date('Y-m-d H:i:s');
        $productId = $productId_versionId['product_id'];

        // ---- Process similar products
        if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productSimilar, $productId)) {
            $this->_processProductRelationship(
                $productId_versionId,
                $this->_findSimilarProducts($remoteProductData),
                ProductRelationshipTypeEnumV2::SIMILAR,
                $this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productSimilarCross, $productId),
                $dateTime
            );
        }

        // ---- Process alternate products
        if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productAlternate, $productId)) {
            $this->_processProductRelationship(
                $productId_versionId,
                $this->_findAlternateProducts($remoteProductData),
                ProductRelationshipTypeEnumV2::ALTERNATE,
                $this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productAlternateCross, $productId),
                $dateTime
            );
        }

        // ---- Process related products (accessories)
        if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::RELATIONSHIO_OPTION_productRelated, $productId)) {
            $this->_processProductRelationship(
                $productId_versionId,
                $this->_findRelatedProducts($remoteProductData),
                ProductRelationshipTypeEnumV2::RELATED,
                $this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productRelatedCross, $productId),
                $dateTime
            );
        }

        // ---- Process bundled products
        if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productBundled, $productId)) {
            $this->_processProductRelationship(
                $productId_versionId,
                $this->findBundledProducts($remoteProductData),
                ProductRelationshipTypeEnumV2::BUNDLED,
                $this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productBundledCross, $productId),
                $dateTime
            );
        }

        // ---- Process color variant products
        if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productColorVariant, $productId)) {
            $this->_processProductRelationship(
                $productId_versionId,
                $this->_findColorVariantProducts($remoteProductData),
                ProductRelationshipTypeEnumV2::COLOR_VARIANT,
                $this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productVariantColorCross, $productId),
                $dateTime
            );
        }

        // ---- Process capacity variant products
        if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productCapacityVariant, $productId)) {
            $this->_processProductRelationship(
                $productId_versionId,
                $this->_findCapacityVariantProducts($remoteProductData),
                ProductRelationshipTypeEnumV2::CAPACITY_VARIANT,
                $this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productVariantCapacityCross, $productId),
                $dateTime
            );
        }

        // ---- Process general variant products
        if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::RELATIONSHIP_OPTION_productVariant, $productId)) {
            $this->_processProductRelationship(
                $productId_versionId,
                $this->_findVariantProducts($remoteProductData),
                ProductRelationshipTypeEnumV2::VARIANT,
                $this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productVariantCross, $productId),
                $dateTime
            );
        }

        UtilProfiling::stopTimer();
    }

    /**
     * Get cross-selling position for ordering
     */
    private static function _getCrossPosition(ProductRelationshipTypeEnumV2 $crossType): int
    {
        return match ($crossType) {
            ProductRelationshipTypeEnumV2::CAPACITY_VARIANT => 1,
            ProductRelationshipTypeEnumV2::COLOR_VARIANT    => 2,
            ProductRelationshipTypeEnumV2::ALTERNATE        => 3,
            ProductRelationshipTypeEnumV2::RELATED          => 4,
            ProductRelationshipTypeEnumV2::VARIANT          => 5,
            ProductRelationshipTypeEnumV2::BUNDLED          => 6,
            ProductRelationshipTypeEnumV2::SIMILAR          => 7,
            default                                         => throw new RuntimeException("Unknown cross-selling type: {$crossType->value}"),
        };
    }

}

================
File: src/Service/Shopware/BreadcrumbService.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Service\Shopware;

use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Shopware\Core\Framework\Uuid\Uuid;
use Topdata\TopdataFoundationSW6\Util\CliLogger;

/**
 * Service to generate breadcrumb paths for Shopware categories.
 *
 * 06/2025 created
 */
class BreadcrumbService
{
    public function __construct(
        private readonly Connection $connection,
        private readonly ShopwareLanguageService $languageService
    ) {
    }

    /**
     * Generates a breadcrumb string for a given category ID.
     *
     * @param string $categoryId The hexadecimal ID of the category.
     * @return string The breadcrumb path (e.g., "Root > Parent > Child") or the category ID if not found.
     */
    public function getCategoryBreadcrumb(string $categoryId): string
    {
        if (!Uuid::isValid($categoryId)) {
            CliLogger::warning("Invalid category ID for breadcrumb: $categoryId");

            return "Invalid Category ID: $categoryId";
        }

        $defaultLangId = $this->languageService->getDefaultLanguageId();
        $fallbackLangId = $this->languageService->getLanguageId_EN(); // Use English as a fallback

        if (empty($defaultLangId)) {
            CliLogger::warning('Could not determine default language for category breadcrumbs.');

            return $categoryId;
        }

        $pathString = $this->connection->fetchOne(
            'SELECT path FROM category WHERE id = UNHEX(:categoryId)',
            ['categoryId' => $categoryId]
        );

        if (!$pathString) {
            return $categoryId; // Category not found
        }

        $ancestorIdsHex = array_filter(explode('|', $pathString));
        if (empty($ancestorIdsHex)) {
            return $categoryId; // No path found
        }

        $langIdsToFetch = [$defaultLangId];
        if ($fallbackLangId && $fallbackLangId !== $defaultLangId) {
            $langIdsToFetch[] = $fallbackLangId;
        }

        $translations = $this->connection->fetchAllAssociative(
            'SELECT LOWER(HEX(category_id)) as id, LOWER(HEX(language_id)) as lang_id, name
             FROM category_translation
             WHERE category_id IN (:ids) AND language_id IN (:lang_ids)',
            [
                'ids' => array_map('hex2bin', $ancestorIdsHex),
                'lang_ids' => array_map('hex2bin', $langIdsToFetch),
            ],
            [
                'ids' => ArrayParameterType::BINARY,
                'lang_ids' => ArrayParameterType::BINARY,
            ]
        );

        $nameMap = [];
        foreach ($translations as $translation) {
            $nameMap[$translation['id']][$translation['lang_id']] = $translation['name'];
        }

        $breadcrumbParts = [];
        foreach ($ancestorIdsHex as $id) {
            $name = $nameMap[$id][$defaultLangId] ?? ($fallbackLangId ? ($nameMap[$id][$fallbackLangId] ?? null) : null) ?? '(Category Not Translated)';
            $breadcrumbParts[] = $name;
        }

        return implode(' > ', $breadcrumbParts);
    }
}

================
File: src/Service/Shopware/ShopwareLanguageService.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\Shopware;

use Doctrine\DBAL\Connection;
use Shopware\Core\Defaults;
use Shopware\Core\Framework\Uuid\Uuid;

/**
 * Service to reliably fetch language IDs.
 *
 * 06/2025 created (extracted from ShopwarePropertyService)
 * 06/2025 rewritten for more robust language ID detection.
 */
class ShopwareLanguageService
{
    private ?string $enLangID = null;
    private ?string $deLangID = null;

    public function __construct(
        private readonly Connection $connection
    ) {
    }

    /**
     * Gets the English language ID, falling back if not found.
     *
     * @return string|null
     */
    public function getLanguageId_EN(): ?string
    {
        if ($this->enLangID !== null) {
            return $this->enLangID ?: null;
        }

        $result = $this->connection->fetchOne('
            SELECT LOWER(HEX(l.id))
            FROM language l
            JOIN locale loc ON l.translation_code_id = loc.id
            WHERE loc.code LIKE \'en-%\'
            ORDER BY loc.code = \'en-GB\' DESC, l.created_at ASC
            LIMIT 1
        ');
        $this->enLangID = $result ?: false;

        return $this->enLangID ?: null;
    }

    /**
     * Gets the German language ID, falling back if not found.
     *
     * @return string|null
     */
    public function getLanguageId_DE(): ?string
    {
        if ($this->deLangID !== null) {
            return $this->deLangID ?: null;
        }

        $result = $this->connection->fetchOne('
            SELECT LOWER(HEX(l.id))
            FROM language l
            JOIN locale loc ON l.translation_code_id = loc.id
            WHERE loc.code LIKE \'de-%\'
            ORDER BY loc.code = \'de-DE\' DESC, l.created_at ASC
            LIMIT 1
        ');
        $this->deLangID = $result ?: false;

        return $this->deLangID ?: null;
    }

    /**
     * Gets the system's default language ID using a multi-step fallback approach.
     *
     * @return string The default language ID (hex).
     */
    public function getDefaultLanguageId(): string
    {
        // 1. Get from system_config (most reliable system-wide default)
        $configValue = $this->connection->fetchOne(
            'SELECT configuration_value FROM system_config WHERE configuration_key = :key AND sales_channel_id IS NULL LIMIT 1',
            ['key' => 'core.defaultLanguage']
        );

        if ($configValue) {
            $config = json_decode($configValue, true);
            if (isset($config['_value']) && Uuid::isValid($config['_value'])) {
                return strtolower($config['_value']);
            }
        }

        // 2. Fallback: Get language ID from the default Sales Channel (Storefront)
        $defaultSalesChannelTypeId = Uuid::fromHexToBytes(Defaults::SALES_CHANNEL_TYPE_STOREFRONT);
        $languageId = $this->connection->fetchOne(
            'SELECT LOWER(HEX(language_id)) FROM sales_channel WHERE type_id = :typeId ORDER BY created_at ASC LIMIT 1',
            ['typeId' => $defaultSalesChannelTypeId]
        );
        if ($languageId) {
            return $languageId;
        }

        // 3. Last resort fallback: get the very first language created
        return $this->connection->fetchOne(
            'SELECT LOWER(HEX(id)) FROM language ORDER BY created_at ASC LIMIT 1'
        ) ?: '';
    }
}

================
File: src/Service/Shopware/ShopwareProductPropertyService.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\Shopware;


use Doctrine\DBAL\Connection;

/**
 * 05/2025 created (extracted from ShopwareProductService)
 */
class ShopwareProductPropertyService
{

    public function __construct(
        private readonly Connection $connection,
    )
    {
    }


    /**
     * Retrieves product data based on a property group option name.
     *
     * @param string $optionName The name of the property group option.
     * @param string $newColumnName the column "name" gets renamed to the given name
     * @return array An array of product data.
     */
    public function getKeysByOptionValue(string $optionName, string $newColumnName): array
    {
        $query = $this->connection->createQueryBuilder();

        // ---- Building the query to fetch product data
        $query->select([
            "pgot.name {$newColumnName}",
            'p.id',
            'p.version_id'
        ])
            ->from('product', 'p')
            ->innerJoin('p', 'product_property', 'pp', '(pp.product_id = p.id) AND (pp.product_version_id = p.version_id)')
            ->innerJoin('pp', 'property_group_option_translation', 'pgot', 'pgot.property_group_option_id = pp.property_group_option_id')
            ->innerJoin('pp', 'property_group_option', 'pgo', 'pgo.id = pp.property_group_option_id')
            ->innerJoin('pgo', 'property_group_translation', 'pgt', 'pgt.property_group_id = pgo.property_group_id')
            ->where('pgt.name = :option')
            ->setParameter(':option', $optionName);

        return $query->execute()->fetchAllAssociative();
    }


    /**
     * Retrieves product data based on a unique property group option name.
     *
     * @param string $optionName The name of the property group option.
     * @return array An array of product data, where the key is the option name and the value is an array of product IDs and version IDs.
     */
    public function getKeysByOptionValueUnique(string $optionName): array
    {
        $results = $this->getKeysByOptionValue($optionName, 'NAME');
        $returnArray = [];
        foreach ($results as $res) {
            $returnArray[(string)$res['NAME']][] = [
                'id'         => $res['id'],
                'version_id' => $res['version_id'],
            ];
        }

        return $returnArray;
    }

    /**
     * Unlinks properties from a chunk of products.
     *
     * @param string[] $productIds Array of product IDs to unlink properties from.
     */
    public function unlinkProperties(array $productIds): void
    {
        if (!count($productIds)) {
            return;
        }

        $strProductIds = '0x' . implode(',0x', $productIds);
        $this->connection->executeStatement("UPDATE product SET property_ids = NULL WHERE id IN ($strProductIds)");
        $this->connection->executeStatement("DELETE FROM product_property WHERE product_id IN ($strProductIds)");
    }


}

================
File: src/Service/Shopware/ShopwareProductService.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\Shopware;


use Doctrine\DBAL\Connection;

/**
 * Provides utility methods for fetching product data from Shopware 6.
 *
 * This service class encapsulates database queries to retrieve product information
 * based on various criteria such as product number, property group option,
 * manufacturer number (MPN), EAN, and custom field values.
 *
 * 03/2025 created (extracted from ProductMappingService)
 */
class ShopwareProductService
{


    public function __construct(
        private readonly Connection $connection,
    )
    {
    }

    /**
     * Retrieves product data based on the product number.
     *
     * 03/2025 renamed from getKeysByOrdernumber to _getKeysByProductNumber
     *
     * @return array An array of product data, where the key is the product number and the value is an array of product IDs and version IDs.
     */
    public function getKeysByProductNumber(): array
    {
        $query = $this->connection->createQueryBuilder();
        $query->select([
            'p.product_number',
            'p.id',
            'p.version_id'
        ])->from('product', 'p');

        $results = $query->execute()->fetchAllAssociative();

        $ret = [];
        foreach ($results as $res) {
            $ret[(string)$res['product_number']][] = [
                'id'         => $res['id'],
                'version_id' => $res['version_id'],
            ];
        }

        return $ret;
    }



    /**
     * Retrieves product data based on the manufacturer number (MPN).
     * 03/2025 renamed from getKeysBySuppliernumber to getKeysByMpn
     * @return array An array of product data.
     */
    public function getKeysByMpn()
    {
        $query = $this->connection->createQueryBuilder();
        $query->select(['p.manufacturer_number', 'p.id', 'p.version_id'])
            ->from('product', 'p')
            ->where('(p.manufacturer_number != \'\') AND (p.manufacturer_number IS NOT NULL)');

        return $query->execute()->fetchAllAssociative();
    }

    
    /**
     * Retrieves product data based on the EAN.
     *
     * @return array An array of product data.
     */
    public function getKeysByEan()
    {
        $query = $this->connection->createQueryBuilder();
        $query->select(['p.ean', 'p.id', 'p.version_id'])
            ->from('product', 'p')
            ->where('(p.ean != \'\') AND (p.ean IS NOT NULL)');

        return $query->execute()->fetchAllAssociative();
    }



    /**
     * Retrieves product data based on a unique custom field value.
     *
     * @param string $technicalName The technical name of the custom field.
     * @param string|null $fieldName The name of the field (optional).
     * @return array An array of product data.
     */
    public function getKeysByCustomFieldUnique(string $technicalName, ?string $fieldName = null)
    {
        //$technicalName = $this->getCustomFieldTechnicalName($optionName);
        $rez = $this->connection->prepare('SELECT 
                 custom_fields, 
                 LOWER(HEX(product_id)) as `id`, 
                 LOWER(HEX(product_version_id)) as version_id
                 FROM product_translation 
        ');

        $results = $rez->execute()->fetchAllAssociative();
        $returnArray = [];

        // ---- Iterate through the results and extract custom field values
        foreach ($results as $val) {
            if (!$val['custom_fields']) {
                continue;
            }
            $cf = json_decode($val['custom_fields'], true);
            if (empty($cf[$technicalName])) {
                continue;
            }

            // ---- Build the return array based on whether a field name is provided
            if (!empty($fieldName)) {
                $returnArray[] = [
                    $fieldName   => (string)$cf[$technicalName],
                    'id'         => $val['id'],
                    'version_id' => $val['version_id'],
                ];
            } else {
                $returnArray[(string)$cf[$technicalName]][] = [
                    'id'         => $val['id'],
                    'version_id' => $val['version_id'],
                ];
            }
        }

        return $returnArray;
    }



}

================
File: src/Service/Shopware/ShopwarePropertyService.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service\Shopware;

use Doctrine\DBAL\Connection;
use Shopware\Core\Content\Property\PropertyGroupDefinition;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\Uuid\Uuid;
use Topdata\TopdataFoundationSW6\Service\LocaleHelperService;
use Topdata\TopdataFoundationSW6\Util\CliLogger;


/**
 * 06/2025 created (extracted from EntitiesHelperService)
 */
class ShopwarePropertyService
{
    private ?array $propertyGroupsOptionsArray = null;
    private readonly string $systemDefaultLocaleCode;
    private readonly Context $context;

    public function __construct(
        private readonly Connection $connection,
        private readonly EntityRepository $propertyGroupRepository,
        private readonly LocaleHelperService $localeHelperService,
        private readonly ShopwareLanguageService $shopwareLanguageService,
    ) {
        $this->systemDefaultLocaleCode = $this->localeHelperService->getLocaleCodeOfSystemLanguage();
        $this->context = Context::createDefaultContext();
    }


    /**
     * @todo [I18N] Refactor for proper multi-language support.
     *
     * NOTE ON CURRENT LIMITATION:
     * This method currently has a significant limitation regarding multi-language support.
     * It receives a single string for a property name (`$propGroupName`) and its value (`$propValue`),
     * which are fetched from the Topdata webservice in only ONE language (defined in the plugin config).
     *
     * It then proceeds to create translations for multiple languages (e.g., English and German)
     * but uses the *same single-language string* for all of them. For example, if the API language
     * is German, both the 'de-DE' and 'en-GB' translations in Shopware will be in German.
     *
     * TO IMPLEMENT "REAL I18N":
     * 1. The `TopdataWebserviceClient` must be refactored to allow changing the request language at runtime
     *    (e.g., with a `setLanguage()` method).
     * 2. The import process must be updated to loop through all active Shopware languages, call the
     *    webservice for each language, and collect all translations.
     * 3. This `getPropertyId` method must be changed to accept arrays of translations for group names
     *    and values, and then use the repository's `create` or `update` methods to save all
     *    translations in a single, correct operation, removing the direct SQL inserts below.
     */
    public function getPropertyId(string $propGroupName, string $propValue): string
    {
        $propGroups = $this->getPropertyGroupsOptionsArray();

        $currentGroup = null;
        $currentGroupId = null;

        foreach ($propGroups as $id => $propertyGroup) {
            if ($propertyGroup['name'] == $propGroupName) {
                $currentGroupId = $id;
                $currentGroup = $propertyGroup;
                break;
            }
        }

        if ($currentGroup === null) {
            $currentGroupId = Uuid::randomHex();
            $currentOptionId = Uuid::randomHex();

            $this->propertyGroupRepository->create([
                [
                    'id' => $currentGroupId,
                    'sortingType' => PropertyGroupDefinition::SORTING_TYPE_ALPHANUMERIC,
                    'displayType' => PropertyGroupDefinition::DISPLAY_TYPE_TEXT,
                    'filterable' => false,
                    'name' => [
                        $this->systemDefaultLocaleCode => $propGroupName,
                    ],
                    'options' => [
                        [
                            'id' => $currentOptionId,
                            'name' => [
                                $this->systemDefaultLocaleCode => $propValue,
                            ],
                        ],
                    ],
                ],
            ], $this->context);

            $this->addOptionPropertyGroupsOptionsArray($currentGroupId, $propGroupName, $currentOptionId, $propValue);
            return $currentOptionId;
        }

        foreach ($currentGroup['options'] as $id => $value) {
            if ($value == $propValue) {
                return $id;
            }
        }

        $currentOptionId = Uuid::randomHex();
        $currentDateTime = date('Y-m-d H:i:s');
        $enId = $this->shopwareLanguageService->getLanguageId_EN();
        $deId = $this->shopwareLanguageService->getLanguageId_DE();
        CliLogger::debug("# new property group option $propValue");

        $this->connection->executeStatement(
            'INSERT INTO property_group_option (id, property_group_id, created_at) VALUES (0x' . $currentOptionId . ', 0x' . $currentGroupId . ', "' . $currentDateTime . '")'
        );

        // TODO: [I18N] The lines below are the core of the issue. The same `$propValue`
        // is being inserted for both English and German language IDs. This needs to be
        // replaced by a repository call with a proper translations array when implementing "real i18n".
        if ($enId) {
            $this->connection->insert('property_group_option_translation', [
                'property_group_option_id' => Uuid::fromHexToBytes($currentOptionId),
                'language_id' => Uuid::fromHexToBytes($enId),
                'name' => $propValue,
                'created_at' => $currentDateTime,
            ]);
        }

        if ($deId) {
            $this->connection->insert('property_group_option_translation', [
                'property_group_option_id' => Uuid::fromHexToBytes($currentOptionId),
                'language_id' => Uuid::fromHexToBytes($deId),
                'name' => $propValue,
                'created_at' => $currentDateTime,
            ]);
        }

        $this->addOptionPropertyGroupsOptionsArray($currentGroupId, $propGroupName, $currentOptionId, $propValue);
        return $currentOptionId;
    }

    public function getPropertyGroupsOptionsArray(): array
    {
        if (is_array($this->propertyGroupsOptionsArray)) {
            return $this->propertyGroupsOptionsArray;
        }

        $this->propertyGroupsOptionsArray = [];

        $systemLangIdBytes = $this->connection->fetchOne(
            'SELECT language.id FROM language JOIN locale ON language.translation_code_id = locale.id WHERE locale.code = :code',
            ['code' => $this->systemDefaultLocaleCode]
        );

        if ($systemLangIdBytes === false) {
            throw new \RuntimeException(sprintf(
                'System default language with locale code "%s" could not be found in the database.',
                $this->systemDefaultLocaleCode
            ));
        }

        $langIdHex = bin2hex($systemLangIdBytes);

        $result = $this->connection->executeQuery("
            SELECT LOWER(HEX(pg.id)) pg_id, pgt.name pg_name, LOWER(HEX(pgo.id)) pgo_id, pgot.name pgo_name
            FROM property_group_option as pgo, property_group_option_translation as pgot, property_group as pg, property_group_translation as pgt
            WHERE (pg.id = pgo.property_group_id)
                AND (pg.id = pgt.property_group_id)
                AND (pgt.language_id = 0x$langIdHex)
                AND (pgo.id = pgot.property_group_option_id)
                AND (pgot.language_id = 0x$langIdHex)
        ")->fetchAllAssociative();

        foreach ($result as $res) {
            if (!isset($this->propertyGroupsOptionsArray[$res['pg_id']])) {
                $this->propertyGroupsOptionsArray[$res['pg_id']] = [
                    'name'    => $res['pg_name'],
                    'options' => [],
                ];
            }
            $this->propertyGroupsOptionsArray[$res['pg_id']]['options'][$res['pgo_id']] = $res['pgo_name'];
        }

        return $this->propertyGroupsOptionsArray;
    }

    public function addOptionPropertyGroupsOptionsArray($groupId, $groupName, $groupOptId, $groupOptVal): void
    {
        if (!isset($this->propertyGroupsOptionsArray[$groupId])) {
            $this->propertyGroupsOptionsArray[$groupId] = [
                'name'    => $groupName,
                'options' => [],
            ];
        }
        $this->propertyGroupsOptionsArray[$groupId]['options'][$groupOptId] = $groupOptVal;
    }


}

================
File: src/Service/EntitiesHelperService.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Service;

use Doctrine\DBAL\Connection;
use Shopware\Core\Content\Media\MediaService;
use Shopware\Core\Content\Product\ProductEntity;
use Shopware\Core\Content\Property\PropertyGroupDefinition;
use Shopware\Core\Defaults;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\Uuid\Uuid;
use Topdata\TopdataFoundationSW6\Util\CliLogger;
use Topdata\TopdataFoundationSW6\Util\UtilUuid;
use Topdata\TopdataFoundationSW6\Service\LocaleHelperService;

/**
 * Note: this is also used by the TopdataTopfinderProSW6 plugin.
 *
 * 04/2024 EntitiesHelper --> EntitiesHelperService
 */
class EntitiesHelperService
{
    private ?array $categoryTree = null;
    private ?array $manufacturers = null;
    private ?string $defaultCmsListingPageId = null;
    private mixed $temp;
    private readonly string $systemDefaultLocaleCode;
    private readonly Context $context;

    public function __construct(
        private readonly Connection          $connection,
        private readonly EntityRepository    $categoryRepository,
        private readonly EntityRepository    $productManufacturerRepository,
        private readonly LocaleHelperService $localeHelperService,
    )
    {
        $this->systemDefaultLocaleCode = $this->localeHelperService->getLocaleCodeOfSystemLanguage();
        $this->context = Context::createDefaultContext();
    }


    private function buildCategorySubTree(?string $categoryId, $categories): array
    {
        $ret = [];
        foreach ($categories as $category) {
            if ($category->getParentId() === $categoryId) {
                $ret[] = [
                    'id'     => $category->getId(),
                    'name'   => $category->getName(),
                    'childs' => $this->buildCategorySubTree($category->getId(), $categories),
                ];
            }
        }

        return $ret;
    }

    protected function loadCategoryTree()
    {
        $categories = $this->categoryRepository->search(new Criteria(), $this->context)->getEntities();
        $this->categoryTree = $this->buildCategorySubTree(null, $categories);
    }

    public function getCategoryTree(): array
    {
        if (null === $this->categoryTree) {
            $this->loadCategoryTree();
        }

        return $this->categoryTree;
    }

    private function findCategoryBranchByParam(string $paramValue, array $categories, string $paramName, bool $inDepth = true): ?array
    {
        foreach ($categories as $cat) {
            if ($cat[$paramName] == $paramValue) {
                return $cat;
            } elseif (count($cat['childs']) && $inDepth) {
                $found = $this->findCategoryBranchByParam($paramValue, $cat['childs'], $paramName, $inDepth);
                if ($found) {
                    return $found;
                }
            }
        }

        return null;
    }

    private function findInBranch(string $name, array $branch): ?array
    {
        foreach ($branch['childs'] as $child) {
            if ($child['name'] == $name) {
                return $child;
            }
        }

        return null;
    }

    private function prepareCategoriesBranchData(array $categoriesChain): ?array
    {
        if (!$categoriesChain) {
            return null;
        }

        $this->temp = Uuid::randomHex();

        $ret = [
            'id'                    => $this->temp,
            'cmsPageId'             => $this->getDefaultCmsListingPageId(),
            'active'                => true,
            'displayNestedProducts' => true,
            'visible'               => true,
            'type'                  => 'page',
            'name'                  => [
                $this->systemDefaultLocaleCode => $categoriesChain[0]['waregroup'],
            ],
        ];

        $child = $this->prepareCategoriesBranchData(array_slice($categoriesChain, 1));

        if ($child) {
            $ret['children'] = [
                $child,
            ];
        }

        return $ret;
    }

    /**
     * @param array $categoriesChain
     * @param string $parentId
     * @return string Id of a last created child category
     */
    private function createBranch(array $categoriesChain, ?string $parentId): string
    {
        $this->temp = Uuid::randomHex();

        $data = [
            'id'                    => $this->temp,
            'cmsPageId'             => $this->getDefaultCmsListingPageId(),
            'active'                => true,
            'displayNestedProducts' => true,
            'visible'               => true,
            'type'                  => 'page',
            'name'                  => [
                $this->systemDefaultLocaleCode => $categoriesChain[0]['waregroup'],
            ],
        ];
        if ($parentId) {
            $data['parentId'] = $parentId;
        }

        $child = $this->prepareCategoriesBranchData(array_slice($categoriesChain, 1));

        if ($child) {
            $data['children'] = [
                $child,
            ];
        }

        $this->categoryRepository->create([$data], $this->context);

        return $this->temp;
    }

    public function getCategoryId(array $categoriesChain, string $parentCategoryId): string
    {
        if (null === $this->categoryTree) {
            $this->loadCategoryTree();
        }

        if ($parentCategoryId) {
            $branch = $this->findCategoryBranchByParam($parentCategoryId, $this->categoryTree, 'id');
            if (!$branch) {
                return '';
            }
            $parentId = $parentCategoryId;
            foreach ($categoriesChain as $key => $category) {
                $temp = $this->findInBranch($category['waregroup'], $branch);
                if ($temp) {
                    $branch = $temp;
                    $parentId = $temp['id'];
                } else {
                    $parentId = $this->createBranch(array_slice($categoriesChain, $key), $parentId);
                    $this->loadCategoryTree();
                    break;
                }
            }

            return $parentId;
        } else {
            $branch = $this->findCategoryBranchByParam($categoriesChain[0]['waregroup'], $this->categoryTree, 'name', false);
            if (!$branch) {
                $parentId = $this->createBranch($categoriesChain, null);
                $this->loadCategoryTree();

                return $parentId;
            }

            $parentId = $branch['id'];
            foreach ($categoriesChain as $key => $category) {
                if ($key == 0) {
                    continue;
                }
                $temp = $this->findInBranch($category['waregroup'], $branch);
                if ($temp) {
                    $branch = $temp;
                    $parentId = $temp['id'];
                } else {
                    $parentId = $this->createBranch(array_slice($categoriesChain, $key), $parentId);
                    $this->loadCategoryTree();
                    break;
                }
            }

            return $parentId;
        }
    }

    public function getDefaultCmsListingPageId(): string
    {
        if (null !== $this->defaultCmsListingPageId) {
            return $this->defaultCmsListingPageId;
        }
        /*
        $result = $this->connection->fetchColumn('
                SELECT id
                FROM cms_page
                WHERE locked = :locked
                AND type = :type
            ',['locked' => '1','type' => 'product_list']
        );
*/

        $result = $this->connection->executeQuery('
                SELECT id
                FROM cms_page
                WHERE locked = 1
                AND type = "product_list"
            ')->fetchOne();

        if ($result === false) {
            throw new \RuntimeException('Default Cms Listing page not found');
        }

        $this->defaultCmsListingPageId = Uuid::fromBytesToHex((string)$result);

        return $this->defaultCmsListingPageId;
    }



    protected function loadManufacturers(): void
    {
        $manufacturers = $this->productManufacturerRepository->search(new Criteria(), $this->context)->getEntities();
        $ret = [];
        foreach ($manufacturers as $manufacturer) {
            $ret[$manufacturer->getName()] = $manufacturer->getId();
        }
        $this->manufacturers = $ret;
    }

    public function getManufacturerId(string $manufacturerName): string
    {
        if ($this->manufacturers === null) {
            $this->loadManufacturers();
        }

        if (isset($this->manufacturers[$manufacturerName])) {
            $manufacturerId = $this->manufacturers[$manufacturerName];
        } else {
            $manufacturerId = Uuid::randomHex();
            $this->productManufacturerRepository->create([
                [
                    'id'   => $manufacturerId,
                    'name' => [
                        $this->systemDefaultLocaleCode => $manufacturerName,
                    ],
                ],
            ], $this->context);
            $this->manufacturers[$manufacturerName] = $manufacturerId;
        }

        return $manufacturerId;
    }

//    /**
//     * Returns product ids which are compatible with same devices
//     * [['a_id'=>hexid, 'a_version_id'=>hexversionid], ...].
//     * 06/2025 unused
//     */
//    public function getAlternateProductIds(ProductEntity $product): array
//    {
//        $result = $this->connection->executeQuery('
//SELECT DISTINCT LOWER(HEX(a.id)) a_id, LOWER(HEX(a.version_id)) a_version_id
// FROM product a,
//      topdata_device_to_product tdp,
//      topdata_device_to_product tda
// WHERE (0x' . $product->getId() . ' = tdp.product_id) AND (0x' . $product->getVersionId() . ' = tdp.product_version_id)
//   AND  (a.id = tda.product_id) AND (a.version_id = tda.product_version_id)
//   AND  (tdp.device_id = tda.device_id)
//   AND  (0x' . $product->getId() . ' != a.id)
//            ')->fetchAllAssociative();
//
//        return $result;
//
//        /*
//         #all alternates:
//         SELECT DISTINCT p.product_number, a.product_number
// FROM product p,
//      product a,
//      topdata_device_to_product tdp,
//      topdata_device_to_product tda
// WHERE (p.id = tdp.product_id) AND (p.version_id = tdp.product_version_id)
//   AND  (a.id = tda.product_id) AND (a.version_id = tda.product_version_id)
//   AND  (tdp.device_id = tda.device_id)
//   AND  (p.id != a.id)
//         */
//    }


//    public function getPropertyGroupsOptionsArray(): array
//    {
//        if (is_array($this->propertyGroupsOptionsArray)) {
//            return $this->propertyGroupsOptionsArray;
//        }
//
//        $this->propertyGroupsOptionsArray = [];
//
//        // --- THE FIX ---
//        // Step 1: Reliably get the binary ID of the system's default language.
//        // This ensures we are reading the same language that we are writing.
//        $systemLangIdBytes = $this->connection->fetchOne(
//            'SELECT language.id
//             FROM language
//             JOIN locale ON language.translation_code_id = locale.id
//             WHERE locale.code = :code',
//            ['code' => $this->systemDefaultLocaleCode]
//        );
//
//        if ($systemLangIdBytes === false) {
//            // Fallback or throw an error if the system language can't be found.
//            // This protects against a misconfigured system.
//            throw new \RuntimeException(sprintf(
//                'System default language with locale code "%s" could not be found in the database.',
//                $this->systemDefaultLocaleCode
//            ));
//        }
//
//        // Step 2: Convert the binary ID to its hex representation for the SQL query.
//        $langIdHex = bin2hex($systemLangIdBytes);
//        // --- END FIX ---
//
//
//        // Step 3: Use the dynamically determined language ID in the query.
//        $result = $this->connection->executeQuery("
//            SELECT LOWER(HEX(pg.id)) pg_id, pgt.name pg_name, LOWER(HEX(pgo.id)) pgo_id, pgot.name pgo_name
//            FROM property_group_option as pgo,
//                 property_group_option_translation as pgot,
//                 property_group as pg,
//                 property_group_translation as pgt
//            WHERE (pg.id = pgo.property_group_id)
//                AND (pg.id = pgt.property_group_id)
//                AND (pgt.language_id = 0x$langIdHex) -- <-- Using the correct language ID
//                AND (pgo.id = pgot.property_group_option_id)
//                AND (pgot.language_id = 0x$langIdHex) -- <-- Using the correct language ID
//        ")->fetchAllAssociative();
//
//        foreach ($result as $res) {
//            if (!isset($this->propertyGroupsOptionsArray[$res['pg_id']])) {
//                $this->propertyGroupsOptionsArray[$res['pg_id']] = [
//                    'name'    => $res['pg_name'],
//                    'options' => [],
//                ];
//            }
//            $this->propertyGroupsOptionsArray[$res['pg_id']]['options'][$res['pgo_id']] = $res['pgo_name'];
//        }
//
//        return $this->propertyGroupsOptionsArray;
//    }



//    /**
//     * 06/2025 unused
//     */
//    public function productAlternatesCount(string $productId)
//    {
//        if (!UtilUuid::isValidUuid($productId)) {
//            return 0;
//        }
//        /*
//        return $this->connection->executeQuery('
//SELECT COUNT(*) as cnt
// FROM topdata_product_to_alternate
// WHERE 0x'.$productId.' = product_id
//     LIMIT 1
//            ')->fetchColumn();
//*/
//
//        return $this->connection->executeQuery('SELECT COUNT(*) as cnt FROM topdata_product_to_alternate WHERE 0x' . $productId . ' = product_id LIMIT 1')->fetchOne();
//    }

//    public function getDeviceSynonymsIds(string $deviceId): array
//    {
//        $xids = [];
//
//        if (!UtilUuid::isValidUuid($deviceId)) {
//            return $xids;
//        }
//
//        $deviceIds = $this->connection->executeQuery('
//SELECT LOWER(HEX(synonym_id)) as id
// FROM topdata_device_to_synonym
// WHERE 0x' . $deviceId . ' = device_id
//            ')->fetchAllAssociative();
//        foreach ($deviceIds as $id) {
//            $xids[] = $id['id'];
//        }
//
//        return $xids;
//    }
}

================
File: src/Service/ImportService.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6\Service;

use Topdata\TopdataConnectorSW6\DTO\ImportConfig;
use Topdata\TopdataConnectorSW6\Exception\MissingPluginConfigurationException;
use Topdata\TopdataConnectorSW6\Exception\TopdataConnectorPluginInactiveException;
use Topdata\TopdataConnectorSW6\Service\Cache\MappingCacheService;
use Topdata\TopdataConnectorSW6\Service\Checks\ConfigCheckerService;
use Topdata\TopdataConnectorSW6\Service\Config\MergedPluginConfigHelperService;
use Topdata\TopdataConnectorSW6\Service\Config\ProductImportSettingsService;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataDeviceSynonymsService;
use Topdata\TopdataConnectorSW6\Service\Import\DeviceImportService;
use Topdata\TopdataConnectorSW6\Service\Import\DeviceMediaImportService;
use Topdata\TopdataConnectorSW6\Service\Import\MappingHelperService;
use Topdata\TopdataConnectorSW6\Service\Import\ProductMappingService;
use Topdata\TopdataConnectorSW6\Service\Linking\ProductDeviceRelationshipServiceV1;
use Topdata\TopdataConnectorSW6\Service\Linking\ProductDeviceRelationshipServiceV2;
use Topdata\TopdataConnectorSW6\Util\ImportReport;
use Topdata\TopdataConnectorSW6\Util\UtilProfiling;
use Topdata\TopdataFoundationSW6\Service\PluginHelperService;
use Topdata\TopdataFoundationSW6\Util\CliLogger;
use Symfony\Component\Console\Helper\Table;

/**
 * Service class responsible for handling the import operations.
 * This class orchestrates the import process, coordinating various helper services
 * to map products, import device information, and link products to devices.
 * It also handles loading device media, setting device synonyms, and updating product information.
 */
class ImportService
{
    public function __construct(
        private readonly MappingHelperService               $mappingHelperService,
        private readonly ConfigCheckerService               $configCheckerService,
        private readonly MergedPluginConfigHelperService    $mergedPluginConfigHelperService,
        private readonly PluginHelperService                $pluginHelperService,
        private readonly ProductMappingService              $productMappingService,
        private readonly TopdataDeviceSynonymsService       $deviceSynonymsService,
        private readonly ProductInformationServiceV1Slow    $productInformationServiceV1Slow,
        private readonly ProductInformationServiceV2        $productInformationServiceV2,
        private readonly ProductDeviceRelationshipServiceV1 $productDeviceRelationshipServiceV1,
        private readonly ProductDeviceRelationshipServiceV2 $productDeviceRelationshipServiceV2,
        private readonly DeviceImportService                $deviceImportService,
        private readonly DeviceMediaImportService           $deviceMediaImportService, // Added for refactoring
        private readonly ProductImportSettingsService       $productImportSettingsService,
        private readonly MappingCacheService                $mappingCacheService, // Added for cache integration
    )
    {
    }


    /**
     * ==== MAIN ====
     *
     * Executes the import process based on the provided CLI options.
     *
     * This method serves as the main entry point for the import operation.
     * It checks plugin status, configuration, and then dispatches to specific
     * import operations based on the provided CLI options.
     *
     * @param ImportConfig $importConfig The DTO containing the CLI options.
     * @return int The error code indicating the success or failure of the import process.
     * @throws MissingPluginConfigurationException
     */
    public function execute(ImportConfig $importConfig): void
    {
        CliLogger::writeln('Starting work...');

        // ---- Check if plugin is active (can this ever happen? as this code is part of the plugin .. TODO?: remove this check)
        if (!$this->pluginHelperService->isWebserviceConnectorPluginAvailable()) {
            throw new TopdataConnectorPluginInactiveException("The TopdataConnectorSW6 plugin is inactive!");
        }

        // ---- Check if plugin is configured
        if ($this->configCheckerService->isConfigEmpty()) {
            throw new MissingPluginConfigurationException();
        }

        CliLogger::getCliStyle()->dumpDict($importConfig->toDict(), 'ImportConfig');

        // ---- Init webservice client
        $this->mergedPluginConfigHelperService->init();

        // ---- Log category overrides
        $this->productImportSettingsService->logCategoryOverrides();

        // ---- Handle cache purging if requested
        $this->handleCachePurging($importConfig);

        // ---- Execute import operations based on options
        $this->executeImportOperations($importConfig);

        // ---- Dump report
        ImportReport::dumpImportReportToCli();

        // ---- Dump profiling
        UtilProfiling::dumpProfilingToCli();
    }


    /**
     * Executes the import operations based on the provided CLI options.
     *
     * This method determines which import operations to execute based on the
     * options provided in the ImportCommandImportConfig. It calls the relevant
     * helper methods to perform the import operations.
     *
     * @param ImportConfig $importConfig The DTO containing the CLI options.
     */
    private function executeImportOperations(ImportConfig $importConfig): void
    {
        // ---- Product Mapping
        if ($importConfig->getOptionAll() || $importConfig->getOptionMapping()) {
            CliLogger::getCliStyle()->blue('--all || --mapping');
            CliLogger::section('Mapping Products');
            $this->productMappingService->mapProducts($importConfig);
        }

        // ---- Device operations
        $this->_handleDeviceOperations($importConfig);

        // ---- Product operations
        $this->_handleProductOperations($importConfig);
    }

    /**
     * Handles device-related import operations.
     *
     * This method imports brands, series, device types and devices.
     *
     * @param ImportConfig $importConfig The DTO containing the CLI options.
     */
    private function _handleDeviceOperations(ImportConfig $importConfig): void
    {
        // ---- Import all device related data
        if ($importConfig->getOptionAll() || $importConfig->getOptionDevice()) {
            CliLogger::getCliStyle()->blue('--all || --device');
            $this->mappingHelperService->setBrands();
            $this->deviceImportService->setSeries();
            $this->deviceImportService->setDeviceTypes();
            $this->deviceImportService->setDevices();
        } elseif ($importConfig->getOptionDeviceOnly()) {
            // ---- Import only devices (TODO: remove this option)
            CliLogger::getCliStyle()->blue('--device-only');
            $this->deviceImportService->setDevices();
        }
    }

    /**
     * TODO: remove the return of an error code, just throw a exceptions
     * Handles product-related import operations.
     *
     * This method manages the import of product-related data, including linking products to devices,
     * loading device media, handling product information, and setting device synonyms.
     *
     * @param ImportConfig $importConfig The DTO containing the CLI options.
     */
    private function _handleProductOperations(ImportConfig $importConfig): void
    {
        // ---- Product to device linking
        if ($importConfig->getOptionAll() || $importConfig->getOptionProductDevice()) {
            CliLogger::getCliStyle()->blue('--all || --product-device');
            if ($importConfig->getOptionExperimentalV2()) {
                CliLogger::getCliStyle()->caution('Using experimental V2 device linking logic!');
                $this->productDeviceRelationshipServiceV2->syncDeviceProductRelationshipsV2();
            } else {
                // Keep the original call as the default
                $this->productDeviceRelationshipServiceV1->syncDeviceProductRelationshipsV1();
            }
        }

        // ---- Device media
        if ($importConfig->getOptionAll() || $importConfig->getOptionDeviceMedia()) {
            CliLogger::getCliStyle()->blue('--all || --device-media');
            $this->deviceMediaImportService->setDeviceMedia(); // Use the new dedicated service
        }

        // ---- Product information
        if ($importConfig->getOptionAll() ||
            $importConfig->getOptionProductInformation() ||
            $importConfig->getOptionProductMediaOnly()) {
            $this->_handleProductInformation($importConfig);
        }

        // ---- Device synonyms
        if ($importConfig->getOptionAll() || $importConfig->getOptionDeviceSynonyms()) {
            CliLogger::getCliStyle()->blue('--all || --device-synonyms');
            $this->deviceSynonymsService->setDeviceSynonyms();
        }

        // ---- Product variations
        $this->_handleProductVariations($importConfig);
    }

    /**
     * Handles product information import operations.
     *
     * This method imports or updates product information based on the provided CLI options.
     * It checks if the TopFeed plugin is available and then uses the ProductInformationServiceV1Slow
     * to set the product information.
     *
     * @param ImportConfig $importConfig The DTO containing the CLI options.
     */
    private function _handleProductInformation(ImportConfig $importConfig): void
    {
        // ---- Determine if product-related operation should be processed based on CLI options.
        if (
            !$importConfig->getOptionAll() &&
            !$importConfig->getOptionProductInformation() &&
            !$importConfig->getOptionProductMediaOnly()
        ) {
            return;
        }

        // ---- Check if TopFeed plugin is available
        if (!$this->pluginHelperService->isTopFeedPluginAvailable()) {
            CliLogger::writeln('You need TopFeed plugin to update product information!');

            return;
        }

        // ---- Load product information or update media
        if ($importConfig->getOptionExperimentalV2()) {
            $this->productInformationServiceV2->setProductInformationV2();
        } else {
            $this->productInformationServiceV1Slow->setProductInformationV1Slow($importConfig->getOptionProductMediaOnly());
        }
    }


    /**
     * Handles product variations import operations.
     *
     * This method creates product variations based on color and capacity, if the TopFeed plugin is available.
     *
     * @param ImportConfig $importConfig The DTO containing the CLI options.
     */
    private function _handleProductVariations(ImportConfig $importConfig): void
    {
        // ---- Check if product variations should be created
        if ($importConfig->getOptionProductVariations()) {
            // ---- Check if TopFeed plugin is available
            if ($this->pluginHelperService->isTopFeedPluginAvailable()) {
                // ---- Create product variations
                $this->mappingHelperService->setProductColorCapacityVariants();
            } else {
                CliLogger::warning('You need TopFeed plugin to create variated products!');
            }
        }
    }

    /**
     * Handles cache purging if requested via the --purge-cache option.
     *
     * @param ImportConfig $importConfig The DTO containing the CLI options.
     */
    private function handleCachePurging(ImportConfig $importConfig): void
    {
        if ($importConfig->getOptionPurgeCache()) {
            CliLogger::getCliStyle()->blue('--purge-cache');
            CliLogger::section('Purging Mapping Cache');

            // Purge the cache
            $this->mappingCacheService->purgeMappingsCache();

            CliLogger::success('Mapping cache purged successfully.');
        }
    }

}

================
File: src/Service/MediaHelperService.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service;

use Doctrine\DBAL\Connection;
use Shopware\Core\Content\Media\MediaService;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\Uuid\Uuid;
use Topdata\TopdataConnectorSW6\Constants\MergedPluginConfigKeyConstants;
use Topdata\TopdataConnectorSW6\Service\Config\ProductImportSettingsService;
use Topdata\TopdataConnectorSW6\Util\ImportReport;
use Topdata\TopdataFoundationSW6\Util\CliLogger;

/**
 * 11/2024 created (extracted from EntitiesHelperService)
 */
class MediaHelperService
{
    const DEFAULT_MAIN_FOLDER = 'product';
    const UPLOAD_FOLDER_NAME  = 'TopData';


    private Context $context;
    private ?string $uploadFolderId = null;


    public function __construct(
        private readonly EntityRepository             $mediaRepository,
        private readonly EntityRepository             $mediaFolderRepository,
        private readonly MediaService                 $mediaService,
        private readonly ProductImportSettingsService $productImportSettingsService,
        private readonly Connection                   $connection,
    )
    {
        $this->context = Context::createDefaultContext();
    }


    /**
     * Retrieves the media ID for a given image path. If the media does not exist, it creates a new media entry.
     *
     * @param string $imagePath The path to the image file.
     * @param int $imageTimestamp The timestamp to append to the image name. Default is 0.
     * @param string $imagePrefix The prefix to prepend to the image name. Default is an empty string.
     * @param string $echoDownload The message to echo if the image needs to be downloaded. Default is an empty string.
     * @return string The media ID of the image.
     */
    public function getMediaId(string $imagePath, int $imageTimestamp = 0, string $imagePrefix = '', $echoDownload = ''): string
    {
        // Generate the image name using the provided path, timestamp, and prefix.
        $imageName = $imagePrefix . $this->generateMediaName($imagePath, $imageTimestamp);

        // Search for existing media with the generated image name.
        $existingMedia = $this->mediaRepository
            ->search(
                (new Criteria())
                    ->addFilter(new EqualsFilter('fileName', $imageName))
                    ->setLimit(1),
                $this->context
            )
            ->getEntities()
            ->first();

        // If the media exists, return its ID.
        if ($existingMedia) {
            $mediaId = $existingMedia->getId();
        } else {
            // If the media does not exist, echo the download message.
            echo $echoDownload;

            // Read the file content from the provided image path.
            $fileContent = file_get_contents($imagePath);

            // If the file content could not be read, return an empty string.
            if ($fileContent === false) {
                return '';
            }

            // Create a new media entry in the upload folder.
            $mediaId = $this->createMediaInFolder();

            // Save the file content as a new media entry and get its ID.
            $mediaId = $this->mediaService->saveFile(
                $fileContent,
                'jpg',
                'image/jpeg',
                $imageName,
                $this->context,
                null,
                $mediaId,
                false
            );
        }

        // Return the media ID.
        return $mediaId;
    }

    /**
     * Generates a media name based on the provided file path and timestamp.
     *
     * This method extracts the file name from the given path and appends the provided timestamp to it.
     *
     * @param string $path The path to the file.
     * @param int $timestamp The timestamp to append to the file name.
     * @return string The generated media name.
     */
    private function generateMediaName(string $path, int $timestamp): string
    {
        $fileName = pathinfo($path, PATHINFO_FILENAME) . '-' . $timestamp;

        return $fileName;
    }

    /**
     * Creates a new media entry in the upload folder.
     *
     * This method first checks if the upload folder ID is set. If not, it creates the upload folder.
     * Then, it generates a new media ID and creates a new media entry in the repository with the upload folder ID.
     *
     * @return string The newly created media ID.
     */
    private function createMediaInFolder(): string
    {
        if (!$this->uploadFolderId) {
            $this->createUploadFolder();
        }

        $mediaId = Uuid::randomHex();
        $this->mediaRepository->create(
            [
                [
                    'id'            => $mediaId,
                    'private'       => false,
                    'mediaFolderId' => $this->uploadFolderId,
                ],
            ],
            $this->context
        );

        return $mediaId;
    }

    /**
     * Creates the upload folder if it does not exist and sets the upload folder ID.
     *
     * This method first searches for the default folder and then checks if the upload folder
     * exists within the default folder. If the upload folder does not exist, it creates a new one.
     *
     * @return void
     */
    private function createUploadFolder(): void
    {
        $criteria = new Criteria();
        $criteria->addFilter(new EqualsFilter('media_folder.defaultFolder.entity', self::DEFAULT_MAIN_FOLDER));
        $criteria->addAssociation('defaultFolder');
        $criteria->setLimit(1);
        $defaultFolder = $this->mediaFolderRepository->search($criteria, $this->context)->first();

        $criteria = new Criteria();
        $criteria->addFilter(new EqualsFilter('name', self::UPLOAD_FOLDER_NAME));
        $criteria->addFilter(new EqualsFilter('parentId', $defaultFolder->getId()));
        $criteria->setLimit(1);
        $uploadFolder = $this
            ->mediaFolderRepository
            ->search(
                $criteria,
                $this->context
            )
            ->first();

        if ($uploadFolder) {
            $this->uploadFolderId = $uploadFolder->getId();

            return;
        }

        $this->uploadFolderId = Uuid::randomHex();
        $this->mediaFolderRepository->create(
            [
                [
                    'id'              => $this->uploadFolderId,
                    'private'         => false,
                    'name'            => self::UPLOAD_FOLDER_NAME,
                    'parentId'        => $defaultFolder->getId(),
                    'configurationId' => $defaultFolder->getConfigurationId(),
                ],
            ],
            $this->context
        );
    }


    /**
     * Unlinks images from products.
     * 05/2025 moved from ProductInformationServiceV1Slow::_unlinkImages() to MediaHelperService::unlinkImages()
     *
     * @param array $productIds Array of product IDs to unlink images from.
     */
    public function unlinkImages(array $productIds): void
    {
        if (!count($productIds)) {
            return;
        }

        $ids = '0x' . implode(',0x', $productIds);
        $this->connection->executeStatement("UPDATE product SET product_media_id = NULL, product_media_version_id = NULL WHERE id IN ($ids)");
        $this->connection->executeStatement("DELETE FROM product_media WHERE product_id IN ($ids)");
    }


    /**
     * 05/2025 extracted from ProductInformationServiceV1Slow::setProductInformationV1Slow()
     */
    public function deleteDuplicateMedia(array $productDataDeleteDuplicateMedia): void
    {
        $chunks = array_chunk($productDataDeleteDuplicateMedia, 100);
        foreach ($chunks as $chunk) {
            $productIds = [];
            $mediaIds = [];
            $pmIds = [];
            foreach ($chunk as $el) {
                $productIds[] = $el['productId'];
                $mediaIds[] = $el['mediaId'];
                $pmIds[] = $el['id'];
            }
            $productIds = '0x' . implode(', 0x', $productIds);
            $mediaIds = '0x' . implode(', 0x', $mediaIds);
            $pmIds = '0x' . implode(', 0x', $pmIds);

            $numDeleted = $this->connection->executeStatement("
                    DELETE FROM product_media 
                    WHERE (product_id IN ($productIds)) 
                        AND (media_id IN ($mediaIds)) 
                        AND(id NOT IN ($pmIds))
            ");

            ImportReport::incCounter('Deleted Duplicate Media', $numDeleted);

            CliLogger::activity();
        }
    }


}

================
File: src/Service/ProductInformationServiceV1Slow.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service;

use Doctrine\DBAL\Connection;
use Exception;
use Psr\Log\LoggerInterface;
use Shopware\Core\Content\Product\ProductEntity;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\Uuid\Uuid;
use Topdata\TopdataConnectorSW6\Constants\DescriptionImportTypeConstant;
use Topdata\TopdataConnectorSW6\Constants\GlobalPluginConstants;
use Topdata\TopdataConnectorSW6\Constants\MergedPluginConfigKeyConstants;
use Topdata\TopdataConnectorSW6\Constants\WebserviceFilterTypeConstants;
use Topdata\TopdataConnectorSW6\Exception\WebserviceResponseException;
use Topdata\TopdataConnectorSW6\Service\Config\MergedPluginConfigHelperService;
use Topdata\TopdataConnectorSW6\Service\Config\ProductImportSettingsService;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataToProductService;
use Topdata\TopdataConnectorSW6\Service\Linking\ProductProductRelationshipServiceV1;
use Topdata\TopdataConnectorSW6\Service\Shopware\ShopwareProductPropertyService;
use Topdata\TopdataConnectorSW6\Service\Shopware\ShopwarePropertyService;
use Topdata\TopdataConnectorSW6\Util\UtilProfiling;
use Topdata\TopdataConnectorSW6\Util\UtilStringFormatting;
use Topdata\TopdataFoundationSW6\Service\ManufacturerService;
use Topdata\TopdataFoundationSW6\Util\CliLogger;
use Topdata\TopdataFoundationSW6\Util\UtilString;

/**
 * Service for updating product specifications and media.
 *
 * This service handles the retrieval and updating of product specifications and media
 * from an external source, integrating them into the Shopware 6 system. It includes
 * functionalities for fetching data, processing images, and linking related products.
 */
class ProductInformationServiceV1Slow
{
    const CHUNK_SIZE                 = 50;
    const BATCH_SIZE_UPDATE_PRODUCTS = 10;

    private Context $context;


    public function __construct(
        private readonly TopdataToProductService             $topdataToProductHelperService,
        private readonly MergedPluginConfigHelperService     $mergedPluginConfigHelperService,
        private readonly ProductProductRelationshipServiceV1 $productProductRelationshipServiceV1,
        private readonly EntityRepository                    $productRepository,
        private readonly TopdataWebserviceClient             $topdataWebserviceClient,
        private readonly ProductImportSettingsService        $productImportSettingsService,
        private readonly EntitiesHelperService               $entitiesHelperService,
        private readonly MediaHelperService                  $mediaHelperService,
        private readonly LoggerInterface                     $logger,
        private readonly ManufacturerService                 $manufacturerService,
        private readonly Connection                          $connection,
        private readonly ShopwareProductPropertyService      $shopwareProductPropertyService,
        private readonly ShopwarePropertyService             $shopwarePropertyService,
    )
    {
        $this->context = Context::createDefaultContext();
    }


    /**
     * 04/2025 TODO: this method is way too slow .. optimize it
     * Updates product information and media.
     *
     * Fetches product data from a remote server, processes it, and updates the local database.
     * It handles both product information and media updates based on the $onlyMedia flag.
     *
     * @param bool $onlyMedia If true, only media information is updated; otherwise, all product information is updated.
     * @throws Exception If there is an error fetching data from the remote server.
     */
    public function setProductInformationV1Slow(bool $onlyMedia): void
    {
        UtilProfiling::startTimer();

        if ($onlyMedia) {
            CliLogger::section('Product media (--product-media-only)');
        } else {
            CliLogger::section('Product information');
        }

        // ---- Fetch the topid products
        $topid_products = $this->topdataToProductHelperService->getTopdataProductMappings(true);
        $productDataUpdate = [];
        $productDataUpdateCovers = [];
        $productDataDeleteDuplicateMedia = [];

        // ---- Split the topid products into chunks
        $batches = array_chunk(array_keys($topid_products), self::CHUNK_SIZE);
        CliLogger::lap(true);

        foreach ($batches as $idxBatch => $batch) {
            CliLogger::progressBar(($idxBatch + 1), count($batches), 'Fetching data from remote server [Product Information]...');

            // ---- Fetch product data from the webservice
            $response = $this->topdataWebserviceClient->myProductList([
                'products' => implode(',', $batch),
                'filter'   => WebserviceFilterTypeConstants::all,
            ]);

            if (!isset($response->page->available_pages)) {
                throw new WebserviceResponseException($response->error[0]->error_message . 'webservice response has no pages');
            }
            CliLogger::activity('Processing data...');

            $temp = array_slice($topid_products, $idxBatch * self::CHUNK_SIZE, self::CHUNK_SIZE);
            $currentChunkProductIds = [];
            foreach ($temp as $p) {
                $currentChunkProductIds[] = $p[0]['product_id']; // FIXME? isnt this the same as $batch?
            }

            // ---- Load product import settings for the current chunk of products
            $this->productImportSettingsService->loadProductImportSettings($currentChunkProductIds);

            // ---- Unlink products, properties, categories and images before re-linking
            if (!$onlyMedia) {
                $this->productProductRelationshipServiceV1->unlinkProducts($currentChunkProductIds);
                $this->_unlinkProperties($currentChunkProductIds);
                $this->_unlinkCategories($currentChunkProductIds);
            }
            $this->_unlinkImages($currentChunkProductIds);

            $productsForLinking = [];

            // ---- Process products
            foreach ($response->products as $product) {
                if (!isset($topid_products[$product->products_id])) {
                    continue;
                }

                // ---- Prepare product data for update
                $productData = $this->_prepareProduct($topid_products[$product->products_id][0], $product, $onlyMedia);
                if ($productData) {
                    $productDataUpdate[] = $productData;

                    if (isset($productData['media'][0]['id'])) {
                        $productDataUpdateCovers[] = [
                            'id'      => $productData['id'],
                            'coverId' => $productData['media'][0]['id'],
                        ];
                        foreach ($productData['media'] as $tempMedia) {
                            $productDataDeleteDuplicateMedia[] = [
                                'productId' => $productData['id'],
                                'mediaId'   => $tempMedia['mediaId'],
                                'id'        => $tempMedia['id'],
                            ];
                        }
                    }
                }

                // ---- Update product data in chunks
                if (count($productDataUpdate) > self::BATCH_SIZE_UPDATE_PRODUCTS) {
                    $this->productRepository->update($productDataUpdate, $this->context);
                    $productDataUpdate = [];
                    CliLogger::activity();

                    if (count($productDataUpdateCovers)) {
                        $this->productRepository->update($productDataUpdateCovers, $this->context);
                        CliLogger::activity();
                        $productDataUpdateCovers = [];
                    }
                }

                // ---- Collect products for bulk linking
                if (!$onlyMedia) {
                    $productsForLinking[] = [
                        'productId_versionId' => $topid_products[$product->products_id][0],
                        'remoteProductData'   => $product,
                    ];
                }
            }

            // ---- Link products in bulk for the entire batch
            if (!$onlyMedia && !empty($productsForLinking)) {
                $this->productProductRelationshipServiceV1->linkMultipleProducts($productsForLinking);
            }

            CliLogger::mem();
            CliLogger::activity(' ' . CliLogger::lap() . "sec\n");
        }

        // ---- Update remaining product data
        if (count($productDataUpdate)) {
            CliLogger::activity('Updating last ' . count($productDataUpdate) . ' products...');
            $this->productRepository->update($productDataUpdate, $this->context);
            CliLogger::mem();
            CliLogger::activity(' ' . CliLogger::lap() . "sec\n");
        }

        // ---- Update remaining product covers
        if (count($productDataUpdateCovers)) {
            CliLogger::activity("\nUpdating last product covers...");
            $this->productRepository->update($productDataUpdateCovers, $this->context);
            CliLogger::activity(' ' . CliLogger::lap() . "sec\n");
        }

        // ---- Delete duplicate media
        if (count($productDataDeleteDuplicateMedia)) {
            CliLogger::activity("\nDeleting product media duplicates...");
            $this->mediaHelperService->deleteDuplicateMedia($productDataDeleteDuplicateMedia);
            CliLogger::mem();
            CliLogger::activity(' ' . CliLogger::lap() . "sec\n");
        }

        CliLogger::writeln("\nProduct information done!");

        UtilProfiling::stopTimer();
    }


    /**
     * Unlinks categories from products.
     *
     * @param array $productIds Array of product IDs to unlink categories from.
     */
    private function _unlinkCategories(array $productIds): void
    {
        if (!count($productIds)
            || !$this->mergedPluginConfigHelperService->getOption(MergedPluginConfigKeyConstants::PRODUCT_WAREGROUPS)
            || !$this->mergedPluginConfigHelperService->getOption(MergedPluginConfigKeyConstants::PRODUCT_WAREGROUPS_DELETE)) {
            return;
        }

        $idsString = '0x' . implode(',0x', $productIds);
        $this->connection->executeStatement("DELETE FROM product_category WHERE product_id IN ($idsString)");
        $this->connection->executeStatement("DELETE FROM product_category_tree WHERE product_id IN ($idsString)");
        $this->connection->executeStatement("UPDATE product SET category_tree = NULL WHERE id IN ($idsString)");
    }

    /**
     * Prepares product data for update.
     *
     * @param array $productId_versionId Array containing the product ID and version ID.
     * @param object $remoteProductData Remote product data object.
     * @param bool $onlyMedia If true, only media information is prepared; otherwise, all product information is prepared.
     * @return array Prepared product data array.
     */
    private function _prepareProduct(array $productId_versionId, $remoteProductData, $onlyMedia = false): array
    {
        $productData = [];
        $productId = $productId_versionId['product_id'];

        // ---- Prepare product name
        if (!$onlyMedia && $this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productName, $productId) && $remoteProductData->short_description != '') {
            $productData['name'] = UtilString::max255($remoteProductData->short_description);
        } else {
// dd("dsfsdfsdfsdf", $onlyMedia, $this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productName, $productId), $remoteProductData->short_description);
        }

        // ---- Prepare product description
        $descriptionImportType = $this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productDescription, $productId);
        if (!$onlyMedia && $descriptionImportType && ($descriptionImportType !== DescriptionImportTypeConstant::NO_IMPORT) && $remoteProductData->short_description != '') {
            $newDescription = $this->_renderDescription($descriptionImportType, $productId, $remoteProductData->short_description);
            if ($newDescription !== null) {
                $productData['description'] = $newDescription;
            }
        }

        // ---- Prepare product manufacturer
        if (!$onlyMedia && $this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productBrand, $productId) && $remoteProductData->manufacturer != '') {
            $productData['manufacturerId'] = $this->manufacturerService->getManufacturerIdByName($remoteProductData->manufacturer); // fixme
        }
        // ---- Prepare product EAN
        if (!$onlyMedia && $this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productEan, $productId) && count($remoteProductData->eans)) {
            $productData['ean'] = $remoteProductData->eans[0];
        }
        // ---- Prepare product OEM
        if (!$onlyMedia && $this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productOem, $productId) && count($remoteProductData->oems)) {
            $productData['manufacturerNumber'] = $remoteProductData->oems[0];
        }

        // ---- Prepare product images
        if ($this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productImages, $productId)) {
            if (isset($remoteProductData->images) && count($remoteProductData->images)) {
                $media = [];
                foreach ($remoteProductData->images as $k => $img) {
                    if (isset($img->big->url)) {
                        $imageUrl = $img->big->url;
                    } elseif (isset($img->normal->url)) {
                        $imageUrl = $img->normal->url;
                    } elseif (isset($img->thumb->url)) {
                        $imageUrl = $img->thumb->url;
                    } else {
                        continue;
                    }

                    if (isset($img->date)) {
                        $imageDate = strtotime(explode(' ', $img->date)[0]);
                    } else {
                        $imageDate = strtotime('2017-01-01');
                    }

                    try {
                        $echoMediaDownload = 'd';
                        $mediaId = $this->mediaHelperService->getMediaId(
                            $imageUrl,
                            $imageDate,
                            $k . '-' . $remoteProductData->products_id . '-',
                            $echoMediaDownload
                        );
                        if ($mediaId) {
                            $media[] = [
                                'id'       => Uuid::randomHex(), // $mediaId,
                                'position' => $k + 1,
                                'mediaId'  => $mediaId,
                            ];
                        }
                    } catch (Exception $e) {
                        $this->logger->error($e->getMessage());
                        CliLogger::writeln('Exception: ' . $e->getMessage());
                    }
                }
                if (count($media)) {
                    $productData['media'] = $media;
                    //                    $productData['coverId'] = $media[0]['id'];
                }
                CliLogger::activity();
            }
        }

        // ---- Prepare product reference PCD
        if (!$onlyMedia
            && $this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_specReferencePCD, $productId)
            && isset($remoteProductData->reference_pcds)
            && count((array)$remoteProductData->reference_pcds)
        ) {
            $propGroupName = 'Reference PCD';
            foreach ((array)$remoteProductData->reference_pcds as $propValue) {
                $propValue = UtilString::max255(UtilStringFormatting::formatStringNoHTML($propValue));
                if ($propValue == '') {
                    continue;
                }
                $propertyId = $this->shopwarePropertyService->getPropertyId($propGroupName, $propValue);

                if (!isset($productData['properties'])) {
                    $productData['properties'] = [];
                }
                $productData['properties'][] = ['id' => $propertyId];
            }
            CliLogger::activity();
        }

        // ---- Prepare product reference OEM
        if (!$onlyMedia
            && $this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_specReferenceOEM, $productId)
            && isset($remoteProductData->reference_oems)
            && count((array)$remoteProductData->reference_oems)
        ) {
            $propGroupName = 'Reference OEM';
            foreach ((array)$remoteProductData->reference_oems as $propValue) {
                $propValue = UtilString::max255(UtilStringFormatting::formatStringNoHTML($propValue));
                if ($propValue == '') {
                    continue;
                }
                $propertyId = $this->shopwarePropertyService->getPropertyId($propGroupName, $propValue);
                if (!isset($productData['properties'])) {
                    $productData['properties'] = [];
                }
                $productData['properties'][] = ['id' => $propertyId];
            }
            CliLogger::activity();
        }

        // ---- Prepare product specifications
        if (!$onlyMedia
            && $this->productImportSettingsService->isProductOptionEnabled(MergedPluginConfigKeyConstants::OPTION_NAME_productSpecifications, $productId)
            && isset($remoteProductData->specifications)
            && count($remoteProductData->specifications)
        ) {
            $ignoreSpecs = GlobalPluginConstants::IGNORE_SPECS;
            foreach ($remoteProductData->specifications as $spec) {
                if (isset($ignoreSpecs[$spec->specification_id])) {
                    continue;
                }
                $propGroupName = UtilString::max255(UtilStringFormatting::formatStringNoHTML($spec->specification));
                if ($propGroupName == '') {
                    continue;
                }
                $propValue = trim(substr(UtilStringFormatting::formatStringNoHTML(($spec->count > 1 ? $spec->count . ' x ' : '') . $spec->attribute . (isset($spec->attribute_extension) ? ' ' . $spec->attribute_extension : '')), 0, 255));
                if ($propValue == '') {
                    continue;
                }

                $propertyId = $this->shopwarePropertyService->getPropertyId($propGroupName, $propValue);
                if (!isset($productData['properties'])) {
                    $productData['properties'] = [];
                }
                $productData['properties'][] = ['id' => $propertyId];
            }
            CliLogger::activity();
        }

        // ---- Prepare product waregroups (categories)
        if (
            !$onlyMedia
            && $this->mergedPluginConfigHelperService->getOption(MergedPluginConfigKeyConstants::PRODUCT_WAREGROUPS)
            && isset($remoteProductData->waregroups)
        ) {
            foreach ($remoteProductData->waregroups as $waregroupObject) {
                $categoriesChain = json_decode(json_encode($waregroupObject->waregroup_tree), true);
                $categoryId = $this->entitiesHelperService->getCategoryId($categoriesChain, (string)$this->mergedPluginConfigHelperService->getOption(MergedPluginConfigKeyConstants::PRODUCT_WAREGROUPS_PARENT));
                if (!$categoryId) {
                    break;
                }
                if (!isset($productData['categories'])) {
                    $productData['categories'] = [];
                }
                $productData['categories'][] = ['id' => $categoryId];
            }
        }

        if (!count($productData)) {
            return [];
        }

        $productData['id'] = $productId;

        //\Topdata\TopdataFoundationSW6\Util\CliLogger::activity('-'.$productId_versionId['product_id'].'-');
        return $productData;
    }


    /**
     * 03/2025 created
     */
    private function _renderDescription(?string $descriptionImportType, string $productId, $descriptionFromWebservice): ?string
    {
        if (empty($descriptionFromWebservice)) {
            return null;
        }

        if ($descriptionImportType === DescriptionImportTypeConstant::REPLACE) {
            return $descriptionFromWebservice;
        }

        if ($descriptionImportType === DescriptionImportTypeConstant::NO_IMPORT) {
            return null;
        }

        // -- fetch original description from DB
        $criteria = new Criteria([$productId]);
        /** @var ProductEntity $product */
        $product = $this->productRepository->search($criteria, $this->context)->first();
        if (!$product) {
            return null;
        }
        $originalDescription = $product->getDescription();

        // -- append
        if ($descriptionImportType === DescriptionImportTypeConstant::APPEND) {
            return $originalDescription . ' ' . $descriptionFromWebservice; // fixme: will not work if running the 2nd time
        }

        // -- prepend
        if ($descriptionImportType === DescriptionImportTypeConstant::PREPEND) {
            return $descriptionFromWebservice . ' ' . $originalDescription; // fixme: will not work if running the 2nd time
        }

        // -- inject
        if ($descriptionImportType === DescriptionImportTypeConstant::INJECT) {
            $regex = '@<!--\s*TOPDATA_DESCRIPTION_BEGIN\s*-->(.*)<!--\s*TOPDATA_DESCRIPTION_END\s*-->@si'; // si stands for case insensitive and multiline
            $replacement = '<!-- TOPDATA_DESCRIPTION_BEGIN -->' . $descriptionFromWebservice . '<!-- TOPDATA_DESCRIPTION_END -->';
            return preg_replace($regex, $replacement, $originalDescription);
        }

        return $descriptionFromWebservice;
    }

    private function _unlinkImages(array $productIds)
    {
        // --- filter by config
        $productIds = $this->productImportSettingsService->filterProductIdsByConfig(MergedPluginConfigKeyConstants::OPTION_NAME_productImages, $productIds);
        $productIds = $this->productImportSettingsService->filterProductIdsByConfig(MergedPluginConfigKeyConstants::OPTION_NAME_productImagesDelete, $productIds);

        // ---- Delete old media
        $this->mediaHelperService->unlinkImages($productIds);
    }

    private function _unlinkProperties(array $productIds)
    {
        // --- filter by config
        $productIds = $this->productImportSettingsService->filterProductIdsByConfig(MergedPluginConfigKeyConstants::OPTION_NAME_productSpecifications, $productIds);

        // ---- Delete old properties
        $this->shopwareProductPropertyService->unlinkProperties($productIds);
    }

}

================
File: src/Service/ProductInformationServiceV2.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service;

use Doctrine\DBAL\Connection;
use Psr\Log\LoggerInterface;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Topdata\TopdataConnectorSW6\Service\Config\MergedPluginConfigHelperService;
use Topdata\TopdataConnectorSW6\Service\Config\ProductImportSettingsService;
use Topdata\TopdataConnectorSW6\Service\DbHelper\TopdataToProductService;
use Topdata\TopdataConnectorSW6\Service\Linking\ProductProductRelationshipServiceV1;
use Topdata\TopdataFoundationSW6\Service\ManufacturerService;

/**
 * Service for updating product specifications and media.
 *
 * This service handles the retrieval and updating of product specifications and media
 * from an external source, integrating them into the Shopware 6 system. It includes
 * functionalities for fetching data, processing images, and linking related products.
 */
class ProductInformationServiceV2
{
    /**
     * 04/2025 created, WIP .. this version tries to be faster than V1
     */
    public function setProductInformationV2(): void
    {
        throw new \RuntimeException("Not implemented yet");
    }


}

================
File: src/Service/ProgressLoggingService.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Service;


use Topdata\TopdataFoundationSW6\Util\CliLogger;

/**
 * 10/2024 created (extracted from MappingHelperService)
 * TODO: merge this into CliStyle
 */
class ProgressLoggingService
{
//
//    private float $microtime;
//
//    public function __construct()
//    {
//        $this->microtime = microtime(true);
//
//
//    }
//
//    private static function isCLi(): bool
//    {
//        return php_sapi_name() == 'cli';
//    }
//
//    private static function getNewline(): string
//    {
//        if (self::isCli()) {
//            return "\n";
//        } else {
//            return '<br>';
//        }
//    }
//
//    private static function _getCaller()
//    {
//        $ddSource = debug_backtrace()[1];
//
//        return basename($ddSource['file']) . ':' . $ddSource['line'] . self::getNewline();
//    }
//
//    /**
//     * Helper method for logging stuff to stdout with right-aligned caller information.
//     */
//    public function activity(string $str = '.', bool $newLine = false): void
//    {
//        // Get terminal width, default to 80 if can't determine
//        $terminalWidth = (int) (`tput cols` ?? 80);
//        // Get caller information
//        $caller = self::_getCaller();
//        $callerLength = strlen($caller);
//
//        // Calculate padding needed
//        $messageLength = strlen($str);
//        $padding = max(0, $terminalWidth - $messageLength - $callerLength);
//
//        // Write the message, padding, and caller
//        CliLogger::getCliStyle()->write($str);
//        CliLogger::getCliStyle()->write(str_repeat(' ', $padding));
//        CliLogger::getCliStyle()->write($caller, $newLine);
//    }
//
//    /**
//     * logging helper.
//     */
//    public function mem(): void
//    {
//        $this->activity('[' . round(memory_get_usage(true) / 1024 / 1024) . 'Mb]');
//    }
//
//    /**
//     * logging helper.
//     */
//    public function lap($start = false): string
//    {
//        if ($start) {
//            $this->microtime = microtime(true);
//
//            return '';
//        }
//        $lapTime = microtime(true) - $this->microtime;
//        $this->microtime = microtime(true);
//
//        return (string)round($lapTime, 3);
//    }

}

================
File: src/Service/TopdataWebserviceClient.php
================
<?php
/**
 * @author    Christoph Muskalla <muskalla@cm-s.eu>
 * @copyright 2019 CMS (http://www.cm-s.eu)
 * @license   http://opensource.org/licenses/osl-3.0.php  Open Software License (OSL 3.0)
 */

namespace Topdata\TopdataConnectorSW6\Service;

use Shopware\Core\System\SystemConfig\SystemConfigService;
use Topdata\TopdataConnectorSW6\Helper\CurlHttpClient;
use Topdata\TopdataConnectorSW6\Util\ImportReport;

/**
 * A simple http client for the topdata webservice with a retry mechanism (exponential backoff).
 */
class TopdataWebserviceClient
{

    const API_VERSION = '108';

    private $apiVersion = self::API_VERSION;
    private CurlHttpClient $curlHttpClient;


    private string $apiBaseUrl; // can be overridden with --base-url CLI option
    private readonly string $apiUid;
    private readonly string $apiPassword;
    private readonly string $apiSecurityKey;
    private readonly string $apiLanguage;


    public function __construct(
        private readonly SystemConfigService $systemConfigService,
    )
    {
        $pluginConfig = $this->systemConfigService->get('TopdataConnectorSW6.config');
//        var_dump( $pluginConfig);
        $this->apiBaseUrl = rtrim($pluginConfig['apiBaseUrl'], '/') ?? '';
        $this->apiUid = $pluginConfig['apiUid'] ?? '';
        $this->apiPassword = $pluginConfig['apiPassword'] ?? '';
        $this->apiSecurityKey = $pluginConfig['apiSecurityKey'] ?? '';
        $this->apiLanguage = $pluginConfig['apiLanguage'] ?? '';
        $this->curlHttpClient = new CurlHttpClient();
    }


    /**
     * Sends an HTTP GET request to a specified endpoint with optional query parameters.
     *
     * @param string $endpoint API endpoint to call (e.g., '/my_products').
     * @param array $params Optional associative array of query parameters.
     * @return mixed Response from the API.
     * @throws \Exception
     */
    private function httpGet(string $endpoint, array $params = []): mixed
    {
        // Combine common parameters with any additional ones
        $params = array_merge($params, [
            'uid'          => $this->apiUid,
            'security_key' => $this->apiSecurityKey,
            'password'     => $this->apiPassword,
            'version'      => $this->apiVersion,
            'language'     => $this->apiLanguage,
            'filter'       => 'all'
        ]);
        $url = $this->apiBaseUrl . $endpoint . '?' . http_build_query($params);

        ImportReport::incCounter("WS $endpoint"); // just for statistics and debugging

        return $this->curlHttpClient->get($url);
    }


    public function product($id, array $params = []): mixed
    {
        $endpoint = "/product/$id";
        return $this->httpGet($endpoint, $params);
    }


    /**
     * fetches the OEMs assigned to the topdata user account
     * - paginated
     * - the 'match' array contains the data
     */
    public function matchMyOems(array $params = []): mixed
    {
        return $this->httpGet('/match/oem', $params);
    }

    /**
     * fetches the PCDs assigned to the topdata user account
     *
     * - paginated
     * - the 'match' array contains the data
     *
     */
    public function matchMyPcds(array $params = []): mixed
    {
        return $this->httpGet('/match/pcd', $params);
    }

    /**
     * fetches the EANs assigned to the topdata user account
     *
     * - paginated
     * - the 'match' array contains the data
     *
     */
    public function matchMyEANs(array $params = []): mixed
    {
        return $this->httpGet('/match/ean', $params);
    }

    /**
     * fetches the Distributor SKUs assigned to the topdata user account
     *
     * - paginated
     * - the 'match' array contains the data
     */
    public function matchMyDistributor(array $params = []): mixed
    {
        return $this->httpGet('/match/distributor', $params);
    }

    public function myProductList(array $params = []): mixed
    {
        return $this->httpGet('/product_list', $params);
    }

    /**
     * - not paginated
     * - the 'data' array contains the data
     */
    public function getBrands(): mixed
    {
        return $this->httpGet('/finder/ink_toner/brands');
    }

    /**
     * not paginated
     * 06/2025 renamed from getModelTypeByBrandId to getModelTypes and removed brandId parameter (was unused)
     */
    public function getModelTypes(): mixed
    {
        return $this->httpGet('/finder/ink_toner/devicetypes');
    }

    /**
     * not paginated
     * 06/2025 renamed from getModelSeriesByBrandId to getModelSeries and removed brandId parameter (was unused)
     */
    public function getModelSeries(): mixed
    {
        return $this->httpGet('/finder/ink_toner/modelseries');
    }

    /**
     * paginated (limit and start)
     * 06/2025 created
     */
    public function getModels(int $limit, int $start): mixed
    {
        $params = ['limit' => $limit, 'start' => $start];
        return $this->httpGet('/finder/ink_toner/models', $params);
    }

    public function getUserInfo(): mixed
    {
        return $this->httpGet('/user/user_info');
    }


//    // unused
//    public function getFinder(string $finder, string $step, array $params = []): mixed
//    {
//        $endpoint = "/finder/$finder/$step";
//        return $this->httpGet($endpoint, $params);
//    }
//
//    public function myProducts(array $params = []): mixed
//    {
//        return $this->httpGet('/my_products', $params);
//    }
//
//
//    // unused
//    public function myProductsOfWaregroup(int $waregroupId): mixed
//    {
//        if ($waregroupId <= 0) {
//            return false;
//        }
//
//        return $this->httpGet("/waregroup/$waregroupId");
//    }
//
//    // unused
//    public function myDistributorProducts(array $params = []): mixed
//    {
//        return $this->httpGet('/distributor_products', $params);
//    }
//
//    /**
//     * - no pagination
//     * - the 'waregroups' array contains the data
//     */
//    public function myWaregroups(array $params = []): mixed
//    {
//        return $this->httpGet('/waregroups', $params);
//    }
//    public function getModelsBySeriesId(int|string $brandId, int|string $seriesId): mixed
//    {
//        $params = ['brand_id' => $brandId, 'modelserie_id' => $seriesId];
//        return $this->httpGet('/finder/ink_toner/models', $params);
//    }
//
//    public function getModelsByBrandId(int|string $brandId): mixed
//    {
//        return $this->httpGet('/finder/ink_toner/models', ['brand_id' => $brandId]);
//    }
//
//    public function productAccessories(int|string $id, array $params = []): mixed
//    {
//        return $this->httpGet("/product_accessories/$id", $params);
//    }

    public function setBaseUrl(string $getBaseUrl)
    {
        $this->apiBaseUrl = $getBaseUrl;
    }

    public function getBaseUrl(): string
    {
        return $this->apiBaseUrl;
    }


}

================
File: src/Util/ImportReport.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Util;

use Exception;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Helper\TableStyle;
use Topdata\TopdataFoundationSW6\Util\CliLogger;
use Topdata\TopdataFoundationSW6\Util\UtilFormatter;

/**
 * 06/2024 created.
 */
class ImportReport
{
    /**
     * this is always a dict (never a list).
     */
    protected static array $counters = [];

    public static function incCounter(string $key, int $inc = 1): void
    {
        self::$counters[$key] = (self::$counters[$key] ?? 0) + $inc;
    }

    public static function setCounter(string $key, int $count): void
    {
        self::$counters[$key] = $count;
    }

    public static function getCounters(): array
    {
        return self::$counters;
    }

    public static function getCountersSorted(): array
    {
        // sort by key
        ksort(self::$counters);

        return self::$counters;
    }

    public static function getCounter(string $key): ?int
    {
        return self::$counters[$key] ?? null; // GlobalConfigConstants::NUM_ROWS__FAILED; // -2 is a magic number
    }


    /**
     * 06/2025 created
     */
    public static function dumpImportReportToCli(): void // Added void return type for clarity
    {
        $counterDescriptions = [
            'linking_v2.products.found'                => 'Total unique Shopware product IDs identified for processing.',
            'linking_v2.products.chunks'               => 'Number of chunks the product IDs were split into.',
            'linking_v2.chunks.processed'              => 'Number of chunks successfully processed.',
            'linking_v2.webservice.calls'              => 'Number of webservice calls made to fetch device links.',
            'linking_v2.webservice.device_ids_fetched' => 'Total unique device webservice IDs fetched from the webservice.',
            'linking_v2.database.devices_found'        => 'Total corresponding devices found in the local database.',
            'linking_v2.links.deleted'                 => 'Total number of existing device-product links deleted across all chunks.',
            'linking_v2.links.inserted'                => 'Total number of new device-product links inserted across all chunks.',
            'linking_v2.status.devices.enabled'        => 'Total number of devices marked as enabled.',
            'linking_v2.status.devices.disabled'       => 'Total number of devices marked as disabled.',
            'linking_v2.status.brands.enabled'         => 'Total number of brands marked as enabled.',
            'linking_v2.status.brands.disabled'        => 'Total number of brands marked as disabled.',
            'linking_v2.status.series.enabled'         => 'Total number of series marked as enabled.',
            'linking_v2.status.series.disabled'        => 'Total number of series marked as disabled.',
            'linking_v2.status.types.enabled'          => 'Total number of device types marked as enabled.',
            'linking_v2.status.types.disabled'         => 'Total number of device types marked as disabled.',
            'linking_v2.active.devices'                => 'Final count of active devices at the end of the process.',
            'linking_v2.active.brands'                 => 'Final count of active brands at the end of the process.',
            'linking_v2.active.series'                 => 'Final count of active series at the end of the process.',
            'linking_v2.active.types'                  => 'Final count of active device types at the end of the process.',
        ];

        $cliStyle = CliLogger::getCliStyle();
        $counters = ImportReport::getCountersSorted();
        $descriptions = $counterDescriptions;

        $table = new Table($cliStyle);
        $table->setHeaders(['Counter', 'Value', 'Description']);

        // START of new code
        // Create a style for right-aligning the 'Value' column.
        $rightAlignedStyle = new TableStyle();
        $rightAlignedStyle->setPadType(STR_PAD_LEFT);

        // Apply the style to the second column (index 1).
        $table->setColumnStyle(1, $rightAlignedStyle);
        // END of new code

        foreach ($counters as $key => $value) {
            $description = $descriptions[$key] ?? '';
            $table->addRow([$key, number_format($value), $description]);
        }

        $cliStyle->title('Counters Report');
        $table->render();
    }
}

================
File: src/Util/UtilMappingHelper.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Util;

/**
 * 03/2025 created
 */
class UtilMappingHelper
{


    /**
     * Converts binary IDs in a multi-dimensional array to hexadecimal strings.
     *
     * This method iterates over a multi-dimensional array and converts the binary
     * 'id' and 'version_id' fields to their hexadecimal string representations.
     *
     * @param array $arr The input array containing binary IDs.
     * @return array The modified array with hexadecimal string IDs.
     */
    public static function convertMultiArrayBinaryIdsToHex(array $arr): array
    {
        foreach ($arr as $no => $vals) {
            foreach ($vals as $key => $val) {
                if (isset($arr[$no][$key]['id'])) {
                    $arr[$no][$key]['id'] = bin2hex($arr[$no][$key]['id']);
                }
                if (isset($arr[$no][$key]['version_id'])) {
                    $arr[$no][$key]['version_id'] = bin2hex($arr[$no][$key]['version_id']);
                }
            }
        }

        return $arr;
    }


    public static function _fixArrayBinaryIds(array $arr): array
    {
        foreach ($arr as $key => $val) {
            if (isset($arr[$key]['id'])) {
                $arr[$key]['id'] = bin2hex($arr[$key]['id']);
            }
            if (isset($arr[$key]['version_id'])) {
                $arr[$key]['version_id'] = bin2hex($arr[$key]['version_id']);
            }
        }

        return $arr;
    }


}

================
File: src/Util/UtilProfiling.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Util;

use Exception;
use Topdata\TopdataFoundationSW6\Util\CliLogger;
use Topdata\TopdataFoundationSW6\Util\UtilFormatter;

/**
 * TODO: move to TopdataFoundationSW6
 *
 * 04/2025 created.
 */
class UtilProfiling
{
    private static array $startTimes = [];
    private static array $profiling = [];

    /**
     * 04/2025 created
     */
    public static function startTimer(): void
    {
        $key = self::_getCallerKey(1);
        self::$startTimes[$key] = microtime(true);
    }

    /**
     * 04/2025 created
     */
    public static function stopTimer(): void
    {
        $key = self::_getCallerKey(1);
        if(!isset(self::$startTimes[$key])) {
            throw new Exception("Timer for $key not started");
        }
        if(!isset(self::$profiling[$key])) {
            self::$profiling[$key] = [
                'time' => 0,
                'count' => 0,
            ];
        }
        self::$profiling[$key]['time'] += microtime(true) - self::$startTimes[$key];
        self::$profiling[$key]['count']++;
        self::$startTimes[$key] = null;
    }

    /**
     * 04/2025 created
     */
    private static function _getCallerKey(int $skip = 0): string
    {
        // get caller key
        $trace = debug_backtrace();
        $caller = $trace[$skip + 1];

        return $caller['class'] . '::' . $caller['function'];
    }

    /**
     * TODO: use some DTO defined in the foundation plugin
     * *
     * @return array, format: [
     *       [
     *           'method'    => 'Class::method',
     *           'time' => 8123.123, // in seconds
     *           'count' => 22,
     *       ], ...
     *  ]
     * 04/2025 created
     * 05/2025 added sorting
     */
    public static function getProfiling(string|null $sortBy = 'time'): array
    {
        $ret = [];
        foreach (self::$profiling as $key => $val) {
            $ret[] = [
                'method'    => $key,
                'time'      => $val['time'],
                'count'     => $val['count'],
            ];
        }

        // ---- sorting
        if ($sortBy) {
            usort($ret, fn($a, $b) => $a[$sortBy] <=> $b[$sortBy]);
        }

        return $ret;
    }

    /**
     * It prints the profiling data in a table
     *
     * 04/2025 created
     *
     */
    public static function dumpProfilingToCli(): void
    {
        $rows = [];
        foreach (self::getProfiling() as  $row) {
            $rows[] = [
                $row['method'],
                UtilFormatter::formatDuration($row['time']),
                number_format($row['count'], 0, ',', '.'),
            ];
        }

        CliLogger::getCliStyle()->table(['Method', 'Total Time', 'Call Count'], $rows, 'Profiling');
    }
}

================
File: src/Util/UtilStringFormatting.php
================
<?php

namespace Topdata\TopdataConnectorSW6\Util;


/**
 * 11/2024 created (extracted from MappingHelperService)
 */
class UtilStringFormatting
{

    public static function getWordsFromString(string $string): array
    {
        $rez = [];
        $string = str_replace(['-', '/', '+', '&', '.', ','], ' ', $string);
        $words = explode(' ', $string);
        foreach ($words as $word) {
            if (trim($word)) {
                $rez[] = trim($word);
            }
        }

        return $rez;
    }


    public static function firstLetters(string $string): string
    {
        $rez = '';
        foreach (self::getWordsFromString($string) as $word) {
            $rez .= mb_substr($word, 0, 1);
        }

        return $rez;
    }


    public static function formatStringNoHTML($string)
    {
        return self::formatString(strip_tags((string)$string));
    }


    public static function formatString($string)
    {
        return trim(preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/u', '', (string)$string));
    }

    /**
     * 06/2024 made it static.
     */
    public static function formCode(string $label): string
    {
        $replacement = [
            ' ' => '-',
        ];

        return strtolower(preg_replace('/[^a-zA-Z0-9\-]/', '', str_replace(array_keys($replacement), array_values($replacement), $label)));
    }


}

================
File: src/TopdataConnectorSW6.php
================
<?php

declare(strict_types=1);

namespace Topdata\TopdataConnectorSW6;

use Doctrine\DBAL\Connection;
use Shopware\Core\Framework\Plugin;
use Shopware\Core\Framework\Plugin\Context\UninstallContext;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Topdata\TopdataFoundationSW6\DependencyInjection\TopConfigRegistryCompilerPass;


class TopdataConnectorSW6 extends Plugin
{
    const MAPPINGS = [
        'apiBaseUrl'     => 'topdataWebservice.baseUrl',
        'apiUid'         => 'topdataWebservice.credentials.uid',
        'apiPassword'    => 'topdataWebservice.credentials.password',
        'apiSecurityKey' => 'topdataWebservice.credentials.securityKey',
        'apiLanguage'    => 'topdataWebservice.language',
        'mappingType'    => 'import.mappingType',
    ];

    public function build(ContainerBuilder $container): void
    {
        parent::build($container);

        // ---- register the plugin in Topdata Configration Center's TopConfigRegistry
        if (class_exists(TopConfigRegistryCompilerPass::class)) {
            $container->addCompilerPass(new TopConfigRegistryCompilerPass(__CLASS__, self::MAPPINGS));
        }


    }

    /**
     * Uninstalls the plugin and removes all related database tables and fields.
     *
     * @param UninstallContext $context The context of the uninstallation process.
     */
    public function uninstall(UninstallContext $context): void
    {
        parent::uninstall($context);

        // ---- Check if user data should be kept
        if ($context->keepUserData()) {
            return;
        }

        // ---- Get the database connection
        $connection = $this->container->get(Connection::class);

        $this->_dropPluginRelatedTables($connection);
        $this->_removeColumnsFromCustomerTable($connection);
        $this->_removeColumnsFromProductTable($connection);

    }

    /**
     * 05/2025 TODO: I think this is not needed anymore (maybe some artefact from the sw5 version?)
     */
    private function _removeColumnsFromProductTable($connection): void
    {
        // ---- Fetch and store product table fields
        $productFields = [];
        $temp = $connection->fetchAllAssociative('SHOW COLUMNS from `product`');
        foreach ($temp as $field) {
            if (isset($field['Field'])) {
                $productFields[$field['Field']] = $field['Field'];
            }
        }

        // ---- List of product fields to delete
        $productFieldsToDelete = [
            'devices',
            'topdata',
            'alternate_products',
            'similar_products',
            'related_products',
            'bundled_products',
            'variant_products',
            'capacity_variant_products',
            'color_variant_products',
        ];

        // ---- Drop specified fields from `product` table if they exist
        foreach ($productFieldsToDelete as $field) {
            if (isset($productFields[$field])) {
                $connection->executeStatement('ALTER TABLE `product`  DROP `' . $field . '`');
            }
        }
    }

    /**
     * 05/2025 // TODO: probably not needed anymore (probably some artefact from the sw5 version?)
     */
    private function _removeColumnsFromCustomerTable($connection): void
    {
        // ---- Fetch and store customer table fields
        $customerFields = [];
        $temp = $connection->fetchAllAssociative('SHOW COLUMNS from `customer`');
        foreach ($temp as $field) {
            if (isset($field['Field'])) {
                $customerFields[$field['Field']] = $field['Field'];
            }
        }

        // ---- Drop `devices` field from `customer` table if it exists
        if (isset($customerFields['devices'])) {
            $connection->executeStatement('ALTER TABLE `customer` DROP `devices`');
        }
    }

    /**
     * Drop plugin-related tables if they exist
     *
     * 05/2025 created (extracted from uninstall)
     */
    private function _dropPluginRelatedTables($connection): void
    {
        $connection->executeStatement('DROP TABLE IF EXISTS `topdata_brand`');
        $connection->executeStatement('DROP TABLE IF EXISTS `topdata_device`');
        $connection->executeStatement('DROP TABLE IF EXISTS `topdata_device_to_product`');
        $connection->executeStatement('DROP TABLE IF EXISTS `topdata_device_to_customer`');
        $connection->executeStatement('DROP TABLE IF EXISTS `topdata_device_to_synonym`');
        $connection->executeStatement('DROP TABLE IF EXISTS `topdata_device_type`');
        $connection->executeStatement('DROP TABLE IF EXISTS `topdata_series`');
        $connection->executeStatement('DROP TABLE IF EXISTS `topdata_to_product`');
        $connection->executeStatement('DROP TABLE IF EXISTS `topdata_product_to_alternate`');
        $connection->executeStatement('DROP TABLE IF EXISTS `topdata_product_to_similar`');
        $connection->executeStatement('DROP TABLE IF EXISTS `topdata_product_to_related`');
        $connection->executeStatement('DROP TABLE IF EXISTS `topdata_product_to_bundled`');
        $connection->executeStatement('DROP TABLE IF EXISTS `topdata_product_to_color_variant`');
        $connection->executeStatement('DROP TABLE IF EXISTS `topdata_product_to_capacity_variant`');
        $connection->executeStatement('DROP TABLE IF EXISTS `topdata_product_to_variant`');
    }
}

================
File: .gitignore
================
.idea
vendor
composer.lock
var
.aider*
.php-cs-fixer.cache
.env
builds/



# +----------------------------+
# |    Keep .gitkeep files     |
# +----------------------------+
!/**/.gitkeep

================
File: .php-cs-fixer.dist.php
================
<?php

// .php-cs-fixer.dist.php - PHP CS Fixer configuration file
// 2024-06-26 created
// 2024-10-16 updated - added parallel config
// This file defines the coding standards and rules for the project.

use PhpCsFixer\Config;
use PhpCsFixer\Finder;

$finder = Finder::create()
    // ->in(__DIR__) // Uncomment to search in the current directory
    ->in(__DIR__ . '/src') // Searches for PHP files in the 'src' directory
    ->name('*.php') // Only consider files with .php extension
    ->exclude('vendor') // Excludes the 'vendor' directory
    ->exclude('var'); // Excludes the 'var' directory

$config = new Config();
return $config
    ->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect()) // Configures parallel processing based on system capabilities
    ->setRules([
        '@PSR12' => true, // Applies PSR-12 coding standard
        'array_syntax' => ['syntax' => 'short'], // Forces use of short array syntax []
        'binary_operator_spaces' => [
            'default' => 'align_single_space_minimal', // Aligns operators with minimal space
        ],
        'blank_line_after_namespace' => true, // Ensures there's a blank line after namespace declarations
        'blank_line_after_opening_tag' => true, // Adds a blank line after the opening PHP tag
        'blank_line_before_statement' => [
            'statements' => ['return'], // Adds a blank line before return statements
        ],
        'braces' => [
            'position_after_functions_and_oop_constructs' => 'next', // Places opening braces on the next line for functions and classes
        ],
        'cast_spaces' => ['space' => 'none'], // Removes spaces around cast operations
        'concat_space' => ['spacing' => 'one'], // Ensures one space around the concatenation operator
        'declare_equal_normalize' => ['space' => 'none'], // Removes spaces around the equal sign in declare statements
        'function_typehint_space' => true, // Ensures space after type hints in function declarations
        'include' => true, // Ensures proper spacing for include statements
        'lowercase_cast' => true, // Forces lowercase for cast operations
        'no_extra_blank_lines' => [
            'tokens' => [
                'extra',
                'throw',
                'use',
            ], // Removes extra blank lines around specified tokens
        ],
        'no_trailing_whitespace' => true, // Removes trailing whitespace at the end of lines
        'no_whitespace_before_comma_in_array' => true, // Removes whitespace before commas in arrays
        'single_quote' => true, // Converts double quotes to single quotes for strings
        'ternary_operator_spaces' => true, // Standardizes spaces around ternary operators
        'trailing_comma_in_multiline' => ['elements' => ['arrays']], // Adds trailing commas in multiline arrays
        'trim_array_spaces' => true, // Removes extra spaces around array brackets
        'unary_operator_spaces' => true, // Removes space after unary operators
        'no_closing_tag' => true, // Removes closing PHP tag
    ])
    ->setFinder($finder);

================
File: .php-cs-fixer.php
================
<?php declare(strict_types=1);
/**
 * 04/2024 created
 */

$finder = (new PhpCsFixer\Finder())
    ->in([
        __DIR__,
    ])
    ->name('*.php')
    ->ignoreDotFiles(true)
    ->ignoreVCS(true)
    ->exclude('var');


// ---- rules
$rules = [
    '@PSR12'                                      => true,
    'array_syntax'                                => ['syntax' => 'short'],
    'binary_operator_spaces'                      => [
        'default'   => 'single_space',
        'operators' => [
            '=>' => 'align_single_space_minimal',
            '='  => 'align_single_space_minimal',
        ],
    ],
    'blank_line_after_namespace'                  => true,
    'blank_line_after_opening_tag'                => true,
    'blank_line_before_statement'                 => [
        'statements' => ['return']
    ],
    'braces'                                      => true,
    'cast_spaces'                                 => true,
    'class_attributes_separation'                 => ['elements' => ['method' => 'one']],
    'class_definition'                            => true,
    'concat_space'                                => [
        'spacing' => 'one'
    ],
    'declare_equal_normalize'                     => true,
    'elseif'                                      => true,
    'encoding'                                    => true,
    'full_opening_tag'                            => true,
    'fully_qualified_strict_types'                => true, // added by Shift
    'function_declaration'                        => true,
    'function_typehint_space'                     => true,
    'heredoc_to_nowdoc'                           => true,
    'include'                                     => true,
    'increment_style'                             => ['style' => 'post'],
    'indentation_type'                            => true,
    'linebreak_after_opening_tag'                 => true,
    'line_ending'                                 => true,
    'lowercase_cast'                              => true,
    'constant_case'                               => ['case' => 'lower'],
    'lowercase_keywords'                          => true,
    'lowercase_static_reference'                  => true, // added from Symfony
    'magic_method_casing'                         => true, // added from Symfony
    'magic_constant_casing'                       => true,
    'method_argument_space'                       => true,
    'native_function_casing'                      => true,
    'no_alias_functions'                          => false, // risky
    'no_extra_blank_lines'                        => [
        'tokens' => [
            'extra',
            'throw',
            'use',
            'use_trait',
        ]
    ],
    'no_blank_lines_after_class_opening'          => true,
    'no_blank_lines_after_phpdoc'                 => true,
    'no_closing_tag'                              => true,
    'no_empty_phpdoc'                             => true,
    'no_empty_statement'                          => true,
    'no_leading_import_slash'                     => true,
    'no_leading_namespace_whitespace'             => true,
    'no_mixed_echo_print'                         => [
        'use' => 'echo'
    ],
    'no_multiline_whitespace_around_double_arrow' => true,
    'multiline_whitespace_before_semicolons'      => [
        'strategy' => 'no_multi_line'
    ],
    'no_short_bool_cast'                          => true,
    'no_singleline_whitespace_before_semicolons'  => true,
    'no_spaces_after_function_name'               => true,
    'no_spaces_around_offset'                     => true,
    'no_spaces_inside_parenthesis'                => true,
    'no_trailing_comma_in_list_call'              => true,
    'no_trailing_comma_in_singleline_array'       => true,
    'no_trailing_whitespace'                      => true,
    'no_trailing_whitespace_in_comment'           => true,
    'no_unneeded_control_parentheses'             => true,
    'no_unreachable_default_argument_value'       => false, // risky
    'no_useless_return'                           => true,
    'no_whitespace_before_comma_in_array'         => true,
    'no_whitespace_in_blank_line'                 => true,
    'normalize_index_brace'                       => true,
    'not_operator_with_successor_space'           => false,
    'object_operator_without_whitespace'          => true,
    'ordered_imports'                             => ['sort_algorithm' => 'alpha'],
    'phpdoc_indent'                               => true,
    'phpdoc_no_access'                            => true,
    'phpdoc_no_package'                           => true,
    'phpdoc_no_useless_inheritdoc'                => true,
    'phpdoc_scalar'                               => true,
    'phpdoc_single_line_var_spacing'              => true,
    'phpdoc_summary'                              => true,
    'phpdoc_to_comment'                           => true,
    'phpdoc_trim'                                 => true,
    'phpdoc_types'                                => true,
    'phpdoc_var_without_name'                     => true,
    'psr_autoloading'                             => false, // risky
    'self_accessor'                               => false,
    'short_scalar_cast'                           => true,
    'simplified_null_return'                      => false, // disabled by Shift
    'single_blank_line_at_eof'                    => true,
    // 'single_blank_line_before_namespace'          => true,
    'single_class_element_per_statement'          => true,
    'single_import_per_statement'                 => true,
    'single_line_after_imports'                   => true,
    'single_line_comment_style'                   => [
        'comment_types' => ['hash']
    ],
    'single_quote'                                => true,
    'space_after_semicolon'                       => true,
    'standardize_not_equals'                      => true,
    'switch_case_semicolon_to_colon'              => true,
    'switch_case_space'                           => true,
    'ternary_operator_spaces'                     => true,
    'trailing_comma_in_multiline'                 => ['elements' => ['arrays']],
    'trim_array_spaces'                           => true,
    'unary_operator_spaces'                       => true,
    'visibility_required'                         => [
        'elements' => ['method', 'property']
    ],
    'whitespace_after_comma_in_array'             => true,
    'phpdoc_align'                                => ['align' => 'vertical'],
    'yoda_style'                                  => false,
    'array_indentation'                           => true,
    'phpdoc_annotation_without_dot'               => true,
];


return (new PhpCsFixer\Config())
    ->setRules($rules)
    ->setFinder($finder)
    ->setRiskyAllowed(false)
    ->setCacheFile(__DIR__ . '/var/cache/php-cs-fixer.cache')
    ->setUsingCache(true);

================
File: CHANGELOG.md
================
# Changelog

All notable changes to this project will be documented in this file. 
The format is based on [Keep a Changelog](https://keepachangelog.com/) and this project adheres to [Semantic Versioning](https://semver.org/).

## [7.1.1] - 2025-03-18
- removed the `--start` and `--end` cli options from the import command
- choices for description import type are now configurable: NO_IMPORT, REPLACE, APPEND, PREPEND, INJECT

## [7.0.4] - 2024-11-05
### Changed
- `MediaHelperService` added (extracted from `EntityHelperService`)


## [7.0.3] - 2024-11-05
### Fixed
- `$context` variable was missing in TopdataToProductService


## [7.0.2] - 2024-11-04
- removed some classes and the csv import command
- added migration for inserting default credentials if no credentials are present
- added `--print-config` option to `topdata:connector:test-connection` command


## [7.0.0] - 06/2024
- prettier output
- deprecation warnings fixed
- report generation when importing data

================
File: composer.json
================
{
    "name": "topdata/topdata-connector-sw6",
    "description": "Imports product data from TopData Webservice into Shopware 6",
    "version": "7.3.1",
    "type": "shopware-platform-plugin",
    "license": "MIT",
    "authors": [
        {
            "name": "TopData Software GmbH",
            "homepage": "https://www.topdata.de",
            "role": "Manufacturer"
        }
    ],
    "require": {
        "shopware/core": "6.6.* || 6.7.*",
        "topdata/topdata-foundation-sw6": ">=1.2.9",
        "ext-curl": "*"
    },
    "extra": {
        "topdata-user-documentation": "https://www.topdata.de/shopware/seite/connector",
        "topdata-technical-documentation": "https://github.com/topdata-software-gmbh/topdata-webservice-connector-sw6/blob/main/README.md",
        "shopware-plugin-class": "Topdata\\TopdataConnectorSW6\\TopdataConnectorSW6",
        "plugin-icon": "src/Resources/config/plugin.png",
        "copyright": "(c) by TopData Software GmbH",
        "label": {
            "de-DE": "Topdata Webservice Connector",
            "en-GB": "Topdata Webservice Connector"
        },
        "description": {
            "de-DE": "Der Topdata Webservice Connector verbindet Ihren Shopware6 Store mit dem TopData Webservice. Der TopdataWebserviceConnector wird benoetigt um anderer unserer Plugins zu verwenden.",
            "en-GB": "The Topdata Webservice Connector connects your Shopware6 store with the TopData Webservice. The TopdataWebserviceConnector is required to use other of our plugins."
        },
        "manufacturerLink": {
            "de-DE": "https://www.topdata.de",
            "en-GB": "https://www.topdata.de"
        },
        "supportLink": {
            "de-DE": "https://www.topdata.de/shopware/seite/connector",
            "en-GB": "https://www.topdata.de/shopware/seite/connector"
        }
    },
    "autoload": {
        "psr-4": {
            "Topdata\\TopdataConnectorSW6\\": "src/"
        }
    },
    "require-dev": {
        "friendsofphp/php-cs-fixer": "^3.54"
    },
    "config": {
        "allow-plugins": {
            "symfony/runtime": true
        }
    }
}

================
File: LICENSE
================
MIT License

Copyright (c) 2024 TopData Software GmbH

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

================
File: php-cs-fixer.md
================
# PHP CS Fixer Usage Instructions

PHP CS Fixer is a tool for automatically fixing PHP coding standards issues in your code. This document provides basic instructions on how to use PHP CS Fixer in this project.

## Running PHP CS Fixer

1. To perform a dry run (see what would be fixed without actually changing files):
   ```
   php php-cs-fixer.phar fix --dry-run
   ```

2. To fix files:
   ```
   php php-cs-fixer.phar fix
   ```

3. To fix a specific file or directory:
   ```
   php php-cs-fixer.phar fix path/to/file/or/directory
   ```

## Configuration

The PHP CS Fixer configuration is stored in `.php-cs-fixer.dist.php`. You can modify this file to change the coding standards rules applied to your project.

## Integrating with Your Workflow

- Consider running PHP CS Fixer before committing your code or as part of your CI/CD pipeline.
- You can add a pre-commit hook to automatically run PHP CS Fixer on changed files.

For more detailed information, visit the [PHP CS Fixer documentation](https://cs.symfony.com/).

================
File: README.md
================
# TopData Webservice Connector for Shopware 6

## About
This plugin is the base for most of the functionality in other TopData plugins for Shopware 6.
It gives possibility to import devices from TopData Webservice.

## Minimal Requirements
- Shopware 6.6.0 or higher
- PHP 8.1 or higher

## Installation
```bash
# clone the repository
cd custom/plugins
git clone -b main https://github.com/topdata-software-gmbh/topdata-webservice-connector-sw6.git

# install the plugin
bin/console plugin:refresh
bin/console plugin:install --activate --clearCache TopdataConnectorSW6 
```
## Configuration
After installing the plugin, you need to fill in API credentials to connect to TopData Webservice.

### Webservice Credentials
Settings - System - Plugins - TopdataConnector menu (... on the right) - Config

TopData will give you:

- API User-ID
- API Password
- API Security Key

#### Demo Credentials

If you want to test the plugin with demo credentials, you can use the following:
 
- API User-ID: 6
- API Password: nTI9kbsniVWT13Ns
- API Security Key: oateouq974fpby5t6ldf8glzo85mr9t6aebozrox


#### Testing the connection
After saving credentials you can test connection, just select TopData Plugins menu item in main menu and press "Test" button in TopData Connector block.


### Other Options

"please select your search option" - here you select how to map your products:

1. Default OEM/EAN - products are mapped using OEM and EAN fields of a product

2. Custom OEM/EAN - products are mapped using OEM and EAN fields stored as product properties, this case you sholud set name of OEM Field and EAN Field

3. distributor product number - product number in store equals to Top Data API product number

4. custom distributor product number - Webservice product number is stored in product property, you must specify name of a property in "distributor product number Field"

5. Product number is web service id - product number in your store is same as API product id on Top Data Webservice




## Console commands for work with API

### topdata:connector:test-connection

- it tests whether the connection to the TopData Webservice is working

### topdata:connector:import
   
1. `bin/console topdata:connector:import --mapping`  map store products to webservice products

2. `bin/console topdata:connector:import --device`  fetch all information about devices from webservice to local database (devices, device types, series, brands)

3. `bin/console topdata:connector:import --product`  this will connect products in store to devices in local database. Products must be mapped by console command and devices must be fetched from webservice.

4. `bin/console topdata:connector:import --device-media`  this will download device images, this process must have rights to write in website folders (You may use or chown or chmod)

5. `bin/console topdata:connector:import --product-info`  Only works with Topdata TopFeed plugin. This will fetch all product information from webservice to local database. You can select what data to fetch in Topdata TopFeed plugin settings. You need write permisions for process if you select to store product images.

6. `bin/console topdata:connector:import --device-synonyms` this will fetch device synonyms from Webservice, they will be displayed near device on device details page

`bin/console topdata:connector:import --all`  All options 1-6 are active (mapping, device, product, device-media, product-info, device-synonyms)

`bin/console topdata:connector:import --device-only` this will fetch only devices from API (good for chunked commands with --start and --end, no brands/series/types are fetched)

`bin/console topdata:connector:import --product-media-only` this will fetch only product images (good for importing product information first with disabled product media in TopFeed settings, and then download product images with this command)

Command order is important, for example --device-media (4) downloads images only for enabled devices, those devices are enabled by --product (3)

#### Additional options
`-v` key for verbose output, it shows memmory usage, data chunk numbers, time and other information

`--no-debug` keys for faster work and less memmory usage


## Advices and examples

If you download product or device images from TopData Webservice to yours shop locale storage, don't forget to change permissions for files if command and server user are not the same user, e.g.
<!-- TODO: fix the path "." in the command -->
```bash
chown -R www-data:www-data .
```

## One command to import all

```bash
php -d memory_limit=2048M bin/console topdata:connector:import -v --all
```

## Performance considerations
The update of a single image scans the `file_name` column of the `media` table. 
Unfortunately this column has no index which leads to a slow full table scan. To speed up the searching in the `media` table, consider adding an index:

```sql
CREATE INDEX IX__file_name ON media (file_name(255));
```

================
File: VERSIONING.md
================
# Versioning Scheme
- for each major shopware6 version there is a branch with a major plugin version number:
    - Shopware 6.4: branch=main-sw64 major-version=1
    - Shopware 6.5: branch=main-sw65 major-version=2
    - Shopware 6.6: branch=main-sw66 major-version=3



================================================================
End of Codebase
================================================================
