Skip to content

Vehicle Data - Manual Vehicle Management

Overview

While most vehicle data comes from external sources (DriveRight, WheelSize), the system supports manual vehicle creation and modification for:

  • Vehicles not in external databases
  • Corrections to external data
  • Custom/modified vehicles
  • New models before they appear in data sources

Manual Entry Types

Type Purpose Priority
New Vehicle Create vehicle not in any source Uses only manual data
Override Correct external data Manual takes precedence
Extension Add fitments to existing vehicle Merged with external data

Entities

ManualVehicle

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

    #[ORM\ManyToOne(targetEntity: VehicleVariant::class)]
    private ?VehicleVariant $linkedVehicle; // null = new vehicle

    #[ORM\Column(length: 20)]
    private string $entryType; // 'new', 'override', 'extension'

    // Vehicle identification (for new vehicles)
    #[ORM\Column(length: 100, nullable: true)]
    private ?string $makeName;

    #[ORM\Column(length: 100, nullable: true)]
    private ?string $modelName;

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

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

    #[ORM\Column(length: 100, nullable: true)]
    private ?string $variantName;

    // Audit fields
    #[ORM\ManyToOne(targetEntity: User::class)]
    private User $createdBy;

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

    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $notes;

    #[ORM\OneToMany(mappedBy: 'manualVehicle', targetEntity: ManualFitmentRule::class)]
    private Collection $fitmentRules;
}

ManualFitmentRule

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

    #[ORM\ManyToOne(targetEntity: ManualVehicle::class)]
    private ManualVehicle $manualVehicle;

    #[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;

    // Tyre specifications
    #[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;

    // Wheel specifications
    #[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;

    #[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\ManyToOne(targetEntity: User::class)]
    private User $createdBy;

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

Admin Interface

Vehicle Creation Form

class ManualVehicleType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('entryType', ChoiceType::class, [
                'choices' => [
                    'Nieuw voertuig' => 'new',
                    'Correctie bestaand' => 'override',
                    'Uitbreiding bestaand' => 'extension',
                ],
            ])
            ->add('linkedVehicle', EntityType::class, [
                'class' => VehicleVariant::class,
                'required' => false,
                'placeholder' => 'Selecteer voertuig (alleen voor correctie/uitbreiding)',
            ])
            // New vehicle fields
            ->add('makeName', TextType::class, ['required' => false])
            ->add('modelName', TextType::class, ['required' => false])
            ->add('yearFrom', IntegerType::class, ['required' => false])
            ->add('yearTo', IntegerType::class, ['required' => false])
            ->add('variantName', TextType::class, ['required' => false])
            ->add('notes', TextareaType::class, ['required' => false])
            // Fitment rules added dynamically via CollectionType
            ->add('fitmentRules', CollectionType::class, [
                'entry_type' => ManualFitmentRuleType::class,
                'allow_add' => true,
                'allow_delete' => true,
                'by_reference' => false,
            ]);
    }
}

Fitment Rule Form

class ManualFitmentRuleType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('productType', ChoiceType::class, [
                'choices' => [
                    'Band' => 'tyre',
                    'Velg' => 'wheel',
                ],
            ])
            ->add('position', ChoiceType::class, [
                'choices' => [
                    'Alle' => 'both',
                    'Voor' => 'front',
                    'Achter' => 'rear',
                ],
            ])
            ->add('isOriginalEquipment', CheckboxType::class, ['required' => false])
            // Tyre fields (shown when productType = tyre)
            ->add('tyreWidth', IntegerType::class, ['required' => false])
            ->add('tyreAspectRatio', IntegerType::class, ['required' => false])
            ->add('tyreDiameter', NumberType::class, ['required' => false])
            // Wheel fields (shown when productType = wheel)
            ->add('wheelDiameter', NumberType::class, ['required' => false])
            ->add('wheelWidth', NumberType::class, ['required' => false])
            ->add('pcd', TextType::class, ['required' => false, 'attr' => ['placeholder' => '5x112']])
            ->add('centerBore', NumberType::class, ['required' => false])
            ->add('offsetMin', IntegerType::class, ['required' => false])
            ->add('offsetMax', IntegerType::class, ['required' => false]);
    }
}

Admin Controller

