Skip to content

Vehicle Data - Vehicle-Product Mapping

Overview

The vehicle-product mapping system connects vehicles to compatible products (tyres and wheels) based on technical specifications. This enables "search by vehicle" functionality in the webshop.

Mapping Types

Type Description Criteria
Tyre Fitment Tyres that fit a vehicle Width, aspect ratio, diameter
Wheel Fitment Wheels that fit a vehicle PCD, center bore, offset, diameter
OE Match Original Equipment sizes Exact factory specifications

Entities

VehicleFitmentRule

#[ORM\Entity]
class VehicleFitmentRule
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private int $id;

    #[ORM\ManyToOne(targetEntity: VehicleVariant::class)]
    private VehicleVariant $vehicleVariant;

    #[ORM\Column(length: 20)]
    private string $productType; // 'tyre', 'wheel'

    #[ORM\Column(length: 10)]
    private string $position; // 'front', 'rear', 'both'

    #[ORM\Column]
    private bool $isOriginalEquipment = false;

    // For tyres
    #[ORM\Column(nullable: true)]
    private ?int $tyreWidth;

    #[ORM\Column(nullable: true)]
    private ?int $tyreAspectRatio;

    #[ORM\Column(type: 'decimal', precision: 4, scale: 1, nullable: true)]
    private ?string $tyreDiameter;

    // For wheels
    #[ORM\Column(type: 'decimal', precision: 4, scale: 1, nullable: true)]
    private ?string $wheelDiameter;

    #[ORM\Column(type: 'decimal', precision: 3, scale: 1, nullable: true)]
    private ?string $wheelWidth;

    #[ORM\Column(length: 10, nullable: true)]
    private ?string $pcd; // "5x112"

    #[ORM\Column(type: 'decimal', precision: 4, scale: 1, nullable: true)]
    private ?string $centerBore;

    #[ORM\Column(nullable: true)]
    private ?int $offsetMin;

    #[ORM\Column(nullable: true)]
    private ?int $offsetMax;

    #[ORM\Column(length: 20)]
    private string $source; // 'driveright', 'wheelsize', 'manual'
}

ProductVehicleMapping

Pre-computed mapping for fast queries:

#[ORM\Entity]
class ProductVehicleMapping
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private int $id;

    #[ORM\Column(length: 50)]
    private string $articleNumber;

    #[ORM\ManyToOne(targetEntity: VehicleVariant::class)]
    private VehicleVariant $vehicleVariant;

    #[ORM\Column(length: 10)]
    private string $position; // 'front', 'rear', 'both'

    #[ORM\Column]
    private bool $isOriginalEquipment;

    #[ORM\Column]
    private int $matchScore; // 100 = perfect, lower = less ideal

    #[ORM\Column(type: 'datetime_immutable')]
    private \DateTimeImmutable $computedAt;
}

Matching Logic

Tyre Matching Service

class TyreFitmentMatcher
{
    public function findCompatibleTyres(
        VehicleVariant $vehicle,
        TyreRepository $tyreRepository
    ): array {
        $fitmentRules = $vehicle->getFitmentRules('tyre');
        $results = [];

        foreach ($fitmentRules as $rule) {
            $tyres = $tyreRepository->findBySpecs(
                width: $rule->getTyreWidth(),
                aspectRatio: $rule->getTyreAspectRatio(),
                diameter: $rule->getTyreDiameter()
            );

            foreach ($tyres as $tyre) {
                $key = $tyre->getArticleNumber();

                if (!isset($results[$key])) {
                    $results[$key] = new TyreMatch(
                        tyre: $tyre,
                        position: $rule->getPosition(),
                        isOE: $rule->isOriginalEquipment(),
                        score: $this->calculateScore($tyre, $rule)
                    );
                } else {
                    // Update if better match
                    $results[$key] = $this->mergeMatches($results[$key], $rule);
                }
            }
        }

        return $this->sortByScore($results);
    }

    private function calculateScore(Tyre $tyre, VehicleFitmentRule $rule): int
    {
        $score = 80; // Base score

        if ($rule->isOriginalEquipment()) {
            $score += 20; // OE bonus
        }

        // Premium brand bonus
        if ($tyre->getBrand()->isPremium()) {
            $score += 5;
        }

        return min(100, $score);
    }
}

Wheel Matching Service

