Sekce 01

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.

Sekce 02

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.

Sekce 03

Datový model — přehled entit

manufacturers NOVÁ
  • id PK
  • name VARCHAR
  • trade_name VARCHAR
  • street, city, zip VARCHAR
  • country_code CHAR(2)
  • email VARCHAR
  • phone, website VARCHAR
  • timestamps TIMESTAMP
manufacturer_id
product_gpsr NOVÁ
  • 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
responsible_person_id
responsible_people NOVÁ
  • id PK
  • name VARCHAR
  • trade_name VARCHAR
  • street, city, zip VARCHAR
  • country_code CHAR(2) EU
  • email VARCHAR
  • phone, website VARCHAR
  • timestamps TIMESTAMP
product_id FK UNIQUE (CASCADE)
products — existující tabulka
  • 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.

Sekce 04

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');
        });
    }
};
Sekce 05

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);
Sekce 06

Scénáře pro Alpine Pro

Scénář 1: Vlastní výrobek (vyrobeno v ČR)

Výrobce
ALPINE PRO, a.s., Kodaňská 1441/46, Praha 10, CZ
Odpovědná osoba
ALPINE PRO, a.s., Kodaňská 1441/46, Praha 10, CZ

Výrobce = Odpovědná osoba (obojí Alpine Pro)

Scénář 2: Vlastní výrobek vyráběný v Číně

Výrobce
Shenzhen XYZ Garment Co., Ltd., Shenzhen, CN
Odpovědná osoba
ALPINE PRO, a.s., Kodaňská 1441/46, Praha 10, CZ

Výrobce je čínská továrna, Alpine Pro je odpovědná osoba v EU jako dovozce

Scénář 3: Výrobek třetí strany

Výrobce
Jiná firma XY s.r.o., Brno, CZ
Odpovědná osoba
Jiná firma XY s.r.o., Brno, CZ

EU firma je zároveň výrobcem i odpovědnou osobou

Sekce 07

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
Sekce 08

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.'
        );
    }
}
Sekce 09

Volitelné rozšíření: product_compliance

Pro výrobky podléhající specifickým regulacím (hračky, elektronika, kosmetika...)

Sloupec Typ Povinný Popis
idSERIALPKPrimární klíč
product_idFK → productNNOdkaz na produkt
regulation_typeVARCHAR(100)NNTyp regulace (hračky, EMC, LVD...)
certificate_numberVARCHAR(100)Číslo certifikátu
certificate_urlVARCHAR(500)URL na certifikát / prohlášení
valid_untilDATEPlatnost certifikátu
notesTEXTDalší poznámky
created_atTIMESTAMPTZNNDatum vytvoření
updated_atTIMESTAMPTZNNDatum poslední změny
Sekce 10

Poznámky k implementaci

1

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.

2

Eloquent timestamps

Všechny modely používají $timestamps = true (výchozí). Laravel automaticky spravuje created_at a updated_at — není potřeba DB trigger.

3

Eager loading

Product::with('gpsr.manufacturer', 'gpsr.responsiblePerson') zabraňuje N+1 problému na výpisech produktů.

4

ON DELETE chování

cascadeOnDelete() na product_id, restrictOnDelete() na manufacturer_id a responsible_person_id — chrání referenční integritu.

5

Validace EU zemí

DB-level CHECK constraint + konstanta ResponsiblePerson::EU_COUNTRIES pro FormRequest validaci: Rule::in(ResponsiblePerson::EU_COUNTRIES).

6

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()}.

7

firstOrCreate v seederu

Seeder je idempotentní — lze spouštět opakovaně bez duplicit. whereDoesntHave() přeskočí produkty s existujícím GPSR záznamem.

8

Accessor full_address

Přístupový atribut na Manufacturer i ResponsiblePerson — formátovaná adresa pro šablonu bez logiky v Blade.