Eshop — návrh datového modelu v4

Inspirační zdroje

Oblast Inspirace
Katalog Vendure
Pricing Medusa
Inventory Medusa
Attribute/Option rozlišení Sylius
API vlastní REST

Rozhodnutí z review (souhrn všech verzí)

PK strategie

UUID pro veřejnou identitu (API, URL, externí systémy). bigint generated always as identity pro interní PK joinovacích a překladových tabulek.

Typy pro peníze a sazby

Pole Typ
amount (ceny, poplatky) int — v haléřích/centech
exchange_rate numeric(18,8)
tax_rate numeric(5,2)
discount_value numeric(12,4)
weight numeric(10,3)
width, height, length numeric(10,2)

Enumy jako PostgreSQL enum typ

Pro stabilní domény (publication_status, price.type, media.type) používáme PostgreSQL ENUM — menší storage, rychlejší porovnání, přidání hodnoty non-blocking přes ALTER TYPE ... ADD VALUE. Pro proměnlivé domény (reason v dirty queue, EAV klíče) varchar.

Stavový model produktu

publication_status vyjadřuje aktuální stav produktu. Flags vyjadřují původ nebo důvod stavu. Není to duplicita.

deleted není fyzický delete. Fyzický delete probíhá až po archivaci a auditu.

publication_status Popis
draft Rozpracovaný, nezveřejněný
published Aktivní na webu
hidden Dočasně skrytý (viz hide_until)
blocked Blokovaný — viz flag is_blocked_by_editor
discontinued Vyřazený z nabídky
deleted Soft delete — označen ke smazání, fyzicky zůstává do archivace

Flags říkají proč: - is_blocked_by_editor — editor ručně zablokoval - is_deleted_by_import — import označil ke smazání - is_clearance — doprodej (může být published i discontinued) - hide_until — dočasně skrýt do data

Soft delete strategie

Rozhodnutí platí jednotně pro celý model: - productpublication_status = deleted, fyzický delete až po auditu - ostatní entity — deleted_at timestamptz NULL kde je potřeba obnova - join tabulky bez historické hodnoty — fyzický delete + audit log zachytí

Slug unikátnost

UNIQUE(locale, slug) globálně + UNIQUE(entity_id, locale) aby jedna entita neměla dva slugy pro stejný jazyk.

Audit log entity_id

entity_id text — univerzální, pokryje uuid i bigint PK bez JOIN problémů.

reserved_quantity a oversell

CHECK (reserved_quantity <= stocked_quantity) záměrně chybí. Inventura a vrácení dodavateli jsou legitimní operace které by constraint rozbil. Oversell a backorder jsou obchodní rozhodnutí — hlídají se aplikačně při vytváření rezervace.


Vrstva 1 — produkt, identita, stavy, dodavatel

product

Pole Typ Popis
id uuid PK Veřejná identita
code varchar UK Obchodní kód — hlavní klíč pro import a párování
ean varchar EAN kód
brand_id uuid FK RESTRICT
web_id bigint Mapovací klíč na starý systém
publication_status enum draft, published, hidden, blocked, discontinued, deleted
is_blocked_by_editor boolean Původ stavu blocked
is_deleted_by_import boolean Původ stavu deleted
is_clearance boolean Doprodej — nezávislé na publication_status
new_until timestamptz Produkt je "nový" do tohoto data
hide_until timestamptz Dočasně skrýt do tohoto data
created_at timestamptz
updated_at timestamptz

brand

Pole Typ Popis
id uuid PK
name varchar
slug varchar UK Výchozí slug — pokud brand nemá lokalizaci
website varchar
logo varchar
is_local boolean
web_id bigint Mapovací klíč na starý systém

Pokud brand potřebuje lokalizovaný slug, přidat brand_translation.slug s UNIQUE(locale, slug). Zatím řešeno výchozím brand.slug.

product_slug

Pole Typ Popis
id bigint PK
product_id uuid FK CASCADE
locale varchar cs, sk, de, hu, pl
slug varchar
UNIQUE(product_id, locale)
UNIQUE(locale, slug)

supplier

Pole Typ Popis
id uuid PK
name varchar
code varchar UK

product_supplier_feed

Pole Typ Popis
id uuid PK
product_id uuid FK CASCADE
supplier_id uuid FK RESTRICT
supplier_code varchar Kód produktu u dodavatele
supplier_ean varchar
lock_content boolean Nepřepisovat textová data (neprepsat)
last_imported_at timestamptz
UNIQUE(supplier_id, supplier_code)
UNIQUE(supplier_id, supplier_ean) — pouze pokud je EAN u dodavatele spolehlivý

