Skip to content

Webshop - Products

Overview

The product catalog consists of three main product types: tyres, wheels, and accessories. Products are shared across all tenants (master data managed by Super Admin) with visibility rules per tenant.

Legacy gap coverage in this document: - WF-014 Sets catalog and set detail/search - API-010 Front API wheel-set endpoint

Product Types

Article (Base Entity)

All products share a common base:

#[ORM\Entity]
#[ORM\InheritanceType('JOINED')]
#[ORM\DiscriminatorColumn(name: 'type', type: 'string')]
#[ORM\DiscriminatorMap([
    'tyre' => Tyre::class,
    'wheel' => Wheel::class,
    'accessory' => Accessory::class,
])]
abstract class Article
{
    #[ORM\Id]
    #[ORM\Column(length: 50)]
    private string $articleNumber; // Artnr

    #[ORM\ManyToOne(targetEntity: ArticleBrand::class)]
    private ArticleBrand $brand;

    #[ORM\ManyToOne(targetEntity: ArticleCategory::class)]
    private ArticleCategory $category;

    #[ORM\Column(length: 13, nullable: true)]
    private ?string $ean = null;

    #[ORM\Column(length: 50, nullable: true)]
    private ?string $mpn = null; // Manufacturer Part Number

    #[ORM\Column(length: 50, nullable: true)]
    private ?string $topMId = null;

    #[ORM\Column(length: 50, nullable: true)]
    private ?string $tyre24Id = null;

    #[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
    private string $basePrice;

    #[ORM\Column(type: 'decimal', precision: 8, scale: 3)]
    private string $weight; // in kg

    #[ORM\Column]
    private bool $isActive = true;

    #[ORM\OneToMany(mappedBy: 'article', targetEntity: ArticleStock::class)]
    private Collection $stockEntries;

    #[ORM\OneToMany(mappedBy: 'article', targetEntity: ArticleImage::class)]
    private Collection $images;
}

Tyre

#[ORM\Entity]
class Tyre extends Article
{
    #[ORM\Column]
    private int $width; // e.g., 225

    #[ORM\Column]
    private int $aspectRatio; // e.g., 45

    #[ORM\Column]
    private int $diameter; // e.g., 17

    #[ORM\Column(length: 10)]
    private string $loadIndex; // e.g., "91"

    #[ORM\Column(length: 5)]
    private string $speedIndex; // e.g., "Y"

    #[ORM\ManyToOne(targetEntity: TyreType::class)]
    private TyreType $tyreType; // Summer, Winter, All-Season

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

    #[ORM\Column]
    private bool $reinforced = false; // XL

    #[ORM\ManyToMany(targetEntity: Homologation::class)]
    private Collection $homologations; // MO, N0, *, etc.

    // EU Label
    #[ORM\Column(length: 1, nullable: true)]
    private ?string $fuelEfficiency; // A-G

    #[ORM\Column(length: 1, nullable: true)]
    private ?string $wetGrip; // A-G

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

    public function getSize(): string
    {
        return sprintf('%d/%dR%d', $this->width, $this->aspectRatio, $this->diameter);
    }
}

Wheel

#[ORM\Entity]
class Wheel extends Article
{
    #[ORM\ManyToOne(targetEntity: WheelType::class)]
    private WheelType $wheelType; // Model name

    #[ORM\ManyToOne(targetEntity: WheelColor::class)]
    private WheelColor $color;

    #[ORM\Column(type: 'decimal', precision: 4, scale: 1)]
    private string $diameter; // Inch, e.g., 19.0

    #[ORM\Column(type: 'decimal', precision: 4, scale: 1)]
    private string $width; // e.g., 8.5

    #[ORM\Column]
    private int $holes; // Number of bolts, e.g., 5

    #[ORM\Column(type: 'decimal', precision: 5, scale: 1)]
    private string $pcd; // Pitch Circle Diameter, e.g., 112.0

    #[ORM\Column]
    private int $offset; // ET, e.g., 45

    #[ORM\Column(type: 'decimal', precision: 4, scale: 1)]
    private string $centerBore; // CTR, e.g., 66.6

