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)
);