Import logika: dodavatel insertuje pokud neexistuje code/ean → produkt vznikne jako draft. Po prvním insertu přepisuje pouze ceny a skladovou dostupnost. lock_content = true chrání textová data.

product_supplier_feed_raw

Raw payload pro debugging importů. Implementovat retenci — držet posledních N importů nebo mazat starší než X dní.

Pole Typ Popis
id bigint PK
supplier_feed_id uuid FK CASCADE
raw_data jsonb Surová data z importu
imported_at timestamptz

Vrstva 2 — lokalizace

Fallback na výchozí jazyk cs řeší aplikační vrstva.

product_translation

Pole Typ Popis
id bigint PK
product_id uuid FK CASCADE
locale varchar cs, sk, de, hu, pl
name varchar
name_heureka varchar Název pro Heureka feed
description_short text
description_long text
description_tech text
out_of_stock_text text Obchodní sdělení pro zákazníka
is_machine_translated boolean Příznak pro editorial workflow
created_at timestamptz
updated_at timestamptz
UNIQUE(product_id, locale)

brand_translation

Pole Typ Popis
id bigint PK
brand_id uuid FK CASCADE
locale varchar
name varchar
slug varchar NULL Lokalizovaný slug — pokud se liší od brand.slug
created_at timestamptz
updated_at timestamptz
UNIQUE(brand_id, locale)
UNIQUE(locale, slug) WHERE slug IS NOT NULL

Vrstva 3 — varianty a atributy

Rozlišení Option vs. Attribute (Sylius)

Typ Definice Vliv na SKU
Option Osa varianty — velikost, barva Ano
Attribute Popisná vlastnost — materiál, váha Ne

Historické typy varianty a barvy mapují jako option_group.type = variant a option_group.type = color.

product_variant

Pole Typ Popis
id uuid PK
product_id uuid FK CASCADE
code varchar UK
ean varchar
is_active boolean
is_default boolean
sort_order int
created_at timestamptz
updated_at timestamptz
UNIQUE(product_id) WHERE is_default = true — jen jedna výchozí varianta

product_variant_translation

Pole Typ Popis
id bigint PK
variant_id uuid FK CASCADE
locale varchar
name varchar
UNIQUE(variant_id, locale)

option_group

Pole Typ Popis
id uuid PK
product_id uuid FK CASCADE
code varchar
type varchar variant, color
sort_order int
UNIQUE(product_id, code)

option_group_translation

Pole Typ Popis
id bigint PK
option_group_id uuid FK CASCADE
locale varchar
name varchar
UNIQUE(option_group_id, locale)

option_value

Pole Typ Popis
id uuid PK
option_group_id uuid FK CASCADE
code varchar
color_hex varchar Pro type = color
image varchar Vzorník
sort_order int
UNIQUE(option_group_id, code)

option_value_translation

Pole Typ Popis
id bigint PK
option_value_id uuid FK CASCADE
locale varchar
name varchar
UNIQUE(option_value_id, locale)

product_variant_option_value

Pole Typ Popis
variant_id uuid FK CASCADE
option_value_id uuid FK RESTRICT
PRIMARY KEY (variant_id, option_value_id)

Constrainty aplikační vrstvou / procedurou: - Jedna varianta nesmí mít dvě hodnoty ze stejné option_group. - Jedna kombinace option values nesmí existovat dvakrát pro stejný produkt.

attribute

Pole Typ Popis
id uuid PK
code varchar UK
type varchar text, number, boolean

attribute_translation

Pole Typ Popis
id bigint PK
attribute_id uuid FK CASCADE
locale varchar
name varchar
UNIQUE(attribute_id, locale)

product_attribute_value

Pole Typ Popis
id bigint PK
product_id uuid FK CASCADE
attribute_id uuid FK RESTRICT
value varchar
locale varchar
UNIQUE(product_id, attribute_id, locale)

Vrstva 4 — ceny (Medusa pricing model)

Filosofie

Varianta nemá jednu cenu — má price_set ze kterého se vybírá cena podle kontextu. Všechny peněžní hodnoty jsou int v haléřích/centech. Ceny se nikdy fyzicky nepřepisují — ukončuje se jejich platnost (valid_to).

region

Pole Typ Popis
id uuid PK
code varchar UK CZ, SK, DE, HU, PL
name varchar
currency_code varchar CZK, EUR
tax_mode varchar inclusive, exclusive
exchange_rate numeric(18,8) Přepočet z CZK
CHECK (exchange_rate > 0)

