Refactoring DDD approach
This commit is contained in:
parent
48d3940288
commit
acd669e180
36
migrations/Version20250604043747.php
Normal file
36
migrations/Version20250604043747.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20250604043747 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add car_properties_embeddings table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE car_properties_embeddings (
|
||||
car_property_id VARCHAR(255) NOT NULL,
|
||||
embedding_id VARCHAR(255) NOT NULL,
|
||||
PRIMARY KEY(car_property_id, embedding_id)
|
||||
)');
|
||||
|
||||
$this->addSql('ALTER TABLE car_properties_embeddings ADD CONSTRAINT FK_car_properties_embeddings_car_property_id FOREIGN KEY (car_property_id) REFERENCES car_properties (id)');
|
||||
$this->addSql('ALTER TABLE car_properties_embeddings ADD CONSTRAINT FK_car_properties_embeddings_embedding_id FOREIGN KEY (embedding_id) REFERENCES embeddings (id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE car_properties_embeddings');
|
||||
}
|
||||
}
|
||||
29
migrations/Version20250604051942.php
Normal file
29
migrations/Version20250604051942.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20250604051942 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add image column to car_revisions table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE car_revisions ADD COLUMN image TEXT');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE car_revisions DROP COLUMN image');
|
||||
}
|
||||
}
|
||||
@ -5,8 +5,8 @@ namespace App\Application\Commands;
|
||||
use App\Domain\AI\AIClient;
|
||||
use App\Domain\Model\Persistence\PersistedEmbedding;
|
||||
use App\Domain\Repository\EmbeddingRepository;
|
||||
use App\Domain\Model\AI\Embedding;
|
||||
use App\Domain\Model\Value\EmbeddingId;
|
||||
use App\Domain\Model\Embedding\Embedding;
|
||||
use App\Domain\Model\Id\EmbeddingId;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
@ -10,10 +10,10 @@ use App\Domain\Model\Image;
|
||||
use App\Domain\Model\Value\Date;
|
||||
use App\Domain\Model\Value\Price;
|
||||
use App\Domain\Model\Value\Currency;
|
||||
use App\Domain\Model\Value\BrandId;
|
||||
use App\Domain\Model\Value\CarModelId;
|
||||
use App\Domain\Model\Value\CarRevisionId;
|
||||
use App\Domain\Model\Value\CarPropertyId;
|
||||
use App\Domain\Model\Id\BrandId;
|
||||
use App\Domain\Model\Id\CarModelId;
|
||||
use App\Domain\Model\Id\CarRevisionId;
|
||||
use App\Domain\Model\Id\CarPropertyId;
|
||||
use App\Domain\Model\Battery\CellChemistry;
|
||||
use App\Domain\Model\CarProperty;
|
||||
use App\Domain\Model\CarPropertyType;
|
||||
@ -75,10 +75,8 @@ class LoadFixtures extends Command
|
||||
$io->title('Loading EV Wiki Fixtures');
|
||||
$fixtures = $this->getFixtures();
|
||||
|
||||
// Extract unique brands
|
||||
$brandNames = array_unique(array_column($fixtures, 'brand'));
|
||||
|
||||
// Load brands
|
||||
$io->section('Loading Brands');
|
||||
$io->progressStart(count($brandNames));
|
||||
|
||||
@ -119,7 +117,6 @@ class LoadFixtures extends Command
|
||||
$io->progressFinish();
|
||||
$io->success(sprintf('Successfully loaded %d car models', count($models)));
|
||||
|
||||
// Count total revisions for progress bar
|
||||
$totalRevisions = 0;
|
||||
foreach ($fixtures as $brandFixture) {
|
||||
foreach ($brandFixture['models'] as $modelFixture) {
|
||||
@ -127,7 +124,6 @@ class LoadFixtures extends Command
|
||||
}
|
||||
}
|
||||
|
||||
// Load car revisions and properties
|
||||
$io->section('Loading Car Revisions and Properties');
|
||||
$io->progressStart($totalRevisions);
|
||||
|
||||
@ -138,16 +134,17 @@ class LoadFixtures extends Command
|
||||
$carModel = $this->carModels[$modelFixture['model']];
|
||||
|
||||
foreach ($modelFixture['revisions'] as $revisionFixture) {
|
||||
// Create car revision
|
||||
$image = $revisionFixture['image'];
|
||||
|
||||
$carRevision = new CarRevision(
|
||||
CarRevisionId::generate(),
|
||||
$carModel->carModelId,
|
||||
$revisionFixture['revision']
|
||||
$revisionFixture['revision'],
|
||||
$image
|
||||
);
|
||||
|
||||
$this->carRevisionRepository->save($carRevision);
|
||||
|
||||
// Create properties
|
||||
foreach ($revisionFixture['properties'] as $propertyData) {
|
||||
$property = new CarProperty(
|
||||
CarPropertyId::generate(),
|
||||
@ -155,8 +152,9 @@ class LoadFixtures extends Command
|
||||
$propertyData['type'],
|
||||
$propertyData['value']
|
||||
);
|
||||
$this->carPropertyRepository->save($property);
|
||||
|
||||
$this->carPropertyEmbedder->createPersistedEmbedding($property, $carRevision, $brand);
|
||||
$this->carPropertyRepository->save($property);
|
||||
}
|
||||
|
||||
$io->progressAdvance();
|
||||
@ -172,7 +170,7 @@ class LoadFixtures extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array{brand: string, models: array<array{model: string, revisions: array<array{revision: string, properties: array<array{type: CarPropertyType, value: mixed}>}>}>}>
|
||||
* @return array<array{brand: string, models: array<array{model: string, revisions: array<array{revision: string, image: Image, properties: array<array{type: CarPropertyType, value: mixed}>}>}>}>
|
||||
*/
|
||||
private function getFixtures(): array
|
||||
{
|
||||
@ -185,6 +183,7 @@ class LoadFixtures extends Command
|
||||
'revisions' => [
|
||||
[
|
||||
'revision' => 'Plaid',
|
||||
'image' => new Image(externalPublicUrl: 'https://digitalassets.tesla.com/tesla-contents/image/upload/f_auto,q_auto/Model-S-Main-Hero-Desktop-LHD.jpg'),
|
||||
'properties' => [
|
||||
['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2021)],
|
||||
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(750)],
|
||||
@ -200,7 +199,6 @@ class LoadFixtures extends Command
|
||||
['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(628)],
|
||||
['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(652)],
|
||||
['type' => CarPropertyType::CATALOG_PRICE, 'value' => new Price(129990, Currency::euro())],
|
||||
['type' => CarPropertyType::IMAGE_EXTERNAL_PUBLIC_URL, 'value' => new Image(externalPublicUrl: 'https://digitalassets.tesla.com/tesla-contents/image/upload/f_auto,q_auto/Model-S-Main-Hero-Desktop-LHD.jpg')]
|
||||
]
|
||||
]
|
||||
]
|
||||
@ -210,6 +208,7 @@ class LoadFixtures extends Command
|
||||
'revisions' => [
|
||||
[
|
||||
'revision' => 'Long Range',
|
||||
'image' => new Image(externalPublicUrl: 'https://digitalassets.tesla.com/tesla-contents/image/upload/f_auto,q_auto/Model-3-Main-Hero-Desktop-LHD.jpg'),
|
||||
'properties' => [
|
||||
['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2020)],
|
||||
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(366)],
|
||||
@ -225,7 +224,6 @@ class LoadFixtures extends Command
|
||||
['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(602)],
|
||||
['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(614)],
|
||||
['type' => CarPropertyType::CATALOG_PRICE, 'value' => new Price(49990, Currency::euro())],
|
||||
['type' => CarPropertyType::IMAGE_EXTERNAL_PUBLIC_URL, 'value' => new Image(externalPublicUrl: 'https://digitalassets.tesla.com/tesla-contents/image/upload/f_auto,q_auto/Model-3-Main-Hero-Desktop-LHD.jpg')]
|
||||
]
|
||||
]
|
||||
]
|
||||
@ -240,6 +238,7 @@ class LoadFixtures extends Command
|
||||
'revisions' => [
|
||||
[
|
||||
'revision' => 'xDrive50',
|
||||
'image' => new Image(externalPublicUrl: 'https://www.bmw.de/content/dam/bmw/common/all-models/x-series/ix/2021/highlights/bmw-ix-sp-desktop.jpg'),
|
||||
'properties' => [
|
||||
['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2021)],
|
||||
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(385)],
|
||||
@ -255,7 +254,6 @@ class LoadFixtures extends Command
|
||||
['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(630)],
|
||||
['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(680)],
|
||||
['type' => CarPropertyType::CATALOG_PRICE, 'value' => new Price(77300, Currency::euro())],
|
||||
['type' => CarPropertyType::IMAGE_EXTERNAL_PUBLIC_URL, 'value' => new Image(externalPublicUrl: 'https://www.bmw.de/content/dam/bmw/common/all-models/x-series/ix/2021/highlights/bmw-ix-sp-desktop.jpg')]
|
||||
]
|
||||
]
|
||||
]
|
||||
@ -270,6 +268,7 @@ class LoadFixtures extends Command
|
||||
'revisions' => [
|
||||
[
|
||||
'revision' => 'RS',
|
||||
'image' => new Image(externalPublicUrl: 'https://www.audi.de/content/dam/nemo/models/e-tron-gt/my-2021/1920x1080-gallery/1920x1080_AudiRS_e-tron_GT_19.jpg'),
|
||||
'properties' => [
|
||||
['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2021)],
|
||||
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(475)],
|
||||
@ -285,7 +284,6 @@ class LoadFixtures extends Command
|
||||
['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(472)],
|
||||
['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(487)],
|
||||
['type' => CarPropertyType::CATALOG_PRICE, 'value' => new Price(142900, Currency::euro())],
|
||||
['type' => CarPropertyType::IMAGE_EXTERNAL_PUBLIC_URL, 'value' => new Image(externalPublicUrl: 'https://www.audi.de/content/dam/nemo/models/e-tron-gt/my-2021/1920x1080-gallery/1920x1080_AudiRS_e-tron_GT_19.jpg')]
|
||||
]
|
||||
]
|
||||
]
|
||||
@ -300,6 +298,7 @@ class LoadFixtures extends Command
|
||||
'revisions' => [
|
||||
[
|
||||
'revision' => '450+',
|
||||
'image' => new Image(externalPublicUrl: 'https://www.mercedes-benz.de/content/germany/de/mercedes-benz/vehicles/passenger-cars/eqs/sedan/overview/_jcr_content/root/responsivegrid/simple_stage.component.damq2.3318859008536.jpg'),
|
||||
'properties' => [
|
||||
['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2021)],
|
||||
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(245)],
|
||||
@ -315,7 +314,6 @@ class LoadFixtures extends Command
|
||||
['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(756)],
|
||||
['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(770)],
|
||||
['type' => CarPropertyType::CATALOG_PRICE, 'value' => new Price(106374, Currency::euro())],
|
||||
['type' => CarPropertyType::IMAGE_EXTERNAL_PUBLIC_URL, 'value' => new Image(externalPublicUrl: 'https://www.mercedes-benz.de/content/germany/de/mercedes-benz/vehicles/passenger-cars/eqs/sedan/overview/_jcr_content/root/responsivegrid/simple_stage.component.damq2.3318859008536.jpg')]
|
||||
]
|
||||
]
|
||||
]
|
||||
@ -330,6 +328,7 @@ class LoadFixtures extends Command
|
||||
'revisions' => [
|
||||
[
|
||||
'revision' => 'Pro',
|
||||
'image' => new Image(externalPublicUrl: 'https://www.volkswagen.de/content/dam/vw-ngw/vw_pkw/importers/de/models/id-4/gallery/id4-gallery-exterior-01-16x9.jpg'),
|
||||
'properties' => [
|
||||
['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2020)],
|
||||
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(150)],
|
||||
@ -345,7 +344,6 @@ class LoadFixtures extends Command
|
||||
['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(520)],
|
||||
['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(549)],
|
||||
['type' => CarPropertyType::CATALOG_PRICE, 'value' => new Price(51515, Currency::euro())],
|
||||
['type' => CarPropertyType::IMAGE_EXTERNAL_PUBLIC_URL, 'value' => new Image(externalPublicUrl: 'https://www.volkswagen.de/content/dam/vw-ngw/vw_pkw/importers/de/models/id-4/gallery/id4-gallery-exterior-01-16x9.jpg')]
|
||||
]
|
||||
]
|
||||
]
|
||||
@ -360,6 +358,7 @@ class LoadFixtures extends Command
|
||||
'revisions' => [
|
||||
[
|
||||
'revision' => 'Turbo S',
|
||||
'image' => new Image(externalPublicUrl: 'https://www.porsche.com/germany/models/taycan/taycan-models/turbo-s/_jcr_content/par/twocolumnlayout/par_left/image.transform.porsche-model-desktop-xl.jpg'),
|
||||
'properties' => [
|
||||
['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2019)],
|
||||
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(560)],
|
||||
@ -375,7 +374,6 @@ class LoadFixtures extends Command
|
||||
['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(440)],
|
||||
['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(452)],
|
||||
['type' => CarPropertyType::CATALOG_PRICE, 'value' => new Price(185456, Currency::euro())],
|
||||
['type' => CarPropertyType::IMAGE_EXTERNAL_PUBLIC_URL, 'value' => new Image(externalPublicUrl: 'https://www.porsche.com/germany/models/taycan/taycan-models/turbo-s/_jcr_content/par/twocolumnlayout/par_left/image.transform.porsche-model-desktop-xl.jpg')]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Domain\AI;
|
||||
|
||||
use App\Domain\Model\AI\LargeEmbeddingVector;
|
||||
use App\Domain\Model\AI\SmallEmbeddingVector;
|
||||
use App\Domain\Model\Embedding\LargeEmbeddingVector;
|
||||
use App\Domain\Model\Embedding\SmallEmbeddingVector;
|
||||
use App\Domain\Model\Value\Vector;
|
||||
use OpenAI;
|
||||
|
||||
|
||||
@ -3,12 +3,11 @@
|
||||
namespace App\Domain\ContentManagement;
|
||||
|
||||
use App\Domain\AI\AIClient;
|
||||
use App\Domain\Model\AI\Embedding;
|
||||
use App\Domain\Model\Embedding\Embedding;
|
||||
use App\Domain\Model\Brand;
|
||||
use App\Domain\Model\CarProperty;
|
||||
use App\Domain\Model\CarRevision;
|
||||
use App\Domain\Model\Persistence\PersistedEmbedding;
|
||||
use App\Domain\Model\Value\EmbeddingId;
|
||||
use App\Domain\Model\Id\EmbeddingId;
|
||||
use App\Domain\Repository\EmbeddingRepository;
|
||||
use Stringable;
|
||||
|
||||
@ -34,9 +33,10 @@ class CarPropertyEmbedder
|
||||
$text .= ', ' . $brand->name;
|
||||
}
|
||||
|
||||
$persistedEmbedding = $this->embeddingRepository->findByPhrase($text);
|
||||
if ($persistedEmbedding) {
|
||||
return $persistedEmbedding;
|
||||
$embedding = $this->embeddingRepository->findByPhrase($text);
|
||||
if ($embedding) {
|
||||
$carProperty->embeddings->add($embedding->embeddingId);
|
||||
return $embedding;
|
||||
}
|
||||
|
||||
$largeEmbedding = $this->aiClient->embedTextLarge($text);
|
||||
@ -45,6 +45,8 @@ class CarPropertyEmbedder
|
||||
$embedding = new Embedding(EmbeddingId::generate(), $text, $largeEmbedding, $smallEmbedding);
|
||||
$this->embeddingRepository->save($embedding);
|
||||
|
||||
$carProperty->embeddings->add($embedding->embeddingId);
|
||||
|
||||
return $embedding;
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Domain\Model;
|
||||
|
||||
use App\Domain\Model\Value\BrandId;
|
||||
use App\Domain\Model\Id\BrandId;
|
||||
|
||||
final readonly class Brand
|
||||
{
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Domain\Model;
|
||||
|
||||
use App\Domain\Model\Value\BrandId;
|
||||
use App\Domain\Model\Value\CarModelId;
|
||||
use App\Domain\Model\Id\BrandId;
|
||||
use App\Domain\Model\Id\CarModelId;
|
||||
|
||||
class CarModel
|
||||
{
|
||||
|
||||
@ -2,8 +2,9 @@
|
||||
|
||||
namespace App\Domain\Model;
|
||||
|
||||
use App\Domain\Model\Value\CarPropertyId;
|
||||
use App\Domain\Model\Value\CarRevisionId;
|
||||
use App\Domain\Model\Id\CarPropertyId;
|
||||
use App\Domain\Model\Id\CarRevisionId;
|
||||
use App\Domain\Model\Id\EmbeddingIdCollection;
|
||||
|
||||
final readonly class CarProperty
|
||||
{
|
||||
@ -12,5 +13,6 @@ final readonly class CarProperty
|
||||
public readonly CarRevisionId $carRevisionId,
|
||||
public CarPropertyType $type,
|
||||
public mixed $value,
|
||||
public readonly EmbeddingIdCollection $embeddings = new EmbeddingIdCollection([]),
|
||||
) {}
|
||||
}
|
||||
@ -53,10 +53,6 @@ enum CarPropertyType: string
|
||||
case CATALOG_PRICE_CURRENCY = 'catalog_price_currency';
|
||||
case CATALOG_PRICE_INCLUDES_VAT = 'catalog_price_includes_vat';
|
||||
|
||||
// Image fields
|
||||
case IMAGE_EXTERNAL_PUBLIC_URL = 'image_external_public_url';
|
||||
case IMAGE_RELATIVE_PUBLIC_URL = 'image_relative_public_url';
|
||||
|
||||
public function humanReadable(): string
|
||||
{
|
||||
return match ($this) {
|
||||
@ -84,8 +80,6 @@ enum CarPropertyType: string
|
||||
self::CATALOG_PRICE => 'Catalog Price',
|
||||
self::CATALOG_PRICE_CURRENCY => 'Catalog Price Currency',
|
||||
self::CATALOG_PRICE_INCLUDES_VAT => 'Catalog Price Includes VAT',
|
||||
self::IMAGE_EXTERNAL_PUBLIC_URL => 'External Public URL',
|
||||
self::IMAGE_RELATIVE_PUBLIC_URL => 'Relative Public URL',
|
||||
self::CHARGE_TIME_0_TO_100 => 'Charge Time 0 to 100',
|
||||
self::CHARGE_TIME_0_TO_70 => 'Charge Time 0 to 70',
|
||||
self::CHARGE_TIME_10_TO_70 => 'Charge Time 10 to 70',
|
||||
|
||||
@ -2,8 +2,9 @@
|
||||
|
||||
namespace App\Domain\Model;
|
||||
|
||||
use App\Domain\Model\Value\CarModelId;
|
||||
use App\Domain\Model\Value\CarRevisionId;
|
||||
use App\Domain\Model\Id\CarModelId;
|
||||
use App\Domain\Model\Id\CarRevisionId;
|
||||
use App\Domain\Model\Image;
|
||||
|
||||
final readonly class CarRevision
|
||||
{
|
||||
@ -11,5 +12,6 @@ final readonly class CarRevision
|
||||
public readonly CarRevisionId $carRevisionId,
|
||||
public readonly CarModelId $carModelId,
|
||||
public readonly string $name,
|
||||
public readonly ?Image $image = null,
|
||||
) {}
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Model\AI;
|
||||
namespace App\Domain\Model\Embedding;
|
||||
|
||||
use App\Domain\Model\Value\EmbeddingId;
|
||||
use App\Domain\Model\Id\EmbeddingId;
|
||||
|
||||
class Embedding
|
||||
{
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Model\AI;
|
||||
namespace App\Domain\Model\Embedding;
|
||||
|
||||
use App\Domain\Model\Value\Vector;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Model\AI;
|
||||
namespace App\Domain\Model\Embedding;
|
||||
|
||||
use App\Domain\Model\Value\Vector;
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Domain\Model;
|
||||
|
||||
use App\Domain\Model\AI\Embedding;
|
||||
use App\Domain\Model\Embedding\Embedding;
|
||||
|
||||
class EmbeddingCollection
|
||||
{
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Model\Value;
|
||||
namespace App\Domain\Model\Id;
|
||||
|
||||
final readonly class BrandId
|
||||
{
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Model\Value;
|
||||
namespace App\Domain\Model\Id;
|
||||
|
||||
final readonly class CarModelId
|
||||
{
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Model\Value;
|
||||
namespace App\Domain\Model\Id;
|
||||
|
||||
final readonly class CarPropertyId
|
||||
{
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Model\Value;
|
||||
namespace App\Domain\Model\Id;
|
||||
|
||||
final readonly class CarRevisionId
|
||||
{
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Model\Value;
|
||||
namespace App\Domain\Model\Id;
|
||||
|
||||
final readonly class EmbeddingId
|
||||
{
|
||||
@ -26,4 +26,4 @@ final readonly class EmbeddingId
|
||||
{
|
||||
return new EmbeddingId(uniqid('embedding_', true));
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/Domain/Model/Id/EmbeddingIdCollection.php
Normal file
30
src/Domain/Model/Id/EmbeddingIdCollection.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Model\Id;
|
||||
|
||||
final class EmbeddingIdCollection
|
||||
{
|
||||
/**
|
||||
* @param list<EmbeddingId> $values
|
||||
*/
|
||||
public function __construct(
|
||||
public array $values
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<EmbeddingId>
|
||||
*/
|
||||
public function array(): array
|
||||
{
|
||||
return $this->values;
|
||||
}
|
||||
|
||||
public function add(EmbeddingId $embeddingId): void
|
||||
{
|
||||
if (in_array($embeddingId->value, array_map(fn(EmbeddingId $id) => $id->value, $this->values), true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->values[] = $embeddingId;
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,7 @@ namespace App\Domain\Repository;
|
||||
use App\Domain\Model\Brand;
|
||||
use App\Domain\Model\BrandCollection;
|
||||
use App\Domain\Model\CarModel;
|
||||
use App\Domain\Model\Value\BrandId;
|
||||
use App\Domain\Model\Id\BrandId;
|
||||
|
||||
interface BrandRepository
|
||||
{
|
||||
|
||||
@ -5,8 +5,8 @@ namespace App\Domain\Repository;
|
||||
use App\Domain\Model\CarModel;
|
||||
use App\Domain\Model\CarModelCollection;
|
||||
use App\Domain\Model\CarRevision;
|
||||
use App\Domain\Model\Value\CarModelId;
|
||||
use App\Domain\Model\Value\BrandId;
|
||||
use App\Domain\Model\Id\CarModelId;
|
||||
use App\Domain\Model\Id\BrandId;
|
||||
|
||||
interface CarModelRepository
|
||||
{
|
||||
|
||||
@ -5,8 +5,8 @@ namespace App\Domain\Repository;
|
||||
use App\Domain\Model\CarProperty;
|
||||
use App\Domain\Model\CarPropertyCollection;
|
||||
use App\Domain\Model\CarRevision;
|
||||
use App\Domain\Model\Value\CarRevisionId;
|
||||
use App\Domain\Model\Value\EmbeddingId;
|
||||
use App\Domain\Model\Id\CarRevisionId;
|
||||
use App\Domain\Model\Id\EmbeddingId;
|
||||
|
||||
interface CarPropertyRepository
|
||||
{
|
||||
|
||||
@ -4,8 +4,8 @@ namespace App\Domain\Repository;
|
||||
|
||||
use App\Domain\Model\CarRevision;
|
||||
use App\Domain\Model\CarRevisionCollection;
|
||||
use App\Domain\Model\Value\CarRevisionId;
|
||||
use App\Domain\Model\Value\CarModelId;
|
||||
use App\Domain\Model\Id\CarRevisionId;
|
||||
use App\Domain\Model\Id\CarModelId;
|
||||
|
||||
interface CarRevisionRepository
|
||||
{
|
||||
|
||||
@ -2,10 +2,11 @@
|
||||
|
||||
namespace App\Domain\Repository;
|
||||
|
||||
use App\Domain\Model\AI\Embedding;
|
||||
use App\Domain\Model\AI\LargeEmbeddingVector;
|
||||
use App\Domain\Model\AI\SmallEmbeddingVector;
|
||||
use App\Domain\Model\Embedding\Embedding;
|
||||
use App\Domain\Model\Embedding\LargeEmbeddingVector;
|
||||
use App\Domain\Model\Embedding\SmallEmbeddingVector;
|
||||
use App\Domain\Model\EmbeddingCollection;
|
||||
use App\Domain\Model\Id\EmbeddingIdCollection;
|
||||
|
||||
interface EmbeddingRepository
|
||||
{
|
||||
@ -32,4 +33,10 @@ interface EmbeddingRepository
|
||||
* @return Embedding|null
|
||||
*/
|
||||
public function findByPhrase(string $phrase): ?Embedding;
|
||||
|
||||
/**
|
||||
* @param EmbeddingIdCollection $embeddingIdCollection
|
||||
* @return EmbeddingCollection
|
||||
*/
|
||||
public function findByEmbeddingIdCollection(EmbeddingIdCollection $embeddingIdCollection): EmbeddingCollection;
|
||||
}
|
||||
@ -3,7 +3,7 @@
|
||||
namespace App\Domain\Search;
|
||||
|
||||
use App\Domain\AI\AIClient;
|
||||
use App\Domain\Model\AI\Embedding;
|
||||
use App\Domain\Model\Embedding\Embedding;
|
||||
use App\Domain\Repository\CarPropertyRepository;
|
||||
use App\Domain\Repository\EmbeddingRepository;
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
namespace App\Domain\Search;
|
||||
|
||||
use App\Domain\Model\CarPropertyCollection;
|
||||
use App\Domain\Model\Value\CarRevisionId;
|
||||
use App\Domain\Model\Id\CarRevisionId;
|
||||
use App\Domain\Repository\BrandRepository;
|
||||
use App\Domain\Repository\CarModelRepository;
|
||||
use App\Domain\Repository\CarRevisionRepository;
|
||||
|
||||
@ -25,18 +25,12 @@ class CarTileBuilder
|
||||
|
||||
if ($carProperties->hasTypes([
|
||||
CarPropertyType::CATALOG_PRICE,
|
||||
CarPropertyType::IMAGE_EXTERNAL_PUBLIC_URL,
|
||||
CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION,
|
||||
])) {
|
||||
$imageProperty = $carProperties->getOne(CarPropertyType::IMAGE_EXTERNAL_PUBLIC_URL);
|
||||
$priceProperty = $carProperties->getOne(CarPropertyType::CATALOG_PRICE);
|
||||
$accelerationProperty = $carProperties->getOne(CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION);
|
||||
|
||||
if ($imageProperty !== null && $priceProperty !== null && $accelerationProperty !== null) {
|
||||
// Handle Image - it expects externalPublicUrl as string|null
|
||||
$imageValue = $imageProperty->value;
|
||||
$image = $imageValue instanceof Image ? $imageValue : new Image(is_string($imageValue) ? $imageValue : null);
|
||||
|
||||
if ($priceProperty !== null && $accelerationProperty !== null && $carRevision->image !== null) {
|
||||
// Handle Price - it should already be a Price object
|
||||
$priceValue = $priceProperty->value;
|
||||
if (!$priceValue instanceof Price) {
|
||||
@ -50,7 +44,7 @@ class CarTileBuilder
|
||||
}
|
||||
|
||||
$subTiles->add(new CarTile(
|
||||
$image,
|
||||
$carRevision->image,
|
||||
[
|
||||
new PriceTile($priceValue),
|
||||
new AccelerationTile($accelerationValue),
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
namespace App\Infrastructure\PostgreSQL\Repository\BrandRepository;
|
||||
|
||||
use App\Domain\Model\Brand;
|
||||
use App\Domain\Model\Value\BrandId;
|
||||
use App\Domain\Model\Id\BrandId;
|
||||
|
||||
class ModelMapper
|
||||
{
|
||||
|
||||
@ -5,11 +5,11 @@ namespace App\Infrastructure\PostgreSQL\Repository\BrandRepository;
|
||||
use App\Domain\Model\Brand;
|
||||
use App\Domain\Model\BrandCollection;
|
||||
use App\Domain\Model\CarModel;
|
||||
use App\Domain\Model\Value\BrandId;
|
||||
use App\Domain\Model\Id\BrandId;
|
||||
use App\Domain\Repository\BrandRepository;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
final class PostgreSQLBrandRepository implements BrandRepository
|
||||
final class SqlBrandRepository implements BrandRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
@ -4,8 +4,8 @@ namespace App\Infrastructure\PostgreSQL\Repository\CarModelRepository;
|
||||
|
||||
use App\Domain\Model\CarModel;
|
||||
use App\Domain\Model\Brand;
|
||||
use App\Domain\Model\Value\BrandId;
|
||||
use App\Domain\Model\Value\CarModelId;
|
||||
use App\Domain\Model\Id\BrandId;
|
||||
use App\Domain\Model\Id\CarModelId;
|
||||
|
||||
class ModelMapper
|
||||
{
|
||||
|
||||
@ -5,12 +5,12 @@ namespace App\Infrastructure\PostgreSQL\Repository\CarModelRepository;
|
||||
use App\Domain\Model\CarModel;
|
||||
use App\Domain\Model\CarModelCollection;
|
||||
use App\Domain\Model\CarRevision;
|
||||
use App\Domain\Model\Value\CarModelId;
|
||||
use App\Domain\Model\Value\BrandId;
|
||||
use App\Domain\Model\Id\CarModelId;
|
||||
use App\Domain\Model\Id\BrandId;
|
||||
use App\Domain\Repository\CarModelRepository;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
final class PostgreSQLCarModelRepository implements CarModelRepository
|
||||
final class SqlCarModelRepository implements CarModelRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
@ -6,12 +6,12 @@ use App\Domain\Model\CarProperty;
|
||||
use App\Domain\Model\CarPropertyCollection;
|
||||
use App\Domain\Model\CarPropertyType;
|
||||
use App\Domain\Model\CarRevision;
|
||||
use App\Domain\Model\Value\CarPropertyId;
|
||||
use App\Domain\Model\Value\CarRevisionId;
|
||||
use App\Domain\Model\Id\CarPropertyId;
|
||||
use App\Domain\Model\Id\CarRevisionId;
|
||||
use App\Domain\Repository\CarPropertyRepository;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
final class PostgreSQLCarPropertyRepository implements CarPropertyRepository
|
||||
final class SqlCarPropertyRepository implements CarPropertyRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
@ -50,8 +50,9 @@ final class PostgreSQLCarPropertyRepository implements CarPropertyRepository
|
||||
$placeholders = str_repeat('?,', count($phraseHashes) - 1) . '?';
|
||||
$result = $this->connection->executeQuery(
|
||||
"SELECT cp.* FROM car_properties cp
|
||||
INNER JOIN embeddings e ON e.phrase_hash IN ($placeholders)
|
||||
WHERE cp.id = e.car_property_id",
|
||||
INNER JOIN car_properties_embeddings cpe ON cpe.car_property_id = cp.id
|
||||
INNER JOIN embeddings e ON e.id = cpe.embedding_id
|
||||
WHERE e.phrase_hash IN ($placeholders)",
|
||||
array_values($phraseHashes)
|
||||
);
|
||||
|
||||
@ -68,15 +69,29 @@ final class PostgreSQLCarPropertyRepository implements CarPropertyRepository
|
||||
|
||||
public function save(CarProperty $carProperty): void
|
||||
{
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO car_properties (id, car_revision_id, type, value) VALUES (?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET car_revision_id = EXCLUDED.car_revision_id, type = EXCLUDED.type, value = EXCLUDED.value',
|
||||
[
|
||||
$carProperty->carPropertyId->value,
|
||||
$carProperty->carRevisionId->value,
|
||||
$carProperty->type->value,
|
||||
serialize($carProperty->value),
|
||||
]
|
||||
);
|
||||
$this->connection->transactional(function (Connection $connection) use ($carProperty) {
|
||||
$connection->executeStatement(
|
||||
'INSERT INTO car_properties (id, car_revision_id, type, value) VALUES (?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET car_revision_id = EXCLUDED.car_revision_id, type = EXCLUDED.type, value = EXCLUDED.value',
|
||||
[
|
||||
$carProperty->carPropertyId->value,
|
||||
$carProperty->carRevisionId->value,
|
||||
$carProperty->type->value,
|
||||
serialize($carProperty->value),
|
||||
]
|
||||
);
|
||||
|
||||
$connection->executeStatement(
|
||||
'DELETE FROM car_properties_embeddings WHERE car_property_id = ?',
|
||||
[$carProperty->carPropertyId->value]
|
||||
);
|
||||
|
||||
foreach ($carProperty->embeddings->array() as $embeddingId) {
|
||||
$connection->executeStatement(
|
||||
'INSERT INTO car_properties_embeddings (car_property_id, embedding_id) VALUES (?, ?) ON CONFLICT DO NOTHING',
|
||||
[$carProperty->carPropertyId->value, $embeddingId->value]
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(CarProperty $carProperty): void
|
||||
@ -89,7 +104,10 @@ final class PostgreSQLCarPropertyRepository implements CarPropertyRepository
|
||||
|
||||
public function deleteAll(): void
|
||||
{
|
||||
$this->connection->executeStatement('DELETE FROM car_properties');
|
||||
$this->connection->transactional(function (Connection $connection) {
|
||||
$connection->executeStatement('DELETE FROM car_properties_embeddings');
|
||||
$connection->executeStatement('DELETE FROM car_properties');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -3,8 +3,9 @@
|
||||
namespace App\Infrastructure\PostgreSQL\Repository\CarRevisionRepository;
|
||||
|
||||
use App\Domain\Model\CarRevision;
|
||||
use App\Domain\Model\Value\CarModelId;
|
||||
use App\Domain\Model\Value\CarRevisionId;
|
||||
use App\Domain\Model\Id\CarModelId;
|
||||
use App\Domain\Model\Id\CarRevisionId;
|
||||
use App\Domain\Model\Image;
|
||||
|
||||
class ModelMapper
|
||||
{
|
||||
@ -15,11 +16,13 @@ class ModelMapper
|
||||
{
|
||||
$carRevisionId = is_string($row['id'] ?? null) ? $row['id'] : throw new \InvalidArgumentException('CarRevision ID is required');
|
||||
$carModelId = is_string($row['car_model_id'] ?? null) ? $row['car_model_id'] : throw new \InvalidArgumentException('CarModel ID is required');
|
||||
$image = is_string($row['image'] ?? null) ? new Image(externalPublicUrl: $row['image']) : null;
|
||||
|
||||
return new CarRevision(
|
||||
carRevisionId: new CarRevisionId($carRevisionId),
|
||||
carModelId: new CarModelId($carModelId),
|
||||
name: is_string($row['name'] ?? null) ? $row['name'] : throw new \InvalidArgumentException('CarRevision name is required'),
|
||||
image: $image,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -4,12 +4,12 @@ namespace App\Infrastructure\PostgreSQL\Repository\CarRevisionRepository;
|
||||
|
||||
use App\Domain\Model\CarRevision;
|
||||
use App\Domain\Model\CarRevisionCollection;
|
||||
use App\Domain\Model\Value\CarRevisionId;
|
||||
use App\Domain\Model\Value\CarModelId;
|
||||
use App\Domain\Model\Id\CarRevisionId;
|
||||
use App\Domain\Model\Id\CarModelId;
|
||||
use App\Domain\Repository\CarRevisionRepository;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
final class PostgreSQLCarRevisionRepository implements CarRevisionRepository
|
||||
final class SqlCarRevisionRepository implements CarRevisionRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
@ -62,12 +62,13 @@ final class PostgreSQLCarRevisionRepository implements CarRevisionRepository
|
||||
|
||||
public function save(CarRevision $carRevision): void
|
||||
{
|
||||
$sql = 'INSERT INTO car_revisions (id, car_model_id, name) VALUES (?, ?, ?) ON CONFLICT (id) DO UPDATE SET car_model_id = EXCLUDED.car_model_id, name = EXCLUDED.name';
|
||||
$sql = 'INSERT INTO car_revisions (id, car_model_id, name, image) VALUES (?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET car_model_id = EXCLUDED.car_model_id, name = EXCLUDED.name, image = EXCLUDED.image';
|
||||
|
||||
$this->connection->executeStatement($sql, [
|
||||
$carRevision->carRevisionId->value,
|
||||
$carRevision->carModelId->value,
|
||||
$carRevision->name
|
||||
$carRevision->name,
|
||||
$carRevision->image?->externalPublicUrl,
|
||||
]);
|
||||
|
||||
return;
|
||||
@ -4,14 +4,15 @@ namespace App\Infrastructure\PostgreSQL\Repository\EmbeddingRepository;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use App\Domain\Repository\EmbeddingRepository;
|
||||
use App\Domain\Model\AI\Embedding;
|
||||
use App\Domain\Model\AI\LargeEmbeddingVector;
|
||||
use App\Domain\Model\AI\SmallEmbeddingVector;
|
||||
use App\Domain\Model\Embedding\Embedding;
|
||||
use App\Domain\Model\Embedding\LargeEmbeddingVector;
|
||||
use App\Domain\Model\Embedding\SmallEmbeddingVector;
|
||||
use App\Domain\Model\EmbeddingCollection;
|
||||
use App\Domain\Model\Value\Vector;
|
||||
use App\Domain\Model\Value\EmbeddingId;
|
||||
use App\Domain\Model\Id\EmbeddingId;
|
||||
use App\Domain\Model\Id\EmbeddingIdCollection;
|
||||
|
||||
final class PostgreSQLEmbeddingRepository implements EmbeddingRepository
|
||||
final class SqlEmbeddingRepository implements EmbeddingRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
@ -35,6 +36,13 @@ final class PostgreSQLEmbeddingRepository implements EmbeddingRepository
|
||||
);
|
||||
}
|
||||
|
||||
public function saveAll(EmbeddingCollection $embeddingCollection): void
|
||||
{
|
||||
foreach ($embeddingCollection->array() as $embedding) {
|
||||
$this->save($embedding);
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(Embedding $embedding): void
|
||||
{
|
||||
$this->connection->executeStatement(
|
||||
@ -58,23 +66,25 @@ final class PostgreSQLEmbeddingRepository implements EmbeddingRepository
|
||||
return $this->mapRowToEmbedding($row);
|
||||
}
|
||||
|
||||
public function searchByLargeEmbeddingVector(LargeEmbeddingVector $embeddingVector, int $limit = 10): EmbeddingCollection
|
||||
public function searchByLargeEmbeddingVector(LargeEmbeddingVector $embeddingVector, int $limit = 100): EmbeddingCollection
|
||||
{
|
||||
$result = $this->connection->executeQuery(
|
||||
'SELECT *
|
||||
'SELECT *, large_embedding_vector <=> :embeddingVector AS distance
|
||||
FROM embeddings
|
||||
WHERE large_embedding_vector IS NOT NULL
|
||||
ORDER BY large_embedding_vector <=> ?
|
||||
LIMIT ?',
|
||||
WHERE large_embedding_vector IS NOT NULL
|
||||
ORDER BY large_embedding_vector <=> :embeddingVector
|
||||
LIMIT :limit',
|
||||
[
|
||||
'[' . implode(',', $embeddingVector->vector->values) . ']',
|
||||
$limit,
|
||||
'embeddingVector' => '[' . implode(',', $embeddingVector->vector->values) . ']',
|
||||
'limit' => $limit,
|
||||
]
|
||||
);
|
||||
|
||||
$embeddings = [];
|
||||
foreach ($result->fetchAllAssociative() as $row) {
|
||||
$embeddings[] = $this->mapRowToEmbedding($row);
|
||||
if ($row['distance'] < 0.7) {
|
||||
$embeddings[] = $this->mapRowToEmbedding($row);
|
||||
}
|
||||
}
|
||||
|
||||
return new EmbeddingCollection($embeddings);
|
||||
@ -102,6 +112,22 @@ final class PostgreSQLEmbeddingRepository implements EmbeddingRepository
|
||||
return new EmbeddingCollection($embeddings);
|
||||
}
|
||||
|
||||
public function findByEmbeddingIdCollection(EmbeddingIdCollection $embeddingIdCollection): EmbeddingCollection
|
||||
{
|
||||
$ids = implode(',', array_map(fn(EmbeddingId $id) => $id->value, $embeddingIdCollection->array()));
|
||||
|
||||
$result = $this->connection->executeQuery(
|
||||
"SELECT * FROM embeddings WHERE id IN ($ids)",
|
||||
);
|
||||
|
||||
$embeddings = [];
|
||||
foreach ($result->fetchAllAssociative() as $row) {
|
||||
$embeddings[] = $this->mapRowToEmbedding($row);
|
||||
}
|
||||
|
||||
return new EmbeddingCollection($embeddings);
|
||||
}
|
||||
|
||||
public function deleteAll(): void
|
||||
{
|
||||
$this->connection->executeStatement('DELETE FROM embeddings');
|
||||
@ -8,24 +8,4 @@
|
||||
|
||||
{% include '_components/search.html.twig' %}
|
||||
</div>
|
||||
|
||||
<div id="brandsSection">
|
||||
<h2 class="section-title"><i class="fas fa-industry"></i> Popular Electric Vehicle Brands</h2>
|
||||
<div class="brands-grid">
|
||||
{% for brand in brands %}
|
||||
<div class="brand-card">
|
||||
<div class="brand-name"><i class="fas fa-car"></i> {{ brand.name }}</div>
|
||||
{% if brand.description %}
|
||||
<div class="brand-description">{{ brand.description }}</div>
|
||||
{% endif %}
|
||||
<div class="brand-year"><i class="fas fa-calendar-alt"></i> Founded: {{ brand.foundedYear }}</div>
|
||||
{% if brand.headquarters %}
|
||||
<div class="brand-year"><i class="fas fa-map-marker-alt"></i> {{ brand.headquarters }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="no-results"><i class="fas fa-exclamation-triangle"></i> No brands available</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Loading…
x
Reference in New Issue
Block a user