class WheelFitmentMatcher
{
    public function findCompatibleWheels(
        VehicleVariant $vehicle,
        WheelRepository $wheelRepository
    ): array {
        $fitmentRules = $vehicle->getFitmentRules('wheel');
        $results = [];

        foreach ($fitmentRules as $rule) {
            // Find wheels with matching base specs
            $wheels = $wheelRepository->findByBaseSpecs(
                pcd: $rule->getPcd(),
                centerBoreMin: $rule->getCenterBore()
            );

            foreach ($wheels as $wheel) {
                // Check detailed compatibility
                if (!$this->isWheelCompatible($wheel, $rule)) {
                    continue;
                }

                $key = $wheel->getArticleNumber();

                $results[$key] = new WheelMatch(
                    wheel: $wheel,
                    position: $rule->getPosition(),
                    fitmentDetails: $this->getFitmentDetails($wheel, $rule),
                    score: $this->calculateScore($wheel, $rule)
                );
            }
        }

        return $this->sortByScore($results);
    }

    private function isWheelCompatible(Wheel $wheel, VehicleFitmentRule $rule): bool
    {
        // PCD must match exactly
        if ($wheel->getPcd() !== $rule->getPcd()) {
            return false;
        }

        // Center bore: wheel must be >= vehicle requirement
        // (hub centric rings can be used if wheel bore is larger)
        if ($wheel->getCenterBore() < $rule->getCenterBore()) {
            return false;
        }

        // Offset must be within range
        $offset = $wheel->getOffset();
        if ($offset < $rule->getOffsetMin() || $offset > $rule->getOffsetMax()) {
            return false;
        }

        // Diameter must match one of the allowed sizes
        if ($rule->getWheelDiameter() && $wheel->getDiameter() != $rule->getWheelDiameter()) {
            return false;
        }

        return true;
    }

    private function calculateScore(Wheel $wheel, VehicleFitmentRule $rule): int
    {
        $score = 70; // Base score

        // Perfect center bore match (no ring needed)
        if (abs($wheel->getCenterBore() - $rule->getCenterBore()) < 0.1) {
            $score += 15;
        }

        // Offset in middle of range (safest)
        $midOffset = ($rule->getOffsetMin() + $rule->getOffsetMax()) / 2;
        $offsetDiff = abs($wheel->getOffset() - $midOffset);
        if ($offsetDiff <= 5) {
            $score += 10;
        }

        // Premium brand bonus
        if ($wheel->getBrand()->isPremium()) {
            $score += 5;
        }

        return min(100, $score);
    }

    private function getFitmentDetails(Wheel $wheel, VehicleFitmentRule $rule): array
    {
        $details = [];

        // Hub centric ring needed?
        $boreDiff = $wheel->getCenterBore() - $rule->getCenterBore();
        if ($boreDiff > 0.1) {
            $details['hubRingRequired'] = true;
            $details['hubRingSize'] = sprintf(
                '%.1f → %.1f',
                $wheel->getCenterBore(),
                $rule->getCenterBore()
            );
        }

        // Spacers might be needed
        if ($wheel->getOffset() < $rule->getOffsetMin() + 5) {
            $details['spacerRecommended'] = true;
        }

        return $details;
    }
}

Pre-computation Service

For performance, mappings are pre-computed:

class ProductVehicleMappingComputer
{
    public function __construct(
        private TyreFitmentMatcher $tyreMatcher,
        private WheelFitmentMatcher $wheelMatcher,
        private ProductVehicleMappingRepository $mappingRepository,
        private VehicleVariantRepository $vehicleRepository,
        private ArticleRepository $articleRepository
    ) {}

    /**
     * Recompute all mappings for a specific vehicle
     */
    public function computeForVehicle(VehicleVariant $vehicle): void
    {
        // Clear existing mappings
        $this->mappingRepository->deleteForVehicle($vehicle);

        // Compute tyre mappings
        $tyres = $this->articleRepository->findAllActiveTyres();
        foreach ($tyres as $tyre) {
            $this->computeTyreMapping($vehicle, $tyre);
        }

        // Compute wheel mappings
        $wheels = $this->articleRepository->findAllActiveWheels();
        foreach ($wheels as $wheel) {
            $this->computeWheelMapping($vehicle, $wheel);
        }
    }