price_set

Pole Typ Popis
id uuid PK
variant_id uuid FK CASCADE UNIQUE — 1:1 s variantou
created_at timestamptz

price

Pole Typ Popis
id uuid PK
price_set_id uuid FK CASCADE
region_id uuid FK RESTRICT NULL NULL = platí pro všechny regiony
currency_code varchar
amount int V haléřích/centech
type enum sale, original, cost, install
min_quantity int NULL
max_quantity int NULL
valid_from timestamptz NULL
valid_to timestamptz NULL
created_at timestamptz
updated_at timestamptz
CHECK (amount >= 0)
CHECK (min_quantity IS NULL OR min_quantity > 0)
CHECK (max_quantity IS NULL OR max_quantity >= min_quantity)
CHECK (valid_to IS NULL OR valid_to > valid_from)
Index: (price_set_id, region_id, currency_code, type, min_quantity, valid_from, valid_to)

Překryvy platnosti řešeny aplikačně nebo procedurou.

Mapování: cena_asale, cena_boriginal, cena_ccost, cena_montazeinstall.

tax_rate

Pole Typ Popis
id uuid PK
region_id uuid FK RESTRICT
name varchar
rate numeric(5,2)
is_default boolean
CHECK (rate >= 0)
UNIQUE(region_id) WHERE is_default = true

promotion

Nahrazuje polymorfní target_type/target_id explicitními pivot tabulkami.

Pole Typ Popis
id uuid PK
code varchar UK
name varchar
discount_type enum percent, fixed
discount_value numeric(12,4)
priority int Řeší konflikt více akcí
only_in_stock boolean Akce jen pokud skladem
valid_from timestamptz
valid_to timestamptz
is_active boolean
created_at timestamptz
updated_at timestamptz
CHECK (discount_value >= 0)
CHECK (discount_type <> 'percent' OR discount_value <= 100)

promotion_product

Pole Typ
promotion_id uuid FK CASCADE
product_id uuid FK CASCADE
PRIMARY KEY (promotion_id, product_id)

promotion_brand

Pole Typ
promotion_id uuid FK CASCADE
brand_id uuid FK CASCADE
PRIMARY KEY (promotion_id, brand_id)

promotion_category

Pole Typ
promotion_id uuid FK CASCADE
category_id uuid FK CASCADE
PRIMARY KEY (promotion_id, category_id)

Vrstva 5 — sklad a dostupnost (Medusa inventory model)

Filosofie

Fyzický stock je oddělen od obchodní dostupnosti. reserved_quantity je buď cache součtu inventory_reservation (varianta A), nebo počítá se za běhu (varianta B) — smíšený přístup není doporučen.

inventory_item

Pole Typ Popis
id uuid PK
variant_id uuid FK CASCADE UNIQUE — 1:1 s variantou
sku varchar UK
requires_shipping boolean
weight numeric(10,3) kg
width numeric(10,2) cm
height numeric(10,2) cm
length numeric(10,2) cm
created_at timestamptz
updated_at timestamptz

stock_location

Pole Typ Popis
id uuid PK
code varchar UK
name varchar
is_central boolean Mapuje eshop_produkty_sklad_centrala

inventory_level

Pole Typ Popis
id bigint PK
inventory_item_id uuid FK RESTRICT
location_id uuid FK RESTRICT
stocked_quantity int Fyzicky na skladě
reserved_quantity int Cache rezervací — viz inventory_reservation
incoming_quantity int Na cestě (na_ceste)
updated_at timestamptz
UNIQUE(inventory_item_id, location_id)
CHECK (stocked_quantity >= 0)
CHECK (reserved_quantity >= 0)
CHECK (incoming_quantity >= 0)

Dostupné množství = stocked_quantity - reserved_quantity. CHECK (reserved <= stocked) záměrně chybí — inventura nebo vrácení dodavateli může dočasně vytvořit záporný rozdíl. Hlídáno aplikačně při vytváření rezervace.

inventory_reservation

Dohledatelná tabulka rezervací. inventory_level.reserved_quantity je cache jejich součtu.

Pole Typ Popis
id uuid PK
inventory_item_id uuid FK RESTRICT
location_id uuid FK RESTRICT
order_id uuid NULL Vazba na objednávku
quantity int
status varchar pending, confirmed, released
created_at timestamptz
released_at timestamptz NULL
CHECK (quantity > 0)

supplier_availability

