Use postgresql and improved visuals
This commit is contained in:
parent
3160d60eaf
commit
65ef2ed89c
28
.cursor/rules/project.mdc
Normal file
28
.cursor/rules/project.mdc
Normal file
@ -0,0 +1,28 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
# EV Wiki
|
||||
The EV Wiki project is a website for searching, finding and comparison of ev vehicles.
|
||||
It works like a search engine and aggregates the data from the database to show the user the requested information in a tile based view.
|
||||
|
||||
## Structue
|
||||
The project is structured in domain driven design manner. Inside the src-Folder, you find:
|
||||
- Application (Controllers, Commands and other symfony related application stuff)
|
||||
- Domain: The core business logic of the project. This is 100% framework agnostic code
|
||||
- Infrastructure: Implementations of Interfaces provided by the Domain layer.
|
||||
|
||||
The dependency is going from Application -> Domain -> Infrastrcture, but not in the opposite direction.
|
||||
The Domain does not know about the existence of the application folder, and also the infrastructure doesn't know anything about the Domain and Application folder.
|
||||
|
||||
## Concepts
|
||||
### Tile based results
|
||||
One core concept of the search results in the ev wiki project is the tile based search result.
|
||||
The Tile Classes e.g. are holding view data for specific tile types. These tiles are displayed by custom twig template, one template for one tile type.
|
||||
The Tiles were aggregated and built by the Engine class that is responsible for building the tile view.
|
||||
|
||||
### Project dependencies
|
||||
We will barely use external dependencies. Some depencies are explicitly avoided:
|
||||
- No ORM: The project follows the philosophy, that orms destroy the needed abstraction between model (Domain) and database layer (Infrastrcture).
|
||||
Since SQL is already a human readable language, there is no need to introduce heavy coupling between the model and the database layer.
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -49,3 +49,8 @@ next-env.d.ts
|
||||
/var/
|
||||
/vendor/
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
###> symfony/asset-mapper ###
|
||||
/public/assets/
|
||||
/assets/vendor/
|
||||
###< symfony/asset-mapper ###
|
||||
|
||||
@ -2,7 +2,7 @@ FROM php:8.4-fpm-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies including zsh
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
git \
|
||||
curl \
|
||||
@ -14,14 +14,15 @@ RUN apk add --no-cache \
|
||||
autoconf \
|
||||
g++ \
|
||||
make \
|
||||
wget
|
||||
wget \
|
||||
postgresql-dev \
|
||||
zsh
|
||||
|
||||
# Install Oh My Zsh
|
||||
RUN sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended
|
||||
|
||||
# Install PHP extensions
|
||||
RUN docker-php-ext-install pdo pdo_mysql
|
||||
|
||||
# Install MongoDB PHP extension
|
||||
RUN pecl install mongodb \
|
||||
&& docker-php-ext-enable mongodb
|
||||
RUN docker-php-ext-install pdo pdo_pgsql pgsql
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
117
README.md
117
README.md
@ -8,22 +8,22 @@ A modern Symfony application for browsing and searching electric vehicle informa
|
||||
- **Brand Directory**: Browse popular electric vehicle manufacturers
|
||||
- **Vehicle Database**: Comprehensive information about electric car models and revisions
|
||||
- **RESTful API**: JSON API endpoints for integration
|
||||
- **MongoDB Integration**: NoSQL database for flexible data storage
|
||||
- **PostgreSQL Integration**: Relational database for structured data storage
|
||||
- **Responsive Design**: Mobile-friendly interface without external CSS frameworks
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Backend**: Symfony 7.2 (PHP 8.2+)
|
||||
- **Database**: MongoDB with Doctrine ODM
|
||||
- **Backend**: Symfony 7.2 (PHP 8.3+)
|
||||
- **Database**: PostgreSQL with native PDO
|
||||
- **Frontend**: Vanilla JavaScript with modern CSS
|
||||
- **Architecture**: Clean Architecture with SOLID principles
|
||||
|
||||
## Requirements
|
||||
|
||||
- PHP 8.2 or higher
|
||||
- MongoDB 4.4 or higher
|
||||
- PHP 8.3 or higher
|
||||
- PostgreSQL 13 or higher
|
||||
- Composer
|
||||
- MongoDB PHP Extension
|
||||
- PostgreSQL PHP Extension (pdo_pgsql)
|
||||
|
||||
## Installation
|
||||
|
||||
@ -43,19 +43,21 @@ A modern Symfony application for browsing and searching electric vehicle informa
|
||||
cp .env.local.example .env.local
|
||||
```
|
||||
|
||||
Edit `.env.local` and set your MongoDB connection:
|
||||
Edit `.env.local` and set your PostgreSQL connection:
|
||||
```
|
||||
APP_ENV=dev
|
||||
APP_SECRET=your-secret-key-here
|
||||
MONGODB_URI=mongodb://localhost:27017
|
||||
DATABASE_DSN="pgsql:host=localhost;port=5432;dbname=evwiki"
|
||||
DATABASE_USER=postgres
|
||||
DATABASE_PASSWORD=postgres
|
||||
```
|
||||
|
||||
4. **Start MongoDB**
|
||||
Make sure MongoDB is running on your system.
|
||||
4. **Start PostgreSQL**
|
||||
Make sure PostgreSQL is running on your system.
|
||||
|
||||
5. **Seed the database**
|
||||
5. **Initialize the database**
|
||||
```bash
|
||||
php bin/console app:seed-data
|
||||
php bin/console app:database:init
|
||||
```
|
||||
|
||||
6. **Start the development server**
|
||||
@ -68,26 +70,48 @@ A modern Symfony application for browsing and searching electric vehicle informa
|
||||
php -S localhost:8000 -t public/
|
||||
```
|
||||
|
||||
## Docker Setup
|
||||
|
||||
You can also run the application using Docker:
|
||||
|
||||
1. **Start the services**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
2. **Initialize the database in the container**
|
||||
```bash
|
||||
docker-compose exec app php bin/console app:database:init
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── Command/ # Console commands
|
||||
├── Controller/ # HTTP controllers
|
||||
├── Document/ # MongoDB document models
|
||||
├── Repository/ # Data access layer
|
||||
├── Service/ # Business logic layer
|
||||
└── Kernel.php # Application kernel
|
||||
├── Command/ # Console commands
|
||||
├── Application/
|
||||
│ └── Controller/ # HTTP controllers
|
||||
├── Domain/
|
||||
│ ├── Model/ # Domain models
|
||||
│ └── Repository/ # Repository interfaces
|
||||
├── Infrastructure/
|
||||
│ └── PostgreSQL/ # PostgreSQL implementation
|
||||
│ ├── PostgreSQLClient.php
|
||||
│ └── Repository/ # Concrete repositories
|
||||
└── Kernel.php # Application kernel
|
||||
|
||||
templates/
|
||||
├── base.html.twig # Base template
|
||||
└── home/ # Home page templates
|
||||
├── base.html.twig # Base template
|
||||
└── home/ # Home page templates
|
||||
|
||||
database/
|
||||
└── schema.sql # PostgreSQL schema
|
||||
|
||||
config/
|
||||
├── bundles.php # Bundle configuration
|
||||
├── packages/ # Package configurations
|
||||
├── routes.yaml # Route definitions
|
||||
└── services.yaml # Service container
|
||||
├── bundles.php # Bundle configuration
|
||||
├── packages/ # Package configurations
|
||||
├── routes.yaml # Route definitions
|
||||
└── services.yaml # Service container
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
@ -107,50 +131,63 @@ config/
|
||||
This application follows clean architecture principles:
|
||||
|
||||
### Domain Layer
|
||||
- **Documents**: MongoDB document models (`Brand`, `CarModel`, `CarRevision`)
|
||||
- **Models**: Core domain models (`Brand`, `CarModel`, `CarRevision`)
|
||||
- **Repositories**: Data access interfaces
|
||||
|
||||
### Application Layer
|
||||
- **Services**: Business logic (`CarSearchService`)
|
||||
- **Controllers**: HTTP request handlers
|
||||
- **Commands**: Console commands for data management
|
||||
|
||||
### Infrastructure Layer
|
||||
- **Controllers**: HTTP request handlers
|
||||
- **PostgreSQL**: Database implementation with native PDO
|
||||
- **Templates**: Twig templates for rendering
|
||||
|
||||
### Key Design Principles
|
||||
|
||||
1. **SOLID Principles**: Each class has a single responsibility
|
||||
2. **Dependency Injection**: All dependencies are injected via constructor
|
||||
3. **No Else Statements**: Code uses early returns for better readability
|
||||
4. **Readable Names**: Self-documenting code with descriptive names
|
||||
3. **Clean Architecture**: Domain logic is independent of infrastructure
|
||||
4. **Readable Code**: Self-documenting code with descriptive names
|
||||
|
||||
## Development
|
||||
## Database Commands
|
||||
|
||||
### Adding New Data
|
||||
|
||||
Use the seed command to populate the database:
|
||||
### Initialize Database
|
||||
Initialize the database with schema and sample data:
|
||||
```bash
|
||||
php bin/console app:seed-data
|
||||
php bin/console app:database:init
|
||||
```
|
||||
|
||||
### Console Commands
|
||||
|
||||
List all available commands:
|
||||
```bash
|
||||
php bin/console list
|
||||
```
|
||||
|
||||
### Database Operations
|
||||
## Database Schema
|
||||
|
||||
The application uses MongoDB with Doctrine ODM. All database operations are handled through repositories following the Repository pattern.
|
||||
The application uses PostgreSQL with the following main tables:
|
||||
|
||||
- **brands**: Electric vehicle manufacturers
|
||||
- **car_models**: Vehicle models belonging to brands
|
||||
- **car_revisions**: Specific revisions of car models with detailed specifications
|
||||
|
||||
All database operations are handled through repositories following the Repository pattern, using native PDO for optimal performance.
|
||||
|
||||
## Styling Guidelines
|
||||
|
||||
- **No External CSS Frameworks**: Pure CSS following modern standards
|
||||
- **Responsive Design**: Mobile-first approach
|
||||
- **Modern UI**: Clean, minimalist design inspired by search engines
|
||||
- **Accessibility**: Semantic HTML and proper contrast ratios
|
||||
- **Mobile-First**: Responsive design approach
|
||||
- **Clean Design**: Minimalist interface focusing on content
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Required environment variables:
|
||||
|
||||
- `APP_ENV`: Application environment (dev/prod)
|
||||
- `APP_SECRET`: Secret key for Symfony
|
||||
- `DATABASE_DSN`: PostgreSQL DSN connection string
|
||||
- `DATABASE_USER`: PostgreSQL username
|
||||
- `DATABASE_PASSWORD`: PostgreSQL password
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
17
assets/app.js
Normal file
17
assets/app.js
Normal file
@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Welcome to your app's main JavaScript file!
|
||||
*
|
||||
* This file will be included onto the page via the importmap() Twig function,
|
||||
* which should already be in your base.html.twig.
|
||||
*/
|
||||
|
||||
// Import Font Awesome CSS
|
||||
import '@fortawesome/fontawesome-free/css/fontawesome.min.css';
|
||||
import '@fortawesome/fontawesome-free/css/solid.min.css';
|
||||
import '@fortawesome/fontawesome-free/css/brands.min.css';
|
||||
import '@fortawesome/fontawesome-free/css/regular.min.css';
|
||||
|
||||
// Import Font Awesome JavaScript (optional - for advanced features)
|
||||
import '@fortawesome/fontawesome-free';
|
||||
|
||||
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');
|
||||
3
bin/docker
Executable file
3
bin/docker
Executable file
@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
docker compose exec app /bin/zsh
|
||||
@ -9,7 +9,10 @@
|
||||
"php": "^8.3",
|
||||
"ext-ctype": "*",
|
||||
"ext-iconv": "*",
|
||||
"mongodb/mongodb": "*",
|
||||
"ext-pdo": "*",
|
||||
"ext-pgsql": "*",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.4",
|
||||
"symfony/asset-mapper": "^7.3",
|
||||
"symfony/console": "7.2.*",
|
||||
"symfony/dotenv": "7.2.*",
|
||||
"symfony/flex": "^2",
|
||||
@ -46,7 +49,8 @@
|
||||
"scripts": {
|
||||
"auto-scripts": {
|
||||
"cache:clear": "symfony-cmd",
|
||||
"assets:install %PUBLIC_DIR%": "symfony-cmd"
|
||||
"assets:install %PUBLIC_DIR%": "symfony-cmd",
|
||||
"importmap:install": "symfony-cmd"
|
||||
},
|
||||
"post-install-cmd": [
|
||||
"@auto-scripts"
|
||||
@ -55,4 +59,4 @@
|
||||
"@auto-scripts"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
1634
composer.lock
generated
1634
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@ -5,4 +5,6 @@ return [
|
||||
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
|
||||
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
||||
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
|
||||
];
|
||||
|
||||
11
config/packages/asset_mapper.yaml
Normal file
11
config/packages/asset_mapper.yaml
Normal file
@ -0,0 +1,11 @@
|
||||
framework:
|
||||
asset_mapper:
|
||||
# The paths to make available to the asset mapper.
|
||||
paths:
|
||||
- assets/
|
||||
missing_import_mode: strict
|
||||
|
||||
when@prod:
|
||||
framework:
|
||||
asset_mapper:
|
||||
missing_import_mode: warn
|
||||
24
config/packages/doctrine.yaml
Normal file
24
config/packages/doctrine.yaml
Normal file
@ -0,0 +1,24 @@
|
||||
doctrine:
|
||||
dbal:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
|
||||
# IMPORTANT: You MUST configure your server version,
|
||||
# either here or in the DATABASE_URL env var (see .env file)
|
||||
#server_version: '16'
|
||||
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
use_savepoints: true
|
||||
|
||||
when@test:
|
||||
doctrine:
|
||||
dbal:
|
||||
# "TEST_TOKEN" is typically set by ParaTest
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
|
||||
framework:
|
||||
cache:
|
||||
pools:
|
||||
doctrine.result_cache_pool:
|
||||
adapter: cache.app
|
||||
doctrine.system_cache_pool:
|
||||
adapter: cache.system
|
||||
6
config/packages/doctrine_migrations.yaml
Normal file
6
config/packages/doctrine_migrations.yaml
Normal file
@ -0,0 +1,6 @@
|
||||
doctrine_migrations:
|
||||
migrations_paths:
|
||||
# namespace is arbitrary but should be different from App\Migrations
|
||||
# as migrations classes should NOT be autoloaded
|
||||
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
||||
enable_profiler: false
|
||||
@ -9,10 +9,5 @@ services:
|
||||
resource: '../src/'
|
||||
exclude:
|
||||
- '../src/DependencyInjection/'
|
||||
- '../src/Document/'
|
||||
- '../src/Kernel.php'
|
||||
|
||||
App\Infrastructure\MongoDB\MongoDBClient:
|
||||
arguments:
|
||||
$dsl: '%env(MONGODB_DSL)%'
|
||||
$databaseName: '%env(MONGODB_DATABASE)%'
|
||||
- '../src/Entity/'
|
||||
- '../src/Kernel.php'
|
||||
@ -1,5 +1,3 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
@ -9,9 +7,7 @@ services:
|
||||
volumes:
|
||||
- .:/app
|
||||
depends_on:
|
||||
- mongodb
|
||||
environment:
|
||||
- MONGODB_URI=mongodb://mongodb:27017/evwiki
|
||||
- database
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.app.entrypoints=web"
|
||||
@ -20,41 +16,44 @@ services:
|
||||
networks:
|
||||
- proxy
|
||||
|
||||
mongodb:
|
||||
image: mongo:latest
|
||||
hostname: mongodb.evwiki.test
|
||||
volumes:
|
||||
- mongodb_data:/data/db
|
||||
networks:
|
||||
- proxy
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.mongodb.entrypoints=mongodb"
|
||||
- "traefik.http.routers.mongodb.rule=Host(`mongodb.evwiki.test`)"
|
||||
- "traefik.http.services.mongodb.loadbalancer.server.port=27017"
|
||||
|
||||
mongo-express:
|
||||
image: mongo-express:latest
|
||||
hostname: mongo-express.evwiki.test
|
||||
depends_on:
|
||||
- mongodb
|
||||
database:
|
||||
image: postgres:17-alpine
|
||||
hostname: database.evwiki.test
|
||||
environment:
|
||||
- ME_CONFIG_MONGODB_SERVER=mongodb
|
||||
- ME_CONFIG_MONGODB_PORT=27017
|
||||
- ME_CONFIG_MONGODB_ENABLE_ADMIN=true
|
||||
- ME_CONFIG_BASICAUTH_USERNAME=admin
|
||||
- ME_CONFIG_BASICAUTH_PASSWORD=pass
|
||||
networks:
|
||||
- proxy
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=evwiki
|
||||
volumes:
|
||||
- postgres_evwiki:/var/lib/postgresql/data
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.mongo-express.entrypoints=web"
|
||||
- "traefik.http.routers.mongo-express.rule=Host(`mongo-express.evwiki.test`)"
|
||||
- "traefik.http.services.mongo-express.loadbalancer.server.port=8081"
|
||||
- "traefik.tcp.routers.database.entrypoints=postgres"
|
||||
- "traefik.tcp.routers.database.rule=HostSNI(`database.evwiki.test`)"
|
||||
- "traefik.tcp.routers.database.tls=true"
|
||||
- "traefik.tcp.routers.database.tls.passthrough=true"
|
||||
- "traefik.tcp.services.database.loadbalancer.server.port=5432"
|
||||
networks:
|
||||
- proxy
|
||||
|
||||
adminer:
|
||||
image: adminer
|
||||
hostname: adminer.evwiki.test
|
||||
environment:
|
||||
- ADMINER_DEFAULT_SERVER=database.evwiki.test
|
||||
- ADMINER_DEFAULT_DB=evwiki
|
||||
- ADMINER_DESIGN=darkly
|
||||
- ADMINER_USER=postgres_evwiki
|
||||
- ADMINER_PASSWORD=postgres_evwiki
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.adminer.rule=Host(`adminer.evwiki.test`)"
|
||||
- "traefik.http.services.adminer.loadbalancer.server.port=8080"
|
||||
networks:
|
||||
- proxy
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
mongodb_data:
|
||||
postgres_evwiki:
|
||||
38
importmap.php
Normal file
38
importmap.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Returns the importmap for this application.
|
||||
*
|
||||
* - "path" is a path inside the asset mapper system. Use the
|
||||
* "debug:asset-map" command to see the full list of paths.
|
||||
*
|
||||
* - "entrypoint" (JavaScript only) set to true for any module that will
|
||||
* be used as an "entrypoint" (and passed to the importmap() Twig function).
|
||||
*
|
||||
* The "importmap:require" command can be used to add new entries to this file.
|
||||
*/
|
||||
return [
|
||||
'app' => [
|
||||
'path' => './assets/app.js',
|
||||
'entrypoint' => true,
|
||||
],
|
||||
'@fortawesome/fontawesome-free' => [
|
||||
'version' => '6.7.2',
|
||||
],
|
||||
'@fortawesome/fontawesome-free/css/fontawesome.min.css' => [
|
||||
'version' => '6.7.2',
|
||||
'type' => 'css',
|
||||
],
|
||||
'@fortawesome/fontawesome-free/css/solid.min.css' => [
|
||||
'version' => '6.7.2',
|
||||
'type' => 'css',
|
||||
],
|
||||
'@fortawesome/fontawesome-free/css/brands.min.css' => [
|
||||
'version' => '6.7.2',
|
||||
'type' => 'css',
|
||||
],
|
||||
'@fortawesome/fontawesome-free/css/regular.min.css' => [
|
||||
'version' => '6.7.2',
|
||||
'type' => 'css',
|
||||
],
|
||||
];
|
||||
57
migrations/Version20250529155930.php
Normal file
57
migrations/Version20250529155930.php
Normal file
@ -0,0 +1,57 @@
|
||||
<?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 Version20250529155930 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create base tables';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<SQL
|
||||
CREATE TABLE brands (
|
||||
id VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
content JSON NOT NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<SQL
|
||||
CREATE TABLE car_models (
|
||||
id VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
brand_id VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
content JSON NOT NULL,
|
||||
FOREIGN KEY (brand_id) REFERENCES brands(id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<SQL
|
||||
CREATE TABLE car_revisions (
|
||||
id VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
car_model_id VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
content JSON NOT NULL,
|
||||
FOREIGN KEY (car_model_id) REFERENCES car_models(id)
|
||||
)
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE car_revisions');
|
||||
$this->addSql('DROP TABLE car_models');
|
||||
$this->addSql('DROP TABLE brands');
|
||||
}
|
||||
}
|
||||
513
src/Application/Commands/LoadFixtures.php
Normal file
513
src/Application/Commands/LoadFixtures.php
Normal file
@ -0,0 +1,513 @@
|
||||
<?php
|
||||
|
||||
namespace App\Application\Commands;
|
||||
|
||||
use App\Domain\Model\Brand;
|
||||
use App\Domain\Model\CarModel;
|
||||
use App\Domain\Model\CarRevision;
|
||||
use App\Domain\Model\DrivingCharacteristics;
|
||||
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\Battery\BatteryProperties;
|
||||
use App\Domain\Model\Battery\CellChemistry;
|
||||
use App\Domain\Model\Charging\ChargingProperties;
|
||||
use App\Domain\Model\RangeProperties;
|
||||
use App\Domain\Model\Range\WltpRange;
|
||||
use App\Domain\Model\Range\NefzRange;
|
||||
use App\Domain\Model\Value\Energy;
|
||||
use App\Domain\Model\Value\Power;
|
||||
use App\Domain\Model\Value\Acceleration;
|
||||
use App\Domain\Model\Value\Speed;
|
||||
use App\Domain\Model\Value\Consumption;
|
||||
use App\Domain\Model\Value\Range;
|
||||
use App\Domain\Repository\BrandRepository;
|
||||
use App\Domain\Repository\CarModelRepository;
|
||||
use App\Domain\Repository\CarRevisionRepository;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:load-fixtures',
|
||||
description: 'Load fixture data for EV brands, car models and car revisions'
|
||||
)]
|
||||
class LoadFixtures extends Command
|
||||
{
|
||||
private array $brandIds = [];
|
||||
private array $carModelIds = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly BrandRepository $brandRepository,
|
||||
private readonly CarModelRepository $carModelRepository,
|
||||
private readonly CarRevisionRepository $carRevisionRepository,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io->title('Loading EV Wiki Fixtures');
|
||||
|
||||
// Load brands
|
||||
$brands = $this->getFixtureBrands();
|
||||
$io->section('Loading Brands');
|
||||
$io->progressStart(count($brands));
|
||||
|
||||
foreach ($brands as $brand) {
|
||||
$persistedBrand = $this->brandRepository->create($brand);
|
||||
$this->brandIds[$brand->name] = $persistedBrand->id;
|
||||
$io->progressAdvance();
|
||||
}
|
||||
|
||||
$io->progressFinish();
|
||||
$io->success(sprintf('Successfully loaded %d brands', count($brands)));
|
||||
|
||||
// Load car models
|
||||
$carModels = $this->getFixtureCarModels();
|
||||
$io->section('Loading Car Models');
|
||||
$io->progressStart(count($carModels));
|
||||
|
||||
foreach ($carModels as $carModelData) {
|
||||
$persistedCarModel = $this->carModelRepository->create(
|
||||
$carModelData['model'],
|
||||
$this->brandIds[$carModelData['brand']]
|
||||
);
|
||||
$this->carModelIds[$carModelData['model']->name] = $persistedCarModel->id;
|
||||
$io->progressAdvance();
|
||||
}
|
||||
|
||||
$io->progressFinish();
|
||||
$io->success(sprintf('Successfully loaded %d car models', count($carModels)));
|
||||
|
||||
// Load car revisions
|
||||
$carRevisions = $this->getFixtureCarRevisions();
|
||||
$io->section('Loading Car Revisions');
|
||||
$io->progressStart(count($carRevisions));
|
||||
|
||||
foreach ($carRevisions as $carRevisionData) {
|
||||
$this->carRevisionRepository->create(
|
||||
$carRevisionData['revision'],
|
||||
$this->carModelIds[$carRevisionData['model']]
|
||||
);
|
||||
$io->progressAdvance();
|
||||
}
|
||||
|
||||
$io->progressFinish();
|
||||
$io->success(sprintf('Successfully loaded %d car revisions', count($carRevisions)));
|
||||
|
||||
$io->success('All fixtures loaded successfully!');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Brand[]
|
||||
*/
|
||||
private function getFixtureBrands(): array
|
||||
{
|
||||
return [
|
||||
new Brand(
|
||||
name: 'Tesla',
|
||||
logo: 'https://logo.clearbit.com/tesla.com',
|
||||
description: 'American electric vehicle and clean energy company founded by Elon Musk',
|
||||
foundedYear: 2003,
|
||||
headquarters: 'Austin, Texas, USA',
|
||||
website: 'https://tesla.com',
|
||||
),
|
||||
new Brand(
|
||||
name: 'BMW',
|
||||
logo: 'https://logo.clearbit.com/bmw.com',
|
||||
description: 'German multinational corporation producing luxury vehicles with strong EV lineup',
|
||||
foundedYear: 1916,
|
||||
headquarters: 'Munich, Germany',
|
||||
website: 'https://bmw.com',
|
||||
),
|
||||
new Brand(
|
||||
name: 'Audi',
|
||||
logo: 'https://logo.clearbit.com/audi.com',
|
||||
description: 'German automotive manufacturer of luxury vehicles with e-tron electric series',
|
||||
foundedYear: 1909,
|
||||
headquarters: 'Ingolstadt, Germany',
|
||||
website: 'https://audi.com',
|
||||
),
|
||||
new Brand(
|
||||
name: 'Mercedes-Benz',
|
||||
logo: 'https://logo.clearbit.com/mercedes-benz.com',
|
||||
description: 'German luxury automotive brand with EQS, EQC and other electric models',
|
||||
foundedYear: 1926,
|
||||
headquarters: 'Stuttgart, Germany',
|
||||
website: 'https://mercedes-benz.com',
|
||||
),
|
||||
new Brand(
|
||||
name: 'Volkswagen',
|
||||
logo: 'https://logo.clearbit.com/vw.com',
|
||||
description: 'German motor vehicle manufacturer with ID series electric vehicles',
|
||||
foundedYear: 1937,
|
||||
headquarters: 'Wolfsburg, Germany',
|
||||
website: 'https://vw.com',
|
||||
),
|
||||
new Brand(
|
||||
name: 'Porsche',
|
||||
logo: 'https://logo.clearbit.com/porsche.com',
|
||||
description: 'German sports car manufacturer with Taycan electric sports cars',
|
||||
foundedYear: 1931,
|
||||
headquarters: 'Stuttgart, Germany',
|
||||
website: 'https://porsche.com',
|
||||
),
|
||||
new Brand(
|
||||
name: 'Lucid Motors',
|
||||
logo: 'https://logo.clearbit.com/lucidmotors.com',
|
||||
description: 'American electric vehicle manufacturer focused on luxury sedans',
|
||||
foundedYear: 2007,
|
||||
headquarters: 'Newark, California, USA',
|
||||
website: 'https://lucidmotors.com',
|
||||
),
|
||||
new Brand(
|
||||
name: 'Rivian',
|
||||
logo: 'https://logo.clearbit.com/rivian.com',
|
||||
description: 'American electric vehicle manufacturer focusing on electric trucks and vans',
|
||||
foundedYear: 2009,
|
||||
headquarters: 'Irvine, California, USA',
|
||||
website: 'https://rivian.com',
|
||||
),
|
||||
new Brand(
|
||||
name: 'NIO',
|
||||
logo: 'https://logo.clearbit.com/nio.com',
|
||||
description: 'Chinese electric vehicle manufacturer with innovative battery swapping technology',
|
||||
foundedYear: 2014,
|
||||
headquarters: 'Shanghai, China',
|
||||
website: 'https://nio.com',
|
||||
),
|
||||
new Brand(
|
||||
name: 'BYD',
|
||||
logo: 'https://logo.clearbit.com/byd.com',
|
||||
description: 'Chinese electric vehicle and battery manufacturer, world leader in EV sales',
|
||||
foundedYear: 1995,
|
||||
headquarters: 'Shenzhen, China',
|
||||
website: 'https://byd.com',
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array{brand: string, model: CarModel}>
|
||||
*/
|
||||
private function getFixtureCarModels(): array
|
||||
{
|
||||
return [
|
||||
// Tesla Models
|
||||
[
|
||||
'brand' => 'Tesla',
|
||||
'model' => new CarModel('Model S'),
|
||||
],
|
||||
[
|
||||
'brand' => 'Tesla',
|
||||
'model' => new CarModel('Model 3'),
|
||||
],
|
||||
[
|
||||
'brand' => 'Tesla',
|
||||
'model' => new CarModel('Model X'),
|
||||
],
|
||||
[
|
||||
'brand' => 'Tesla',
|
||||
'model' => new CarModel('Model Y'),
|
||||
],
|
||||
|
||||
// BMW Models
|
||||
[
|
||||
'brand' => 'BMW',
|
||||
'model' => new CarModel('iX'),
|
||||
],
|
||||
[
|
||||
'brand' => 'BMW',
|
||||
'model' => new CarModel('i4'),
|
||||
],
|
||||
[
|
||||
'brand' => 'BMW',
|
||||
'model' => new CarModel('iX3'),
|
||||
],
|
||||
|
||||
// Audi Models
|
||||
[
|
||||
'brand' => 'Audi',
|
||||
'model' => new CarModel('e-tron GT'),
|
||||
],
|
||||
[
|
||||
'brand' => 'Audi',
|
||||
'model' => new CarModel('Q4 e-tron'),
|
||||
],
|
||||
[
|
||||
'brand' => 'Audi',
|
||||
'model' => new CarModel('e-tron'),
|
||||
],
|
||||
|
||||
// Mercedes-Benz Models
|
||||
[
|
||||
'brand' => 'Mercedes-Benz',
|
||||
'model' => new CarModel('EQS'),
|
||||
],
|
||||
[
|
||||
'brand' => 'Mercedes-Benz',
|
||||
'model' => new CarModel('EQC'),
|
||||
],
|
||||
[
|
||||
'brand' => 'Mercedes-Benz',
|
||||
'model' => new CarModel('EQA'),
|
||||
],
|
||||
|
||||
// Volkswagen Models
|
||||
[
|
||||
'brand' => 'Volkswagen',
|
||||
'model' => new CarModel('ID.4'),
|
||||
],
|
||||
[
|
||||
'brand' => 'Volkswagen',
|
||||
'model' => new CarModel('ID.3'),
|
||||
],
|
||||
[
|
||||
'brand' => 'Volkswagen',
|
||||
'model' => new CarModel('ID.Buzz'),
|
||||
],
|
||||
|
||||
// Porsche Models
|
||||
[
|
||||
'brand' => 'Porsche',
|
||||
'model' => new CarModel('Taycan'),
|
||||
],
|
||||
[
|
||||
'brand' => 'Porsche',
|
||||
'model' => new CarModel('Macan Electric'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array{model: string, revision: CarRevision}>
|
||||
*/
|
||||
private function getFixtureCarRevisions(): array
|
||||
{
|
||||
return [
|
||||
// Tesla Model S Plaid
|
||||
[
|
||||
'model' => 'Model S',
|
||||
'revision' => new CarRevision(
|
||||
name: 'Plaid',
|
||||
productionBegin: new Date(1, 1, 2021),
|
||||
drivingCharacteristics: new DrivingCharacteristics(
|
||||
power: new Power(750),
|
||||
acceleration: new Acceleration(2.1),
|
||||
topSpeed: new Speed(322),
|
||||
consumption: new Consumption(new Energy(19.3))
|
||||
),
|
||||
battery: new BatteryProperties(
|
||||
usableCapacity: new Energy(95.0),
|
||||
totalCapacity: new Energy(100.0),
|
||||
cellChemistry: CellChemistry::LithiumNickelManganeseOxide,
|
||||
model: '4680',
|
||||
manufacturer: 'Tesla'
|
||||
),
|
||||
chargingProperties: new ChargingProperties(
|
||||
topChargingSpeed: new Power(250)
|
||||
),
|
||||
rangeProperties: new RangeProperties(
|
||||
wltp: new WltpRange(new Range(628)),
|
||||
nefz: new NefzRange(new Range(652))
|
||||
),
|
||||
catalogPrice: new Price(129990, Currency::euro()),
|
||||
image: new Image('https://digitalassets.tesla.com/tesla-contents/image/upload/f_auto,q_auto/Model-S-Main-Hero-Desktop-LHD.jpg')
|
||||
),
|
||||
],
|
||||
|
||||
// Tesla Model 3 Long Range
|
||||
[
|
||||
'model' => 'Model 3',
|
||||
'revision' => new CarRevision(
|
||||
name: 'Long Range',
|
||||
productionBegin: new Date(1, 1, 2020),
|
||||
drivingCharacteristics: new DrivingCharacteristics(
|
||||
power: new Power(366),
|
||||
acceleration: new Acceleration(4.4),
|
||||
topSpeed: new Speed(233),
|
||||
consumption: new Consumption(new Energy(14.9))
|
||||
),
|
||||
battery: new BatteryProperties(
|
||||
usableCapacity: new Energy(75.0),
|
||||
totalCapacity: new Energy(82.0),
|
||||
cellChemistry: CellChemistry::LithiumNickelManganeseOxide,
|
||||
model: '2170',
|
||||
manufacturer: 'Tesla'
|
||||
),
|
||||
chargingProperties: new ChargingProperties(
|
||||
topChargingSpeed: new Power(250)
|
||||
),
|
||||
rangeProperties: new RangeProperties(
|
||||
wltp: new WltpRange(new Range(602)),
|
||||
nefz: new NefzRange(new Range(614))
|
||||
),
|
||||
catalogPrice: new Price(49990, Currency::euro()),
|
||||
image: new Image('https://digitalassets.tesla.com/tesla-contents/image/upload/f_auto,q_auto/Model-3-Main-Hero-Desktop-LHD.jpg')
|
||||
),
|
||||
],
|
||||
|
||||
// BMW iX xDrive50
|
||||
[
|
||||
'model' => 'iX',
|
||||
'revision' => new CarRevision(
|
||||
name: 'xDrive50',
|
||||
productionBegin: new Date(1, 1, 2021),
|
||||
drivingCharacteristics: new DrivingCharacteristics(
|
||||
power: new Power(385),
|
||||
acceleration: new Acceleration(4.6),
|
||||
topSpeed: new Speed(200),
|
||||
consumption: new Consumption(new Energy(19.8))
|
||||
),
|
||||
battery: new BatteryProperties(
|
||||
usableCapacity: new Energy(71.2),
|
||||
totalCapacity: new Energy(76.6),
|
||||
cellChemistry: CellChemistry::LithiumNickelManganeseOxide,
|
||||
model: 'BMW Gen5',
|
||||
manufacturer: 'CATL'
|
||||
),
|
||||
chargingProperties: new ChargingProperties(
|
||||
topChargingSpeed: new Power(195)
|
||||
),
|
||||
rangeProperties: new RangeProperties(
|
||||
wltp: new WltpRange(new Range(630)),
|
||||
nefz: new NefzRange(new Range(680))
|
||||
),
|
||||
catalogPrice: new Price(77300, Currency::euro()),
|
||||
image: new Image('https://www.bmw.de/content/dam/bmw/common/all-models/x-series/ix/2021/highlights/bmw-ix-sp-desktop.jpg')
|
||||
),
|
||||
],
|
||||
|
||||
// Audi e-tron GT RS
|
||||
[
|
||||
'model' => 'e-tron GT',
|
||||
'revision' => new CarRevision(
|
||||
name: 'RS',
|
||||
productionBegin: new Date(1, 1, 2021),
|
||||
drivingCharacteristics: new DrivingCharacteristics(
|
||||
power: new Power(475),
|
||||
acceleration: new Acceleration(3.3),
|
||||
topSpeed: new Speed(250),
|
||||
consumption: new Consumption(new Energy(19.6))
|
||||
),
|
||||
battery: new BatteryProperties(
|
||||
usableCapacity: new Energy(83.7),
|
||||
totalCapacity: new Energy(93.4),
|
||||
cellChemistry: CellChemistry::LithiumNickelManganeseOxide,
|
||||
model: 'PPE Platform',
|
||||
manufacturer: 'LG Energy Solution'
|
||||
),
|
||||
chargingProperties: new ChargingProperties(
|
||||
topChargingSpeed: new Power(270)
|
||||
),
|
||||
rangeProperties: new RangeProperties(
|
||||
wltp: new WltpRange(new Range(472)),
|
||||
nefz: new NefzRange(new Range(487))
|
||||
),
|
||||
catalogPrice: new Price(142900, Currency::euro()),
|
||||
image: new Image('https://www.audi.de/content/dam/nemo/models/e-tron-gt/my-2021/1920x1080-gallery/1920x1080_AudiRS_e-tron_GT_19.jpg')
|
||||
),
|
||||
],
|
||||
|
||||
// Mercedes EQS 450+
|
||||
[
|
||||
'model' => 'EQS',
|
||||
'revision' => new CarRevision(
|
||||
name: '450+',
|
||||
productionBegin: new Date(1, 1, 2021),
|
||||
drivingCharacteristics: new DrivingCharacteristics(
|
||||
power: new Power(245),
|
||||
acceleration: new Acceleration(6.2),
|
||||
topSpeed: new Speed(210),
|
||||
consumption: new Consumption(new Energy(15.7))
|
||||
),
|
||||
battery: new BatteryProperties(
|
||||
usableCapacity: new Energy(90.0),
|
||||
totalCapacity: new Energy(107.8),
|
||||
cellChemistry: CellChemistry::LithiumNickelManganeseOxide,
|
||||
model: 'EVA Platform',
|
||||
manufacturer: 'CATL'
|
||||
),
|
||||
chargingProperties: new ChargingProperties(
|
||||
topChargingSpeed: new Power(200)
|
||||
),
|
||||
rangeProperties: new RangeProperties(
|
||||
wltp: new WltpRange(new Range(756)),
|
||||
nefz: new NefzRange(new Range(770))
|
||||
),
|
||||
catalogPrice: new Price(106374, Currency::euro()),
|
||||
image: new Image('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')
|
||||
),
|
||||
],
|
||||
|
||||
// Volkswagen ID.4 Pro
|
||||
[
|
||||
'model' => 'ID.4',
|
||||
'revision' => new CarRevision(
|
||||
name: 'Pro',
|
||||
productionBegin: new Date(1, 1, 2020),
|
||||
drivingCharacteristics: new DrivingCharacteristics(
|
||||
power: new Power(150),
|
||||
acceleration: new Acceleration(8.5),
|
||||
topSpeed: new Speed(160),
|
||||
consumption: new Consumption(new Energy(16.3))
|
||||
),
|
||||
battery: new BatteryProperties(
|
||||
usableCapacity: new Energy(77.0),
|
||||
totalCapacity: new Energy(82.0),
|
||||
cellChemistry: CellChemistry::LithiumNickelManganeseOxide,
|
||||
model: 'MEB Platform',
|
||||
manufacturer: 'LG Energy Solution'
|
||||
),
|
||||
chargingProperties: new ChargingProperties(
|
||||
topChargingSpeed: new Power(125)
|
||||
),
|
||||
rangeProperties: new RangeProperties(
|
||||
wltp: new WltpRange(new Range(520)),
|
||||
nefz: new NefzRange(new Range(549))
|
||||
),
|
||||
catalogPrice: new Price(51515, Currency::euro()),
|
||||
image: new Image('https://www.volkswagen.de/content/dam/vw-ngw/vw_pkw/importers/de/models/id-4/gallery/id4-gallery-exterior-01-16x9.jpg')
|
||||
),
|
||||
],
|
||||
|
||||
// Porsche Taycan Turbo S
|
||||
[
|
||||
'model' => 'Taycan',
|
||||
'revision' => new CarRevision(
|
||||
name: 'Turbo S',
|
||||
productionBegin: new Date(1, 1, 2019),
|
||||
drivingCharacteristics: new DrivingCharacteristics(
|
||||
power: new Power(560),
|
||||
acceleration: new Acceleration(2.8),
|
||||
topSpeed: new Speed(260),
|
||||
consumption: new Consumption(new Energy(23.7))
|
||||
),
|
||||
battery: new BatteryProperties(
|
||||
usableCapacity: new Energy(83.7),
|
||||
totalCapacity: new Energy(93.4),
|
||||
cellChemistry: CellChemistry::LithiumNickelManganeseOxide,
|
||||
model: 'J1 Platform',
|
||||
manufacturer: 'LG Energy Solution'
|
||||
),
|
||||
chargingProperties: new ChargingProperties(
|
||||
topChargingSpeed: new Power(270)
|
||||
),
|
||||
rangeProperties: new RangeProperties(
|
||||
wltp: new WltpRange(new Range(440)),
|
||||
nefz: new NefzRange(new Range(452))
|
||||
),
|
||||
catalogPrice: new Price(185456, Currency::euro()),
|
||||
image: new Image('https://www.porsche.com/germany/models/taycan/taycan-models/turbo-s/_jcr_content/par/twocolumnlayout/par_left/image.transform.porsche-model-desktop-xl.jpg')
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -5,13 +5,11 @@ namespace App\Domain\Model;
|
||||
final readonly class Brand
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $id,
|
||||
public readonly string $name,
|
||||
public readonly string $logo,
|
||||
public readonly string $description,
|
||||
public readonly int $foundedYear,
|
||||
public readonly string $headquarters,
|
||||
public readonly string $website,
|
||||
public readonly array $carModels,
|
||||
public readonly ?string $logo = null,
|
||||
public readonly ?string $description = null,
|
||||
public readonly ?int $foundedYear = null,
|
||||
public readonly ?string $headquarters = null,
|
||||
public readonly ?string $website = null,
|
||||
) {}
|
||||
}
|
||||
16
src/Domain/Model/CarModelCollection.php
Normal file
16
src/Domain/Model/CarModelCollection.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Model;
|
||||
|
||||
class CarModelCollection
|
||||
{
|
||||
public function __construct(
|
||||
private readonly array $carModels,
|
||||
) {
|
||||
}
|
||||
|
||||
public function array(): array
|
||||
{
|
||||
return $this->carModels;
|
||||
}
|
||||
}
|
||||
16
src/Domain/Model/CarRevisionCollection.php
Normal file
16
src/Domain/Model/CarRevisionCollection.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Model;
|
||||
|
||||
class CarRevisionCollection
|
||||
{
|
||||
public function __construct(
|
||||
private readonly array $carRevisions,
|
||||
) {
|
||||
}
|
||||
|
||||
public function array(): array
|
||||
{
|
||||
return $this->carRevisions;
|
||||
}
|
||||
}
|
||||
13
src/Domain/Model/Persistence/PersistedBrand.php
Normal file
13
src/Domain/Model/Persistence/PersistedBrand.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Model\Persistence;
|
||||
|
||||
use App\Domain\Model\Brand;
|
||||
|
||||
class PersistedBrand
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $id,
|
||||
public readonly Brand $brand,
|
||||
) {}
|
||||
}
|
||||
13
src/Domain/Model/Persistence/PersistedCarModel.php
Normal file
13
src/Domain/Model/Persistence/PersistedCarModel.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Model\Persistence;
|
||||
|
||||
use App\Domain\Model\CarModel;
|
||||
|
||||
class PersistedCarModel
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $id,
|
||||
public readonly CarModel $carModel,
|
||||
) {}
|
||||
}
|
||||
13
src/Domain/Model/Persistence/PersistedCarRevision.php
Normal file
13
src/Domain/Model/Persistence/PersistedCarRevision.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Model\Persistence;
|
||||
|
||||
use App\Domain\Model\CarRevision;
|
||||
|
||||
class PersistedCarRevision
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $id,
|
||||
public readonly CarRevision $carRevision,
|
||||
) {}
|
||||
}
|
||||
@ -14,7 +14,7 @@ class Acceleration
|
||||
return $this->secondsFrom0To100 . ' sec (0-100 km/h)';
|
||||
}
|
||||
|
||||
public function getSeconds(): float
|
||||
public function seconds(): float
|
||||
{
|
||||
return $this->secondsFrom0To100;
|
||||
}
|
||||
|
||||
@ -5,17 +5,22 @@ namespace App\Domain\Model\Value;
|
||||
class Consumption
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Energy $energyPerKm,
|
||||
public readonly Energy $energyPer100Km,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->energyPerKm->kwh() . ' kWh/100km';
|
||||
return $this->energyPer100Km->kwh() . ' ' . $this->unit();
|
||||
}
|
||||
|
||||
public function energyPerKm(): Energy
|
||||
public function energyPer100Km(): Energy
|
||||
{
|
||||
return $this->energyPerKm;
|
||||
return $this->energyPer100Km;
|
||||
}
|
||||
|
||||
public function unit(): string
|
||||
{
|
||||
return 'kWh/100km';
|
||||
}
|
||||
}
|
||||
@ -15,21 +15,6 @@ class Currency
|
||||
return $this->symbol;
|
||||
}
|
||||
|
||||
public function symbol(): string
|
||||
{
|
||||
return $this->symbol;
|
||||
}
|
||||
|
||||
public function currency(): string
|
||||
{
|
||||
return $this->currency;
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public static function euro(): self
|
||||
{
|
||||
return new self('€', 'EUR', 'Euro');
|
||||
|
||||
@ -12,6 +12,11 @@ class Price
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return number_format($this->price, 0, ',', '.') . ' ' . $this->currency->symbol();
|
||||
return $this->formattedPrice() . ' ' . $this->currency->symbol;
|
||||
}
|
||||
|
||||
public function formattedPrice(): string
|
||||
{
|
||||
return number_format($this->price, 0, ',', '.');
|
||||
}
|
||||
}
|
||||
@ -2,9 +2,15 @@
|
||||
|
||||
namespace App\Domain\Repository;
|
||||
|
||||
use App\Domain\Model\Brand;
|
||||
use App\Domain\Model\BrandCollection;
|
||||
use App\Domain\Model\Persistence\PersistedBrand;
|
||||
|
||||
interface BrandRepository
|
||||
{
|
||||
public function findAll(): BrandCollection;
|
||||
}
|
||||
|
||||
public function create(Brand $brand): PersistedBrand;
|
||||
|
||||
public function update(PersistedBrand $persistedBrand): void;
|
||||
}
|
||||
@ -1,98 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
namespace App\Domain\Repository;
|
||||
|
||||
use App\Domain\Model\CarModel;
|
||||
use Doctrine\ODM\MongoDB\DocumentManager;
|
||||
use Doctrine\ODM\MongoDB\Repository\DocumentRepository;
|
||||
use App\Domain\Model\CarModelCollection;
|
||||
use App\Domain\Model\Persistence\PersistedCarModel;
|
||||
|
||||
class CarModelRepository extends DocumentRepository
|
||||
interface CarModelRepository
|
||||
{
|
||||
public function __construct(DocumentManager $dm)
|
||||
{
|
||||
parent::__construct($dm, $dm->getUnitOfWork(), $dm->getClassMetadata(CarModel::class));
|
||||
}
|
||||
public function findAll(): CarModelCollection;
|
||||
|
||||
public function findById(string $id): ?PersistedCarModel;
|
||||
|
||||
public function findByBrandId(string $brandId): CarModelCollection;
|
||||
|
||||
public function create(CarModel $carModel, string $brandId): PersistedCarModel;
|
||||
|
||||
public function findAllCarModels(): array
|
||||
{
|
||||
return $this->createQueryBuilder()
|
||||
->sort('name', 'asc')
|
||||
->getQuery()
|
||||
->execute()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function findCarModelById(string $id): ?CarModel
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function findCarModelsByBrandId(string $brandId): array
|
||||
{
|
||||
return $this->createQueryBuilder()
|
||||
->field('brand.$id')->equals(new \MongoDB\BSON\ObjectId($brandId))
|
||||
->sort('name', 'asc')
|
||||
->getQuery()
|
||||
->execute()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function findCarModelsByName(string $name): array
|
||||
{
|
||||
return $this->createQueryBuilder()
|
||||
->field('name')->equals(new \MongoDB\BSON\Regex($name, 'i'))
|
||||
->sort('name', 'asc')
|
||||
->getQuery()
|
||||
->execute()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function findCarModelsByCategory(string $category): array
|
||||
{
|
||||
return $this->createQueryBuilder()
|
||||
->field('category')->equals($category)
|
||||
->sort('name', 'asc')
|
||||
->getQuery()
|
||||
->execute()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function findCarModelsByYearRange(int $startYear, int $endYear): array
|
||||
{
|
||||
return $this->createQueryBuilder()
|
||||
->field('productionStartYear')->gte($startYear)
|
||||
->field('productionStartYear')->lte($endYear)
|
||||
->sort('productionStartYear', 'asc')
|
||||
->getQuery()
|
||||
->execute()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function searchCarModels(string $query): array
|
||||
{
|
||||
$regex = new \MongoDB\BSON\Regex($query, 'i');
|
||||
|
||||
return $this->createQueryBuilder()
|
||||
->addOr(
|
||||
$this->createQueryBuilder()->field('name')->equals($regex)->getQueryArray(),
|
||||
$this->createQueryBuilder()->field('description')->equals($regex)->getQueryArray(),
|
||||
$this->createQueryBuilder()->field('category')->equals($regex)->getQueryArray()
|
||||
)
|
||||
->sort('name', 'asc')
|
||||
->getQuery()
|
||||
->execute()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function saveCarModel(CarModel $carModel): void
|
||||
{
|
||||
$this->getDocumentManager()->persist($carModel);
|
||||
$this->getDocumentManager()->flush();
|
||||
}
|
||||
|
||||
public function deleteCarModel(CarModel $carModel): void
|
||||
{
|
||||
$this->getDocumentManager()->remove($carModel);
|
||||
$this->getDocumentManager()->flush();
|
||||
}
|
||||
public function update(PersistedCarModel $persistedCarModel): void;
|
||||
|
||||
public function delete(PersistedCarModel $persistedCarModel): void;
|
||||
}
|
||||
22
src/Domain/Repository/CarRevisionRepository.php
Normal file
22
src/Domain/Repository/CarRevisionRepository.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Repository;
|
||||
|
||||
use App\Domain\Model\CarRevision;
|
||||
use App\Domain\Model\CarRevisionCollection;
|
||||
use App\Domain\Model\Persistence\PersistedCarRevision;
|
||||
|
||||
interface CarRevisionRepository
|
||||
{
|
||||
public function findAll(): CarRevisionCollection;
|
||||
|
||||
public function findById(string $id): ?PersistedCarRevision;
|
||||
|
||||
public function findByCarModelId(string $carModelId): CarRevisionCollection;
|
||||
|
||||
public function create(CarRevision $carRevision, string $carModelId): PersistedCarRevision;
|
||||
|
||||
public function update(PersistedCarRevision $persistedCarRevision): void;
|
||||
|
||||
public function delete(PersistedCarRevision $persistedCarRevision): void;
|
||||
}
|
||||
@ -17,6 +17,7 @@ use App\Domain\Model\Value\Consumption;
|
||||
use App\Domain\Model\Value\Speed;
|
||||
use App\Domain\Model\Value\Drivetrain;
|
||||
use App\Domain\Model\Value\Energy;
|
||||
use App\Domain\Model\Value\ChargingSpeed;
|
||||
use App\Domain\Model\Charging\ChargingProperties;
|
||||
use App\Domain\Model\Charging\ChargeCurve;
|
||||
use App\Domain\Model\Charging\ChargeTimeProperties;
|
||||
@ -41,21 +42,15 @@ use App\Domain\Search\Tiles\AvailabilityTile;
|
||||
use App\Domain\Search\Tiles\CarTile;
|
||||
use App\Domain\Search\Tiles\TopSpeedTile;
|
||||
use App\Domain\Search\Tiles\DrivetrainTile;
|
||||
// New tiles
|
||||
use App\Domain\Search\Tiles\ChargeCurveTile;
|
||||
use App\Domain\Search\Tiles\ChargeTimeTile;
|
||||
use App\Domain\Search\Tiles\ChargingConnectivityTile;
|
||||
use App\Domain\Search\Tiles\BatteryDetailsTile;
|
||||
use App\Domain\Search\Tiles\ProductionPeriodTile;
|
||||
use App\Domain\Search\Tiles\RangeComparisonTile;
|
||||
use App\Domain\Search\Tiles\RealRangeTile;
|
||||
use App\Domain\Search\Tiles\PerformanceOverviewTile;
|
||||
|
||||
class Engine
|
||||
{
|
||||
public function search(string $query): TileCollection
|
||||
{
|
||||
// Create comprehensive test data showing all the new features
|
||||
$batteryProperties = new BatteryProperties(
|
||||
usableCapacity: new Energy(77.0),
|
||||
totalCapacity: new Energy(82.0),
|
||||
@ -123,7 +118,7 @@ class Engine
|
||||
$skodaElroq85 = new CarRevision(
|
||||
name: 'Skoda Enyaq iV 85',
|
||||
productionBegin: new Date(1, 1, 2020),
|
||||
productionEnd: null, // Still in production
|
||||
productionEnd: null,
|
||||
drivingCharacteristics: $drivingCharacteristics,
|
||||
battery: $batteryProperties,
|
||||
chargingProperties: $chargingProperties,
|
||||
@ -132,25 +127,30 @@ class Engine
|
||||
image: new Image('https://www.scherer-gruppe.de/media/f3b72d42-4b26-4606-8df4-d840efeff017/01_elroc.jpg?w=1920&h=758&action=crop&scale=both&anchor=middlecenter')
|
||||
);
|
||||
|
||||
$chargingSpeed = new ChargingSpeed(
|
||||
dcMaxKw: new Power(175),
|
||||
acMaxKw: new Power(11)
|
||||
);
|
||||
|
||||
return new TileCollection([
|
||||
new SectionTile('Skoda Enyaq iV 85', [
|
||||
new CarTile($skodaElroq85->image, [
|
||||
new BrandTile('Skoda'),
|
||||
new PriceTile($skodaElroq85->catalogPrice),
|
||||
new ProductionPeriodTile($skodaElroq85->productionBegin, $skodaElroq85->productionEnd),
|
||||
new AvailabilityTile('Verfügbar', new Date(1, 1, 2020)),
|
||||
new RangeTile($wltpRange->range),
|
||||
new ConsumptionTile($drivingCharacteristics->consumption),
|
||||
new AccelerationTile($drivingCharacteristics->acceleration),
|
||||
]),
|
||||
|
||||
new SubSectionTile('Performance', [
|
||||
new PowerTile($drivingCharacteristics->power),
|
||||
new AccelerationTile($drivingCharacteristics->acceleration),
|
||||
new TopSpeedTile($drivingCharacteristics->topSpeed),
|
||||
new DrivetrainTile(new Drivetrain('rear')),
|
||||
new ConsumptionTile($drivingCharacteristics->consumption),
|
||||
], 'Individual performance metrics'),
|
||||
|
||||
new SubSectionTile('Reichweite', [
|
||||
new RangeTile($wltpRange->range),
|
||||
new RangeComparisonTile($skodaElroq85->rangeProperties),
|
||||
new RealRangeTile($realRangeTests),
|
||||
], 'Range data from different sources'),
|
||||
|
||||
@ -160,9 +160,9 @@ class Engine
|
||||
], 'Battery capacity and technology'),
|
||||
|
||||
new SubSectionTile('Laden', [
|
||||
new ChargingTile($chargingSpeed),
|
||||
new ChargeTimeTile($chargingProperties->chargeTimeProperties),
|
||||
new ChargingConnectivityTile($chargingProperties->chargingConnectivity),
|
||||
new ChargeCurveTile($chargingProperties->chargeCurve),
|
||||
], 'Charging capabilities and compatibility'),
|
||||
]),
|
||||
]);
|
||||
|
||||
@ -2,10 +2,12 @@
|
||||
|
||||
namespace App\Domain\Search\Tiles;
|
||||
|
||||
use App\Domain\Model\Value\Date;
|
||||
|
||||
class AvailabilityTile
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $status,
|
||||
public readonly ?string $availableSince = null,
|
||||
public readonly ?Date $availableSince = null,
|
||||
) {}
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\MongoDB;
|
||||
|
||||
use MongoDB\Client;
|
||||
use MongoDB\Database;
|
||||
|
||||
class MongoDBClient
|
||||
{
|
||||
private Client $client;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $dsl,
|
||||
private readonly string $databaseName,
|
||||
) {
|
||||
$this->client = new Client($this->dsl);
|
||||
}
|
||||
|
||||
public function getClient(): Client
|
||||
{
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
public function getDatabase(): Database
|
||||
{
|
||||
return $this->client->selectDatabase($this->databaseName);
|
||||
}
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\MongoDB\Repository\BrandRepository;
|
||||
|
||||
use App\Domain\Model\Brand;
|
||||
|
||||
class ModelMapper
|
||||
{
|
||||
public function map(array $data): Brand
|
||||
{
|
||||
$id = $data['_id'] ?? null;
|
||||
if ($id !== null) {
|
||||
$id = (string) $id;
|
||||
}
|
||||
|
||||
return new Brand(
|
||||
id: $id,
|
||||
name: $data['name'] ?? null,
|
||||
logo: $data['logo'] ?? null,
|
||||
description: $data['description'] ?? null,
|
||||
foundedYear: $data['foundedYear'] ?? null,
|
||||
headquarters: $data['headquarters'] ?? null,
|
||||
website: $data['website'] ?? null,
|
||||
carModels: $data['carModels'] ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\MongoDB\Repository\BrandRepository;
|
||||
|
||||
use App\Domain\Model\BrandCollection;
|
||||
use App\Domain\Repository\BrandRepository;
|
||||
use App\Infrastructure\MongoDB\MongoDBClient;
|
||||
|
||||
final class MongoDBBrandRepository implements BrandRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MongoDBClient $client,
|
||||
) {
|
||||
}
|
||||
|
||||
public function findAll(): BrandCollection
|
||||
{
|
||||
$result = $this->client->getDatabase()->selectCollection('brands')->find();
|
||||
$brands = [];
|
||||
|
||||
$mapper = new ModelMapper();
|
||||
|
||||
/** @var \MongoDB\Model\BSONDocument $brand */
|
||||
foreach ($result as $brand) {
|
||||
$brands[] = $mapper->map($brand->getArrayCopy());
|
||||
}
|
||||
|
||||
return new BrandCollection($brands);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\PostgreSQL\Repository\BrandRepository;
|
||||
|
||||
use App\Domain\Model\Brand;
|
||||
|
||||
class ModelMapper
|
||||
{
|
||||
public function map(array $data): Brand
|
||||
{
|
||||
return new Brand(
|
||||
id: (string) ($data['id'] ?? null),
|
||||
name: $data['name'] ?? '',
|
||||
logo: $data['logo'] ?? '',
|
||||
description: $data['description'] ?? '',
|
||||
foundedYear: (int) ($data['founded_year'] ?? 0),
|
||||
headquarters: $data['headquarters'] ?? '',
|
||||
website: $data['website'] ?? '',
|
||||
carModels: json_decode($data['car_models'] ?? '[]', true),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\PostgreSQL\Repository\BrandRepository;
|
||||
|
||||
use App\Domain\Model\Brand;
|
||||
use App\Domain\Model\BrandCollection;
|
||||
use App\Domain\Model\Persistence\PersistedBrand;
|
||||
use App\Domain\Repository\BrandRepository;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
final class PostgreSQLBrandRepository implements BrandRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
public function findAll(): BrandCollection
|
||||
{
|
||||
$sql = 'SELECT * FROM brands ORDER BY name ASC';
|
||||
$result = $this->connection->executeQuery($sql);
|
||||
|
||||
$brands = [];
|
||||
$mapper = new ModelMapper();
|
||||
|
||||
foreach ($result as $brand) {
|
||||
$brands[] = $mapper->map($brand);
|
||||
}
|
||||
|
||||
return new BrandCollection($brands);
|
||||
}
|
||||
|
||||
public function create(Brand $brand): PersistedBrand
|
||||
{
|
||||
// Generate an ID for the brand since Brand model doesn't have one
|
||||
$brandId = uniqid('brand_', true);
|
||||
|
||||
$sql = <<<'SQL'
|
||||
INSERT INTO brands (id, name, content)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
content = EXCLUDED.content
|
||||
SQL;
|
||||
|
||||
$content = json_encode([
|
||||
'logo' => $brand->logo,
|
||||
'description' => $brand->description,
|
||||
'founded_year' => $brand->foundedYear,
|
||||
'headquarters' => $brand->headquarters,
|
||||
'website' => $brand->website,
|
||||
]);
|
||||
|
||||
$this->connection->executeStatement($sql, [
|
||||
$brandId,
|
||||
$brand->name,
|
||||
$content,
|
||||
]);
|
||||
|
||||
return new PersistedBrand($brandId, $brand);
|
||||
}
|
||||
|
||||
public function update(PersistedBrand $persistedBrand): void
|
||||
{
|
||||
$brand = $persistedBrand->brand;
|
||||
|
||||
$sql = <<<'SQL'
|
||||
UPDATE brands SET
|
||||
name = ?,
|
||||
content = ?
|
||||
WHERE id = ?
|
||||
SQL;
|
||||
|
||||
$content = json_encode([
|
||||
'logo' => $brand->logo,
|
||||
'description' => $brand->description,
|
||||
'founded_year' => $brand->foundedYear,
|
||||
'headquarters' => $brand->headquarters,
|
||||
'website' => $brand->website,
|
||||
]);
|
||||
|
||||
$this->connection->executeStatement($sql, [
|
||||
$brand->name,
|
||||
$content,
|
||||
$persistedBrand->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\PostgreSQL\Repository\CarModelRepository;
|
||||
|
||||
use App\Domain\Model\CarModel;
|
||||
use App\Domain\Model\Brand;
|
||||
|
||||
class ModelMapper
|
||||
{
|
||||
public function map(array $data): CarModel
|
||||
{
|
||||
$content = json_decode($data['content'] ?? '{}', true);
|
||||
|
||||
$brand = null;
|
||||
if (!empty($content['brand'])) {
|
||||
$brand = new Brand($content['brand']);
|
||||
}
|
||||
|
||||
return new CarModel(
|
||||
name: $data['name'] ?? '',
|
||||
brand: $brand,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\PostgreSQL\Repository\CarModelRepository;
|
||||
|
||||
use App\Domain\Model\CarModel;
|
||||
use App\Domain\Model\CarModelCollection;
|
||||
use App\Domain\Model\Persistence\PersistedCarModel;
|
||||
use App\Domain\Repository\CarModelRepository;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
final class PostgreSQLCarModelRepository implements CarModelRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
public function findAll(): CarModelCollection
|
||||
{
|
||||
$sql = 'SELECT * FROM car_models ORDER BY name ASC';
|
||||
$result = $this->connection->executeQuery($sql);
|
||||
|
||||
$carModels = [];
|
||||
$mapper = new ModelMapper();
|
||||
|
||||
foreach ($result as $carModel) {
|
||||
$carModels[] = $mapper->map($carModel);
|
||||
}
|
||||
|
||||
return new CarModelCollection($carModels);
|
||||
}
|
||||
|
||||
public function findById(string $id): ?PersistedCarModel
|
||||
{
|
||||
$sql = 'SELECT * FROM car_models WHERE id = ?';
|
||||
$result = $this->connection->executeQuery($sql, [$id]);
|
||||
$data = $result->fetchAssociative();
|
||||
|
||||
if (!$data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mapper = new ModelMapper();
|
||||
$carModel = $mapper->map($data);
|
||||
|
||||
return new PersistedCarModel($data['id'], $carModel);
|
||||
}
|
||||
|
||||
public function findByBrandId(string $brandId): CarModelCollection
|
||||
{
|
||||
$sql = 'SELECT * FROM car_models WHERE brand_id = ? ORDER BY name ASC';
|
||||
$result = $this->connection->executeQuery($sql, [$brandId]);
|
||||
|
||||
$carModels = [];
|
||||
$mapper = new ModelMapper();
|
||||
|
||||
foreach ($result as $carModel) {
|
||||
$carModels[] = $mapper->map($carModel);
|
||||
}
|
||||
|
||||
return new CarModelCollection($carModels);
|
||||
}
|
||||
|
||||
public function create(CarModel $carModel, string $brandId): PersistedCarModel
|
||||
{
|
||||
// Generate an ID for the car model
|
||||
$carModelId = uniqid('carmodel_', true);
|
||||
|
||||
$sql = <<<'SQL'
|
||||
INSERT INTO car_models (id, brand_id, name, content)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
brand_id = EXCLUDED.brand_id,
|
||||
name = EXCLUDED.name,
|
||||
content = EXCLUDED.content
|
||||
SQL;
|
||||
|
||||
$content = json_encode([
|
||||
'brand' => $carModel->brand?->name ?? null,
|
||||
]);
|
||||
|
||||
$this->connection->executeStatement($sql, [
|
||||
$carModelId,
|
||||
$brandId,
|
||||
$carModel->name,
|
||||
$content,
|
||||
]);
|
||||
|
||||
return new PersistedCarModel($carModelId, $carModel);
|
||||
}
|
||||
|
||||
public function update(PersistedCarModel $persistedCarModel): void
|
||||
{
|
||||
$carModel = $persistedCarModel->carModel;
|
||||
|
||||
$sql = <<<'SQL'
|
||||
UPDATE car_models SET
|
||||
name = ?,
|
||||
content = ?
|
||||
WHERE id = ?
|
||||
SQL;
|
||||
|
||||
$content = json_encode([
|
||||
'brand' => $carModel->brand?->name ?? null,
|
||||
]);
|
||||
|
||||
$this->connection->executeStatement($sql, [
|
||||
$carModel->name,
|
||||
$content,
|
||||
$persistedCarModel->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function delete(PersistedCarModel $persistedCarModel): void
|
||||
{
|
||||
$sql = 'DELETE FROM car_models WHERE id = ?';
|
||||
$this->connection->executeStatement($sql, [$persistedCarModel->id]);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\PostgreSQL\Repository\CarRevisionRepository;
|
||||
|
||||
use App\Domain\Model\CarRevision;
|
||||
use App\Domain\Model\DrivingCharacteristics;
|
||||
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\Battery\BatteryProperties;
|
||||
use App\Domain\Model\Battery\CellChemistry;
|
||||
use App\Domain\Model\Charging\ChargingProperties;
|
||||
use App\Domain\Model\RangeProperties;
|
||||
use App\Domain\Model\Range\WltpRange;
|
||||
use App\Domain\Model\Range\NefzRange;
|
||||
use App\Domain\Model\Value\Energy;
|
||||
use App\Domain\Model\Value\Power;
|
||||
use App\Domain\Model\Value\Acceleration;
|
||||
use App\Domain\Model\Value\Speed;
|
||||
use App\Domain\Model\Value\Consumption;
|
||||
use App\Domain\Model\Value\Range;
|
||||
|
||||
class ModelMapper
|
||||
{
|
||||
public function map(array $data): CarRevision
|
||||
{
|
||||
$content = json_decode($data['content'] ?? '{}', true);
|
||||
|
||||
$productionBegin = null;
|
||||
if (!empty($content['production_begin'])) {
|
||||
$productionBegin = new Date(1, 1, $content['production_begin']);
|
||||
}
|
||||
|
||||
$productionEnd = null;
|
||||
if (!empty($content['production_end'])) {
|
||||
$productionEnd = new Date(1, 1, $content['production_end']);
|
||||
}
|
||||
|
||||
$catalogPrice = null;
|
||||
if (!empty($content['catalog_price']) && !empty($content['catalog_price_currency'])) {
|
||||
$currency = match($content['catalog_price_currency']) {
|
||||
'EUR' => Currency::euro(),
|
||||
'USD' => Currency::usd(),
|
||||
default => Currency::euro(), // fallback to euro
|
||||
};
|
||||
|
||||
$catalogPrice = new Price(
|
||||
$content['catalog_price'],
|
||||
$currency
|
||||
);
|
||||
}
|
||||
|
||||
$image = null;
|
||||
if (!empty($content['image_url'])) {
|
||||
$image = new Image($content['image_url']);
|
||||
}
|
||||
|
||||
$drivingCharacteristics = null;
|
||||
if (!empty($content['driving_characteristics'])) {
|
||||
$dc = $content['driving_characteristics'];
|
||||
$drivingCharacteristics = new DrivingCharacteristics(
|
||||
power: !empty($dc['power_kw']) ? new Power($dc['power_kw']) : null,
|
||||
acceleration: !empty($dc['acceleration_0_100']) ? new Acceleration($dc['acceleration_0_100']) : null,
|
||||
topSpeed: !empty($dc['top_speed_kmh']) ? new Speed($dc['top_speed_kmh']) : null,
|
||||
consumption: !empty($dc['consumption_kwh_100km']) ? new Consumption(new Energy($dc['consumption_kwh_100km'])) : null,
|
||||
);
|
||||
}
|
||||
|
||||
$battery = null;
|
||||
if (!empty($content['battery'])) {
|
||||
$b = $content['battery'];
|
||||
if (!empty($b['usable_capacity_kwh']) && !empty($b['total_capacity_kwh'])) {
|
||||
$battery = new BatteryProperties(
|
||||
usableCapacity: new Energy($b['usable_capacity_kwh']),
|
||||
totalCapacity: new Energy($b['total_capacity_kwh']),
|
||||
cellChemistry: !empty($b['cell_chemistry']) ? CellChemistry::from($b['cell_chemistry']) : CellChemistry::LithiumIronPhosphate,
|
||||
model: $b['model'] ?? '',
|
||||
manufacturer: $b['manufacturer'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$chargingProperties = null;
|
||||
if (!empty($content['charging']['top_charging_speed_kw'])) {
|
||||
$chargingProperties = new ChargingProperties(
|
||||
topChargingSpeed: new Power($content['charging']['top_charging_speed_kw'])
|
||||
);
|
||||
}
|
||||
|
||||
$rangeProperties = null;
|
||||
if (!empty($content['range'])) {
|
||||
$r = $content['range'];
|
||||
$wltp = !empty($r['wltp_km']) ? new WltpRange(new Range($r['wltp_km'])) : null;
|
||||
$nefz = !empty($r['nefz_km']) ? new NefzRange(new Range($r['nefz_km'])) : null;
|
||||
|
||||
if ($wltp || $nefz) {
|
||||
$rangeProperties = new RangeProperties(
|
||||
wltp: $wltp,
|
||||
nefz: $nefz,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new CarRevision(
|
||||
name: $data['name'] ?? '',
|
||||
productionBegin: $productionBegin,
|
||||
productionEnd: $productionEnd,
|
||||
drivingCharacteristics: $drivingCharacteristics,
|
||||
battery: $battery,
|
||||
chargingProperties: $chargingProperties,
|
||||
rangeProperties: $rangeProperties,
|
||||
catalogPrice: $catalogPrice,
|
||||
carModel: null, // CarModel would need to be loaded separately if needed
|
||||
image: $image,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\PostgreSQL\Repository\CarRevisionRepository;
|
||||
|
||||
use App\Domain\Model\CarRevision;
|
||||
use App\Domain\Model\CarRevisionCollection;
|
||||
use App\Domain\Model\Persistence\PersistedCarRevision;
|
||||
use App\Domain\Repository\CarRevisionRepository;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
final class PostgreSQLCarRevisionRepository implements CarRevisionRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
public function findAll(): CarRevisionCollection
|
||||
{
|
||||
$sql = 'SELECT * FROM car_revisions ORDER BY name ASC';
|
||||
$result = $this->connection->executeQuery($sql);
|
||||
|
||||
$carRevisions = [];
|
||||
$mapper = new ModelMapper();
|
||||
|
||||
foreach ($result as $carRevision) {
|
||||
$carRevisions[] = $mapper->map($carRevision);
|
||||
}
|
||||
|
||||
return new CarRevisionCollection($carRevisions);
|
||||
}
|
||||
|
||||
public function findById(string $id): ?PersistedCarRevision
|
||||
{
|
||||
$sql = 'SELECT * FROM car_revisions WHERE id = ?';
|
||||
$result = $this->connection->executeQuery($sql, [$id]);
|
||||
$data = $result->fetchAssociative();
|
||||
|
||||
if (!$data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mapper = new ModelMapper();
|
||||
$carRevision = $mapper->map($data);
|
||||
|
||||
return new PersistedCarRevision($data['id'], $carRevision);
|
||||
}
|
||||
|
||||
public function findByCarModelId(string $carModelId): CarRevisionCollection
|
||||
{
|
||||
$sql = 'SELECT * FROM car_revisions WHERE car_model_id = ? ORDER BY name ASC';
|
||||
$result = $this->connection->executeQuery($sql, [$carModelId]);
|
||||
|
||||
$carRevisions = [];
|
||||
$mapper = new ModelMapper();
|
||||
|
||||
foreach ($result as $carRevision) {
|
||||
$carRevisions[] = $mapper->map($carRevision);
|
||||
}
|
||||
|
||||
return new CarRevisionCollection($carRevisions);
|
||||
}
|
||||
|
||||
public function create(CarRevision $carRevision, string $carModelId): PersistedCarRevision
|
||||
{
|
||||
// Generate an ID for the car revision
|
||||
$carRevisionId = uniqid('carrevision_', true);
|
||||
|
||||
$sql = <<<'SQL'
|
||||
INSERT INTO car_revisions (id, car_model_id, name, content)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
car_model_id = EXCLUDED.car_model_id,
|
||||
name = EXCLUDED.name,
|
||||
content = EXCLUDED.content
|
||||
SQL;
|
||||
|
||||
$content = json_encode([
|
||||
'production_begin' => $carRevision->productionBegin?->year ?? null,
|
||||
'production_end' => $carRevision->productionEnd?->year ?? null,
|
||||
'catalog_price' => $carRevision->catalogPrice?->price ?? null,
|
||||
'catalog_price_currency' => $carRevision->catalogPrice?->currency->currency ?? null,
|
||||
'image_url' => $carRevision->image?->url ?? null,
|
||||
'driving_characteristics' => [
|
||||
'power_kw' => $carRevision->drivingCharacteristics?->power?->kilowatts ?? null,
|
||||
'acceleration_0_100' => $carRevision->drivingCharacteristics?->acceleration?->secondsFrom0To100 ?? null,
|
||||
'top_speed_kmh' => $carRevision->drivingCharacteristics?->topSpeed?->kmh ?? null,
|
||||
'consumption_kwh_100km' => $carRevision->drivingCharacteristics?->consumption?->energyPer100Km->kwh() ?? null,
|
||||
],
|
||||
'battery' => [
|
||||
'usable_capacity_kwh' => $carRevision->battery?->usableCapacity->kwh() ?? null,
|
||||
'total_capacity_kwh' => $carRevision->battery?->totalCapacity->kwh() ?? null,
|
||||
'cell_chemistry' => $carRevision->battery?->cellChemistry->value ?? null,
|
||||
'model' => $carRevision->battery?->model ?? null,
|
||||
'manufacturer' => $carRevision->battery?->manufacturer ?? null,
|
||||
],
|
||||
'charging' => [
|
||||
'top_charging_speed_kw' => $carRevision->chargingProperties?->topChargingSpeed?->kilowatts ?? null,
|
||||
],
|
||||
'range' => [
|
||||
'wltp_km' => $carRevision->rangeProperties?->wltp?->range->kilometers ?? null,
|
||||
'nefz_km' => $carRevision->rangeProperties?->nefz?->range->kilometers ?? null,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->connection->executeStatement($sql, [
|
||||
$carRevisionId,
|
||||
$carModelId,
|
||||
$carRevision->name,
|
||||
$content,
|
||||
]);
|
||||
|
||||
return new PersistedCarRevision($carRevisionId, $carRevision);
|
||||
}
|
||||
|
||||
public function update(PersistedCarRevision $persistedCarRevision): void
|
||||
{
|
||||
$carRevision = $persistedCarRevision->carRevision;
|
||||
|
||||
$sql = <<<'SQL'
|
||||
UPDATE car_revisions SET
|
||||
name = ?,
|
||||
content = ?
|
||||
WHERE id = ?
|
||||
SQL;
|
||||
|
||||
$content = json_encode([
|
||||
'production_begin' => $carRevision->productionBegin?->year ?? null,
|
||||
'production_end' => $carRevision->productionEnd?->year ?? null,
|
||||
'catalog_price' => $carRevision->catalogPrice?->price ?? null,
|
||||
'catalog_price_currency' => $carRevision->catalogPrice?->currency->currency ?? null,
|
||||
'image_url' => $carRevision->image?->url ?? null,
|
||||
'driving_characteristics' => [
|
||||
'power_kw' => $carRevision->drivingCharacteristics?->power?->kilowatts ?? null,
|
||||
'acceleration_0_100' => $carRevision->drivingCharacteristics?->acceleration?->secondsFrom0To100 ?? null,
|
||||
'top_speed_kmh' => $carRevision->drivingCharacteristics?->topSpeed?->kmh ?? null,
|
||||
'consumption_kwh_100km' => $carRevision->drivingCharacteristics?->consumption?->energyPer100Km->kwh() ?? null,
|
||||
],
|
||||
'battery' => [
|
||||
'usable_capacity_kwh' => $carRevision->battery?->usableCapacity->kwh() ?? null,
|
||||
'total_capacity_kwh' => $carRevision->battery?->totalCapacity->kwh() ?? null,
|
||||
'cell_chemistry' => $carRevision->battery?->cellChemistry->value ?? null,
|
||||
'model' => $carRevision->battery?->model ?? null,
|
||||
'manufacturer' => $carRevision->battery?->manufacturer ?? null,
|
||||
],
|
||||
'charging' => [
|
||||
'top_charging_speed_kw' => $carRevision->chargingProperties?->topChargingSpeed?->kilowatts ?? null,
|
||||
],
|
||||
'range' => [
|
||||
'wltp_km' => $carRevision->rangeProperties?->wltp?->range->kilometers ?? null,
|
||||
'nefz_km' => $carRevision->rangeProperties?->nefz?->range->kilometers ?? null,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->connection->executeStatement($sql, [
|
||||
$carRevision->name,
|
||||
$content,
|
||||
$persistedCarRevision->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function delete(PersistedCarRevision $persistedCarRevision): void
|
||||
{
|
||||
$sql = 'DELETE FROM car_revisions WHERE id = ?';
|
||||
$this->connection->executeStatement($sql, [$persistedCarRevision->id]);
|
||||
}
|
||||
}
|
||||
51
symfony.lock
51
symfony.lock
@ -1,4 +1,55 @@
|
||||
{
|
||||
"doctrine/deprecations": {
|
||||
"version": "1.1",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.0",
|
||||
"ref": "87424683adc81d7dc305eefec1fced883084aab9"
|
||||
}
|
||||
},
|
||||
"doctrine/doctrine-bundle": {
|
||||
"version": "2.14",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "2.13",
|
||||
"ref": "620b57f496f2e599a6015a9fa222c2ee0a32adcb"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/doctrine.yaml",
|
||||
"src/Entity/.gitignore",
|
||||
"src/Repository/.gitignore"
|
||||
]
|
||||
},
|
||||
"doctrine/doctrine-migrations-bundle": {
|
||||
"version": "3.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.1",
|
||||
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/doctrine_migrations.yaml",
|
||||
"migrations/.gitignore"
|
||||
]
|
||||
},
|
||||
"symfony/asset-mapper": {
|
||||
"version": "7.3",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.4",
|
||||
"ref": "5ad1308aa756d58f999ffbe1540d1189f5d7d14a"
|
||||
},
|
||||
"files": [
|
||||
"assets/app.js",
|
||||
"assets/styles/app.css",
|
||||
"config/packages/asset_mapper.yaml",
|
||||
"importmap.php"
|
||||
]
|
||||
},
|
||||
"symfony/console": {
|
||||
"version": "7.2",
|
||||
"recipe": {
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
<div class="search-container" id="searchForm">
|
||||
<input type="text" id="searchInput" class="search-input" placeholder="Search for electric vehicles, brands, or models..." value="{{ query|default('') }}">
|
||||
<button type="button" id="searchButton" class="search-button">Search</button>
|
||||
<input type="text" id="searchInput" class="search-input" placeholder="🔍 Search for electric vehicles, brands, or models..." value="{{ query|default('') }}">
|
||||
<button type="button" id="searchButton" class="search-button">
|
||||
<i class="fas fa-search"></i> Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
@ -89,7 +89,7 @@
|
||||
/* Tiles Grid Layout */
|
||||
.tiles-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
|
||||
gap: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
@ -159,9 +159,9 @@
|
||||
}
|
||||
|
||||
.tile-subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
margin-bottom: 1.2rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@ -326,5 +326,6 @@
|
||||
</div>
|
||||
|
||||
{% block javascripts %}{% endblock %}
|
||||
{% block importmap %}{{ importmap('app') }}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@ -4,27 +4,27 @@
|
||||
|
||||
{% block body %}
|
||||
<div class="header">
|
||||
<h1 class="title">E-WIKI</h1>
|
||||
<h1 class="title"><i class="fas fa-car-battery"></i> E-WIKI</h1>
|
||||
|
||||
{% include '_components/search.html.twig' %}
|
||||
</div>
|
||||
|
||||
<div id="brandsSection">
|
||||
<h2 class="section-title">Popular Electric Vehicle Brands</h2>
|
||||
<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" data-brand-id="{{ brand.id }}">
|
||||
<div class="brand-name">{{ brand.name }}</div>
|
||||
<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">Founded: {{ brand.foundedYear }}</div>
|
||||
<div class="brand-year"><i class="fas fa-calendar-alt"></i> Founded: {{ brand.foundedYear }}</div>
|
||||
{% if brand.headquarters %}
|
||||
<div class="brand-year">{{ brand.headquarters }}</div>
|
||||
<div class="brand-year"><i class="fas fa-map-marker-alt"></i> {{ brand.headquarters }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="no-results">No brands available</div>
|
||||
<div class="no-results"><i class="fas fa-exclamation-triangle"></i> No brands available</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
<div class="tile">
|
||||
<div class="tile-title">{{ tile.acceleration }}</div>
|
||||
<div class="tile-title">
|
||||
<i class="fas fa-rocket" style="color: #28a745; margin-right: 8px;"></i>
|
||||
{{ tile.acceleration.seconds() }} s
|
||||
</div>
|
||||
<div class="tile-subtitle">0 auf 100 km/h</div>
|
||||
<small>Beschleunigung</small>
|
||||
</div>
|
||||
@ -1,9 +1,10 @@
|
||||
<div class="tile">
|
||||
<div class="tile-content">
|
||||
<div class="tile-title">{{ tile.status }}</div>
|
||||
{% if tile.availableSince %}
|
||||
<div class="tile-subtitle">seit {{ tile.availableSince }}</div>
|
||||
{% endif %}
|
||||
<div class="tile-title">
|
||||
<i class="fas fa-calendar-alt" style="color: #007acc; margin-right: 8px;"></i>
|
||||
{{ tile.availableSince.year }}
|
||||
</div>
|
||||
<small>Verfügbarkeit</small>
|
||||
{% if tile.availableSince.month %}
|
||||
<div class="tile-subtitle">{{ tile.availableSince.month }}</div>
|
||||
{% endif %}
|
||||
<small>Verfügbar ab</small>
|
||||
</div>
|
||||
@ -1,4 +1,7 @@
|
||||
<div class="tile">
|
||||
<div class="tile-title">{{ tile.battery }}</div>
|
||||
<div class="tile-title">
|
||||
<i class="fas fa-car-battery" style="color: #007acc; margin-right: 8px;"></i>
|
||||
{{ tile.battery }}
|
||||
</div>
|
||||
<small>Batterie</small>
|
||||
</div>
|
||||
@ -1,21 +1,19 @@
|
||||
<div class="tile battery-details-tile">
|
||||
<div class="tile-title">Zellchemie</div>
|
||||
<div class="battery-info" style="margin-top: 12px; text-align: center;">
|
||||
<!-- Chemistry Badge -->
|
||||
<div class="tile-title">
|
||||
<i class="fas fa-car-battery" style="color: #007acc; margin-right: 8px;"></i>
|
||||
Batterie Details
|
||||
</div>
|
||||
<div class="chemistry-info" style="margin-top: 12px; text-align: center;">
|
||||
{% set chemistryColor = tile.batteryProperties.cellChemistry.value == 'LFP' ? '#28a745' : (tile.batteryProperties.cellChemistry.value == 'NMC' ? '#007acc' : '#6f42c1') %}
|
||||
<div style="margin-bottom: 12px;">
|
||||
<span style="background: {{ chemistryColor }}; color: white; padding: 6px 12px; border-radius: 12px; font-size: 14px; font-weight: bold;">
|
||||
{{ tile.batteryProperties.cellChemistry.value }}
|
||||
</span>
|
||||
<div style="font-weight: bold; font-size: 24px; color: {{ chemistryColor }}; margin-bottom: 4px;">
|
||||
{{ tile.batteryProperties.cellChemistry.value }}
|
||||
</div>
|
||||
|
||||
<!-- Manufacturer & Model -->
|
||||
{% if tile.batteryProperties.manufacturer != 'Tesla' or tile.batteryProperties.model != '4680' %}
|
||||
<div style="font-size: 12px; color: #666;">
|
||||
{{ tile.batteryProperties.manufacturer }}<br>
|
||||
{{ tile.batteryProperties.model }}
|
||||
{% if tile.batteryProperties.cellChemistry.value == 'LFP' %}Lithium-Eisenphosphat
|
||||
{% elseif tile.batteryProperties.cellChemistry.value == 'NMC' %}Lithium-Nickel-Mangan
|
||||
{% else %}{{ tile.batteryProperties.cellChemistry.value }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<small style="color: #666;">Batterietechnologie</small>
|
||||
</div>
|
||||
@ -3,7 +3,10 @@
|
||||
<img src="{{ tile.logo }}" alt="{{ tile.name }}" class="tile-logo">
|
||||
{% endif %}
|
||||
<div class="tile-content">
|
||||
<div class="tile-title">{{ tile.name }}</div>
|
||||
<div class="tile-title">
|
||||
<i class="fas fa-building" style="color: #083d77; margin-right: 8px;"></i>
|
||||
{{ tile.name }}
|
||||
</div>
|
||||
</div>
|
||||
<small>Marke</small>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<div style="grid-column: span 2; grid-row: span 4;">
|
||||
<div style="grid-column: span 2; grid-row: span 2">
|
||||
{% if tile.image %}
|
||||
<img src="{{ tile.image.externalPublicUrl }}" style="width: 100%; height: 100%; object-fit: cover;">
|
||||
{% endif %}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
<div class="tile charge-curve-tile" style="grid-column: span 3; grid-row: span 2;">
|
||||
<div class="tile-title">Ladekurve</div>
|
||||
<div class="tile-title">
|
||||
<i class="fas fa-chart-area" style="color: #007acc; margin-right: 8px;"></i>
|
||||
Ladekurve
|
||||
</div>
|
||||
<div class="charge-curve-container" style="position: relative; height: 120px; margin-top: 10px;">
|
||||
<svg width="100%" height="100%" viewBox="0 0 300 100" style="overflow: visible;">
|
||||
<!-- Grid lines -->
|
||||
|
||||
@ -1,19 +1,16 @@
|
||||
<div class="tile charge-time-tile">
|
||||
<div class="tile-title">Schnellladen</div>
|
||||
<div class="charge-times" style="margin-top: 12px;">
|
||||
<div class="tile-title">
|
||||
<i class="fas fa-clock" style="color: #007acc; margin-right: 8px;"></i>
|
||||
Ladedauer
|
||||
</div>
|
||||
<div class="charge-time" style="margin-top: 12px; text-align: center;">
|
||||
{% if tile.chargeTimeProperties.minutesFrom10To80 %}
|
||||
<div style="text-align: center; margin-bottom: 12px;">
|
||||
<div style="font-weight: bold; font-size: 24px; color: #007acc;">{{ tile.chargeTimeProperties.minutesFrom10To80 }}</div>
|
||||
<small style="color: #666; font-weight: 600;">Minuten 10-80%</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if tile.chargeTimeProperties.minutesFrom20To80 %}
|
||||
<div style="text-align: center;">
|
||||
<div style="font-weight: bold; font-size: 18px; color: #6c757d;">{{ tile.chargeTimeProperties.minutesFrom20To80 }}</div>
|
||||
<small style="color: #666;">Minuten 20-80%</small>
|
||||
</div>
|
||||
<div style="font-weight: bold; font-size: 28px; color: #007acc; margin-bottom: 4px;">{{ tile.chargeTimeProperties.minutesFrom10To80 }}</div>
|
||||
<div style="font-size: 12px; color: #666;">Minuten (10-80%)</div>
|
||||
{% elseif tile.chargeTimeProperties.minutesFrom20To80 %}
|
||||
<div style="font-weight: bold; font-size: 28px; color: #007acc; margin-bottom: 4px;">{{ tile.chargeTimeProperties.minutesFrom20To80 }}</div>
|
||||
<div style="font-size: 12px; color: #666;">Minuten (20-80%)</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<small style="color: #666;">DC Laden</small>
|
||||
<small style="color: #666;">DC Schnellladen</small>
|
||||
</div>
|
||||
@ -1,4 +1,7 @@
|
||||
<div class="tile">
|
||||
<div class="tile-title">{{ tile.chargingSpeed }}</div>
|
||||
<small>Laden</small>
|
||||
<div class="tile-title">
|
||||
<i class="fas fa-charging-station" style="color: #28a745; margin-right: 8px;"></i>
|
||||
{{ tile.chargingSpeed.dcMax.kilowatts }} kW
|
||||
</div>
|
||||
<small>DC Laden</small>
|
||||
</div>
|
||||
@ -1,35 +1,25 @@
|
||||
<div class="tile charging-connectivity-tile">
|
||||
<div class="tile-title">Ladekompatibilität</div>
|
||||
<div class="connectivity-features" style="margin-top: 8px;">
|
||||
<!-- Voltage Support -->
|
||||
<div style="display: flex; gap: 6px; margin-bottom: 6px;">
|
||||
{% if tile.chargingConnectivity.is400v %}
|
||||
<span style="background: #28a745; color: white; padding: 2px 6px; border-radius: 12px; font-size: 10px; font-weight: bold;">400V</span>
|
||||
{% endif %}
|
||||
{% if tile.chargingConnectivity.is800v %}
|
||||
<span style="background: #007acc; color: white; padding: 2px 6px; border-radius: 12px; font-size: 10px; font-weight: bold;">800V</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Plug & Charge -->
|
||||
{% if tile.chargingConnectivity.plugAndCharge %}
|
||||
<div style="margin-bottom: 6px;">
|
||||
<span style="background: rgba(40, 167, 69, 0.1); color: #28a745; padding: 3px 8px; border-radius: 4px; font-size: 11px;">
|
||||
⚡ Plug & Charge
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Connector Types -->
|
||||
<div class="tile-title">
|
||||
<i class="fas fa-plug" style="color: #007acc; margin-right: 8px;"></i>
|
||||
Stecker
|
||||
</div>
|
||||
<div class="connector-info" style="margin-top: 8px; text-align: center;">
|
||||
<!-- Main Connector Types -->
|
||||
{% if tile.chargingConnectivity.connectorTypes|length > 0 %}
|
||||
<div class="connector-types" style="display: flex; gap: 4px; flex-wrap: wrap;">
|
||||
<div style="margin-bottom: 8px;">
|
||||
{% for connector in tile.chargingConnectivity.connectorTypes %}
|
||||
<span style="background: rgba(108,117,125,0.1); color: #6c757d; padding: 2px 6px; border-radius: 8px; font-size: 10px; font-weight: 500;">
|
||||
{{ connector.value }}
|
||||
</span>
|
||||
<div style="font-weight: bold; font-size: 18px; color: #007acc; margin-bottom: 2px;">{{ connector.value }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Voltage Support -->
|
||||
{% if tile.chargingConnectivity.is400v or tile.chargingConnectivity.is800v %}
|
||||
<div style="font-size: 12px; color: #666;">
|
||||
{% if tile.chargingConnectivity.is400v %}400V{% endif %}
|
||||
{% if tile.chargingConnectivity.is800v %}{% if tile.chargingConnectivity.is400v %} / {% endif %}800V{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<small style="color: #666;">Ladeschnittstellen & Features</small>
|
||||
<small style="color: #666;">Ladeanschluss</small>
|
||||
</div>
|
||||
@ -1,4 +1,8 @@
|
||||
<div class="tile">
|
||||
<div class="tile-title">{{ tile.consumption }}</div>
|
||||
<div class="tile-title">
|
||||
<i class="fas fa-leaf" style="color: #28a745; margin-right: 8px;"></i>
|
||||
{{ tile.consumption.energyPer100Km.kwh|round(1) }}
|
||||
</div>
|
||||
<div class="tile-subtitle">{{ tile.consumption.unit }}</div>
|
||||
<small>Verbrauch</small>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,4 +1,7 @@
|
||||
<div class="tile">
|
||||
<div class="tile-title">{{ tile.drivetrain }}</div>
|
||||
<div class="tile-title">
|
||||
<i class="fas fa-cogs" style="color: #6c757d; margin-right: 8px;"></i>
|
||||
{{ tile.drivetrain }}
|
||||
</div>
|
||||
<small>Antrieb</small>
|
||||
</div>
|
||||
@ -1,5 +1,8 @@
|
||||
<div class="tile performance-overview-tile" style="grid-column: span 3; grid-row: span 2;">
|
||||
<div class="tile-title">Performance-Übersicht</div>
|
||||
<div class="tile-title">
|
||||
<i class="fas fa-trophy" style="color: #ffc107; margin-right: 8px;"></i>
|
||||
Performance-Übersicht
|
||||
</div>
|
||||
<div class="performance-grid" style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-top: 12px; height: calc(100% - 40px);">
|
||||
<!-- Power -->
|
||||
{% if tile.drivingCharacteristics.power %}
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
<div class="tile">
|
||||
<div class="tile-title">{{ tile.power }}</div>
|
||||
<div class="tile-title">
|
||||
<i class="fas fa-bolt" style="color: #ffc107; margin-right: 8px;"></i>
|
||||
{{ tile.power }}
|
||||
</div>
|
||||
<small>Leistung</small>
|
||||
</div>
|
||||
@ -1,6 +1,8 @@
|
||||
<div class="tile">
|
||||
<div class="tile-content">
|
||||
<div class="tile-title">{{ tile.price }}</div>
|
||||
<div class="tile-title">
|
||||
<i class="fas fa-euro-sign" style="color: #28a745; margin-right: 8px;"></i>
|
||||
{{ tile.price }}
|
||||
</div>
|
||||
<div class="tile-subtitle">{{ tile.price.currency.name }}</div>
|
||||
<small>Preis</small>
|
||||
</div>
|
||||
@ -1,5 +1,8 @@
|
||||
<div class="tile production-period-tile">
|
||||
<div class="tile-title">Produktionszeitraum</div>
|
||||
<div class="tile-title">
|
||||
<i class="fas fa-industry" style="color: #6c757d; margin-right: 8px;"></i>
|
||||
Produktionszeitraum
|
||||
</div>
|
||||
<div class="production-timeline" style="margin-top: 8px;">
|
||||
{% if tile.productionBegin or tile.productionEnd %}
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
<div class="tile">
|
||||
<div class="tile-title">{{ tile.range }}</div>
|
||||
<div class="tile-title">
|
||||
<i class="fas fa-road" style="color: #6f42c1; margin-right: 8px;"></i>
|
||||
{{ tile.range }}
|
||||
</div>
|
||||
<small>Reichweite</small>
|
||||
</div>
|
||||
@ -1,5 +1,8 @@
|
||||
<div class="tile range-comparison-tile">
|
||||
<div class="tile-title">Reichweiten-Vergleich</div>
|
||||
<div class="tile-title">
|
||||
<i class="fas fa-balance-scale" style="color: #007acc; margin-right: 8px;"></i>
|
||||
Reichweiten-Vergleich
|
||||
</div>
|
||||
<div class="range-comparison" style="margin-top: 8px;">
|
||||
<!-- WLTP Range -->
|
||||
{% if tile.rangeProperties.wltp %}
|
||||
|
||||
@ -1,18 +1,16 @@
|
||||
<div class="tile real-range-tile">
|
||||
<div class="tile-title">Praxis-Tests</div>
|
||||
<div class="real-tests" style="margin-top: 8px;">
|
||||
{% for realTest in tile.realRangeTests|slice(0, 3) %}
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; padding: 4px 8px; background: rgba(40,167,69,0.1); border-radius: 4px; font-size: 13px;">
|
||||
<span style="color: #28a745;">
|
||||
{% if realTest.season %}{{ realTest.season.value }}{% endif %}
|
||||
{% if realTest.averageSpeed %} • {{ realTest.averageSpeed }}{% endif %}
|
||||
</span>
|
||||
<span style="font-weight: bold;">{{ realTest.range.kilometers }} km</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if tile.realRangeTests|length > 3 %}
|
||||
<small style="color: #666; font-style: italic; text-align: center; display: block;">+{{ tile.realRangeTests|length - 3 }} weitere</small>
|
||||
{% endif %}
|
||||
<div class="tile-title">
|
||||
<i class="fas fa-chart-line" style="color: #28a745; margin-right: 8px;"></i>
|
||||
Praxis-Test
|
||||
</div>
|
||||
{% if tile.realRangeTests|length > 0 %}
|
||||
<div class="best-test" style="margin-top: 8px; text-align: center;">
|
||||
<div style="font-weight: bold; font-size: 24px; color: #28a745; margin-bottom: 4px;">{{ tile.realRangeTests[0].range.kilometers }} km</div>
|
||||
<div style="font-size: 12px; color: #666;">
|
||||
{% if tile.realRangeTests[0].season %}{{ tile.realRangeTests[0].season.value }}{% endif %}
|
||||
{% if tile.realRangeTests[0].averageSpeed %} • {{ tile.realRangeTests[0].averageSpeed.kmh }} km/h{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<small style="color: #666;">Real-World Reichweite</small>
|
||||
</div>
|
||||
@ -1,3 +1,3 @@
|
||||
<h1 class="section-title" style="grid-column: span 4;">{{ tile.title }}</h1>
|
||||
<h1 class="section-title" style="grid-column: span 5;">{{ tile.title }}</h1>
|
||||
|
||||
{% include 'result/tiles/collection.html.twig' with { tiles: tile.tiles } %}
|
||||
@ -1,2 +1,2 @@
|
||||
<h2 class="subsection-title" style="grid-column: span 4">{{ tile.title }}</h2>
|
||||
<h2 class="subsection-title" style="grid-column: span 5">{{ tile.title }}</h2>
|
||||
{% include 'result/tiles/collection.html.twig' with { tiles: tile.tiles } %}
|
||||
@ -1,4 +1,7 @@
|
||||
<div class="tile">
|
||||
<div class="tile-title">{{ tile.topSpeed }}</div>
|
||||
<div class="tile-title">
|
||||
<i class="fas fa-tachometer-alt" style="color: #ffc107; margin-right: 8px;"></i>
|
||||
{{ tile.topSpeed }}
|
||||
</div>
|
||||
<small>Höchstgeschwindigkeit</small>
|
||||
</div>
|
||||
Loading…
x
Reference in New Issue
Block a user