Kontext a legislativa
Nařízení (EU) 2023/988 (General Product Safety Regulation, GPSR) platné od 13. 12. 2024 vyžaduje, aby e-shop na produktové stránce uváděl (čl. 19):
- Jméno výrobce, obchodní název/ochrannou známku, poštovní a elektronickou adresu
- Jméno, poštovní a elektronickou adresu odpovědné osoby v EU (pokud výrobce není v EU)
- Bezpečnostní upozornění a varování v jazyce spotřebitele
- Identifikaci výrobku (obrázek, typ, číslo šarže/série, jiný identifikátor)
Alpine Pro, a.s.
IČO: 49970321, sídlo: Kodaňská 1441/46, Praha 10
Výrobce i prodejce — část produktů vyrábí v ČR, část u externích dodavatelů (Čína, Bangladéš). U vlastních výrobků je Alpine Pro zároveň výrobcem i odpovědnou osobou. U výrobků třetích stran může být odpovědná osoba odlišná od výrobce.
Proč odděleně?
Klíčový princip
Výrobce a odpovědná osoba jsou často různé subjekty — typicky výrobce je čínská firma a odpovědná osoba je lokální dovozce nebo provozovatel e-shopu. Zároveň jednu odpovědnou osobu lze přiřadit stovkám produktů, čímž se eliminuje duplicita dat. Proto jsou modelovány jako samostatné entity.
Datový model — přehled entit
- id PK
- name VARCHAR
- trade_name VARCHAR
- street, city, zip VARCHAR
- country_code CHAR(2)
- email VARCHAR
- phone, website VARCHAR
- timestamps TIMESTAMP
- id PK
- product_id FK UNIQUE
- manufacturer_id FK
- responsible_person_id FK
- model_type_identifier VARCHAR
- safety_warning_cs/sk/en TEXT
- ce_marking BOOLEAN
- conformity_url VARCHAR
- timestamps TIMESTAMP
- id PK
- name VARCHAR
- trade_name VARCHAR
- street, city, zip VARCHAR
- country_code CHAR(2) EU
- email VARCHAR
- phone, website VARCHAR
- timestamps TIMESTAMP
- id PK
- sku VARCHAR UNIQUE
- ean VARCHAR(13)
- name VARCHAR
- slug VARCHAR UNIQUE
- price DECIMAL(10,2)
- category VARCHAR
- description TEXT
- active BOOLEAN
- timestamps TIMESTAMP
Jak se GPSR data zobrazí na e-shopu?
Do existující tabulky products se nic nemění — nepřidávají se žádné sloupce.
Tabulka product_gpsr je 1:1 rozšíření připojené přes product_id.
Na stránce detailu produktu stačí udělat LEFT JOIN product_gpsr ON product_gpsr.product_id = products.id:
- Záznam existuje → zobrazit GPSR blok (výrobce, odpovědná osoba, CE označení, bezpečnostní upozornění)
- Záznam neexistuje → nezobrazovat nic — GPSR je volitelné per produkt
GPSR údaje se tedy přiřazují postupně přes admin rozhraní, bez nutnosti měnit stávající kód produktového katalogu.
Laravel migrace
Spustit: php artisan migrate
create_manufacturers_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('manufacturers', function (Blueprint $table) {
$table->id();
$table->string('name'); // Název firmy
$table->string('trade_name')->nullable(); // Obchodní název / ochranná známka
$table->string('street');
$table->string('city', 100);
$table->string('zip', 20);
$table->char('country_code', 2); // ISO 3166-1 alpha-2
$table->string('email');
$table->string('phone', 50)->nullable();
$table->string('website')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('manufacturers');
}
};
create_responsible_people_table.php
return new class extends Migration
{
public function up(): void
{
Schema::create('responsible_people', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('trade_name')->nullable();
$table->string('street');
$table->string('city', 100);
$table->string('zip', 20);
$table->char('country_code', 2); // MUSÍ být EU stát
$table->string('email');
$table->string('phone', 50)->nullable();
$table->string('website')->nullable();
$table->timestamps();
});
// CHECK constraint — odpovědná osoba musí být v EU
DB::statement("ALTER TABLE responsible_people
ADD CONSTRAINT chk_eu_country CHECK (
country_code IN (
'AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR',
'DE','GR','HU','IE','IT','LV','LT','LU','MT','NL',
'PL','PT','RO','SK','SI','ES','SE'
)
)");
}
};
create_product_gpsr_table.php
return new class extends Migration
{
public function up(): void
{
Schema::create('product_gpsr', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')
->unique()
->constrained()->cascadeOnDelete();
$table->foreignId('manufacturer_id')
->constrained('manufacturers')->restrictOnDelete();
$table->foreignId('responsible_person_id')
->constrained('responsible_people')->restrictOnDelete();
$table->string('model_type_identifier')->nullable();
$table->text('safety_warning_cs')->nullable();
$table->text('safety_warning_sk')->nullable();
$table->text('safety_warning_en')->nullable();
$table->boolean('ce_marking')->default(false);
$table->string('declaration_of_conformity_url', 500)->nullable();
$table->timestamps();
$table->index('manufacturer_id');
$table->index('responsible_person_id');
});
}
};
Eloquent modely a relace
App\Models\Manufacturer
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Manufacturer extends Model
{
protected $fillable = [
'name', 'trade_name', 'street', 'city',
'zip', 'country_code', 'email', 'phone', 'website',
];
public function productGpsrs(): HasMany
{
return $this->hasMany(ProductGpsr::class);
}
public function getFullAddressAttribute(): string
{
return "{$this->street}, {$this->zip} {$this->city}, {$this->country_code}";
}
}
App\Models\ResponsiblePerson
class ResponsiblePerson extends Model
{
protected $fillable = [
'name', 'trade_name', 'street', 'city',
'zip', 'country_code', 'email', 'phone', 'website',
];
// Validační pravidlo pro EU země
const EU_COUNTRIES = [
'AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR',
'DE','GR','HU','IE','IT','LV','LT','LU','MT','NL',
'PL','PT','RO','SK','SI','ES','SE',
];
public function getFullAddressAttribute(): string
{
return "{$this->street}, {$this->zip} {$this->city}, {$this->country_code}";
}
}
App\Models\ProductGpsr
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProductGpsr extends Model
{
protected $table = 'product_gpsr';
protected $fillable = [
'product_id', 'manufacturer_id', 'responsible_person_id',
'model_type_identifier', 'safety_warning_cs',
'safety_warning_sk', 'safety_warning_en',
'ce_marking', 'declaration_of_conformity_url',
];
protected $casts = [
'ce_marking' => 'boolean',
];
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function manufacturer(): BelongsTo
{
return $this->belongsTo(Manufacturer::class);
}
public function responsiblePerson(): BelongsTo
{
return $this->belongsTo(ResponsiblePerson::class);
}
}
Relace v existujícím modelu Product
// App\Models\Product — přidat relaci:
public function gpsr(): HasOne
{
return $this->hasOne(ProductGpsr::class);
}
// Použití v controlleru:
$product = Product::with('gpsr.manufacturer', 'gpsr.responsiblePerson')
->findOrFail($id);
Scénáře pro Alpine Pro
Scénář 1: Vlastní výrobek (vyrobeno v ČR)
Výrobce = Odpovědná osoba (obojí Alpine Pro)
Scénář 2: Vlastní výrobek vyráběný v Číně
Výrobce je čínská továrna, Alpine Pro je odpovědná osoba v EU jako dovozce
Scénář 3: Výrobek třetí strany
EU firma je zároveň výrobcem i odpovědnou osobou
Blade šablona na produktové stránce
Partial: resources/views/products/partials/gpsr.blade.php
gpsr.blade.php
{{-- @include('products.partials.gpsr', ['gpsr' => $product->gpsr]) --}}
@if($gpsr)
<div class="gpsr-info">
<h3>Informace o výrobku dle GPSR</h3>
<div class="gpsr-manufacturer">
<strong>Výrobce:</strong>
{{ $gpsr->manufacturer->name }}
@if($gpsr->manufacturer->trade_name)
({{ $gpsr->manufacturer->trade_name }})
@endif<br>
{{ $gpsr->manufacturer->full_address }}<br>
{{ $gpsr->manufacturer->email }}
</div>
<div class="gpsr-responsible-person">
<strong>Odpovědná osoba v EU:</strong>
{{ $gpsr->responsiblePerson->name }}<br>
{{ $gpsr->responsiblePerson->full_address }}<br>
{{ $gpsr->responsiblePerson->email }}
</div>
@if($gpsr->safety_warning_cs)
<div class="gpsr-warning">
<strong>Bezpečnostní upozornění:</strong>
{{ $gpsr->safety_warning_cs }}
</div>
@endif
@if($gpsr->ce_marking)
<span class="ce-mark">CE</span>
@endif
@if($gpsr->declaration_of_conformity_url)
<a href="{{ $gpsr->declaration_of_conformity_url }}">
EU prohlášení o shodě
</a>
@endif
</div>
@endif
Seeder pro existující data
Spustit: php artisan db:seed --class=GpsrSeeder
database/seeders/GpsrSeeder.php
namespace Database\Seeders;
use App\Models\Manufacturer;
use App\Models\ResponsiblePerson;
use App\Models\Product;
use App\Models\ProductGpsr;
use Illuminate\Database\Seeder;
class GpsrSeeder extends Seeder
{
public function run(): void
{
// 1. Alpine Pro jako výrobce
$manufacturer = Manufacturer::firstOrCreate(
['email' => 'info@alpinepro.cz'],
[
'name' => 'ALPINE PRO, a.s.',
'trade_name' => 'ALPINE PRO',
'street' => 'Kodaňská 1441/46',
'city' => 'Praha 10',
'zip' => '101 00',
'country_code' => 'CZ',
'website' => 'https://www.alpinepro.cz',
]
);
// 2. Alpine Pro jako odpovědná osoba v EU
$responsible = ResponsiblePerson::firstOrCreate(
['email' => 'gpsr@alpinepro.cz'],
[
'name' => 'ALPINE PRO, a.s.',
'trade_name' => 'ALPINE PRO',
'street' => 'Kodaňská 1441/46',
'city' => 'Praha 10',
'zip' => '101 00',
'country_code' => 'CZ',
'website' => 'https://www.alpinepro.cz',
]
);
// 3. Hromadně přiřadit GPSR ke všem produktům bez záznamu
Product::whereDoesntHave('gpsr')
->each(function (Product $product) use ($manufacturer, $responsible) {
$product->gpsr()->create([
'manufacturer_id' => $manufacturer->id,
'responsible_person_id' => $responsible->id,
]);
});
$this->command->info(
'GPSR přiřazeno k ' . ProductGpsr::count() . ' produktům.'
);
}
}
Volitelné rozšíření: product_compliance
Pro výrobky podléhající specifickým regulacím (hračky, elektronika, kosmetika...)
| Sloupec | Typ | Povinný | Popis |
|---|---|---|---|
| id | SERIAL | PK | Primární klíč |
| product_id | FK → product | NN | Odkaz na produkt |
| regulation_type | VARCHAR(100) | NN | Typ regulace (hračky, EMC, LVD...) |
| certificate_number | VARCHAR(100) | Číslo certifikátu | |
| certificate_url | VARCHAR(500) | URL na certifikát / prohlášení | |
| valid_until | DATE | Platnost certifikátu | |
| notes | TEXT | Další poznámky | |
| created_at | TIMESTAMPTZ | NN | Datum vytvoření |
| updated_at | TIMESTAMPTZ | NN | Datum poslední změny |
Poznámky k implementaci
Oddělená tabulka product_gpsr
HasOne relace na Product — GPSR data lze přidat bez zásahu do stávajícího modelu, minimalizuje riziko při migraci.
Eloquent timestamps
Všechny modely používají $timestamps = true (výchozí). Laravel automaticky spravuje created_at a updated_at — není potřeba DB trigger.
Eager loading
Product::with('gpsr.manufacturer', 'gpsr.responsiblePerson') zabraňuje N+1 problému na výpisech produktů.
ON DELETE chování
cascadeOnDelete() na product_id, restrictOnDelete() na manufacturer_id a responsible_person_id — chrání referenční integritu.
Validace EU zemí
DB-level CHECK constraint + konstanta ResponsiblePerson::EU_COUNTRIES pro FormRequest validaci: Rule::in(ResponsiblePerson::EU_COUNTRIES).
Vícejazyčná varování
Alpine Pro prodává v CZ i SK, proto sloupce pro oba jazyky. V Blade šabloně lze přepínat: $gpsr->{"safety_warning_" . app()->getLocale()}.
firstOrCreate v seederu
Seeder je idempotentní — lze spouštět opakovaně bez duplicit. whereDoesntHave() přeskočí produkty s existujícím GPSR záznamem.
Accessor full_address
Přístupový atribut na Manufacturer i ResponsiblePerson — formátovaná adresa pro šablonu bez logiky v Blade.