    #[ORM\Column(nullable: true)]
    private ?int $maxLoad; // Max wheel load in kg

    #[ORM\ManyToOne(targetEntity: WheelMaterial::class)]
    private WheelMaterial $material; // Aluminium, Steel

    // Secondary PCD (for multi-fit wheels)
    #[ORM\Column(nullable: true)]
    private ?int $holes2;

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

    // For blank wheels (range instead of fixed value)
    #[ORM\Column(nullable: true)]
    private ?int $offsetFrom;

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

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

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

Accessory

#[ORM\Entity]
class Accessory extends Article
{
    #[ORM\ManyToOne(targetEntity: AccessoryType::class)]
    private AccessoryType $accessoryType;

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

    #[ORM\Column(type: 'json', nullable: true)]
    private ?array $specifications;
}

Legacy Set Catalog And Set Search/Detail (WF-014)

Legacy storefront set flow is a first-class product surface, not just a derived wheel detail:

  1. Page registration:
  2. _core/config/pages.xml registers sets-car on SetController with localized slugs and set-specific params.

  3. Route map:

  4. _core/config/routing.php exposes:
  5. setsearch -> SetSearchController
  6. set -> SetDetailController

  7. Search behavior (SetSearchController):

  8. Requires vehicle generation (motor) and season selector (winter / summer / allseason).
  9. Builds seasonal vehicle sets through VehicleSetCreator.
  10. Applies filters (brand, delivery, speed/load index, size, xcoins) and multiple sort modes.

  11. Detail behavior (SetDetailController):

  12. Reconstructs front/rear set composition.
  13. Validates signed hash (VehicleSetHash) before allowing add-to-cart flows.
  14. Supports mini-shop quote/order flow and persists VisitorOrder for distributor workflows.

Front API Wheel-Set Endpoint (API-010)

Legacy front API exposes wheel-set pricing/discovery:

  • Route: GET /api/wheel-sets/{productId}/vehicle/{generationId}
  • Controller: Atraxion\Front\Controller\Api\WheelSetController::find

Observed behavior: 1. Returns empty result unless generation and wheel exist, wheel brand allows sets, and minimum validated wheel stock is available. 2. Computes set candidates per tyre season (winter/summer/allseason). 3. Returns cheapest formatted seasonal option with localized set URI targeting sets-car search flow. 4. Updates cached cheapest set price metadata via UpdateProductPrice.

Wheel Sets - Target Projection Model (Day 1)

Wheel sets are treated as virtual composite articles with deterministic keys and precomputed projections.

Deterministic Set Key

set_key = hash(
  wheel_front_article,
  wheel_rear_article_or_null,
  tyre_front_article,
  tyre_rear_article_or_null,
  tpms_article_or_null,
  mounting_type,
  vehicle_variant_id,
  season
)

This key is the canonical identifier for API responses, cart lines, and pricing/availability caches.

Projection Schema

#[ORM\Entity]
class WheelSetProjection
{
    #[ORM\Id]
    #[ORM\Column(length: 64)]
    private string $setKey;

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

    #[ORM\Column(length: 50, nullable: true)]
    private ?string $wheelRearArticleNumber = null;

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

    #[ORM\Column(length: 50, nullable: true)]
    private ?string $tyreRearArticleNumber = null;

    #[ORM\Column(length: 50, nullable: true)]
    private ?string $tpmsArticleNumber = null;

    #[ORM\Column(length: 20)]
    private string $mountingType; // 'standard' or 'staggered'

    #[ORM\Column]
    private int $vehicleVariantId;

    #[ORM\Column(length: 20)]
    private string $season; // winter/summer/allseason

    #[ORM\Column]
    private int $sellableQuantity;

    #[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
    private string $setPrice;

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

Quantity Calculation Rules

Standard sets (same wheel/tyre for all corners):

sellable_qty = floor(min(wheel_qty, tyre_qty) / 4)

Staggered sets (front/rear differ):

sellable_front = floor(min(wheel_front_qty, tyre_front_qty) / 2)
sellable_rear  = floor(min(wheel_rear_qty, tyre_rear_qty) / 2)
sellable_qty   = min(sellable_front, sellable_rear)

Optional TPMS and mounting capacity constraints are applied as additional minimum bounds.

Projection Refresh Triggers

Recompute affected set projections on:

