Eshop — návrh datového modelu v3
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) |
Stavový model produktu
publication_status vyjadřuje aktuální stav produktu. Flags vyjadřují původ nebo důvod stavu. Není to duplicita — je to záměrná vrstva.
publication_status |
Bedeutung |
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 |
Označen ke smazání — viz flag is_deleted_by_import |
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 (systém automaticky přepne na hidden/published)
is_new — odstraněno
is_new jako boolean nedává smysl — "nový" je časově závislý stav. Nahrazeno:
- new_until timestamptz — produkt je "nový" dokud toto datum neuplyne
- nebo odvozovat z created_at v aplikační vrstvě
Slug unikátnost
Vždy složená UNIQUE(locale, slug) pro unikátnost napříč jazyky. Plus UNIQUE(entity_id, locale) aby jedna entita neměla dva slugy pro stejný jazyk.
Audit log entity_id
Různé entity mají různé typy PK. Řešeno jako entity_id text — univerzální, bez JOIN problémů.
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 |
|
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 |
|
website |
varchar |
|
logo |
varchar |
|
is_local |
boolean |
|
web_id |
bigint |
Mapovací klíč na starý systém |
product_slug
| Pole |
Typ |
Popis |
id |
bigint PK |
|
product_id |
uuid FK |
|
locale |
varchar |
cs, sk, de, hu, pl |
slug |
varchar |
|
|
|
UNIQUE(product_id, locale) — jeden slug per jazyk per produkt |
|
|
UNIQUE(locale, slug) — globální unikátnost slugu v jazyce |
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 |
|
supplier_id |
uuid FK |
|
supplier_code |
varchar |
Kód produktu u dodavatele |
supplier_ean |
varchar |
|
lock_content |
boolean |
Nepřepisovat textová data (neprepsat) |
last_imported_at |
timestamptz |
|
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
| Pole |
Typ |
Popis |
id |
bigint PK |
|
supplier_feed_id |
uuid FK |
|
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 |
|
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 |
|
locale |
varchar |
|
name |
varchar |
|
|
|
UNIQUE(brand_id, locale) |
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 |
|
code |
varchar UK |
|
ean |
varchar |
|
is_active |
boolean |
|
is_default |
boolean |
|
sort_order |
int |
|
created_at |
timestamptz |
|
updated_at |
timestamptz |
|
product_variant_translation
| Pole |
Typ |
Popis |
id |
bigint PK |
|
variant_id |
uuid FK |
|
locale |
varchar |
|
name |
varchar |
|
|
|
UNIQUE(variant_id, locale) |
option_group
| Pole |
Typ |
Popis |
id |
uuid PK |
|
product_id |
uuid FK |
|
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 |
|
locale |
varchar |
|
name |
varchar |
|
|
|
UNIQUE(option_group_id, locale) |
option_value
| Pole |
Typ |
Popis |
id |
uuid PK |
|
option_group_id |
uuid FK |
|
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 |
|
locale |
varchar |
|
name |
varchar |
|
|
|
UNIQUE(option_value_id, locale) |
product_variant_option_value
| Pole |
Typ |
Popis |
variant_id |
uuid FK |
|
option_value_id |
uuid FK |
|
|
|
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 |
|
locale |
varchar |
|
name |
varchar |
|
|
|
UNIQUE(attribute_id, locale) |
product_attribute_value
| Pole |
Typ |
Popis |
id |
bigint PK |
|
product_id |
uuid FK |
|
attribute_id |
uuid FK |
|
value |
varchar |
|
locale |
varchar |
Atributy mohou být lokalizované |
|
|
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.
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 |
price_set
| Pole |
Typ |
Popis |
id |
uuid PK |
|
variant_id |
uuid FK UNIQUE |
1:1 s variantou |
created_at |
timestamptz |
|
price
| Pole |
Typ |
Popis |
id |
uuid PK |
|
price_set_id |
uuid FK |
|
region_id |
uuid FK NULL |
NULL = platí pro všechny regiony |
currency_code |
varchar |
|
amount |
int |
V haléřích/centech |
type |
varchar |
sale, original, cost, install |
min_quantity |
int NULL |
|
max_quantity |
int NULL |
|
valid_from |
timestamptz NULL |
|
valid_to |
timestamptz NULL |
|
|
|
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) |
Překryvy platnosti musí být řešeny aplikačně nebo procedurou — DB constraint by byl příliš komplexní.
Mapování: cena_a → sale, cena_b → original, cena_c → cost, cena_montaze → install.
tax_rate
| Pole |
Typ |
Popis |
id |
uuid PK |
|
region_id |
uuid FK |
|
name |
varchar |
|
rate |
numeric(5,2) |
|
is_default |
boolean |
|
| Pole |
Typ |
Popis |
id |
uuid PK |
|
code |
varchar UK |
|
name |
varchar |
|
target_type |
varchar |
product, brand, category |
target_id |
uuid |
ID cílové entity |
discount_type |
varchar |
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 |
|
Vrstva 5 — sklad a dostupnost (Medusa inventory model)
Filosofie
Fyzický stock je oddělen od obchodní dostupnosti. Zákazník vidí obchodní sdělení, systém pracuje s fyzickými zásobami per lokace.
inventory_item
| Pole |
Typ |
Popis |
id |
uuid PK |
|
variant_id |
uuid FK 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 |
|
location_id |
uuid FK |
|
stocked_quantity |
int |
Fyzicky na skladě |
reserved_quantity |
int |
Rezervováno v otevřených objednávkách |
incoming_quantity |
int |
Na cestě (na_ceste) |
|
|
UNIQUE(inventory_item_id, location_id) |
|
|
CHECK (stocked_quantity >= 0) |
|
|
CHECK (reserved_quantity >= 0) |
|
|
CHECK (incoming_quantity >= 0) |
CHECK (reserved_quantity <= stocked_quantity) záměrně chybí. V reálném provozu nastane situace kdy inventura nebo vrácení dodavateli sníží stocked_quantity pod aktuální reserved_quantity. DB constraint by způsobil selhání legitimní operace. Oversell a backorder jsou obchodní rozhodnutí — hlídají se aplikačně při vytváření rezervace, ne jako DB invariant.
Dostupné množství = stocked_quantity - reserved_quantity
supplier_availability
| Pole |
Typ |
Popis |
id |
bigint PK |
|
inventory_item_id |
uuid FK |
|
supplier_id |
uuid FK |
|
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 |
|
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 pro rychlý přístup k přímému rodiči. Konzistence musí být zajištěna procedurou — nikdy přímým zápisem do category_closure.
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 pro případ corruption
category
| Pole |
Typ |
Popis |
id |
uuid PK |
|
parent_id |
uuid FK NULL |
Convenience cache — zdroj pravdy je closure table |
code |
varchar UK |
|
legacy_id |
bigint |
Staré číselné ID pro migraci |
depth |
int |
Hloubka v stromu |
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 |
|
locale |
varchar |
|
name |
varchar |
|
description |
text |
|
slug |
varchar |
|
meta_title |
varchar |
|
meta_description |
varchar |
|
|
|
UNIQUE(category_id, locale) |
|
|
UNIQUE(locale, slug) |
product_category_assignment
| Pole |
Typ |
Popis |
id |
bigint PK |
|
product_id |
uuid FK |
|
category_id |
uuid FK |
|
is_primary |
boolean |
Hlavní kategorie pro breadcrumb a URL |
sort_order |
int |
|
|
|
UNIQUE(product_id, category_id) |
category_migration_map
| Pole |
Typ |
Popis |
id |
bigint PK |
|
legacy_id |
bigint |
Staré číselné ID |
new_category_id |
uuid FK |
|
legacy_pipe_value |
varchar |
Hodnota jak vypadala v pipe listu (\|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 |
|
locale |
varchar |
|
name |
varchar |
|
slug |
varchar |
|
description |
text |
|
|
|
UNIQUE(collection_id, locale) |
|
|
UNIQUE(locale, slug) |
collection_product
| Pole |
Typ |
Popis |
collection_id |
uuid FK |
|
product_id |
uuid FK |
|
sort_order |
int |
|
|
|
PRIMARY KEY (collection_id, product_id) |
| Pole |
Typ |
Popis |
id |
uuid PK |
|
product_id |
uuid FK |
|
variant_id |
uuid FK NULL |
Nullable — médium může patřit variantě |
type |
varchar |
image, video, document |
url |
varchar |
|
alt |
varchar |
|
sort_order |
int |
|
is_primary |
boolean |
|
variant_id musí patřit ke stejnému product_id. Hlídáno procedurou nebo triggerem — DB FK to sám o sobě nezaručí.
product_relation
| Pole |
Typ |
Popis |
id |
bigint PK |
|
product_id |
uuid FK |
|
related_product_id |
uuid FK |
|
relation_type |
varchar |
related, category_related |
sort_order |
int |
|
|
|
UNIQUE(product_id, related_product_id, relation_type) |
product_accessory
| Pole |
Typ |
Popis |
id |
bigint PK |
|
product_id |
uuid FK |
|
accessory_product_id |
uuid FK |
|
sort_order |
int |
|
|
|
UNIQUE(product_id, accessory_product_id) |
product_merchandising
| Pole |
Typ |
Popis |
id |
bigint PK |
|
product_id |
uuid FK UNIQUE |
1:1 s produktem |
is_featured |
boolean |
Doporučujeme |
is_bestseller |
boolean |
|
is_recommended |
boolean |
|
is_sale |
boolean |
Akční nabídka |
priority |
int |
|
badge_label |
varchar |
Štítek (stitek) |
in_slider_1 |
boolean |
|
in_slider_2 |
boolean |
|
slider_1_priority |
int |
|
slider_2_priority |
int |
|
| Pole |
Typ |
Popis |
id |
bigint PK |
|
product_id |
uuid FK |
|
channel |
varchar |
google, heureka, zbozi |
key |
varchar |
|
value |
text |
|
|
|
UNIQUE(product_id, channel, key) |
product_compliance
| Pole |
Typ |
Popis |
id |
bigint PK |
|
product_id |
uuid FK |
|
fee_type |
varchar |
phe, recycling |
fee_code |
varchar |
skupina_phe, phe_kod |
linked_product_id |
uuid FK NULL |
Přidružená položka do košíku |
fee_amount |
int |
V haléřích |
|
|
CHECK (fee_amount >= 0) |
product_search_document
| Pole |
Typ |
Popis |
id |
bigint PK |
|
product_id |
uuid FK |
|
locale |
varchar |
|
search_text |
text |
Fulltext — název, kód, EAN, výrobce |
structured_data |
jsonb |
Cena, dostupnost, facety — vše bez joinů |
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 |
|
locale |
varchar |
|
reasons |
varchar[] |
{price_changed, stock_changed, content_changed, …} — array, ne jeden řádek per reason |
created_at |
timestamptz |
|
processed_at |
timestamptz NULL |
NULL = čeká na zpracování |
|
|
UNIQUE(product_id, locale) WHERE processed_at IS NULL — partial unique index |
reasons je array aby jeden pending job per produkt per locale pokryl všechny důvody najednou. Při vícenásobné změně před zpracováním se reasons doplní do existujícího záznamu (INSERT ... ON CONFLICT DO UPDATE SET reasons = reasons || EXCLUDED.reasons), ne vytvoří nový řádek.
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 |
|
created_at |
timestamptz |
|
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
5. inventory_item, stock_location, inventory_level
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. 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 do search dokumentu?
- Constraint překryvu cen — aplikační procedura nebo DB trigger?
- Closure table procedury — PL/pgSQL funkce nebo aplikační vrstvou?