#[Route('/admin/vehicles/manual')]
class ManualVehicleController extends AbstractController
{
    #[Route('', name: 'admin_manual_vehicles')]
    public function index(ManualVehicleRepository $repository): Response
    {
        return $this->render('admin/vehicles/manual/index.html.twig', [
            'vehicles' => $repository->findAllWithCreator(),
        ]);
    }

    #[Route('/create', name: 'admin_manual_vehicle_create')]
    public function create(
        Request $request,
        EntityManagerInterface $em,
        ManualVehicleProcessor $processor
    ): Response {
        $manualVehicle = new ManualVehicle();

        $form = $this->createForm(ManualVehicleType::class, $manualVehicle);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $manualVehicle->setCreatedBy($this->getUser());
            $manualVehicle->setCreatedAt(new \DateTimeImmutable());

            $em->persist($manualVehicle);
            $em->flush();

            // Process into main vehicle tables
            $processor->process($manualVehicle);

            $this->addFlash('success', 'Voertuig toegevoegd');
            return $this->redirectToRoute('admin_manual_vehicles');
        }

        return $this->render('admin/vehicles/manual/form.html.twig', [
            'form' => $form,
        ]);
    }

    #[Route('/{id}/edit', name: 'admin_manual_vehicle_edit')]
    public function edit(
        ManualVehicle $manualVehicle,
        Request $request,
        EntityManagerInterface $em,
        ManualVehicleProcessor $processor
    ): Response {
        $form = $this->createForm(ManualVehicleType::class, $manualVehicle);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $em->flush();

            // Reprocess
            $processor->process($manualVehicle);

            $this->addFlash('success', 'Voertuig bijgewerkt');
            return $this->redirectToRoute('admin_manual_vehicles');
        }

        return $this->render('admin/vehicles/manual/form.html.twig', [
            'form' => $form,
            'vehicle' => $manualVehicle,
        ]);
    }

    #[Route('/{id}/delete', name: 'admin_manual_vehicle_delete', methods: ['POST'])]
    public function delete(
        ManualVehicle $manualVehicle,
        EntityManagerInterface $em,
        ManualVehicleProcessor $processor
    ): Response {
        // Remove from main tables if it was the only source
        $processor->remove($manualVehicle);

        $em->remove($manualVehicle);
        $em->flush();

        $this->addFlash('success', 'Voertuig verwijderd');
        return $this->redirectToRoute('admin_manual_vehicles');
    }
}

Processing Service

class ManualVehicleProcessor
{
    public function __construct(
        private VehicleMakeRepository $makeRepository,
        private VehicleModelRepository $modelRepository,
        private VehicleGenerationRepository $generationRepository,
        private VehicleVariantRepository $variantRepository,
        private VehicleFitmentRuleRepository $fitmentRepository,
        private EntityManagerInterface $em
    ) {}

    public function process(ManualVehicle $manual): void
    {
        match ($manual->getEntryType()) {
            'new' => $this->processNewVehicle($manual),
            'override' => $this->processOverride($manual),
            'extension' => $this->processExtension($manual),
        };
    }

    private function processNewVehicle(ManualVehicle $manual): void
    {
        // Create or find make
        $make = $this->makeRepository->findByName($manual->getMakeName())
            ?? $this->createMake($manual->getMakeName());

        // Create or find model
        $model = $this->modelRepository->findByMakeAndName($make, $manual->getModelName())
            ?? $this->createModel($make, $manual->getModelName());

        // Create generation
        $generation = $this->createGeneration($model, $manual->getYearFrom(), $manual->getYearTo());

        // Create variant
        $variant = $this->createVariant($generation, $manual->getVariantName());

        // Link manual entry to created variant
        $manual->setLinkedVehicle($variant);
        $this->em->flush();

        // Create fitment rules
        $this->createFitmentRules($variant, $manual);
    }

    private function processOverride(ManualVehicle $manual): void
    {
        $variant = $manual->getLinkedVehicle();

        // Remove existing fitment rules for this vehicle
        $this->fitmentRepository->deleteByVehicleAndSource($variant, 'manual');

        // Add manual fitment rules with high priority
        $this->createFitmentRules($variant, $manual, overrideExisting: true);
    }

    private function processExtension(ManualVehicle $manual): void
    {
        $variant = $manual->getLinkedVehicle();

        // Add additional fitment rules (don't remove existing)
        $this->createFitmentRules($variant, $manual, overrideExisting: false);
    }