  1. Component stock updates.
  2. Component price changes.
  3. Vehicle fitment mapping changes.
  4. Set composition rule changes (season/profile exclusions, mounting rules).

Processing model:

  1. Incremental recompute by changed component/vehicle keys.
  2. Nightly reconciliation pass for drift detection.
  3. Replay support through queue jobs with idempotency keys.

API Contract Behavior

GET /api/wheel-sets/{productId}/vehicle/{generationId} reads from projection tables first.

Fallback behavior:

  1. If projection row exists: return projected cheapest candidates.
  2. If missing: trigger async projection build and return empty/processing response contract.

No runtime brute-force composition query should run on storefront requests.

Performance Targets (SLI)

  1. Set list/detail and wheel-set API endpoints: p95 < 300 ms.
  2. Projection freshness lag after stock/price update: < 5 minutes.
  3. Projection rebuild failure rate: < 0.5% per day with operator replay path.

Product Identification

Products can be identified by multiple IDs for matching purposes:

Identifier Priority Purpose
EAN 1 European Article Number (barcode)
MPN 2 Manufacturer Part Number
Article Number 3 Internal article number
Tyre24 ID 4 Tyre24 platform reference
TopM ID 5 TopM software reference

Stock Management

Stock comes from multiple suppliers and is managed by the Stock Import island.

#[ORM\Entity]
class ArticleStock
{
    #[ORM\ManyToOne(targetEntity: Article::class)]
    private Article $article;

    #[ORM\ManyToOne(targetEntity: Supplier::class)]
    private Supplier $supplier;

    #[ORM\Column]
    private int $quantity;

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

Atraxion Stock

Supplier with ID 1 is Atraxion's own stock (master stock):

class Supplier
{
    public const ATRAXION = 1;
}

class StockService
{
    public function getAtraxionStock(Article $article): int
    {
        $stock = $this->stockRepository->findOneBy([
            'article' => $article,
            'supplier' => Supplier::ATRAXION,
        ]);

        return $stock?->getQuantity() ?? 0;
    }

    public function getTotalStock(Article $article): int
    {
        return $this->stockRepository->sumQuantityByArticle($article);
    }
}

Sibling Detection (NEW FEATURE)

Unlike the current system, the new webshop will show related products (siblings) on the product detail page.

What are Siblings?

Products that are the same model but in different: - Colors (for wheels) - Sizes (diameter, width)

Sibling Detection Logic

class WheelSiblingService
{
    /**
     * Find all siblings of a wheel (same model, different color/size)
     */
    public function findSiblings(Wheel $wheel): WheelSiblingCollection
    {
        $siblings = $this->wheelRepository->findBy([
            'wheelType' => $wheel->getWheelType(), // Same model
            'brand' => $wheel->getBrand(),
        ]);

        return new WheelSiblingCollection($wheel, $siblings);
    }
}

class WheelSiblingCollection
{
    private Wheel $current;
    private array $siblings;

    public function getAvailableColors(): array
    {
        $colors = [];
        foreach ($this->siblings as $sibling) {
            if ($sibling->getDiameter() === $this->current->getDiameter()
                && $sibling->getWidth() === $this->current->getWidth()) {
                $colors[$sibling->getColor()->getId()] = [
                    'color' => $sibling->getColor(),
                    'article' => $sibling,
                    'inStock' => $sibling->hasStock(),
                ];
            }
        }
        return $colors;
    }