    /**
     * Recompute all mappings for a specific product
     */
    public function computeForProduct(Article $article): void
    {
        // Clear existing mappings
        $this->mappingRepository->deleteForArticle($article->getArticleNumber());

        // Get all vehicles
        $vehicles = $this->vehicleRepository->findAll();

        foreach ($vehicles as $vehicle) {
            if ($article instanceof Tyre) {
                $this->computeTyreMapping($vehicle, $article);
            } elseif ($article instanceof Wheel) {
                $this->computeWheelMapping($vehicle, $article);
            }
        }
    }

    private function computeTyreMapping(VehicleVariant $vehicle, Tyre $tyre): void
    {
        foreach ($vehicle->getFitmentRules('tyre') as $rule) {
            if ($this->tyreMatchesRule($tyre, $rule)) {
                $mapping = new ProductVehicleMapping();
                $mapping->setArticleNumber($tyre->getArticleNumber());
                $mapping->setVehicleVariant($vehicle);
                $mapping->setPosition($rule->getPosition());
                $mapping->setIsOriginalEquipment($rule->isOriginalEquipment());
                $mapping->setMatchScore($this->calculateTyreScore($tyre, $rule));
                $mapping->setComputedAt(new \DateTimeImmutable());

                $this->mappingRepository->save($mapping);
                return; // One mapping per vehicle-product pair
            }
        }
    }

    private function tyreMatchesRule(Tyre $tyre, VehicleFitmentRule $rule): bool
    {
        return $tyre->getWidth() === $rule->getTyreWidth()
            && $tyre->getAspectRatio() === $rule->getTyreAspectRatio()
            && abs($tyre->getDiameter() - $rule->getTyreDiameter()) < 0.1;
    }
}

Query Service

class VehicleProductQueryService
{
    public function __construct(
        private ProductVehicleMappingRepository $mappingRepository,
        private ArticleRepository $articleRepository
    ) {}

    /**
     * Fast lookup using pre-computed mappings
     */
    public function findProductsForVehicle(
        VehicleVariant $vehicle,
        string $productType,
        array $filters = []
    ): PaginatedResult {
        $mappings = $this->mappingRepository->findByVehicle(
            vehicleId: $vehicle->getId(),
            productType: $productType,
            filters: $filters
        );

        $articleNumbers = array_map(
            fn($m) => $m->getArticleNumber(),
            $mappings
        );

        // Fetch actual products with additional filters
        return $this->articleRepository->findByNumbers(
            articleNumbers: $articleNumbers,
            filters: $filters
        );
    }

    /**
     * Find vehicles compatible with a product
     */
    public function findVehiclesForProduct(string $articleNumber): array
    {
        return $this->mappingRepository->findVehiclesByArticle($articleNumber);
    }

    /**
     * Check if specific product fits specific vehicle
     */
    public function checkFitment(string $articleNumber, int $vehicleId): ?FitmentResult
    {
        $mapping = $this->mappingRepository->findOne($articleNumber, $vehicleId);

        if (!$mapping) {
            return null;
        }

        return new FitmentResult(
            fits: true,
            position: $mapping->getPosition(),
            isOE: $mapping->isOriginalEquipment(),
            score: $mapping->getMatchScore()
        );
    }
}

Scheduler for Pre-computation

#[AsCommand(name: 'vehicle:compute-mappings')]
class ComputeVehicleMappingsCommand extends Command
{
    public function __construct(
        private ProductVehicleMappingComputer $computer,
        private VehicleVariantRepository $vehicleRepository,
        private ArticleRepository $articleRepository
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addOption('vehicle', null, InputOption::VALUE_OPTIONAL, 'Specific vehicle ID')
            ->addOption('product', null, InputOption::VALUE_OPTIONAL, 'Specific article number')
            ->addOption('full', null, InputOption::VALUE_NONE, 'Full recomputation');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        if ($vehicleId = $input->getOption('vehicle')) {
            $vehicle = $this->vehicleRepository->find($vehicleId);
            $this->computer->computeForVehicle($vehicle);
            $output->writeln("Computed mappings for vehicle {$vehicleId}");
            return Command::SUCCESS;
        }

        if ($articleNumber = $input->getOption('product')) {
            $article = $this->articleRepository->find($articleNumber);
            $this->computer->computeForProduct($article);
            $output->writeln("Computed mappings for product {$articleNumber}");
            return Command::SUCCESS;
        }

        if ($input->getOption('full')) {
            $this->fullRecomputation($output);
            return Command::SUCCESS;
        }

        $output->writeln('Specify --vehicle, --product, or --full');
        return Command::FAILURE;
    }