    private function createFitmentRules(
        VehicleVariant $variant,
        ManualVehicle $manual,
        bool $overrideExisting = false
    ): void {
        foreach ($manual->getFitmentRules() as $manualRule) {
            $rule = new VehicleFitmentRule();
            $rule->setVehicleVariant($variant);
            $rule->setProductType($manualRule->getProductType());
            $rule->setPosition($manualRule->getPosition());
            $rule->setIsOriginalEquipment($manualRule->isOriginalEquipment());
            $rule->setSource('manual');

            if ($manualRule->getProductType() === 'tyre') {
                $rule->setTyreWidth($manualRule->getTyreWidth());
                $rule->setTyreAspectRatio($manualRule->getTyreAspectRatio());
                $rule->setTyreDiameter($manualRule->getTyreDiameter());
            } else {
                $rule->setWheelDiameter($manualRule->getWheelDiameter());
                $rule->setWheelWidth($manualRule->getWheelWidth());
                $rule->setPcd($manualRule->getPcd());
                $rule->setCenterBore($manualRule->getCenterBore());
                $rule->setOffsetMin($manualRule->getOffsetMin());
                $rule->setOffsetMax($manualRule->getOffsetMax());
            }

            $this->em->persist($rule);
        }

        $this->em->flush();
    }

    public function remove(ManualVehicle $manual): void
    {
        if ($manual->getEntryType() === 'new' && $manual->getLinkedVehicle()) {
            // Only delete vehicle if it was created by this manual entry
            // and has no other data sources
            $variant = $manual->getLinkedVehicle();
            $hasOtherSources = $this->fitmentRepository->hasNonManualSources($variant);

            if (!$hasOtherSources) {
                // Safe to delete entire vehicle hierarchy if no children
                $this->deleteVariantIfOrphan($variant);
            }
        }

        // Remove manual fitment rules
        $this->fitmentRepository->deleteByManualVehicle($manual);
    }
}

Linking Service

For later linking manual vehicles to external data sources:

class VehicleLinkingService
{
    public function __construct(
        private VehicleDataAggregator $aggregator,
        private ManualVehicleRepository $manualRepository,
        private EntityManagerInterface $em
    ) {}

    /**
     * Attempt to link manual vehicle to external source
     */
    public function attemptLink(ManualVehicle $manual): ?LinkResult
    {
        if ($manual->getEntryType() !== 'new') {
            return null; // Only new vehicles need linking
        }

        // Search external sources for matching vehicle
        $candidates = $this->aggregator->searchVehicles(
            make: $manual->getMakeName(),
            model: $manual->getModelName(),
            yearFrom: $manual->getYearFrom(),
            yearTo: $manual->getYearTo()
        );

        if (empty($candidates)) {
            return LinkResult::noMatch();
        }

        return LinkResult::candidates($candidates);
    }

    /**
     * Confirm link between manual vehicle and external source
     */
    public function confirmLink(ManualVehicle $manual, string $externalId, string $source): void
    {
        $variant = $manual->getLinkedVehicle();

        // Add external ID mapping
        $externalMapping = new VehicleExternalId();
        $externalMapping->setVehicle($variant);
        $externalMapping->setSourceId($source);
        $externalMapping->setExternalId($externalId);

        $this->em->persist($externalMapping);
        $this->em->flush();

        // Sync additional data from external source
        $this->aggregator->syncVehicle($variant);
    }
}

Gherkin Scenarios

Feature: Manual Vehicle Management
  As an admin
  I want to manually add and modify vehicle data
  So I can handle vehicles not in external databases

  Scenario: Create new vehicle not in database
    Given no external source has "Tesla Cybertruck"
    When admin creates manual vehicle:
      | make    | Tesla      |
      | model   | Cybertruck |
      | year    | 2024-2025  |
      | variant | AWD        |
    And adds tyre fitment 285/65R20
    Then vehicle should be searchable
    And tyre 285/65R20 should show compatible

  Scenario: Override incorrect external data
    Given DriveRight has Golf 8 with incorrect offset ET35-ET40
    When admin creates override for Golf 8
    And sets correct offset ET35-ET50
    Then manual offset should be used
    And DriveRight offset should be ignored

  Scenario: Extend existing vehicle fitments
    Given Golf 8 has fitments from WheelSize
    When admin extends Golf 8
    And adds aftermarket 20" wheel option
    Then both WheelSize and manual fitments apply

  Scenario: Audit trail for manual changes
    Given admin created manual vehicle yesterday
    When viewing the manual vehicle
    Then created by should show admin name
    And created at should show yesterday's date
    And notes should be visible

  Scenario: Delete manual-only vehicle
    Given vehicle was created manually
    And has no external source data
    When admin deletes the manual entry
    Then vehicle should be removed from search
    And all fitment rules should be deleted

  Scenario: Link manual vehicle to external source later
    Given manual vehicle "Rivian R1T" exists
    When DriveRight adds Rivian R1T to their database
    And admin links manual vehicle to DriveRight
    Then external fitment data is synced
    And manual extensions are preserved

