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/
|
/var/
|
||||||
/vendor/
|
/vendor/
|
||||||
###< symfony/framework-bundle ###
|
###< 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
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies including zsh
|
# Install system dependencies
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
git \
|
git \
|
||||||
curl \
|
curl \
|
||||||
@ -14,14 +14,15 @@ RUN apk add --no-cache \
|
|||||||
autoconf \
|
autoconf \
|
||||||
g++ \
|
g++ \
|
||||||
make \
|
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
|
# Install PHP extensions
|
||||||
RUN docker-php-ext-install pdo pdo_mysql
|
RUN docker-php-ext-install pdo pdo_pgsql pgsql
|
||||||
|
|
||||||
# Install MongoDB PHP extension
|
|
||||||
RUN pecl install mongodb \
|
|
||||||
&& docker-php-ext-enable mongodb
|
|
||||||
|
|
||||||
# Install Composer
|
# Install Composer
|
||||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/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
|
- **Brand Directory**: Browse popular electric vehicle manufacturers
|
||||||
- **Vehicle Database**: Comprehensive information about electric car models and revisions
|
- **Vehicle Database**: Comprehensive information about electric car models and revisions
|
||||||
- **RESTful API**: JSON API endpoints for integration
|
- **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
|
- **Responsive Design**: Mobile-friendly interface without external CSS frameworks
|
||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
- **Backend**: Symfony 7.2 (PHP 8.2+)
|
- **Backend**: Symfony 7.2 (PHP 8.3+)
|
||||||
- **Database**: MongoDB with Doctrine ODM
|
- **Database**: PostgreSQL with native PDO
|
||||||
- **Frontend**: Vanilla JavaScript with modern CSS
|
- **Frontend**: Vanilla JavaScript with modern CSS
|
||||||
- **Architecture**: Clean Architecture with SOLID principles
|
- **Architecture**: Clean Architecture with SOLID principles
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- PHP 8.2 or higher
|
- PHP 8.3 or higher
|
||||||
- MongoDB 4.4 or higher
|
- PostgreSQL 13 or higher
|
||||||
- Composer
|
- Composer
|
||||||
- MongoDB PHP Extension
|
- PostgreSQL PHP Extension (pdo_pgsql)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@ -43,19 +43,21 @@ A modern Symfony application for browsing and searching electric vehicle informa
|
|||||||
cp .env.local.example .env.local
|
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_ENV=dev
|
||||||
APP_SECRET=your-secret-key-here
|
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**
|
4. **Start PostgreSQL**
|
||||||
Make sure MongoDB is running on your system.
|
Make sure PostgreSQL is running on your system.
|
||||||
|
|
||||||
5. **Seed the database**
|
5. **Initialize the database**
|
||||||
```bash
|
```bash
|
||||||
php bin/console app:seed-data
|
php bin/console app:database:init
|
||||||
```
|
```
|
||||||
|
|
||||||
6. **Start the development server**
|
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/
|
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
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── Command/ # Console commands
|
├── Command/ # Console commands
|
||||||
├── Controller/ # HTTP controllers
|
├── Application/
|
||||||
├── Document/ # MongoDB document models
|
│ └── Controller/ # HTTP controllers
|
||||||
├── Repository/ # Data access layer
|
├── Domain/
|
||||||
├── Service/ # Business logic layer
|
│ ├── Model/ # Domain models
|
||||||
└── Kernel.php # Application kernel
|
│ └── Repository/ # Repository interfaces
|
||||||
|
├── Infrastructure/
|
||||||
|
│ └── PostgreSQL/ # PostgreSQL implementation
|
||||||
|
│ ├── PostgreSQLClient.php
|
||||||
|
│ └── Repository/ # Concrete repositories
|
||||||
|
└── Kernel.php # Application kernel
|
||||||
|
|
||||||
templates/
|
templates/
|
||||||
├── base.html.twig # Base template
|
├── base.html.twig # Base template
|
||||||
└── home/ # Home page templates
|
└── home/ # Home page templates
|
||||||
|
|
||||||
|
database/
|
||||||
|
└── schema.sql # PostgreSQL schema
|
||||||
|
|
||||||
config/
|
config/
|
||||||
├── bundles.php # Bundle configuration
|
├── bundles.php # Bundle configuration
|
||||||
├── packages/ # Package configurations
|
├── packages/ # Package configurations
|
||||||
├── routes.yaml # Route definitions
|
├── routes.yaml # Route definitions
|
||||||
└── services.yaml # Service container
|
└── services.yaml # Service container
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
@ -107,50 +131,63 @@ config/
|
|||||||
This application follows clean architecture principles:
|
This application follows clean architecture principles:
|
||||||
|
|
||||||
### Domain Layer
|
### Domain Layer
|
||||||
- **Documents**: MongoDB document models (`Brand`, `CarModel`, `CarRevision`)
|
- **Models**: Core domain models (`Brand`, `CarModel`, `CarRevision`)
|
||||||
- **Repositories**: Data access interfaces
|
- **Repositories**: Data access interfaces
|
||||||
|
|
||||||
### Application Layer
|
### Application Layer
|
||||||
- **Services**: Business logic (`CarSearchService`)
|
- **Controllers**: HTTP request handlers
|
||||||
- **Commands**: Console commands for data management
|
- **Commands**: Console commands for data management
|
||||||
|
|
||||||
### Infrastructure Layer
|
### Infrastructure Layer
|
||||||
- **Controllers**: HTTP request handlers
|
- **PostgreSQL**: Database implementation with native PDO
|
||||||
- **Templates**: Twig templates for rendering
|
- **Templates**: Twig templates for rendering
|
||||||
|
|
||||||
### Key Design Principles
|
### Key Design Principles
|
||||||
|
|
||||||
1. **SOLID Principles**: Each class has a single responsibility
|
1. **SOLID Principles**: Each class has a single responsibility
|
||||||
2. **Dependency Injection**: All dependencies are injected via constructor
|
2. **Dependency Injection**: All dependencies are injected via constructor
|
||||||
3. **No Else Statements**: Code uses early returns for better readability
|
3. **Clean Architecture**: Domain logic is independent of infrastructure
|
||||||
4. **Readable Names**: Self-documenting code with descriptive names
|
4. **Readable Code**: Self-documenting code with descriptive names
|
||||||
|
|
||||||
## Development
|
## Database Commands
|
||||||
|
|
||||||
### Adding New Data
|
### Initialize Database
|
||||||
|
Initialize the database with schema and sample data:
|
||||||
Use the seed command to populate the database:
|
|
||||||
```bash
|
```bash
|
||||||
php bin/console app:seed-data
|
php bin/console app:database:init
|
||||||
```
|
```
|
||||||
|
|
||||||
### Console Commands
|
### Console Commands
|
||||||
|
|
||||||
List all available commands:
|
List all available commands:
|
||||||
```bash
|
```bash
|
||||||
php bin/console list
|
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
|
## Styling Guidelines
|
||||||
|
|
||||||
- **No External CSS Frameworks**: Pure CSS following modern standards
|
- **No External CSS Frameworks**: Pure CSS following modern standards
|
||||||
- **Responsive Design**: Mobile-first approach
|
- **Mobile-First**: Responsive design approach
|
||||||
- **Modern UI**: Clean, minimalist design inspired by search engines
|
- **Clean Design**: Minimalist interface focusing on content
|
||||||
- **Accessibility**: Semantic HTML and proper contrast ratios
|
|
||||||
|
## 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
|
## 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",
|
"php": "^8.3",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
"mongodb/mongodb": "*",
|
"ext-pdo": "*",
|
||||||
|
"ext-pgsql": "*",
|
||||||
|
"doctrine/doctrine-migrations-bundle": "^3.4",
|
||||||
|
"symfony/asset-mapper": "^7.3",
|
||||||
"symfony/console": "7.2.*",
|
"symfony/console": "7.2.*",
|
||||||
"symfony/dotenv": "7.2.*",
|
"symfony/dotenv": "7.2.*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
@ -46,7 +49,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"auto-scripts": {
|
"auto-scripts": {
|
||||||
"cache:clear": "symfony-cmd",
|
"cache:clear": "symfony-cmd",
|
||||||
"assets:install %PUBLIC_DIR%": "symfony-cmd"
|
"assets:install %PUBLIC_DIR%": "symfony-cmd",
|
||||||
|
"importmap:install": "symfony-cmd"
|
||||||
},
|
},
|
||||||
"post-install-cmd": [
|
"post-install-cmd": [
|
||||||
"@auto-scripts"
|
"@auto-scripts"
|
||||||
@ -55,4 +59,4 @@
|
|||||||
"@auto-scripts"
|
"@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\TwigBundle\TwigBundle::class => ['all' => true],
|
||||||
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
|
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
|
||||||
Symfony\Bundle\MakerBundle\MakerBundle::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/'
|
resource: '../src/'
|
||||||
exclude:
|
exclude:
|
||||||
- '../src/DependencyInjection/'
|
- '../src/DependencyInjection/'
|
||||||
- '../src/Document/'
|
- '../src/Entity/'
|
||||||
- '../src/Kernel.php'
|
- '../src/Kernel.php'
|
||||||
|
|
||||||
App\Infrastructure\MongoDB\MongoDBClient:
|
|
||||||
arguments:
|
|
||||||
$dsl: '%env(MONGODB_DSL)%'
|
|
||||||
$databaseName: '%env(MONGODB_DATABASE)%'
|
|
||||||
@ -1,5 +1,3 @@
|
|||||||
version: '3'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
@ -9,9 +7,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
depends_on:
|
depends_on:
|
||||||
- mongodb
|
- database
|
||||||
environment:
|
|
||||||
- MONGODB_URI=mongodb://mongodb:27017/evwiki
|
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.app.entrypoints=web"
|
- "traefik.http.routers.app.entrypoints=web"
|
||||||
@ -20,41 +16,44 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
|
|
||||||
mongodb:
|
database:
|
||||||
image: mongo:latest
|
image: postgres:17-alpine
|
||||||
hostname: mongodb.evwiki.test
|
hostname: database.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
|
|
||||||
environment:
|
environment:
|
||||||
- ME_CONFIG_MONGODB_SERVER=mongodb
|
- POSTGRES_USER=postgres
|
||||||
- ME_CONFIG_MONGODB_PORT=27017
|
- POSTGRES_PASSWORD=postgres
|
||||||
- ME_CONFIG_MONGODB_ENABLE_ADMIN=true
|
- POSTGRES_DB=evwiki
|
||||||
- ME_CONFIG_BASICAUTH_USERNAME=admin
|
volumes:
|
||||||
- ME_CONFIG_BASICAUTH_PASSWORD=pass
|
- postgres_evwiki:/var/lib/postgresql/data
|
||||||
networks:
|
|
||||||
- proxy
|
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.mongo-express.entrypoints=web"
|
- "traefik.tcp.routers.database.entrypoints=postgres"
|
||||||
- "traefik.http.routers.mongo-express.rule=Host(`mongo-express.evwiki.test`)"
|
- "traefik.tcp.routers.database.rule=HostSNI(`database.evwiki.test`)"
|
||||||
- "traefik.http.services.mongo-express.loadbalancer.server.port=8081"
|
- "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:
|
networks:
|
||||||
proxy:
|
proxy:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
volumes:
|
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
|
final readonly class Brand
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly string $id,
|
|
||||||
public readonly string $name,
|
public readonly string $name,
|
||||||
public readonly string $logo,
|
public readonly ?string $logo = null,
|
||||||
public readonly string $description,
|
public readonly ?string $description = null,
|
||||||
public readonly int $foundedYear,
|
public readonly ?int $foundedYear = null,
|
||||||
public readonly string $headquarters,
|
public readonly ?string $headquarters = null,
|
||||||
public readonly string $website,
|
public readonly ?string $website = null,
|
||||||
public readonly array $carModels,
|
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
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)';
|
return $this->secondsFrom0To100 . ' sec (0-100 km/h)';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSeconds(): float
|
public function seconds(): float
|
||||||
{
|
{
|
||||||
return $this->secondsFrom0To100;
|
return $this->secondsFrom0To100;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,17 +5,22 @@ namespace App\Domain\Model\Value;
|
|||||||
class Consumption
|
class Consumption
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly Energy $energyPerKm,
|
public readonly Energy $energyPer100Km,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function __toString(): string
|
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;
|
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
|
public static function euro(): self
|
||||||
{
|
{
|
||||||
return new self('€', 'EUR', 'Euro');
|
return new self('€', 'EUR', 'Euro');
|
||||||
|
|||||||
@ -12,6 +12,11 @@ class Price
|
|||||||
|
|
||||||
public function __toString(): string
|
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;
|
namespace App\Domain\Repository;
|
||||||
|
|
||||||
|
use App\Domain\Model\Brand;
|
||||||
use App\Domain\Model\BrandCollection;
|
use App\Domain\Model\BrandCollection;
|
||||||
|
use App\Domain\Model\Persistence\PersistedBrand;
|
||||||
|
|
||||||
interface BrandRepository
|
interface BrandRepository
|
||||||
{
|
{
|
||||||
public function findAll(): BrandCollection;
|
public function findAll(): BrandCollection;
|
||||||
}
|
|
||||||
|
public function create(Brand $brand): PersistedBrand;
|
||||||
|
|
||||||
|
public function update(PersistedBrand $persistedBrand): void;
|
||||||
|
}
|
||||||
@ -1,98 +1,22 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Repository;
|
namespace App\Domain\Repository;
|
||||||
|
|
||||||
use App\Domain\Model\CarModel;
|
use App\Domain\Model\CarModel;
|
||||||
use Doctrine\ODM\MongoDB\DocumentManager;
|
use App\Domain\Model\CarModelCollection;
|
||||||
use Doctrine\ODM\MongoDB\Repository\DocumentRepository;
|
use App\Domain\Model\Persistence\PersistedCarModel;
|
||||||
|
|
||||||
class CarModelRepository extends DocumentRepository
|
interface CarModelRepository
|
||||||
{
|
{
|
||||||
public function __construct(DocumentManager $dm)
|
public function findAll(): CarModelCollection;
|
||||||
{
|
|
||||||
parent::__construct($dm, $dm->getUnitOfWork(), $dm->getClassMetadata(CarModel::class));
|
public function findById(string $id): ?PersistedCarModel;
|
||||||
}
|
|
||||||
|
public function findByBrandId(string $brandId): CarModelCollection;
|
||||||
|
|
||||||
|
public function create(CarModel $carModel, string $brandId): PersistedCarModel;
|
||||||
|
|
||||||
public function findAllCarModels(): array
|
public function update(PersistedCarModel $persistedCarModel): void;
|
||||||
{
|
|
||||||
return $this->createQueryBuilder()
|
public function delete(PersistedCarModel $persistedCarModel): void;
|
||||||
->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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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\Speed;
|
||||||
use App\Domain\Model\Value\Drivetrain;
|
use App\Domain\Model\Value\Drivetrain;
|
||||||
use App\Domain\Model\Value\Energy;
|
use App\Domain\Model\Value\Energy;
|
||||||
|
use App\Domain\Model\Value\ChargingSpeed;
|
||||||
use App\Domain\Model\Charging\ChargingProperties;
|
use App\Domain\Model\Charging\ChargingProperties;
|
||||||
use App\Domain\Model\Charging\ChargeCurve;
|
use App\Domain\Model\Charging\ChargeCurve;
|
||||||
use App\Domain\Model\Charging\ChargeTimeProperties;
|
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\CarTile;
|
||||||
use App\Domain\Search\Tiles\TopSpeedTile;
|
use App\Domain\Search\Tiles\TopSpeedTile;
|
||||||
use App\Domain\Search\Tiles\DrivetrainTile;
|
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\ChargeTimeTile;
|
||||||
use App\Domain\Search\Tiles\ChargingConnectivityTile;
|
use App\Domain\Search\Tiles\ChargingConnectivityTile;
|
||||||
use App\Domain\Search\Tiles\BatteryDetailsTile;
|
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\RealRangeTile;
|
||||||
use App\Domain\Search\Tiles\PerformanceOverviewTile;
|
|
||||||
|
|
||||||
class Engine
|
class Engine
|
||||||
{
|
{
|
||||||
public function search(string $query): TileCollection
|
public function search(string $query): TileCollection
|
||||||
{
|
{
|
||||||
// Create comprehensive test data showing all the new features
|
|
||||||
$batteryProperties = new BatteryProperties(
|
$batteryProperties = new BatteryProperties(
|
||||||
usableCapacity: new Energy(77.0),
|
usableCapacity: new Energy(77.0),
|
||||||
totalCapacity: new Energy(82.0),
|
totalCapacity: new Energy(82.0),
|
||||||
@ -123,7 +118,7 @@ class Engine
|
|||||||
$skodaElroq85 = new CarRevision(
|
$skodaElroq85 = new CarRevision(
|
||||||
name: 'Skoda Enyaq iV 85',
|
name: 'Skoda Enyaq iV 85',
|
||||||
productionBegin: new Date(1, 1, 2020),
|
productionBegin: new Date(1, 1, 2020),
|
||||||
productionEnd: null, // Still in production
|
productionEnd: null,
|
||||||
drivingCharacteristics: $drivingCharacteristics,
|
drivingCharacteristics: $drivingCharacteristics,
|
||||||
battery: $batteryProperties,
|
battery: $batteryProperties,
|
||||||
chargingProperties: $chargingProperties,
|
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')
|
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([
|
return new TileCollection([
|
||||||
new SectionTile('Skoda Enyaq iV 85', [
|
new SectionTile('Skoda Enyaq iV 85', [
|
||||||
new CarTile($skodaElroq85->image, [
|
new CarTile($skodaElroq85->image, [
|
||||||
new BrandTile('Skoda'),
|
new BrandTile('Skoda'),
|
||||||
new PriceTile($skodaElroq85->catalogPrice),
|
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 SubSectionTile('Performance', [
|
||||||
new PowerTile($drivingCharacteristics->power),
|
new PowerTile($drivingCharacteristics->power),
|
||||||
new AccelerationTile($drivingCharacteristics->acceleration),
|
|
||||||
new TopSpeedTile($drivingCharacteristics->topSpeed),
|
new TopSpeedTile($drivingCharacteristics->topSpeed),
|
||||||
new DrivetrainTile(new Drivetrain('rear')),
|
new DrivetrainTile(new Drivetrain('rear')),
|
||||||
new ConsumptionTile($drivingCharacteristics->consumption),
|
|
||||||
], 'Individual performance metrics'),
|
], 'Individual performance metrics'),
|
||||||
|
|
||||||
new SubSectionTile('Reichweite', [
|
new SubSectionTile('Reichweite', [
|
||||||
new RangeTile($wltpRange->range),
|
new RangeTile($wltpRange->range),
|
||||||
new RangeComparisonTile($skodaElroq85->rangeProperties),
|
|
||||||
new RealRangeTile($realRangeTests),
|
new RealRangeTile($realRangeTests),
|
||||||
], 'Range data from different sources'),
|
], 'Range data from different sources'),
|
||||||
|
|
||||||
@ -160,9 +160,9 @@ class Engine
|
|||||||
], 'Battery capacity and technology'),
|
], 'Battery capacity and technology'),
|
||||||
|
|
||||||
new SubSectionTile('Laden', [
|
new SubSectionTile('Laden', [
|
||||||
|
new ChargingTile($chargingSpeed),
|
||||||
new ChargeTimeTile($chargingProperties->chargeTimeProperties),
|
new ChargeTimeTile($chargingProperties->chargeTimeProperties),
|
||||||
new ChargingConnectivityTile($chargingProperties->chargingConnectivity),
|
new ChargingConnectivityTile($chargingProperties->chargingConnectivity),
|
||||||
new ChargeCurveTile($chargingProperties->chargeCurve),
|
|
||||||
], 'Charging capabilities and compatibility'),
|
], 'Charging capabilities and compatibility'),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Domain\Search\Tiles;
|
namespace App\Domain\Search\Tiles;
|
||||||
|
|
||||||
|
use App\Domain\Model\Value\Date;
|
||||||
|
|
||||||
class AvailabilityTile
|
class AvailabilityTile
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly string $status,
|
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": {
|
"symfony/console": {
|
||||||
"version": "7.2",
|
"version": "7.2",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
<div class="search-container" id="searchForm">
|
<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('') }}">
|
<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>
|
<button type="button" id="searchButton" class="search-button">
|
||||||
|
<i class="fas fa-search"></i> Search
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@ -89,7 +89,7 @@
|
|||||||
/* Tiles Grid Layout */
|
/* Tiles Grid Layout */
|
||||||
.tiles-grid {
|
.tiles-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -159,9 +159,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tile-subtitle {
|
.tile-subtitle {
|
||||||
font-size: 0.9rem;
|
font-size: 0.8rem;
|
||||||
color: #666;
|
color: #999;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 1.2rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -326,5 +326,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% block javascripts %}{% endblock %}
|
{% block javascripts %}{% endblock %}
|
||||||
|
{% block importmap %}{{ importmap('app') }}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -4,27 +4,27 @@
|
|||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="header">
|
<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' %}
|
{% include '_components/search.html.twig' %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="brandsSection">
|
<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">
|
<div class="brands-grid">
|
||||||
{% for brand in brands %}
|
{% for brand in brands %}
|
||||||
<div class="brand-card" data-brand-id="{{ brand.id }}">
|
<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 %}
|
{% if brand.description %}
|
||||||
<div class="brand-description">{{ brand.description }}</div>
|
<div class="brand-description">{{ brand.description }}</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
<div class="tile">
|
<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>
|
<small>Beschleunigung</small>
|
||||||
</div>
|
</div>
|
||||||
@ -1,9 +1,10 @@
|
|||||||
<div class="tile">
|
<div class="tile">
|
||||||
<div class="tile-content">
|
<div class="tile-title">
|
||||||
<div class="tile-title">{{ tile.status }}</div>
|
<i class="fas fa-calendar-alt" style="color: #007acc; margin-right: 8px;"></i>
|
||||||
{% if tile.availableSince %}
|
{{ tile.availableSince.year }}
|
||||||
<div class="tile-subtitle">seit {{ tile.availableSince }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<small>Verfügbarkeit</small>
|
{% if tile.availableSince.month %}
|
||||||
|
<div class="tile-subtitle">{{ tile.availableSince.month }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<small>Verfügbar ab</small>
|
||||||
</div>
|
</div>
|
||||||
@ -1,4 +1,7 @@
|
|||||||
<div class="tile">
|
<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>
|
<small>Batterie</small>
|
||||||
</div>
|
</div>
|
||||||
@ -1,21 +1,19 @@
|
|||||||
<div class="tile battery-details-tile">
|
<div class="tile battery-details-tile">
|
||||||
<div class="tile-title">Zellchemie</div>
|
<div class="tile-title">
|
||||||
<div class="battery-info" style="margin-top: 12px; text-align: center;">
|
<i class="fas fa-car-battery" style="color: #007acc; margin-right: 8px;"></i>
|
||||||
<!-- Chemistry Badge -->
|
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') %}
|
{% set chemistryColor = tile.batteryProperties.cellChemistry.value == 'LFP' ? '#28a745' : (tile.batteryProperties.cellChemistry.value == 'NMC' ? '#007acc' : '#6f42c1') %}
|
||||||
<div style="margin-bottom: 12px;">
|
<div style="font-weight: bold; font-size: 24px; color: {{ chemistryColor }}; margin-bottom: 4px;">
|
||||||
<span style="background: {{ chemistryColor }}; color: white; padding: 6px 12px; border-radius: 12px; font-size: 14px; font-weight: bold;">
|
{{ tile.batteryProperties.cellChemistry.value }}
|
||||||
{{ tile.batteryProperties.cellChemistry.value }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Manufacturer & Model -->
|
|
||||||
{% if tile.batteryProperties.manufacturer != 'Tesla' or tile.batteryProperties.model != '4680' %}
|
|
||||||
<div style="font-size: 12px; color: #666;">
|
<div style="font-size: 12px; color: #666;">
|
||||||
{{ tile.batteryProperties.manufacturer }}<br>
|
{% if tile.batteryProperties.cellChemistry.value == 'LFP' %}Lithium-Eisenphosphat
|
||||||
{{ tile.batteryProperties.model }}
|
{% elseif tile.batteryProperties.cellChemistry.value == 'NMC' %}Lithium-Nickel-Mangan
|
||||||
|
{% else %}{{ tile.batteryProperties.cellChemistry.value }}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<small style="color: #666;">Batterietechnologie</small>
|
<small style="color: #666;">Batterietechnologie</small>
|
||||||
</div>
|
</div>
|
||||||
@ -3,7 +3,10 @@
|
|||||||
<img src="{{ tile.logo }}" alt="{{ tile.name }}" class="tile-logo">
|
<img src="{{ tile.logo }}" alt="{{ tile.name }}" class="tile-logo">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="tile-content">
|
<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>
|
</div>
|
||||||
<small>Marke</small>
|
<small>Marke</small>
|
||||||
</div>
|
</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 %}
|
{% if tile.image %}
|
||||||
<img src="{{ tile.image.externalPublicUrl }}" style="width: 100%; height: 100%; object-fit: cover;">
|
<img src="{{ tile.image.externalPublicUrl }}" style="width: 100%; height: 100%; object-fit: cover;">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
<div class="tile charge-curve-tile" style="grid-column: span 3; grid-row: span 2;">
|
<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;">
|
<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;">
|
<svg width="100%" height="100%" viewBox="0 0 300 100" style="overflow: visible;">
|
||||||
<!-- Grid lines -->
|
<!-- Grid lines -->
|
||||||
|
|||||||
@ -1,19 +1,16 @@
|
|||||||
<div class="tile charge-time-tile">
|
<div class="tile charge-time-tile">
|
||||||
<div class="tile-title">Schnellladen</div>
|
<div class="tile-title">
|
||||||
<div class="charge-times" style="margin-top: 12px;">
|
<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 %}
|
{% if tile.chargeTimeProperties.minutesFrom10To80 %}
|
||||||
<div style="text-align: center; margin-bottom: 12px;">
|
<div style="font-weight: bold; font-size: 28px; color: #007acc; margin-bottom: 4px;">{{ tile.chargeTimeProperties.minutesFrom10To80 }}</div>
|
||||||
<div style="font-weight: bold; font-size: 24px; color: #007acc;">{{ tile.chargeTimeProperties.minutesFrom10To80 }}</div>
|
<div style="font-size: 12px; color: #666;">Minuten (10-80%)</div>
|
||||||
<small style="color: #666; font-weight: 600;">Minuten 10-80%</small>
|
{% elseif tile.chargeTimeProperties.minutesFrom20To80 %}
|
||||||
</div>
|
<div style="font-weight: bold; font-size: 28px; color: #007acc; margin-bottom: 4px;">{{ tile.chargeTimeProperties.minutesFrom20To80 }}</div>
|
||||||
{% endif %}
|
<div style="font-size: 12px; color: #666;">Minuten (20-80%)</div>
|
||||||
|
|
||||||
{% 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>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<small style="color: #666;">DC Laden</small>
|
<small style="color: #666;">DC Schnellladen</small>
|
||||||
</div>
|
</div>
|
||||||
@ -1,4 +1,7 @@
|
|||||||
<div class="tile">
|
<div class="tile">
|
||||||
<div class="tile-title">{{ tile.chargingSpeed }}</div>
|
<div class="tile-title">
|
||||||
<small>Laden</small>
|
<i class="fas fa-charging-station" style="color: #28a745; margin-right: 8px;"></i>
|
||||||
|
{{ tile.chargingSpeed.dcMax.kilowatts }} kW
|
||||||
|
</div>
|
||||||
|
<small>DC Laden</small>
|
||||||
</div>
|
</div>
|
||||||
@ -1,35 +1,25 @@
|
|||||||
<div class="tile charging-connectivity-tile">
|
<div class="tile charging-connectivity-tile">
|
||||||
<div class="tile-title">Ladekompatibilität</div>
|
<div class="tile-title">
|
||||||
<div class="connectivity-features" style="margin-top: 8px;">
|
<i class="fas fa-plug" style="color: #007acc; margin-right: 8px;"></i>
|
||||||
<!-- Voltage Support -->
|
Stecker
|
||||||
<div style="display: flex; gap: 6px; margin-bottom: 6px;">
|
</div>
|
||||||
{% if tile.chargingConnectivity.is400v %}
|
<div class="connector-info" style="margin-top: 8px; text-align: center;">
|
||||||
<span style="background: #28a745; color: white; padding: 2px 6px; border-radius: 12px; font-size: 10px; font-weight: bold;">400V</span>
|
<!-- Main Connector Types -->
|
||||||
{% 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 -->
|
|
||||||
{% if tile.chargingConnectivity.connectorTypes|length > 0 %}
|
{% 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 %}
|
{% 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;">
|
<div style="font-weight: bold; font-size: 18px; color: #007acc; margin-bottom: 2px;">{{ connector.value }}</div>
|
||||||
{{ connector.value }}
|
|
||||||
</span>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
</div>
|
||||||
<small style="color: #666;">Ladeschnittstellen & Features</small>
|
<small style="color: #666;">Ladeanschluss</small>
|
||||||
</div>
|
</div>
|
||||||
@ -1,4 +1,8 @@
|
|||||||
<div class="tile">
|
<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>
|
<small>Verbrauch</small>
|
||||||
</div>
|
</div>
|
||||||
@ -1,4 +1,7 @@
|
|||||||
<div class="tile">
|
<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>
|
<small>Antrieb</small>
|
||||||
</div>
|
</div>
|
||||||
@ -1,5 +1,8 @@
|
|||||||
<div class="tile performance-overview-tile" style="grid-column: span 3; grid-row: span 2;">
|
<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);">
|
<div class="performance-grid" style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-top: 12px; height: calc(100% - 40px);">
|
||||||
<!-- Power -->
|
<!-- Power -->
|
||||||
{% if tile.drivingCharacteristics.power %}
|
{% if tile.drivingCharacteristics.power %}
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
<div class="tile">
|
<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>
|
<small>Leistung</small>
|
||||||
</div>
|
</div>
|
||||||
@ -1,6 +1,8 @@
|
|||||||
<div class="tile">
|
<div class="tile">
|
||||||
<div class="tile-content">
|
<div class="tile-title">
|
||||||
<div class="tile-title">{{ tile.price }}</div>
|
<i class="fas fa-euro-sign" style="color: #28a745; margin-right: 8px;"></i>
|
||||||
|
{{ tile.price }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="tile-subtitle">{{ tile.price.currency.name }}</div>
|
||||||
<small>Preis</small>
|
<small>Preis</small>
|
||||||
</div>
|
</div>
|
||||||
@ -1,5 +1,8 @@
|
|||||||
<div class="tile production-period-tile">
|
<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;">
|
<div class="production-timeline" style="margin-top: 8px;">
|
||||||
{% if tile.productionBegin or tile.productionEnd %}
|
{% if tile.productionBegin or tile.productionEnd %}
|
||||||
<div style="display: flex; align-items: center; gap: 8px;">
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
<div class="tile">
|
<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>
|
<small>Reichweite</small>
|
||||||
</div>
|
</div>
|
||||||
@ -1,5 +1,8 @@
|
|||||||
<div class="tile range-comparison-tile">
|
<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;">
|
<div class="range-comparison" style="margin-top: 8px;">
|
||||||
<!-- WLTP Range -->
|
<!-- WLTP Range -->
|
||||||
{% if tile.rangeProperties.wltp %}
|
{% if tile.rangeProperties.wltp %}
|
||||||
|
|||||||
@ -1,18 +1,16 @@
|
|||||||
<div class="tile real-range-tile">
|
<div class="tile real-range-tile">
|
||||||
<div class="tile-title">Praxis-Tests</div>
|
<div class="tile-title">
|
||||||
<div class="real-tests" style="margin-top: 8px;">
|
<i class="fas fa-chart-line" style="color: #28a745; margin-right: 8px;"></i>
|
||||||
{% for realTest in tile.realRangeTests|slice(0, 3) %}
|
Praxis-Test
|
||||||
<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>
|
</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>
|
<small style="color: #666;">Real-World Reichweite</small>
|
||||||
</div>
|
</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 } %}
|
{% 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 } %}
|
{% include 'result/tiles/collection.html.twig' with { tiles: tile.tiles } %}
|
||||||
@ -1,4 +1,7 @@
|
|||||||
<div class="tile">
|
<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>
|
<small>Höchstgeschwindigkeit</small>
|
||||||
</div>
|
</div>
|
||||||
Loading…
x
Reference in New Issue
Block a user