    private function fullRecomputation(OutputInterface $output): void
    {
        $output->writeln('Starting full recomputation...');

        $vehicles = $this->vehicleRepository->findAll();
        $progress = new ProgressBar($output, count($vehicles));

        foreach ($vehicles as $vehicle) {
            $this->computer->computeForVehicle($vehicle);
            $progress->advance();
        }

        $progress->finish();
        $output->writeln("\nDone!");
    }
}

Gherkin Scenarios

Feature: Vehicle-Product Mapping
  As the webshop
  I want to show products compatible with a vehicle
  So customers can find the right tyres and wheels

  Scenario: Find tyres for vehicle by size
    Given vehicle "VW Golf 8 2.0 TDI" has tyre fitment 225/45R17
    And tyre "MICH-PILOT-225-45-17" has size 225/45R17
    When I search tyres for this vehicle
    Then "MICH-PILOT-225-45-17" should be in results

  Scenario: OE tyres ranked higher
    Given vehicle has OE tyre size 225/45R17
    And vehicle also accepts 235/40R18
    And both sizes have matching products
    When I search tyres for this vehicle
    Then OE size products should appear first

  Scenario: Wheel PCD must match exactly
    Given vehicle requires PCD 5x112
    And wheel has PCD 5x100
    When I check wheel compatibility
    Then wheel should NOT be compatible

  Scenario: Center bore with hub ring
    Given vehicle requires center bore 57.1mm
    And wheel has center bore 66.6mm
    When I check wheel compatibility
    Then wheel should be compatible
    And fitment details should mention hub ring required

  Scenario: Offset outside range rejected
    Given vehicle accepts offset ET35 to ET45
    And wheel has offset ET30
    When I check wheel compatibility
    Then wheel should NOT be compatible

  Scenario: Pre-computed mappings for performance
    Given mappings were computed yesterday
    When customer searches products for vehicle
    Then pre-computed results are returned immediately
    And no real-time calculation is needed

  Scenario: New product triggers mapping update
    Given new tyre is added to catalog
    When product mapping job runs
    Then tyre is mapped to all compatible vehicles

  Scenario: Staggered fitment (different front/rear)
    Given BMW M3 has different front and rear sizes
    When I search tyres
    Then front tyres are marked as "front"
    And rear tyres are marked as "rear"

Database Schema

-- Fitment rules from data sources
CREATE TABLE vehicle_fitment_rules (
    id INT PRIMARY KEY AUTO_INCREMENT,
    vehicle_variant_id INT NOT NULL,
    product_type VARCHAR(20) NOT NULL,
    position VARCHAR(10) NOT NULL DEFAULT 'both',
    is_original_equipment BOOLEAN DEFAULT FALSE,

    -- Tyre specs
    tyre_width INT,
    tyre_aspect_ratio INT,
    tyre_diameter DECIMAL(4,1),

    -- Wheel specs
    wheel_diameter DECIMAL(4,1),
    wheel_width DECIMAL(3,1),
    pcd VARCHAR(10),
    center_bore DECIMAL(4,1),
    offset_min INT,
    offset_max INT,

    source VARCHAR(20) NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,

    FOREIGN KEY (vehicle_variant_id) REFERENCES vehicle_variants(id),
    INDEX idx_vehicle_type (vehicle_variant_id, product_type),
    INDEX idx_tyre_specs (tyre_width, tyre_aspect_ratio, tyre_diameter),
    INDEX idx_wheel_specs (pcd, center_bore)
);

-- Pre-computed product-vehicle mappings
CREATE TABLE product_vehicle_mappings (
    id INT PRIMARY KEY AUTO_INCREMENT,
    article_number VARCHAR(50) NOT NULL,
    vehicle_variant_id INT NOT NULL,
    position VARCHAR(10) NOT NULL DEFAULT 'both',
    is_original_equipment BOOLEAN DEFAULT FALSE,
    match_score INT NOT NULL DEFAULT 80,
    computed_at DATETIME NOT NULL,

    FOREIGN KEY (vehicle_variant_id) REFERENCES vehicle_variants(id),
    INDEX idx_article (article_number),
    INDEX idx_vehicle (vehicle_variant_id),
    INDEX idx_score (match_score DESC),
    UNIQUE KEY (article_number, vehicle_variant_id)
);