Database Schema

-- Manual vehicle entries
CREATE TABLE manual_vehicles (
    id INT PRIMARY KEY AUTO_INCREMENT,
    linked_vehicle_id INT,
    entry_type VARCHAR(20) NOT NULL,
    make_name VARCHAR(100),
    model_name VARCHAR(100),
    year_from INT,
    year_to INT,
    variant_name VARCHAR(100),
    notes TEXT,
    created_by INT NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,

    FOREIGN KEY (linked_vehicle_id) REFERENCES vehicle_variants(id),
    FOREIGN KEY (created_by) REFERENCES users(id),
    INDEX idx_entry_type (entry_type),
    INDEX idx_created_by (created_by)
);

-- Manual fitment rules (separate from computed rules)
CREATE TABLE manual_fitment_rules (
    id INT PRIMARY KEY AUTO_INCREMENT,
    manual_vehicle_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,

    created_by INT NOT NULL,
    created_at DATETIME NOT NULL,

    FOREIGN KEY (manual_vehicle_id) REFERENCES manual_vehicles(id) ON DELETE CASCADE,
    FOREIGN KEY (created_by) REFERENCES users(id)
);

Admin UI Wireframe

┌────────────────────────────────────────────────────────────────────┐
│ Handmatige Voertuigen                                    [+ Nieuw] │
├────────────────────────────────────────────────────────────────────┤
│                                                                    │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Type     │ Voertuig              │ Fitments │ Door    │ Datum  │ │
│ ├──────────┼───────────────────────┼──────────┼─────────┼────────┤ │
│ │ Nieuw    │ Tesla Cybertruck AWD  │ 2        │ admin   │ 01-02  │ │
│ │ Override │ VW Golf 8 2.0 TDI     │ 1        │ jan     │ 28-01  │ │
│ │ Extensie │ BMW M3 Competition    │ 3        │ admin   │ 25-01  │ │
│ └──────────┴───────────────────────┴──────────┴─────────┴────────┘ │
│                                                                    │
└────────────────────────────────────────────────────────────────────┘

┌────────────────────────────────────────────────────────────────────┐
│ Nieuw Handmatig Voertuig                                           │
├────────────────────────────────────────────────────────────────────┤
│                                                                    │
│ Type: ○ Nieuw voertuig  ○ Correctie bestaand  ○ Uitbreiding       │
│                                                                    │
│ ┌─ Voertuig Gegevens ─────────────────────────────────────────────┐│
│ │ Merk:    [Tesla        ]    Model: [Cybertruck    ]            ││
│ │ Jaar:    [2024] - [2025]    Variant: [AWD         ]            ││
│ └─────────────────────────────────────────────────────────────────┘│
│                                                                    │
│ ┌─ Fitment Regels ───────────────────────────────────────────────┐ │
│ │                                                                 │ │
│ │ [Band ▼] [Alle ▼]  Breedte: [285] Hoogte: [65] Diameter: [20] │ │
│ │                    ☑ OE Maat                        [Verwijder]│ │
│ │ ─────────────────────────────────────────────────────────────── │ │
│ │ [Velg ▼] [Alle ▼]  Diameter: [20] Breedte: [9]  PCD: [6x139.7]│ │
│ │                    Naaf: [106.1] ET: [0] - [25]     [Verwijder]│ │
│ │                                                                 │ │
│ │                                            [+ Fitment Toevoegen]│ │
│ └─────────────────────────────────────────────────────────────────┘│
│                                                                    │
│ Notities: [                                                      ] │
│                                                                    │
│                                           [Annuleren] [Opslaan]    │
└────────────────────────────────────────────────────────────────────┘