    public function getAvailableSizes(): array
    {
        $sizes = [];
        foreach ($this->siblings as $sibling) {
            if ($sibling->getColor()->getId() === $this->current->getColor()->getId()) {
                $key = sprintf('%sx%s', $sibling->getWidth(), $sibling->getDiameter());
                $sizes[$key] = [
                    'width' => $sibling->getWidth(),
                    'diameter' => $sibling->getDiameter(),
                    'article' => $sibling,
                    'inStock' => $sibling->hasStock(),
                ];
            }
        }
        return $sizes;
    }
}

Display on Product Page

{# Product detail page #}
<div class="product-siblings">
    {% if siblings.availableColors|length > 1 %}
    <div class="color-selector">
        <h4>{{ 'Also available in'|trans }}:</h4>
        <div class="color-swatches">
            {% for colorOption in siblings.availableColors %}
                <a href="{{ path('product_detail', {id: colorOption.article.id}) }}"
                   class="color-swatch {{ colorOption.color.id == currentColor.id ? 'active' : '' }}"
                   style="background-color: {{ colorOption.color.hexCode }}"
                   title="{{ colorOption.color.name }}">
                    {% if not colorOption.inStock %}
                        <span class="out-of-stock-indicator"></span>
                    {% endif %}
                </a>
            {% endfor %}
        </div>
    </div>
    {% endif %}

    {% if siblings.availableSizes|length > 1 %}
    <div class="size-selector">
        <h4>{{ 'Other sizes'|trans }}:</h4>
        <div class="size-options">
            {% for sizeOption in siblings.availableSizes %}
                <a href="{{ path('product_detail', {id: sizeOption.article.id}) }}"
                   class="size-option {{ sizeOption.diameter == currentDiameter ? 'active' : '' }}">
                    {{ sizeOption.width }}x{{ sizeOption.diameter }}"
                    {% if not sizeOption.inStock %}
                        <span class="out-of-stock">({{ 'Out of stock'|trans }})</span>
                    {% endif %}
                </a>
            {% endfor %}
        </div>
    </div>
    {% endif %}
</div>

Search by Specifications

class ProductSearchService
{
    public function searchTyres(TyreSearchCriteria $criteria): array
    {
        $qb = $this->tyreRepository->createQueryBuilder('t');

        if ($criteria->width) {
            $qb->andWhere('t.width = :width')->setParameter('width', $criteria->width);
        }

        if ($criteria->aspectRatio) {
            $qb->andWhere('t.aspectRatio = :aspect')->setParameter('aspect', $criteria->aspectRatio);
        }

        if ($criteria->diameter) {
            $qb->andWhere('t.diameter = :diameter')->setParameter('diameter', $criteria->diameter);
        }

        if ($criteria->season) {
            $qb->join('t.tyreType', 'tt')
               ->andWhere('tt.season = :season')
               ->setParameter('season', $criteria->season);
        }

        if ($criteria->brandIds) {
            $qb->andWhere('t.brand IN (:brands)')->setParameter('brands', $criteria->brandIds);
        }

        return $qb->getQuery()->getResult();
    }

    public function searchWheels(WheelSearchCriteria $criteria): array
    {
        $qb = $this->wheelRepository->createQueryBuilder('w');

        if ($criteria->diameter) {
            $qb->andWhere('w.diameter = :diameter')->setParameter('diameter', $criteria->diameter);
        }

        if ($criteria->pcd) {
            // Match either primary or secondary PCD
            $qb->andWhere('(w.pcd = :pcd OR w.pcd2 = :pcd)')
               ->setParameter('pcd', $criteria->pcd);
        }

        if ($criteria->holes) {
            $qb->andWhere('(w.holes = :holes OR w.holes2 = :holes)')
               ->setParameter('holes', $criteria->holes);
        }

        if ($criteria->colorGroup) {
            $qb->join('w.color', 'c')
               ->andWhere('c.colorGroup = :colorGroup')
               ->setParameter('colorGroup', $criteria->colorGroup);
        }

        return $qb->getQuery()->getResult();
    }
}

Search by Vehicle

Products can be searched by vehicle compatibility (see vehicle-data island for mapping logic):

class VehicleProductSearchService
{
    public function findWheelsForVehicle(Vehicle $vehicle): array
    {
        // Call Vehicle Data island API
        $compatibleWheelIds = $this->vehicleDataClient->getCompatibleWheels(
            $vehicle->getChassisId()
        );

        return $this->wheelRepository->findByIds($compatibleWheelIds);
    }

    public function findTyresForVehicle(Vehicle $vehicle): array
    {
        // Get OEM tyre sizes for vehicle
        $tyreSizes = $this->vehicleDataClient->getOemTyreSizes(
            $vehicle->getChassisId()
        );

        $tyres = [];
        foreach ($tyreSizes as $size) {
            $tyres = array_merge($tyres, $this->tyreRepository->findBySize(
                $size['width'],
                $size['aspectRatio'],
                $size['diameter']
            ));
        }

        return $tyres;
    }
}

Pricing

Price Hierarchy

  1. Tenant-specific price (if exists)
  2. Base price (Atraxion price)
  3. Customer margin (if customer has custom margin)
class PriceCalculator
{
    public function calculatePrice(
        Article $article,
        Tenant $tenant,
        ?Customer $customer = null
    ): Money {
        // 1. Get base price (tenant override or article base)
        $tenantPrice = $this->tenantPriceRepository->findCurrentPrice($article, $tenant);
        $price = $tenantPrice?->getPrice() ?? $article->getBasePrice();

        // 2. Apply customer margin if applicable
        if ($customer) {
            $price = $this->applyCustomerPricing($price, $article, $customer);
        }

        return Money::EUR($price);
    }

    private function applyCustomerPricing(
        string $basePrice,
        Article $article,
        Customer $customer
    ): string {
        // Customer can have margin per category, brand, or global
        $margin = $this->customerMarginRepository->findApplicableMargin(
            $customer,
            $article->getCategory(),
            $article->getBrand()
        );

        if ($margin) {
            // Margin is percentage, e.g., -10 means 10% discount
            $multiplier = 1 + ($margin->getPercentage() / 100);
            return bcmul($basePrice, (string)$multiplier, 2);
        }

        return $basePrice;
    }
}

Gherkin Scenarios

Feature: Product Catalog
  As a customer
  I want to browse and search products
  So that I can find what I need

  Scenario: View tyre details
    Given a tyre "Michelin Pilot Sport 4" exists
    And the tyre has size "225/45R17"
    When I view the product detail page
    Then I should see the tyre specifications
    And I should see the price
    And I should see the stock availability

  Scenario: View wheel with sibling colors
    Given a wheel "BBS SR" exists in colors "Black" and "Silver"
    And both colors are in stock
    When I view the "Black" wheel detail page
    Then I should see a color selector
    And "Silver" should be shown as available

  Scenario: View wheel with out-of-stock sibling
    Given a wheel "OZ Racing" exists in "19 inch" and "20 inch"
    And "19 inch" is in stock
    And "20 inch" is out of stock
    When I view the "19 inch" wheel detail page
    Then I should see "20 inch" with an out-of-stock indicator

  Scenario: Search tyres by size
    Given tyres exist with size "225/45R17"
    When I search for tyres with width 225, aspect 45, diameter 17
    Then I should see all matching tyres

  Scenario: Search wheels by PCD
    Given wheels exist with PCD "5x112"
    When I search for wheels with 5 holes and PCD 112
    Then I should see all matching wheels

  Scenario: Tenant product visibility
    Given tenant "ACME" can only see brand "Michelin"
    And products exist from "Michelin" and "Continental"
    When I browse products as an ACME customer
    Then I should only see "Michelin" products

  Scenario: Customer-specific pricing
    Given customer "Garage XYZ" has a 10% discount on tyres
    And a tyre costs €100 base price
    When "Garage XYZ" views the tyre
    Then the price should be €90

Database Schema

-- Base article table (shared columns)
CREATE TABLE articles (
    article_number VARCHAR(50) PRIMARY KEY,
    type VARCHAR(20) NOT NULL,
    brand_id INT NOT NULL,
    category_id INT NOT NULL,
    ean VARCHAR(13),
    mpn VARCHAR(50),
    top_m_id VARCHAR(50),
    tyre24_id VARCHAR(50),
    base_price DECIMAL(10,2) NOT NULL,
    weight DECIMAL(8,3) NOT NULL,
    is_active BOOLEAN DEFAULT TRUE,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    FOREIGN KEY (brand_id) REFERENCES article_brands(id),
    FOREIGN KEY (category_id) REFERENCES article_categories(id),
    INDEX idx_ean (ean),
    INDEX idx_mpn (mpn),
    INDEX idx_tyre24_id (tyre24_id)
);

-- Tyre-specific columns
CREATE TABLE tyres (
    article_number VARCHAR(50) PRIMARY KEY,
    width INT NOT NULL,
    aspect_ratio INT NOT NULL,
    diameter INT NOT NULL,
    load_index VARCHAR(10) NOT NULL,
    speed_index VARCHAR(5) NOT NULL,
    tyre_type_id INT NOT NULL,
    run_flat BOOLEAN DEFAULT FALSE,
    reinforced BOOLEAN DEFAULT FALSE,
    fuel_efficiency CHAR(1),
    wet_grip CHAR(1),
    noise_level INT,
    FOREIGN KEY (article_number) REFERENCES articles(article_number),
    FOREIGN KEY (tyre_type_id) REFERENCES tyre_types(id),
    INDEX idx_size (width, aspect_ratio, diameter)
);

-- Wheel-specific columns
CREATE TABLE wheels (
    article_number VARCHAR(50) PRIMARY KEY,
    wheel_type_id INT NOT NULL,
    color_id INT NOT NULL,
    diameter DECIMAL(4,1) NOT NULL,
    width DECIMAL(4,1) NOT NULL,
    holes INT NOT NULL,
    pcd DECIMAL(5,1) NOT NULL,
    offset INT NOT NULL,
    center_bore DECIMAL(4,1) NOT NULL,
    max_load INT,
    material_id INT NOT NULL,
    holes2 INT,
    pcd2 DECIMAL(5,1),
    offset_from INT,
    offset_to INT,
    min_holes INT,
    max_holes INT,
    FOREIGN KEY (article_number) REFERENCES articles(article_number),
    FOREIGN KEY (wheel_type_id) REFERENCES wheel_types(id),
    FOREIGN KEY (color_id) REFERENCES wheel_colors(id),
    FOREIGN KEY (material_id) REFERENCES wheel_materials(id),
    INDEX idx_specs (holes, pcd, diameter)
);

-- Stock per supplier
CREATE TABLE article_stock (
    id INT PRIMARY KEY AUTO_INCREMENT,
    article_number VARCHAR(50) NOT NULL,
    supplier_id INT NOT NULL,
    quantity INT NOT NULL DEFAULT 0,
    last_updated DATETIME NOT NULL,
    FOREIGN KEY (article_number) REFERENCES articles(article_number),
    FOREIGN KEY (supplier_id) REFERENCES suppliers(id),
    UNIQUE KEY (article_number, supplier_id)
);

-- Virtual wheel-set projection
CREATE TABLE wheel_set_projection (
    set_key VARCHAR(64) PRIMARY KEY,
    wheel_front_article_number VARCHAR(50) NOT NULL,
    wheel_rear_article_number VARCHAR(50) NULL,
    tyre_front_article_number VARCHAR(50) NOT NULL,
    tyre_rear_article_number VARCHAR(50) NULL,
    tpms_article_number VARCHAR(50) NULL,
    mounting_type VARCHAR(20) NOT NULL,
    vehicle_variant_id INT NOT NULL,
    season VARCHAR(20) NOT NULL,
    sellable_quantity INT NOT NULL DEFAULT 0,
    set_price DECIMAL(10,2) NOT NULL,
    computed_at DATETIME NOT NULL,
    INDEX idx_wsp_vehicle_season (vehicle_variant_id, season),
    INDEX idx_wsp_wheel_front (wheel_front_article_number),
    INDEX idx_wsp_tyre_front (tyre_front_article_number)
);

-- Wheel types (model names)
CREATE TABLE wheel_types (
    id INT PRIMARY KEY AUTO_INCREMENT,
    brand_id INT NOT NULL,
    name VARCHAR(100) NOT NULL,
    FOREIGN KEY (brand_id) REFERENCES article_brands(id)
);

-- Wheel colors
CREATE TABLE wheel_colors (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50) NOT NULL,
    color_group_id INT NOT NULL,
    hex_code VARCHAR(7),
    FOREIGN KEY (color_group_id) REFERENCES wheel_color_groups(id)
);