Pole Typ Popis
id bigint PK
inventory_item_id uuid FK RESTRICT
supplier_id uuid FK RESTRICT
availability_code varchar in_stock, on_order, unavailable
availability_text varchar Textový popis od dodavatele
eta timestamptz Očekávané datum naskladnění
updated_at timestamptz

availability_text

Pole Typ Popis
id bigint PK
inventory_item_id uuid FK CASCADE
locale varchar
text varchar Text zobrazený zákazníkovi
display_type varchar in_stock, low_stock, out_of_stock, on_order
UNIQUE(inventory_item_id, locale)

Vrstva 6 — kategorie a kolekce

Filosofie

category_closure je zdroj pravdy pro stromové dotazy. parent_id je záměrně redundantní convenience cache. Nikdy nezapisovat přímo do category_closure — vždy přes procedury.

Closure table — povinné procedury

category_insert_node(parent_id uuid, ...) → uuid
category_move_subtree(node_id uuid, new_parent_id uuid)
category_delete_subtree(node_id uuid)
category_rebuild_closure()  -- záchranná funkce

category

Pole Typ Popis
id uuid PK
parent_id uuid FK NULL Convenience cache
code varchar UK
legacy_id bigint Staré ID pro migraci
depth int
sort_order int
is_active boolean
is_internal boolean Nezobrazuje se na webu
created_at timestamptz
updated_at timestamptz

category_closure

Pole Typ Popis
ancestor_id uuid FK
descendant_id uuid FK
depth int 0 = uzel sám na sebe
PRIMARY KEY (ancestor_id, descendant_id)
CHECK (depth >= 0)

category_translation

Pole Typ Popis
id bigint PK
category_id uuid FK CASCADE
locale varchar
name varchar
description text
slug varchar
meta_title varchar
meta_description varchar
created_at timestamptz
updated_at timestamptz
UNIQUE(category_id, locale)
UNIQUE(locale, slug)

product_category_assignment

Pole Typ Popis
id bigint PK
product_id uuid FK CASCADE
category_id uuid FK RESTRICT
is_primary boolean
sort_order int
UNIQUE(product_id, category_id)
UNIQUE(product_id) WHERE is_primary = true

category_migration_map

Pole Typ Popis
id bigint PK
legacy_id bigint Staré číselné ID
new_category_id uuid FK
legacy_pipe_value varchar \|123\|
is_mapped boolean

collection

Pole Typ Popis
id uuid PK
code varchar UK
type varchar manual, automated, seasonal
is_active boolean
sort_order int

collection_translation

Pole Typ Popis
id bigint PK
collection_id uuid FK CASCADE
locale varchar
name varchar
slug varchar
description text
UNIQUE(collection_id, locale)
UNIQUE(locale, slug)

collection_product

Pole Typ Popis
collection_id uuid FK CASCADE
product_id uuid FK CASCADE
sort_order int
PRIMARY KEY (collection_id, product_id)

Vrstva 7 — média, vazby, merchandising, metadata

product_media

Pole Typ Popis
id uuid PK
product_id uuid FK CASCADE
variant_id uuid FK NULL Nullable — médium může patřit variantě
type enum image, video, document
url varchar
alt varchar
sort_order int
is_primary boolean
created_at timestamptz
UNIQUE(product_id) WHERE is_primary = true AND variant_id IS NULL
UNIQUE(variant_id) WHERE is_primary = true

variant_id musí patřit ke stejnému product_id. Hlídáno triggerem — FK to nezaručí.

product_relation

Pole Typ Popis
id bigint PK
product_id uuid FK CASCADE
related_product_id uuid FK CASCADE
relation_type varchar related, category_related
sort_order int
created_at timestamptz
UNIQUE(product_id, related_product_id, relation_type)
CHECK (product_id <> related_product_id)

product_accessory

Pole Typ Popis
id bigint PK
product_id uuid FK CASCADE
accessory_product_id uuid FK CASCADE
sort_order int
UNIQUE(product_id, accessory_product_id)
CHECK (product_id <> accessory_product_id)

product_merchandising

Pole Typ Popis
id bigint PK
product_id uuid FK CASCADE UNIQUE — 1:1 s produktem
is_featured boolean
is_bestseller boolean
is_recommended boolean
is_sale boolean
priority int
badge_label varchar Štítek (stitek)
in_slider_1 boolean
in_slider_2 boolean
slider_1_priority int
slider_2_priority int

product_external_metadata

Pole Typ Popis
id bigint PK
product_id uuid FK CASCADE
channel varchar google, heureka, zbozi
key varchar
value text
created_at timestamptz
updated_at timestamptz
UNIQUE(product_id, channel, key)

