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] │
└────────────────────────────────────────────────────────────────────┘