product_compliance

Pole Typ Popis
id bigint PK
product_id uuid FK CASCADE
fee_type varchar phe, recycling
fee_code varchar
linked_product_id uuid FK NULL Přidružená položka do košíku
fee_amount int V haléřích
CHECK (fee_amount >= 0)
CHECK (product_id <> linked_product_id)

product_search_document

Pole Typ Popis
id bigint PK
product_id uuid FK CASCADE
locale varchar
search_text text Fulltext — název, kód, EAN, výrobce
structured_data jsonb Cena, dostupnost, facety
indexed_at timestamptz
UNIQUE(product_id, locale)

product_search_dirty

Dirty queue pro async reindex. Trigger pouze zapíše sem, worker provede reindex.

Pole Typ Popis
id bigint PK
product_id uuid FK CASCADE
locale varchar
reasons varchar[] {price_changed, stock_changed, content_changed, …}
created_at timestamptz
processed_at timestamptz NULL NULL = čeká
attempts int Počet pokusů
last_error text NULL Poslední chyba
locked_at timestamptz NULL Kdy si job vzal worker
locked_by varchar NULL Identifikace workeru
UNIQUE(product_id, locale) WHERE processed_at IS NULL

Při vícenásobné změně: INSERT ... ON CONFLICT DO UPDATE SET reasons = array(SELECT DISTINCT unnest(reasons || EXCLUDED.reasons)) — distinct merge zamezí duplicitám v array.


Audit log

entity_audit_log

Pole Typ Popis
id bigint PK
entity_type varchar product, price, inventory_level, category, translation, …
entity_id text Univerzální — pokryje uuid i bigint PK
action varchar insert, update, delete
old_data jsonb
new_data jsonb
actor_type varchar editor, import, system
actor_id uuid NULL
request_id uuid NULL Pro dohledání konkrétního HTTP requestu nebo importní dávky
source varchar NULL api, admin, import, cron
created_at timestamptz

Migrační tracking

migration_batch

Pole Typ Popis
id uuid PK
source varchar Název zdrojového systému / tabulky
started_at timestamptz
finished_at timestamptz NULL
status varchar running, done, failed
total_rows int
processed_rows int

migration_error

Pole Typ Popis
id bigint PK
batch_id uuid FK
entity_type varchar
legacy_id varchar
error text
raw_data jsonb
created_at timestamptz

ON DELETE pravidla — přehled

Vztah Pravidlo
productproduct_translation CASCADE
productproduct_variant CASCADE
productproduct_slug CASCADE
productproduct_supplier_feed CASCADE
productproduct_category_assignment CASCADE
productproduct_media CASCADE
productproduct_search_document CASCADE
product → order snapshot nikdy FK na živý produkt
brandproduct RESTRICT
supplierproduct_supplier_feed RESTRICT
categoryproduct_category_assignment RESTRICT
option_groupoption_value CASCADE
option_valueproduct_variant_option_value RESTRICT
price_setprice CASCADE
inventory_iteminventory_level CASCADE
promotionpromotion_product/brand/category CASCADE

Doporučené pořadí implementace

1.  product, brand, supplier, product_supplier_feed, product_supplier_feed_raw
2.  product_translation, brand_translation, product_slug
3.  product_variant, option_group, option_value, product_variant_option_value
    attribute, product_attribute_value
4.  region, price_set, price, tax_rate
    promotion, promotion_product, promotion_brand, promotion_category
5.  inventory_item, stock_location, inventory_level, inventory_reservation
    supplier_availability, availability_text
6.  category + category_closure + procedury
    category_translation, product_category_assignment, category_migration_map
7.  collection, product_media, product_relation, product_accessory
    product_merchandising, product_external_metadata, product_compliance
8.  product_search_document, product_search_dirty
9.  entity_audit_log
10. migration_batch, migration_error
11. order item snapshot (samostatná vrstva)

Otevřené otázky

  • Order item snapshot — které pole se snapshotuje a v jakém formátu (flat sloupce vs. jsonb blob)?
  • Facety — samostatná vrstva nad atributy nebo odvozeno z product_attribute_value při indexaci?
  • Constraint překryvu cen — aplikační procedura nebo DB trigger?
  • Closure table procedury — PL/pgSQL nebo aplikační vrstvou?
  • inventory_level.reserved_quantity — cache triggerem z inventory_reservation, nebo počítat za běhu a sloupec odstranit?
  • product_supplier_feed_raw retence — počet posledních N importů, nebo věk záznamů?