Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Moduly / E-shop

E-shop#

Největší modul systému — 84 entit, 40 managerů, 31 Api presenterů (ls app/UI/Api/Eshop/Models/Entities/ | wc -l). Katalog, košík, objednávka, reklamace, feedy pro srovnávače. Kód: app/UI/Api/Eshop/, administrace app/UI/Admin/Eshop/, front app/UI/Front/Eshop/.

Z čeho se modul skládá#

Oblast Hlavní entity Kdo to orchestruje
katalog Product → ProductVariant → ProductSet, ProductCategory, Producer, ProductTag ProductSetManager, ProductManager
ceny ProductSetPrice (časové snímky), Vat resolver v OrderSnapshotBuilder
košík Cart, CartItem CartManager
objednávka Order + OrderItem, OrderShipper, OrderPaymentMethod, OrderVoucher, OrderGift, OrderStatus OrderCheckoutManager, OrderTransitionManager
doprava a platba Shipper, ShipperTariff, ShipperRegion, PaymentMethod ShipperTariffManager
slevy Voucher, Gift VoucherManager, GiftManager
reklamace Complaint, ComplaintStatus, ComplaintSolution ComplaintTransitionManager
feedy Feed, FeedParameterAction FeedManager

Kromě managerů má modul i generátory (OrderInvoiceGenerator, OrderEmailGenerator, VoucherGenerator), helpery bez I/O (OrderCalculator) a procesní služby (OrderSnapshotBuilder).

Prodává se sada, ne produkt#

Product          produkt — marketingová jednotka (název, popis, kategorie)
  └ ProductVariant   varianta (barva, velikost)
      └ ProductSet     prodejní jednotka — TA má cenu a čárový kód; dostupnost se
                       počítá ze skladových karet variant, na které se rozpadne

Košík, sklad i faktura pracují se SADOU

Produkt je jen obal. Kdo hledá cenu nebo skladovou dostupnost na Product, nenajde ji — a protože relace existují, nedostane chybu, jen prázdno.

Sada má dva typy (ProductSetType):

typeId Co to je Z čeho se skládá
1 PRODUCT prodejní karta právě jedné fyzické varianty ProductSet::$variant (unikátní sloupec)
2 BUNDLE balíček z jiných sad přes SetItemRel (rodič ⊃ dítě × počet kusů) — i z hotových balíčků, rekurzivně

Rozpad balíčku na skladové karty dělá jediné místo, SetCompositionService::decompose() (a decomposeMany() pro víc sad naráz). Vlastní procházení SetItemRel rekurzi balíčku v balíčku mine.

Rozpad sady na skladové karty#

SetCompositionService má tři vstupy a liší se tím, jestli množství sčítají:

Metoda Kdo ji volá Co vrací
decompose($setId, $qty) snímek objednávky (OrderSnapshotBuilder) jeden SetDecomposition; je to decomposeMany() pro jednu sadu
decomposeMany([setId => qty]) rezervace, vratka reklamace jeden SetDecomposition se sečtenými množstvími všech sad (jeden košík = jedna rezervace)
decomposeEach([setId => qty]) dostupnost v katalogu (product-set/availability) SetDecomposition pro každou sadu zvlášť; počet SQL je konstantní (nejvýš MAX_DEPTH + 2) bez ohledu na počet sad

Výsledek nese skladové karty s množstvím, unmappedVariantIds (varianta bez skladové karty), unresolvedSetIds (nerozpadnutelné) a inactiveSetIds. Služba čte jen surovým DBAL, protože ji volají save hooky; procházení je ohraničené hloubkou MAX_DEPTH (10) a cyklus z ručního SQL ho zastaví, nezacyklí.

Smazaná nebo neaktivní složka se do skladu nepočítá

Složka s deleted = 1 nebo activated = 0 (a list, jehož varianta je smazaná) se nerozbaluje a vrací se v unresolvedSetIds i inactiveSetIds. Kdyby se počítala, balíček by nabízel dostupnost krytou vyřazeným zbožím a rezervace by ho prodala bez zásoby. Front pak ukáže „na dotaz“, admin podle inactiveSetIds pojmenuje, která složka to je. Výjimku má jen dotazovaná sada v decomposeEach(): admin musí vidět sklad i u sady, kterou zrovna edituje a vypnul; publikovatelnost sady hlídá volající.

Složení je graf, ne strom

Tatáž sada může viset ve dvou hloubkách (X ⊃ {B, M} a M ⊃ B). Rozpad proto jde v topologickém pořadí: uzel se rozbalí, až když jsou hotoví všichni jeho rodiče, a sečtou se mu příspěvky ze všech cest. Kdo by si pamatoval „B už jsem rozbalil“, započítá druhý výskyt jen jednou a rezervuje méně kusů, než objednávka spotřebuje.

Ceny jsou časové řady, ne sloupec#

Cena je snímek klíčovaný časem zveřejnění, množstevní hranicí, skupinou uživatelů a měnou. Historie se tím dá dohledat a naplánované ceny fungují bez zásahu: pohled cms_mod_eshop_product_set_current_prices vybírá jen řádky s published_time <= NOW() a PriceResolver z něj čte. Objednávka se oceňuje k jednomu okamžiku $at, který OrderSnapshotBuilder vezme jednou a předá všem resolverům (zboží, doprava, platba).

Minulý snímek se needituje, přidává se nový

Změna staré ceny přepíše historii — a přepočet staré objednávky pak vyjde jinak, než jak byla zaplacená. Upravit nebo smazat jde jen snímek s časem od dnešní půlnoci (pražský kalendářní den převedený na UTC, jako published_time) dál, tedy dnešní a naplánovaný; starší Api odmítne kódem IMMUTABLE.

Naplánovaná cena na cron nečeká

Cena na detailu, v košíku i řazení výpisu podle ceny čtou pohled aktuálních cen, takže platí, jakmile nastane její čas. Cron /cron/eshop/price/recalc (à 15 minut) dorovnává jen denormalizovaný sloupec product_sets.price (cena ve výchozí měně), podle kterého výpis řadí, jen když front měnu zobrazení nezná.

Ceník zboží je v UTC, doprava a platba v místním čase

published_time cen sad se ukládá jako UTC řetězec, date_from cen dopravy a příplatků za platbu v pásmu aplikace (Europe/Prague). Sdílené $at proto PriceResolver převádí na UTC kopií (DateTimeImmutable::createFromInterface()); resolvery dopravy a platby ho formátují tak, jak přišlo. Kdo $at převede na místě, posune o hodinu až dvě i dopravu a platbu; kdo převod vynechá, objeví se nová cena zboží v checkoutu o hodinu až dvě dřív.

Uložená cena je vždycky základ bez DPH#

Kontrakt platí pro všechny cenové světy modulu — ceny sad, poplatky sady, doprava i platba. Cena s DPH se dopočítává, nikdy neukládá. Závazný popis je v docbloku OrderSnapshotBuilder.

Vrstva Kdo dopočítává
snímek objednávky OrderSnapshotBuilder + OrderCalculator::withVat() (bcmath, mezivýpočet na 6 míst, round3 half-up)
zobrazení na frontu PriceDisplayHelper — záměrně shodná aritmetika, aby se cena na detailu trefila do haléře na tu z checkoutu

Jestli se má na frontu dopočítat, rozhoduje chargeVat DPH engine pro výchozí druh plnění v platebním regionu zobrazení. Prodejce se bere z nastavení 4/invoicer.sellerId, jinak 7/invoice.default.sellerId. Plátce ukazuje cenu s DPH vždy; přepínač prodejce sellers.show_prices_with_vat se už nečte. PriceDisplayHelper::vatNoteMode() vrací null pro neplátce — pak se o DPH v šabloně nesmí zmínit vůbec.

Bez privilegia priceDisplay ukáže front ceny bez DPH

Režim stránky čte front z bridge akce api/eshop/product-set/price-display, která potřebuje záznam :Api:Eshop:ProductSet:priceDisplay (role stejné jako currentPrices). Zamítnutí i výpadek bridge dají prázdné pole, chargeVat vyjde false a plátce ukáže základ bez DPH bez jediné zmínky o dani — bez chyby. Akce product-set/defaults je jen pro admina, front ji volat nesmí.

Katalogová data přes bridge#

Web i administrace berou ceny, dostupnost a počty z interních akcí Api (ApiBridge, bez vnější HTTP routy). Každá vrací data pro celou stránku výpisu jedním voláním:

Akce (api/eshop/…) Co vrací Na co pozor
product-set/current-prices platné ceny sad (PriceResolver nad pohledem aktuálních cen) + vat a withVat k dopočtu cenu ber odsud, nikdy z product_sets.price. Výsledek se cachuje nejdéle do nejbližší naplánované ceny
product-set/next-price-times nejbližší budoucí published_time ceny každé sady (odpočet „Akce týdne“) bere pole setIds, ne jedno id; sdílí klíč currentPrices
product-set/price-display chargeVat a noteKey výchozího druhu plnění viz varování výš
product-set/availability dostupnost sad, konstantní počet SQL bez ohledu na počet sad available se necachuje. inactiveSetIds ⊆ unresolvedSetIds (složka smazaná nebo neaktivní): front ukáže „na dotaz“, admin příčinu
product-set/parameter-counts počty sad podle hodnoty parametru pro facety pošli tytéž filtry jako výpis; id viditelných sad si Api dohledá samo
product-category/product-ids id aktivních produktů v kategorii i jejích potomcích výpis sad filtruje product.id IN (…): kategorie produktu jsou M:N a přímý filtr sad podle kategorie by rozmnožil řádky stránkovaného výpisu
product-category/product-counts vlastní počet produktů každé kategorie stromu sdílí klíč getAll

Nastavení e-shopu 4/priceWithVat zaniklo

Ceník je základ bez DPH bezpodmínečně. Kód, který se ptal na tuhle volbu, nemá co nahradit — otázka „v jakém režimu je ceník“ už nemá smysl.

Starší snímky objednávek se nepřepočítaly

Při přechodu ceníku na základ bez DPH se uložená čísla jen reinterpretovala. Snímky jsou immutable, takže objednávky z doby před touto změnou nejsou dotčené — nesnaž se je „opravit“.

Řazení podle ceny#

Front výpis (ProductSetListTrait::prepareOrderBy()) posílá pro o-2 / o-3 klíč OrderByHelper::currentPriceField($currencyId), tedy cenu v měně zobrazení. Api ProductSetManager::findAll() k němu doplní základní cenovou skupinu (tutéž jako u zobrazené ceny) a ProductSetRepository::applyOrdering() z klíče udělá poddotaz nad pohledem aktuálních cen: řádek s nejvyšší množstevní hranicí from_amount <= 1, tedy cena za kus. Druhým klíčem je vždy id (OrderByHelper::withIdTiebreak()), jinak by se stránky se stejnými cenami překrývaly.

Neřaď podle product_sets.price

Sloupec drží jen výchozí měnu. Na stránce v eurech by výpis seřadil podle korun.

Řadí se podle základu, ne podle ceny, kterou vidí zákazník

Pohled i product_sets.price drží cenu bez DPH. Při jediné sazbě je pořadí stejné, při míchaných sazbách se rozejde.

Sada bez ceny v měně zobrazení se řadí jako nula

Poddotaz vrátí NULL, takže taková sada je při „od nejlevnějšího“ první a při „od nejdražšího“ poslední.

Košík#

Kde košík leží, rozhoduje parametr %eshop.cart.storage% (config/Shared/eshopCart.neon). Je to rozhodnutí při nasazení, ne nastavení v administraci: přepnutí stávající rozpracované košíky nepřevede.

storage Úložiště Klíč košíku
session (výchozí) session sekce eshop.cart session
db cms_mod_eshop_carts přes Api CartManager (api/eshop/cart/load, save) náhodný _dbSessionKey v téže sekci, ne Session::getId() — přihlášení id session přegeneruje a košík by osiřel

Front pracuje jen s CartMapperInterface. Konkrétní mapper vybere CartMapperFactory, obě implementace mají autowired: false. Obě úložiště vracejí stejný tvar {items, vouchers, checkout}.

Trvalý košík přihlášeného zákazníka se slučuje při čtení, ne v události přihlášení: každé load() a saveSnapshot() spojí do aktuálního košíku všechny ostatní košíky téhož userId.

Co Jak se sloučí
položky počty kusů téže sady se sečtou, nejvýš MAX_QUANTITY (999)
vouchery sjednocení kódů bez ohledu na velikost písmen
rozpracovaný checkout po klíčích: neprázdná hodnota aktuálního košíku vyhraje, jinak se vezme z nejnovějšího jiného; false a 0 jsou platné hodnoty
sloučené košíky smažou se

Košík navázaný na účet dostane jen jeho vlastník

Sekce eshop.cart přežije odhlášení i přihlášení jiného zákazníka. Na sdíleném počítači by druhý zákazník převzal košík prvního i s osobními údaji z checkoutu a jeho vlastní starší košíky by se jako „ostatní“ smazaly. releaseForeignCart() proto cizí košík jen odpojí od klíče (dostane nový náhodný sessionKey) a pokračuje se jako bez košíku. Vlastník si ho najde podle userId. Totéž platí pro odhlášeného návštěvníka. Kdo upraví resolveAndMerge(), nesmí tuhle bránu obejít.

Retence: cron /cron/eshop/cart/cleanup maže košíky podle poslední změny, host po 4/cart.expirationDaysGuest dnech (výchozí 30), přihlášený po 4/cart.expirationDaysUser (výchozí 365).

Odpojení cizího košíku nesmí změnit update_datetime

Podle něj se vybírá nejnovější košík při slučování checkoutu a počítá se stáří pro retenci. Proto releaseForeignCart() mění klíč surovým UPDATE jediného sloupce, ne uložením přes manager.

Postup: zakládám objednávku z kódu#

Objednávka vzniká dvěma cestami a nejsou zaměnitelné:

Cesta Vstup Kdo počítá ceny
checkout (OrderCheckoutManager) z prohlížeče přijde jen setId + počet, tariffId, paymentMethodId, kódy voucherů a adresa server z katalogu přes resolver
admin (OrderPresenter::internalActionBuild()) ceny a sazby přímo z formuláře administrátor (je to důvěryhodná cesta)
  1. Z frontu vždy přes OrderCheckoutManager. Neexistuje pole, kterým by šlo cenu podvrhnout — a to je celý smysl toho, že je to samostatná služba.
  2. Rezervace je striktní (allowPartial = false): zákazník nesmí zaplatit nekryté zboží. Pre-flight kontrola rozhodne před zápisem, finálním rozhodčím je reserve() po commitu.
  3. Chybějící prodejce je tvrdá chyba. Bez nastavení 4/invoicer.sellerId objednávka nevznikne — zákazník dostane srozumitelnou hlášku, technický detail jde do Tracy kanálu eshop.general.

Admin cesta bere ceny z formuláře, checkout nikdy

Kdo by chtěl checkout „zjednodušit“ tím, že přijme cenu z klienta, otevře tím možnost koupit cokoli za korunu. Rozdělení na dvě služby je právě proto.

Košík, ve kterém nic nemá cenu v měně objednávky

Po přepnutí na region s jinou měnou (zboží oceněné jen v CZK, region v EUR) náhled checkoutu vynechá sady bez ceny a vrátí je v order.unpricedItems. Když nezbude žádná, náhled skončí úspěchem s prázdnou nabídkou dopravy a plateb (UnpricedCartPreview::NO_OPTIONS) a front podle unpricedItems vrátí zákazníka do košíku; závazné odeslání odmítne noPriceInCurrency. Kdo pustí prázdný graf do DPH engine, dostane taxUnavailable bez seznamu sad — front skutečný důvod nepozná a pustí zákazníka do kroku, kde není žádná doprava.

Co dělá závazné odeslání#

OrderCheckoutManager::place() má pevné pořadí kroků. Každý krok spoléhá na to, že ten předchozí už proběhl:

  1. Kontrola napojení platby. U metody s platební bránou ověří OrderPaymentManager::checkCallbackWiring(), že callback ESHOP_ORDER míří na EshopPaymentHook. Když ne, objednávka nevznikne.
  2. Snímek (buildOrder()): nejdřív kartotéka zákazníka (OrderCustomerManager), pak položky, doprava, platba a prodejce.
  3. Kontrola zásoby před zápisem (checkStrictStockGate(), kód insufficientStock). Nastavení 4/order.outOfStock.allowed uvolní jen nedostatek zásoby; položku bez skladové karty odmítne vždycky.
  4. Uložení; rezervaci udělá completedSave po commitu. Když rezervace prohraje souběh s jiným nákupem a prodej bez zásoby není povolený, objednávka se hned stornuje přechodem bez e-mailů a dokladu a zákazník dostane stockReserveFailed. Košík mu zůstane.
  5. Platba se založí až teď a jen u metody s bránou — platba bez objednávky nevznikne.
  6. Přechod stavu mimo transakci (apply() vnořenou odmítne). Když selže, objednávka zůstane v CREATED a zákazník dostane varování statusTransitionFailed.
  7. Objednávka, která zůstala v CREATED, žádným přechodem neprošla. Doklad ORDER a potvrzovací e-mail jí proto vystaví a zařadí přímo generateOrderDocumentForCreatedOrder(). Je to fail-soft: chyba jen přidá varování.

Pro objednávku v CREATED nevolej apply() na týž stav

buildOrder() nastaví CREATED přímo setterem. apply() na stejný stav je no-op, takže doklad ani e-mail by nevznikly a dobírka bez automatického schválení by zůstala bez potvrzení.

Checkout oceňuje základní cenovou skupinou i přihlášeného

Do buildItems() jde vždy getBaseUserGroupId(), stejně jako katalog ukazuje všem základní ceny. Kdo zapne skupinové ceny jen v checkoutu, uvidí zákazník v košíku jinou cenu než v katalogu. Skupinové ceny na frontu je nutné změnit na obou místech naráz; zákazník přitom může patřit do víc skupin (M:N) a nejdřív je potřeba rozhodnout, která z nich o ceně rozhoduje.

Návratová stránka (summary()) čte stav jen z objednávky a jejích plateb, nikdy z GET parametrů brány — ?result=success si napíše kdokoli. Cizí objednávku přihlášeného zákazníka odmítne kódem orderForbidden. Objednávku bez účtu pustí a vlastnictví u ní hlídá front session (Front\…\CartManager::ownsOrder()). „Opakovat objednávku“ (repeatItems()) je přísnější: objednávku bez účtu odmítne vždy. Ceny nekopíruje, vrací jen setId a počet.

Číslo objednávky a variabilní symbol#

Číslo přidělí OrderNumberGenerator::generate() z OrderManager::beforeSave(), tedy uvnitř už otevřené transakce uložení.

Věc Jak
formát RRRRNNNNN: rok z insertDatetime + pořadí doplněné nulami na 5 míst (202600001); za 99999 se číslo jen prodlouží
řada jeden řádek na rok v cms_mod_eshop_order_number_series (last_order)
souběh INSERT … ON DUPLICATE KEY UPDATE (no-op) a pak SELECT … FOR UPDATE na řádku roku; zámek drží do commitu uložení
kolize číslo, které už existuje (ručně založená objednávka), se přeskočí, nejvýš 100 pokusů; poslední pojistkou je unikátní index order_number
variabilní symbol číslice z čísla objednávky, jen když VS nebyl zadán ručně

Řádek roku nezakládej přes INSERT IGNORE

IGNORE vezme na existujícím řádku jen sdílený zámek a následné FOR UPDATE dvou souběžných objednávek ho povyšuje na výlučný — skončí to uváznutím pokaždé. No-op ON DUPLICATE KEY UPDATE bere výlučný zámek rovnou.

V generátoru čísla jen surové DBAL

Běží v save hooku: jakékoli find() / findAll() přes Api by odpojilo čerstvě zhydratovaný graf objednávky ještě před flushem.

Co dělá uložení objednávky#

Obě cesty končí v OrderManager::save() a hooky manageru dělají víc než zápis řádku:

Hook Co udělá
beforeSave(), nová objednávka doplní insertDatetime, číslo a VS a každému voucheru se živou vazbou zvýší počítadlo (VoucherManager::incrementUsage()); vyčerpaný voucher odmítne celé uložení
beforeSave(), úprava odmítne graf bez položek, když je objednávka v DB má; uloží si rozpad na sklad před uložením; vouchery přidané úpravou započte a odebrané odečte, u stornované objednávky (is_cancelled čtené z DB) ne
beforeSave(), vždy přepočítá totály (OrderCalculator::recalculate()); uloženým totálům se nevěří
completedSave(), nová rezervuje sklad (OrderStockManager::reserve())
completedSave(), úprava obnoví existující doklad ORDER; u nestornované objednávky se změněným rozpadem na sklad zavolá rereserve()
beforeDelete() přečte vazby na vouchery surovým SQL; vazební tabulka má ON DELETE CASCADE a po commitu by byla prázdná
completedDelete() uvolní rezervaci a u nestornované objednávky vrátí počítadla voucherů

completedSave() a completedDelete() běží po commitu a jsou fail-soft: selhání skladu nebo dokladu uložení neshodí, jen se zapíše do logu eshop.general a do Result jako stockWarnings / invoiceWarnings. Objednávka platí i bez rezervace, ukazuje to stock_state. Chování po commitu jde upravit přes $meta:

Klíč $meta Účinek
allowPartial (bool) přebije nastavení 4/order.partial.adminAllowed pro tuhle rezervaci
skipReserve (true) vynechá rezervaci i obnovu dokladu, tedy žádný vedlejší efekt po commitu

Částečnou úpravu objednávky dělej přes updateColumn(), ne grafem

Graf bez items by přes orphanRemoval smazal všechny položky, jejich rozpad na sklad i poplatky a přepočet by zapsal nulové totály. Manager ho odmítne VALIDATION_FAILED; jednotlivé sloupce měň přes updateColumn() / updateColumns(), které hooky nespouštějí.

Počítadla voucherů stornované objednávky vlastní přechod stavu

Storno počítadlo vrátí a obnova ho znovu zvýší. Kdyby s ním hýbala i editace stornované objednávky, obnova by voucher započítala dvakrát.

Sloupce skladu neinvalidují cache výpisů

stockState, reservationExpiresAt a stockDocumentId (spolu s počítadly zobrazení) jsou v OrderManager::$nonInvalidatingColumns. Rezervace a uvolnění by jinak během jednoho checkoutu několikrát zahodily cache výpisů objednávek, a to zevnitř necommitnuté transakce.

Vlastnictví objednávky má jedinou definici, OrderManager::isOwnedBy($orderId, $userId) (čistá shoda je ownershipMatches()). Používá ji brána externí Api cesty, vlastnictví reklamace přes její objednávku i front. Vlastník se čte z databáze podle id, nikdy z payloadu. Objednávka bez účtu nepatří nikomu, ani přihlášenému zákazníkovi; tu hlídá jen front session.

OrderManager do procesních tříd neinjektuj

OrderManager volá ze svých hooků OrderStockManager, OrderInvoiceGenerator a OrderTransitionManager a ty potřebují zpátky jeho updateColumns(). Přímá injekce by uzavřela cyklus v DI. Proto si ho berou (stejně jako OrderCreditNoteManager) z OrderManagerFactory::create() až v okamžiku volání. Továrnu smí injektovat jen managery a generátory.

Postup: měním stav objednávky#

Jediný vstupní bod je OrderTransitionManager::apply($orderId, $newStatusId). Admin formulář, platební hook i crony volají vždycky tohle, nikdy vlastní UPDATE.

  1. Stav nese příznaky akcí, které se při přechodu provedou — potvrdit sklad, stornovat rezervaci, vystavit fakturu, odeslat e-mail.
  2. Celý přechod běží pod per-order zámkem cms_eshop_order_{id} (GET_LOCK, 5 s), který serializuje souběžné přechody a editace.
  3. E-maily se zařadí do fronty v téže transakci jako zápis stavu (transakční outbox). Vyrenderují se předem, mimo zámky; v transakci je jen INSERT do cms_system_email_queue. Vrácený přechod vezme e-maily s sebou, odešle je až cron fronty.
  4. Opakovaný přechod po pádu nevyrobí druhou sadu efektů. Hlídá to pět vrstev: zámek, SourceRef ve skladu (opakovaná rezervace týchž řádků je no-op, potvrzení vrátí existující výdejku), lookup existující faktury podle (orderId, invoiceTypeId), brána věrnostních bodů user_is_updated a jedna finální transakce pro stav, počítadla voucherů, order_payments, úhradu faktur a e-maily.

Přeskočení stavu přeskočí i jeho akce

Skok z potvrzené rovnou na dokončenou znamená, že se sklad nikdy neodepsal. Objednávka vypadá dokončeně, zboží je pořád rezervované a rozdíl se najde až při inventuře.

Pořadí zámků je vždy eshop → store, nikdy obráceně

Store si drží ještě vlastní zámek nad zdrojem. Opačné pořadí by při souběhu vyrobilo uváznutí.

Kompenzační voucher vzniká fail-soft

Cílový stav s OrderStatus::$generateVoucher při přechodu založí voucher (VoucherGenerator). Hodnotu a platnost určuje pásmo podle měny a roundedTotalPriceWithVat (VoucherManager::resolveLevel(), tabulky cms_mod_eshop_voucher_levels a cms_mod_eshop_voucher_level_data): percents > 0 dá procentní voucher, vyplněné fixedAmount pevnou částku. Voucher je jednorázový (maxNumber = 1), platí durationDays dní a vždy dostane řádek VoucherPrice pro měnu objednávky — bez něj by ho VoucherRedemptionService v té měně odmítl. Chybějící pásmo ani chyba databáze přechod neshodí, vrátí jen varování voucherLevelNotFound / voucherGenerationFailed. Cron to na rozdíl od faktury nedohání; voucher pak admin založí ručně.

Volby a výsledek apply()#

apply($orderId, $newStatusId, $opts) nikdy nevyhazuje, vždy vrací Result.

Klíč $opts Význam
statusInformations poznámka ke stavu (status_informations)
paymentId spárovaná System platba, jen u stavu s pairPayment. Nepovinná (dobírka platbu nemá); bez ní se faktury neoznačí jako uhrazené a vrátí se varování paymentNotPaired
expedition datetime, shipperId, branchId, code, informations; jen u stavu s fillExpedition
sendStatusEmail, sendDefaultEmail false vypne konkrétní e-mail (výchozí true)
sendExpeditionEmail výchozí podle Shipper::getSendEmail()
generateInvoice false doklad nevystaví (výchozí true, totéž co checkbox v administraci)
allowPartial jen obnova zrušené objednávky; výchozí false = celá zásoba
userId kdo přechod provedl, jde do výdejky
expireOnlyIfDue cesta cronu: pod zámkem znovu ověří EXPIRE_DUE_PREDICATE; když admin objednávku mezitím přesunul, zrušil nebo prodloužil rezervaci, vrátí úspěch s noop = true

Úspěch nese noop, warnings, invoiceId a stockDocumentId. Varování jsou kódy: holý řetězec (noActiveReservations, parityMismatch) nebo strukturované varování skladu {code, params} (StockWarning, viz Sklad); duplicity se slijí. Administrace je přeloží pod statusForm.warnings.<kód> a kartu vypíše jako KÓD — Název (%item%). Selhání má v datech errorCode: nestedTransaction, orderLocked, orderNotFound, statusNotFound, transitionNotAllowed, renewReserveFailed, stockConfirmFailed, paymentNotFound, shipperNotFound, voucherIncrementFailed, transactionFailed (selhala finální transakce a vrátilo se vše) a transitionFailed (neočekávaná výjimka mimo ni).

Stav se zapisuje jen přes OrderManager::updateColumns() se seznamem skutečně měněných sloupců. Klíče jsou property entity, u vazeb (status, payment, paymentRegion, expeditionShipper) s id jako hodnotou. Selhání updateColumns() se uvnitř finální transakce převede zpět na výjimku a transakce se vrátí celá; jinak by prošel přechod s e-maily a počítadly voucherů, ale bez zapsaného stavu.

apply() nevolej uvnitř otevřené transakce

Odmítne to kódem nestedTransaction. Finální transakce by se v DBAL 4 stala jen savepointem té vnější: zápis stavu i fronta e-mailů by zmizely s jejím rollbackem, zatímco sklad a fakturace už commitovaly samostatně. Platební callback proto svou transakci nejdřív commitne a apply() volá až potom.

Věrnostní body se připisují před finální transakcí

Mají vlastní malou transakci s branou user_is_updated, která musí být commitnutá i při pádu procesu. Spadne-li proces mezi ní a finální transakcí, body jsou připsané a stav se nezměnil; opakovaný přechod stav dožene a body podruhé nepřipíše. Kapacitu voucherů, které přechod znovu započítá (obnova storna), proto checkVoucherCapacity() ověří ještě před body. Úzké okno mezi kontrolou a commitem zůstává: vyčerpá-li voucher mezitím jiná objednávka, přechod skončí voucherIncrementFailed a body zůstanou připsané.

Kolekce, kterou přechod čte, patří do ORDER_INCLUDES

Pod zámkem se objednávka načte znovu s OrderTransitionManager::ORDER_INCLUDES. Kolekce, která tam chybí, se dohydratuje až v generátoru faktury, za applySeller(). Prosté čtení identity mapu nečistí, ale kdyby ji mezitím vyprázdnilo UnitOfWorkReset::abort() nebo hranice cizí operace, prvky kolekce by měly odpojeného vlastníka, flush faktury by spadl na „A new entity was found through the relationship“ a doklad by nevznikl. Eager načtení je obrana. Editace mimo přechod totéž řeší v OrderManager::touchOrderCollectionsForInvoice(): kolekce projde dřív, než zavolá generátor.

Platba z brány#

Na zaplacenou objednávku převádí EshopPaymentHook::handlePayment(), cíl platebního callbacku ESHOP_ORDER. Cíl určuje PaymentCallbackRegistry podle klíče, ne třída v řádku cms_system_payment_callbacks (viz Platební vrstva). Platební vrstva hook volá při každé změně stavu platby, ne jen při úspěchu. Hook proto sám propustí jen COMPLETED a pro každou objednávku platby (order_payments) projde tyto brány:

Brána Když neprojde
stav objednávky je WAITING-ON-PAYMENT, CREATED nebo APPROVED no-op; u zrušené objednávky zvoneček adminovi (vrátit peníze)
platba je ve stejné měně a pokrývá roundedTotalPriceWithVat (tolerance 0,01; přeplatek projde) zůstane neuhrazená, zvoneček a log eshop.general
skladová rezervace pořád drží; propadlou obnoví OrderStockManager::rereserve() zůstane v dosavadním stavu, poznámka do status_informations, zvoneček
OrderTransitionManager::apply() na PAID log eshop.general

Zvoneček je notifikace typu ORDER_PAYMENT_ATTENTION. Vrácení peněz ani storno hook nedělá nikdy, to je obchodní rozhodnutí obsluhy.

Před branami hook zavolá OrderManager::updateStatusForPayment(): dokončená platba doplní platební region každé spárované objednávce, která ho ještě nemá. Totéž dělá ruční spárování v přechodu stavu (paymentId). Pravidlo drží OrderPaymentRegionService::resolveFillIfEmpty(): region se jen doplňuje, ruční hodnotu admina nikdy nepřepíše. Zápis si dělá každý volající sám (callback updateColumn() mimo transakci, přechod uvnitř finální transakce).

Výjimka z hooku nesmí ven

doCallback() nemá vlastní try/catch. Výjimka by shodila odpověď bráně, ta by callback opakovala nebo platbu označila za nevyřízenou. Hook proto vrací vždy úspěch a chybu jen loguje; nový kód v něm musí totéž.

Přesun nebo přejmenování třídy hooku = SQL ve stejném nasazení

Callback se skládá ze sloupců namespace a class. Dokud se SQL nenasadí, checkout s platební bránou objednávku vůbec nezaloží (kontrola napojení v kroku 1 odeslání).

Součtová pole objednávky jsou past#

Jsou to dvě nezávislé dvojice a jejich jména klamou:

Pole Co v něm je
totalPrice / totalPriceWithVat total celé objednávky — doprava a platba už přičtené, vouchery odečtené
rounding zaokrouhlení částky bez DPH
roundingVat zaokrouhlení částky s DPH — ne „DPH ze zaokrouhlení“
roundedTotalPrice / roundedTotalPriceWithVat total po zaokrouhlení podle měny objednávky: orderRoundingWhole = 1 → ceil na celé jednotky, 0 → half-up na precision měny (eura na centy)
totalPrice        = itemsTotal        + shipping.price        + payment.price        − discount
totalPriceWithVat = itemsTotalWithVat + shipping.priceWithVat + payment.priceWithVat − discountWithVat
rounding / roundingVat = rounded − total          (vždy ≥ 0)

Souhrn, který dopravu a slevy vypisuje jako samostatné řádky, je započítá DVAKRÁT

totalPrice je už obsahuje. Souhrn po řádcích musí vyjít z itemsTotalPrice*, ne z totálu. Změřeno živě: řádky 5 302,86 + 301,29 proti uvedenému totálu 5 303,00.

roundingVat NENÍ DPH ze zaokrouhlení

Je to zaokrouhlení částky s DPH. Souhrn v cenách s DPH proto musí brát roundingVat; se špatným polem vyšlo 0,47 tam, kde patřilo 0,43 — a faktura přitom ukazovala u téže objednávky jinou hodnotu, protože generátor to má správně.

Dárky do totálu nevstupují a sleva nepřeroste cenu zboží

Dárek je zdarma. Sleva z voucherů je shora omezená součtem zboží (min(itemsTotal, …)), takže pevný voucher větší než košík total do záporu nepošle; záporné hodnoty voucheru se počítají jako 0 a total se navíc ořízne na nulu. Procentní část se počítá jen ze zboží, nikdy z dopravy a platby.

Rozpis slevy po voucherech a po sazbách DPH#

Sleva v totálu je jedno číslo, ale souhrn košíku, e-mail i faktura ji potřebují rozepsat. Snímek OrderVoucher.price/priceWithVat k tomu nestačí: nese jen pevnou část voucheru, procentní voucher má v něm nulu. Rozpisy proto počítá OrderCalculator ze stejných čísel jako totál:

Metoda Co vrací Kdo ji volá
voucherDiscounts() skutečnou slevu každého voucheru v pořadí kolekce souhrn checkoutu (OrderCheckoutManager), OrderEmailGenerator, OrderInvoiceGenerator
vatRecap() základ, cenu s DPH a daň po sazbách; objednávka bez DPH vrací [] e-mail potvrzení (OrderEmailGenerator)
ancillaryLines() / ancillaryTotals() dopravu a příplatek za platbu rozpočtené na sazby položek faktura, rekapitulace, OrderTaxManager, nabídka dopravy v checkoutu

Pravidla rozpočtu:

  1. Procenta voucherů se přidělují postupně se stropem 100 %; voucher, na který už nezbylo, dostane nulu. Žádný řádek nepřesáhne, co zboží po předchozích řádcích ještě nechá.
  2. Poslední voucher nese zaokrouhlovací rozdíl, takže součet řádků se rovná slevě v totálu.
  3. V rekapitulaci DPH se procentní část slevy dělí mezi sazby poměrně podle zboží té sazby a zbytek nese nejvyšší sazba; pevná část jde celá do nejvyšší sazby položek (tatáž maxVat jako v totálu).
  4. Doprava a příplatek za platbu jsou vedlejší plnění: základ se rozpočítá poměrně podle základů položek po sazbách a zbytek dostane skupina s největším základem. Objednávka bez DPH nebo bez položek má jedinou skupinu se sazbou 0.
  5. Objednávka bez snímku DPH engine (taxTreatment = null) zůstává na jednom řádku s uloženou sazbou; stejně skončí rozpočet, který by nesouhlasil s uloženou cenou s DPH.

Souhrn ze snímků voucherů ukáže procentní slevu jako nulu

Kdo vypíše OrderVoucher.price, ukáže u procentního voucheru „Sleva −0 Kč“, zatímco total slevu obsahuje — řádky souhrnu pak nesečtou na celkovou částku. Řádky slev ber vždy z voucherDiscounts().

Vlastní rozpočet dopravy se rozejde s fakturou

Faktura i rekapitulace berou dopravu a platbu z ancillaryLines(). Kód, který je rozdělí jinak nebo nechá celé v jedné sazbě, ukáže jiné daně než doklad, i když total sedí.

Objednávka je snímek, ne pohled do katalogu#

Všechno obchodní na objednávce — zákazník, položky, doprava, platba, vouchery, dárky, totály i prodejce — je kopie hodnot v okamžiku uložení. Objednávka už nikdy nečte živý katalog ani ceník. Odkazy na číselníky zůstávají jen jako reference s SET NULL, takže smazání sady, dopravce nebo voucheru snímek nezneplatní.

Snímek Co drží Proč ne odkaz
OrderItem název, kód, jednotka, ceny z PriceResolver, sazba z VatResolver sada se přejmenuje a přecení
OrderItemUnit rozpad položky na skladové karty (decompose() × počet kusů) složení balíčku se může kdykoli změnit
OrderShipper cena bez DPH + sazba, výdejní místo (pickup*) výdejní box může dopravce zrušit; tabulka míst neexistuje
OrderVoucher kód, procenta, částka; vat = nejvyšší sazba položek voucher se upraví nebo smaže
seller* / bank* na Order adresa, IČO, DIČ a účet prodejce doklad může vzniknout roky po objednávce, kdy adresář vypadá jinak
customer* na Order fakturační údaje zákazníka customerId je jen informativní zrcadlo z kartotéky Invoiceru, párované podle e-mailu (OrderCustomerManager)

Prodejce (sellerId) je skalární id bez cizího klíče do Invoiceru (cizí modul, stejně jako stock_document_id). Zákazník (customerId, customerContactId) cizí klíč do kartotéky má, s ON DELETE SET NULL: smazání v kartotéce odkaz vynuluje a snímek zůstane. Pro doklad je autoritativní snímek, ne *Id.

Ručně vybraný zákazník mohl mezitím zmizet

Výběr z pickeru ověřuje OrderCustomerManager::manualSelection(): pro smazaného zákazníka vrátí null a cizí nebo neexistující customerContactId tiše nahradí prvním kontaktem vybraného zákazníka. Kdo id z payloadu zapíše bez téhle kontroly, shodí uložení celé objednávky na cizím klíči.

Sklad se uvolňuje a potvrzuje jen podle OrderItemUnit

OrderStockManager sčítá jednotky všech položek podle stockItemId do jednoho SourceRef objednávky. Kdo by při stornu nebo expedici rozpadl balíček znovu podle živého set_item_relations, uvolní jiné karty, než rezervoval — reserved se rozjede a najde se to až nočním přepočtem. Varianta bez skladové karty řádek v OrderItemUnit nemá; nese ji OrderItem::$notInStock.

Staré položky mají poplatky započtené v price

Dnes je price = priceWithoutFees a poplatky (OrderItemFee) jsou jen rozpis — zadaná cena sady je už obsahuje. Starší snímky mají price = priceWithoutFees + Σ poplatků. OrderInvoiceGenerator::hasLegacyFeesInPrice() je rozlišuje podle nerovnosti obou cen; kód, který s položkami počítá, to musí dělat stejně, jinak poplatek u starých objednávek započítá dvakrát.

Pole, která počítá server, se zvenku nezapisují

Číslo objednávky a VS, totály, stav skladu a rezervace, stav fakturace, prodejce, customerId, výdejní místo a Voucher::$currentNumber jsou #[ExternalReadonly]. Stav objednávky mění jen api/eshop/order/transition, počítadlo uplatnění voucheru jen atomické VoucherManager::incrementUsage() / dekrement. Kdo je otevře generickému actionSave, dovolí třetí straně objednávku přecenit nebo přeskočit stav i s jeho akcemi.

Stavba snímku#

Snímek staví OrderSnapshotBuilder a každá část má dvě metody: katalogovou pro checkout a důvěryhodnou (…FromRow) pro formulář v administraci.

Část Checkout (z katalogu) Administrace (z formuláře)
položky buildItems(): cena z PriceResolver, poplatky jako OrderItemFee, celá kolekce se nahradí buildItemsFromRows(): cena a sazba z řádku, poplatky se nevytvářejí, existující položky se přenášejí
doprava buildShipper(): cena z ShippingPriceResolver po odpuštění free_from; má-li kterákoli sada objednávky free_delivery, je doprava zdarma (OrderFreeShippingFlags, ShippingPriceResult::withItemWaiver()) buildShipperFromRow(): cena z formuláře; $price = null (předvyplnění) ji dopočítá jako checkout
platba buildPaymentMethod(): příplatek z PaymentPriceResolver, available = 0 odmítne; sada s free_payment odpustí příplatek (PaymentPriceResult::withItemWaiver()) buildPaymentMethodFromRow(): příplatek z formuláře, brána available = 0 platí i tady
dárky buildGifts(); checkout je vybírá sám (OrderCheckoutManager::applyAutomaticGifts(), níž) buildGifts() s dárky z formuláře
předvyplnění řádku — resolveItemPrefill(): katalogová cena jedné sady bez poplatků, nic nezapisuje

Celou objednávku oceňuje jeden okamžik $at, který volající předá všem metodám. Výchozí skupinu, měnu, jazyk a platební region dostává builder parametrem od manageru; sám System tabulky nečte. Katalog čte surovým DBAL a vazby nastavuje přes $em->getReference(), protože find() by v save hooku odpojilo rozpracovaný graf.

Postup: stavím snímek objednávky

  1. Nastav na objednávce měnu a jazyk. Když chybí, buildItems() vezme výchozí z parametrů a zapíše je na objednávku, aby snímek nesl měnu, ve které vznikl.
  2. Postav položky. Vrácené totalWeight (Σ váha × počet) je vstup výběru pásma dopravy.
  3. Postav dopravu, teprve pak platbu: matice příplatků je tarif × metoda × měna a tarif se bere ze snímku dopravy. Platba bez snímku dopravy skončí VALIDATION_FAILED.
  4. Na katalogové cestě builder sazby DPH neurčuje: ukládá základ, druh plnění, sazbu 0 a cenu s DPH rovnou základu. Zacházení, sazbu a cenu s DPH doplní OrderTaxManager hned po stavbě. Důvěryhodná cesta bere sazbu z řádku a cenu s DPH dopočítá podle withVat objednávky (u neplátce je efektivní sazba 0 a priceWithVat* = price*), takže withVat musí být nastavené dřív.
Pravidlo Jak
brána nové sady smazaná, neaktivní nebo mimo publikační okno se objednat nedá; totéž platí pro vlastnící produkt (vypnutý nebo smazaný produkt, stejné pravidlo jako ProductSetEligibilityFilters::isEligible()). Sada, která už na objednávce je ($existingSetIds, vytažené z položek před přepisem), brána nekontroluje, její snímek zůstává platný
nerozpadnutelná sada nově přidaná na závazném odeslání = VALIDATION_FAILED. V náhledu ($strict = false) a u existující položky se jen celý řádek vede jako nekrytý (notInStock = počet kusů)
varianta bez skladové karty žádné OrderItemUnit pro celý řádek, notInStock = počet kusů
poplatek sady (katalogová cesta) jen s platnou cenou v měně objednávky, jinak se vynechá; sazbu nebere z ceníku poplatku (fee_prices.vat_id), dostane tutéž jako položka
free_from a příznaky zdarma u důvěryhodné cesty uplatní se jen při předvyplnění ($price = null); uložení nese cenu z formuláře, ručně zadaná cena zůstane
chybějící buňka matice plateb důvěryhodná cesta ji toleruje (stará objednávka po změně konfigurace), katalogová odmítne
název dopravy a platby při stejném tarifu nebo metodě zůstává název ze snímku, přejmenování v katalogu se nepropíše
dárek nový se ověří (available = 1, platnost k $at); availableForOrder server nehlídá, řídí jen nabídku v pickeru. Dárek, který objednávka už drží, se přenese bez kontroly

Doprava a platba zdarma, nabídka plateb, automatické dárky#

Pravidlo Jak
doprava / platba zdarma z produktu stačí jedna položka, jejíž sada má product_sets.free_delivery (free_payment), a doprava (platba) celé objednávky je zdarma. OrderFreeShippingFlags::forOrder() čte příznaky skalárně; nabídka v pokladně i snímek použijí withItemWaiver() (isFree = true, cena po odpuštění 0), takže zákazník vidí cenu, která se uloží
nabízená platební metoda jen available = 1 a brána (pokud ji metoda má) podle PaymentGateManager::isOfferedTo(): aktivovaná, pro hosta jen s for_unlogged, pro platební region nevypnutá. Metoda mimo nabídku neprojde ani odesláním (isPaymentMethodOffered() při stavbě objednávky, chyba paymentInvalid); vypnutá brána se už tiše nemění na offline platbu
automatické dárky applyAutomaticGifts() běží po posledním přepočtu ceny (za vouchery), v náhledu i při odeslání, a cenu nemění. Výběr GiftRepository::findForOrder(): jen dostupné, platné a neskryté dárky, podle priority
dárek navázaný na sadu gifts.amount („Kusů na sadu“) × počet kusů těch sad; má-li cenová rozmezí v měně objednávky, jen když do nich spadá celková cena
dárek bez vazby na sadu jednou, jen v cenovém rozmezí objednávky; bez rozmezí v měně se nedá nikdy

Dárky sady ukazuje i detail produktu přes Api akci ProductSet:gifts. Administrace dárky objednávky zobrazí a při úpravě zachová; automaticky je nepřidává.

Uložení z administrace nesmí měnit identitu položek

Řádek s orderItemId této objednávky a stejnou sadou se nezakládá znovu, přenese se existující OrderItem. Na order_item_id odkazují položky dokladů a dobropisů, reklamace (SET NULL, odkaz by zmizel) i skladové jednotky. Přenesená položka mění jen to, co admin změnil: změna ceny ji přecení jako nový řádek, změna jen sazby nebo withVat přepočte varianty s DPH a zachová rozdělení price / priceWithoutFees, nezměněný počet nechá zmrazený rozpad na sklad. Jednotky se při změně počtu slučují podle stockItemId, ne smažou a vloží: Doctrine vkládá před mazáním a narazil by na primární klíč.

Uplatnění voucheru#

Vouchery na snímek převádí VoucherRedemptionService::applyVouchers() před uložením objednávky. Počítadlo currentNumber nezvyšuje, to udělá OrderManager::beforeSave() uvnitř transakce uložení. Nový kód projde kontrolami v tomto pořadí; první selhání vrátí chybu:

  1. kód existuje a má available = 1,
  2. start <= $at <= end,
  3. limit maxNumber > 0 && currentNumber >= maxNumber = vyčerpaný (0 = neomezeně); je to jen předběžná kontrola, pod souběhem rozhodne atomické VoucherManager::incrementUsage(),
  4. voucher má řádek ceny pro měnu objednávky,
  5. hodnota zboží s DPH po slevách leží v [minPrice, maxPrice] toho řádku,
  6. počet kódů nepřekročí 4/voucher.limitPerOrder (0 = bez limitu).

Kód, který nestornovaná objednávka už drží, se nekontroluje vůbec a jeho snímek (procenta, částka) se přenese beze změny. Bez toho by se kontrola kapacity zablokovala sama o sebe (currentNumber tuhle objednávku už počítá) a objednávku s vyčerpaným nebo propadlým voucherem by nešlo upravit. Kódy se porovnávají bez ohledu na velikost písmen; SLEVA10 a sleva10 na jedné objednávce skončí voucherDuplicate.

Přenesený snímek je nová entita, ne původní instance

setVouchers() nahradí vlastněnou kolekci a naplánuje smazání všech starých řádků přes orphanRemoval. Vrácená táž instance (s id) se znovu nepersistuje, takže by řádek tiše zmizel. Proto se vytvoří nový OrderVoucher se zkopírovanými hodnotami; dárky v buildGifts() totéž.

Doprava a platba: dvě časové řady tarifu#

Tarif dopravce (ShipperTariff) vlastní obě cenové kolekce a obě se editují v jednom formuláři tarifu:

Kolekce Klíč řádku Co v řádku je
$prices (ShipperTariffPrice) tarif × měna × dateFrom × váhové pásmo cena bez DPH, active, free_from
$paymentPrices (PaymentMethodPrice) tarif × platební metoda × měna × dateFrom příplatek bez DPH, available, free_from

Obě jsou append-only: uložení zapíše novou generaci (řádky se stejným dateFrom) a starší zůstávají. Platební metoda sama žádné ceny nemá — matici čti vždycky přes tarif. Generace příplatků je na generaci cen dopravy nezávislá: kdyby visela na řádku ceny dopravy, každé přecenění dopravy by matici plateb vyprázdnilo.

Jak resolvery vyberou platný řádek:

Resolver Pravidlo
ShippingPriceResolver pásmo weight_from <= w a (weight_to IS NULL OR weight_to >= w), nejnovější date_from <= $at per tarif, při shodě nejnižší cena. Když je vítězný řádek active = 0, tarif se pro tu váhu a měnu nenabídne — na starší generaci se nespadne
PaymentPriceResolver nejnovější date_from <= $at per buňka (tarif × metoda × měna), ne „poslední generace tarifu jako celek“; available = 0 metodu skryje

Postup: měním ceník dopravy nebo plateb#

  1. Ukládej celou kolekci. Obě kolekce jsou vlastněné s orphanRemoval — co v grafu chybí, Doctrine smaže. ShipperTariffManager::beforeSave() proto porovná graf s databází a odmítne smazání, přepsání i vložení do minulosti u generací, které už nahradila novější. Řádek s vyplněným id musí patřit editovanému tarifu, jinak uložení odmítne i budoucí řádek.
  2. Upravovat jde jen generace od dnešní půlnoci dál a aktuálně platnou generaci každé řady.
  3. Vypnutí pásma nebo metody je nová generace s active = 0 / available = 0, ne smazání řádku — historie zůstává auditovatelná.
  4. Otevřené pásmo (weight_to = null, „od X kg výš“) smí být v generaci nejvýš jedno a musí mít nejvyšší weight_from. Hlídá to formulář tarifu i Api manager (ShipperTariffManager::beforeSave()), databáze ne: unikátní index bere každý NULL jako jinou hodnotu.
  5. Provozovny ($branches, ShipperTariffBranch) se ukládají zvlášť (saveBranch() / deleteBranch()), ale kolekce musí být v EDIT_INCLUDES tarifu.

Tarif uložený bez provozoven je smaže

Bez branches v EDIT_INCLUDES přijde kolekce do uložení prázdná a orphanRemoval všechny provozovny tarifu tiše odstraní.

Řádek ceníku s cizím id by se přestěhoval

Ochrana historie porovnává jen řádky editovaného tarifu. Bez kontroly vlastnictví (guardRowOwnership()) by řádek jiného tarifu s budoucím datem prošel, hydrátor by mu při flushi přepsal cizí klíč a z původního tarifu by zmizel. Admin formulář takový graf nepostaví, hrozí to jen z přímého volání Api.

Provozovny nemají historii. saveBranch($tariffId, $branchId, $data) načte tarif s branches, upraví nebo přidá jednu provozovnu a celou kolekci zapíše přes updateColumn($tariffId, 'branches', …), tedy smazáním a novým vložením. Ponechané provozovny dostanou zpět svá id; nová dostane id od databáze, ale odpověď ho nevrátí (data['id'] je null). Cizí nebo neexistující $branchId skončí ITEM_NOT_FOUND a prázdný název VALIDATION_FAILED. isManaged se z $data nečte, je vždy true.

Po uložení provozovny načti tarif znovu

Kdo s id z odpovědi počítá, dostane u nové provozovny null. Admin (BranchTrait) proto po úspěchu tarif s branches načte znovu a skutečná id bere odtud.

Tvar tarifu hlídá i Api

Kontrolu „v generaci a měně nejvýš jedno otevřené pásmo, a to s nejvyšším weight_from“ a vzájemné vyloučení externalPickup × useShipperBranches dělá kromě formuláře i ShipperTariffManager::beforeSave() (guardOpenBands(), guardPickupMode()), takže import ani ruční request je neobejde: uložení skončí VALIDATION_FAILED se zprávou INVALID: …. Zamčená historie se znovu nekontroluje.

externalPickup a useShipperBranches se vylučují

Tarif s výdejním místem z widgetu dopravce nesmí zároveň nabízet vlastní provozovny — front by ukázal výběr provozovny i tlačítko widgetu naráz. Shipper::$attributeClass je CSS třída ikony, ne klíč driveru výdejních míst; ten je v scriptName.

Výdejní místo z widgetu dopravce#

Checkout přijímá v pickupPoint surový objekt tak, jak ho vydal widget dopravce; front ho jen přepošle. Normalizaci i sanitizaci dělá driver vybraný podle Shipper::$scriptName (CarrierRegistry: Zásilkovna, PPL, Balíkovna) a výsledek zapíše OrderPickupPointApplier do snímku OrderShipper.pickup* a do dodací adresy.

Situace Výsledek
tarif bez external_pickup pickupPoint se tiše ignoruje; starý snímek výdejního místa se smaže, dodací adresa jen když ji admin mezitím ručně nezměnil
tarif s external_pickup, chybí objekt, scriptName nebo driver, nebo objekt driver neuzná závazné odeslání odmítne pickupPointRequired; náhled (persist = false) chybu nevrací
ruční zadání pickupPointManual jen admin cesta order/build; dopravce bez driveru se do snímku uloží jako manual
přijde objekt widgetu i ruční pole vyhrává widget; ruční pole se vezmou, jen když objekt chybí nebo ho driver neuzná
pickupPointTariffId v payloadu se liší od tarifu obě hodnoty se zahodí, jako by admin nic nevybral. Klíč, který v payloadu vůbec není (checkout), kontrolu vypíná

Vybrané místo se zapíše i do dodací adresy (customerDlv*), protože jinou kolonku schéma nemá; chybějící ulici a město doplní název místa. Při změně tarifu nebo přechodu na tarif bez výdejního místa OrderPickupPointApplier::clearDeliveryAddressIfMatchesPickup() adresu vymaže, ale jen když se přesně shoduje s tím, co tam místo zapsalo. Poznámku k adrese (customerDlvInformations) nechává.

clearDeliveryAddressIfMatchesPickup() volej před clearPickupPoint()

Adresu porovnává se starými hodnotami pickup* snímku. Po clearPickupPoint() už je nemá s čím porovnat, adresa výdejního místa zůstane a odejde jako ručně zadaná adresa nového tarifu. OrderSnapshotBuilder::buildShipper() i buildShipperFromRow() to při změně tarifu dělají v tomhle pořadí.

Checkout pickupPointManual nečte a číst nesmí

Kdo by ruční zadání pustil i do order/checkout, dovolí zákazníkovi podstrčit libovolné „výdejní místo“ mimo widget dopravce a zboží odejde na adresu, kterou dopravce nikdy neověřil.

Driver výdejních míst#

Driver je třída v app/UI/Api/Eshop/Carriers/, která implementuje CarrierInterface (obvykle dědí AbstractCarrier). Do CarrierRegistry se dostane sama přes RobotLoader (findByType(CarrierInterface::class)), bez neonu. Klíčem je getScriptName(), tedy hodnota Shipper::$scriptName; konfigurace je v Shipper::$settings ve tvaru klic[hodnota],klic2[…] a driver ji čte jen přes getSetting() / settingString().

Driver (scriptName) Mechanismus návratu (CarrierWidgetConfig::$mechanism) Klíč code výdejního místa
packeta callback: Packeta.Widget.pick(apiKey, callback, options) apiKey[…] id; u externího bodu jiného dopravce carrierId:carrierPickupPointId
ppl customEvent: Web Component <ppl-access-point-widget>, výsledek v event.detail apiKey[…] code (KM…)
balikovna postMessage: <iframe src="scriptUrl">, event.data.message === eventName žádný; disabled[1] ho vypne point.id

getWidgetConfig() vrací null, když dopravce není nakonfigurovaný; front pak tlačítko výdejního místa nenabídne. Ven jdou jen hodnoty, které widget potřebuje — nikdy celé settings. Parametr je sdílený předek Core\Base\Shared\…\Shipper, protože totéž volá i admin formulář objednávky se svou Admin entitou.

Sanitizaci sdílí PickupPointSanitizerTrait: strip_tags, sloučení bílých znaků a ořez na šířku sloupce pickup_* (code 64, name 255, street a city 100, zip 10, country 60; surový payload jako JSON nejvýš 8 000 znaků). Kód země se převádí na velká písmena. Bod bez code nebo name je null.

Postup: přidávám dopravce s widgetem

  1. Založ třídu …Carrier extends AbstractCarrier s unikátním getScriptName() (sloupec pickup_carrier má 45 znaků) a getLabel() pro select v administraci.
  2. V getWidgetConfig() vrať null, chybí-li klíč, a jinak CarrierWidgetConfig s jedním ze tří mechanismů; jiný mechanismus front neumí.
  3. V normalizePickupPoint() vytáhni z payloadu jen pole s jasným významem a předej je buildPickupPoint(). Nové DTO ve složce Dto vyřaď z search v config/common.neon.
  4. Ruční zadání adminem (manualPickupPoint()) nepřepisuj — výchozí je pro všechny stejné.

Balíkovna: na štítek jen PSČ a název provozovny

point.address může nést jiné PSČ, než má provozovna v point.zip. Driver proto street a city vědomě nemapuje a do dodací adresy se doplní název provozovny. Kdo „doplní chybějící ulici“ z address, pošle zásilku s nesprávným PSČ.

Pomocná třída nesmí implementovat CarrierInterface

Registr bere všechno, co rozhraní implementuje, a objevilo by se to i v selectu driverů v administraci. Proto ManualPickupPointFactory (dopravce bez driveru, snímek pickup_carrier = manual) sdílí sanitizaci traitou, ne dědičností.

Sklad#

Skladová karta patří variantě (ProductVariant::$stockItem). Objednávka pracuje se sadami a pro sklad je rozpadne na skladové karty jejich variant (viz rozpad sady výš). Rezervace vzniká při objednávce, odpis při přechodu do stavu s příznakem skladu. Podrobně Sklad.

Všechno, co objednávka se skladem dělá, jde přes OrderStockManager (reserve(), rereserve(), confirm(), release()). Objednávka je ve skladu jediný SourceRef(4, 'order', $orderId) a řádky se sčítají ze snímku OrderItemUnit. Metody berou per-order zámek cms_eshop_order_{id} (5 s), týž jako přechod stavu; je reentrantní, takže volání zevnitř apply() je bezpečné. Výsledek zapíšou do stock_state, reservation_expires_at a stock_document_id objednávky a do order_items.not_in_stock.

Pravidlo Proč
rozhodovací stav se čte pod zámkem surovým SELECTem z orders.stock_state, ne z entity entita z uložení grafu bez pole stockState má výchozí NONE, zamaskovala by CONFIRMED a sklad by se k vydané objednávce alokoval znovu
objednávka s CONFIRMED se znovu nerezervuje: úspěch s varováním alreadyConfirmed, bez volání skladu úprava položek po expedici se řeší vratkou
allowPartial = null znamená nastavení 4/order.partial.adminAllowed (výchozí vypnuto) checkout posílá true jen při povoleném prodeji bez zásoby (4/order.outOfStock.allowed)
platnost rezervace = teď + 4/payment.windowHours (výchozí 72 h) rezervace musí vydržet, dokud objednávka čeká na úhradu
objednávka bez jediné položky se skladovou kartou dostane NONE bez volání skladu taková položka nemá OrderItemUnit, nese ji not_in_stock

OrderStockManager nevolej uvnitř otevřené transakce

Veřejné metody to odmítnou kódem nestedTransaction. Skladový zápis by se stal jen savepointem cizí transakce a její rollback by ho tiše zahodil, zatímco sklad už commitnul ve vlastní relaci. OrderManager ho proto volá až z completedSave() / completedDelete().

reservation_expires_at je v UTC

Cron expirace porovnává sloupec s databázovým NOW() v UTC relaci (OrderTransitionManager::EXPIRE_DUE_PREDICATE), proto OrderStockManager::toUtcColumn() pražský čas převádí na UTC. Kdo zapíše místní čas napřímo, zruší neuhrazené objednávky o hodinu až dvě později.

Fakturace#

Objednávka si vyžádá doklad přes OrderInvoiceGenerator. Vazba na odběratele jde přes kartotéku (Customer), ne přímo na uživatele — týž člověk může fakturovat na firmu i na sebe. Podrobně Fakturace.

Zaokrouhlení počítá e-shop, ne fakturace

U měny se zaokrouhlením na celé jednotky (orderRoundingWhole = 1) a roundingVat > 0 přidá OrderInvoiceGenerator na fakturu extra řádek „Zaokrouhlení“ (sazba 0). Invoicer sám zaokrouhlení nepočítá; bez toho řádku by doklad nesouhlasil s tím, co zákazník zaplatil.

Doklad z objednávky#

OrderInvoiceGenerator staví doklad ze snímku objednávky, nikdy z živého katalogu. Zámek sám nebere: volá se pod per-order zámkem OrderTransitionManager (přechod stavu) nebo OrderCreditNoteManager (dílčí dobropis).

Metoda Kdo ji volá Co udělá
generateForStatus() přechod do stavu s createInvoice, založení objednávky, cron order/invoice typ s multipleUse = 0, který už objednávka má, vrátí beze změny; dobropisový typ vystaví nový dobropis přes strop
regenerateOrderDocument() OrderManager::completedSave() po každé editaci přepíše obsah existujícího nedaňového dokladu ORDER (stejné id i číslo, nové PDF); jiný typ nikdy
generatePartialCreditNote() dílčí dobropis z administrace dobropis vybraných řádků; stav objednávky ani invoiced_status nemění

Doklad se od objednávky liší záměrně:

Co Na dokladu Proč
voucher vždy pevná částka (percents = null), rozpad podle kódů OrderCalculator počítá procento jen ze zboží s capem 100 %, InvoiceCalculator ze všech zpoplatněných řádků bez capu — procentní řádek by doklad podúčtoval
zaokrouhlení řádek „Zaokrouhlení“ se sazbou 0 Invoicer zaokrouhlení nepočítá
poplatky jen v popisu položky cena položky je už obsahuje; starší objednávka (price ≠ priceWithoutFees) se rozpadá na základ + poplatky
IČO, DIČ zkrácené na šířku sloupce faktury (ColumnLimits) a zapsané do logu eshop.general sloupec objednávky je širší; doklad zaplacené objednávky musí vzniknout

Po uložení se kontroluje parita: rozdíl invoice.totalPriceWithVat a order.roundedTotalPriceWithVat nad max(0,01; 0,5 %) doklad nezruší, jen přidá varování parityMismatch a zápis do logu. U dobropisu se parita nekontroluje (je záporný).

Postup: přidávám důvod, proč doklad nejde vystavit

  1. Vrať failure Result s ['errorCode' => '<kód>'].
  2. Pomůže-li opakování (výpadek, zámek), nic dalšího nedělej: cron order/invoice objednávku zkusí znovu s odstupem.
  3. Nepomůže-li, přidej kód do OrderInvoiceGenerator::PERMANENT_ERROR_CODES. Cron pak posune watermark invoiced_status a objednávku pustí; doklad se řeší ručně.

Trvalý důvod mimo PERMANENT_ERROR_CODES žere dávku cronu navždy

Cron čte jen tenhle seznam, ne kopii u volajícího. Např. nothingToCredit nastává běžně (dobropis už existuje) — jako přechodná chyba by se zkoušela při každém běhu.

Dobropis umí i desetinné množství

Faktura s desetinným množstvím (3,5 h, 1,5 l) se dobropisuje stejně jako kusová; strop počítá CreditNoteLedger v bcmath na 3 desetinná místa. Řádky objednávky jsou kusové, takže formulář dílčího dobropisu objednávky nabízí celé kusy.

Dílčí dobropis#

Dobropis na celou objednávku vzniká přechodem do stavu „vrácena“. Dílčí dobropis (vrácení části kusů) přechodem stavu není — vrácení 1 ze 3 kusů neznamená, že je objednávka vrácená. Má proto vlastní vstup OrderCreditNoteManager a vlastní RBAC klíče (api/eshop/order/credit-note s privilegiem creditNote, náhled creditNotePreview).

Metoda Co dělá
preview($orderId, ?$complaintId) řádky zdrojové faktury s fakturovaným, už dobropisovaným množstvím a zbytkem; bez zámku, jen čtení. Zdroj hledá OrderInvoiceGenerator::findCreditNoteSource(), tedy totéž místo, ze kterého dobropis vzniká
issuePartial($orderId, $lines) pod per-order zámkem cms_eshop_order_{id} znovu načte objednávku a zavolá generatePartialCreditNote(); vždy vrací Result

Obě cesty sdílí strop i zámek. Strop drží generátor (buildCreditNoteWithinLimit()), takže ho žádný volající neobejde. Zámek bere vstupní bod, ne generátor, protože generátor běží i pod zámkem přechodu stavu. Bez zámku by dva souběžné požadavky přečetly týž zbytek, oba prošly stropem a vrátily zboží dvakrát.

Předvyplnění z reklamace (preview() s $complaintId) je jen návrh ze schválených položek, oříznutý zbytkem stropu; admin ho potvrzuje.

Z reklamace se předvyplní i řádek poplatku

Párování jde přes invoice_items.order_item_id a ten není unikátní: poplatek položky má vlastní řádek faktury se stejným order_item_id. Předvyplní se oba (s vráceným zbožím se vrací i poplatek za ty kusy); admin může ubrat.

issuePartial() se nesmí volat v otevřené transakci

Vlastní transakce by se změnila na savepoint a vnější rollback by vystavený doklad tiše vzal s sebou. Volání skončí nestedTransaction.

Reklamace#

Vlastní stavy s vlastními příznaky, obdobně jako objednávka — jediný vstupní bod je ComplaintTransitionManager. Lhůty se počítají ve dnech a hlídá je cron /cron/eshop/complaint/expire (denně): reklamaci po expiration_date, která není ve finálním stavu, převede přes apply() do stavu s klíčem EXPIRED. Opakované spuštění nevadí — přechod do téhož stavu je no-op. Metoda expireOverdue() je na ComplaintTransitionManager, ne na ComplaintManager, protože ten už ComplaintManager injektuje a obráceně by vznikl cyklus v DI.

Formulář reklamace v administraci (ComplaintFormTrait) ukládá generickým objektovým grafem, ale stav na něm zvolíš jen při založení. Každou další změnu stavu dělá akce status (ComplaintStatusChangeFormTrait → api/eshop/complaint/transition) — generický save() by obešel skladovou vratku i e-mail.

Přílohy jdou mimo graf v meta['files'] a klíč má tři stavy: chybějící klíč = přílohy nechat, prázdný seznam = smazat všechny, seznam = přesný nový stav. Proto buildComplaintFilesMeta() vrací null, když uploader ve formuláři není, a volající klíč do meta vůbec nedá.

Přílohy (ComplaintFileTrait) Pravidlo
limit nejvýš MAX_FILES (5) souborů po MAX_FILE_SIZE (5 MB) na reklamaci
typ AttachmentFilePolicy podle názvu i obsahu; jinak FILE_TYPE_NOT_ALLOWED
položka {type: 'existing', fileId, order} nebo {type: 'new', name, data, order}; místo base64 data jde path do dočasné složky dropzone, jen z interního volání
úložiště params.4.eshopComplaintDir mimo web root; stahuje se jen řízenou akcí administrace, nikdy veřejnou adresou
odeslání z frontu submitForUser() zkontroluje všechny nové soubory ještě před zápisem reklamace (rejectionReasonForNewFiles()); jeden špatný odmítne celé odeslání
selhání zápisu afterSave() vrátí neúspěch a celá reklamace se vrátí; soubory zapsané v tomto uložení se smažou z disku

Neúspěch přílohy se nesmí jen přeskočit

Kdo vadnou přílohu nebo nepovedený zápis File jen vynechá, uloží reklamaci bez ní a zákazník dostane potvrzení, že ji poslal. Rollback transakce disk nevrací, proto si traita zapsané cesty drží v $pendingComplaintFiles a po neúspěchu je maže sama.

Po skladové vratce jsou položky reklamace zamčené

Jakmile má reklamace stockDocumentId, formulář Multiplier položek nepřidá (šablona ukáže jen tabulku) a buildComplaintEntity() nechá kolekci tak, jak se načetla. POST položky neobsahuje, takže přestavba z něj by je smazala. Změnu položek odmítne i Api (ComplaintManager::beforeSave(), kód itemsLockedAfterReturn); poznámka, kontakt a přílohy se ukládají dál.

Přechod stavu a skladová vratka#

Všechno, co mění stav nebo sklad reklamace, drží týž zámek cms_eshop_complaint_{id}: přechod (ComplaintTransitionManager::apply()), vratka (ComplaintStockManager) i uložení existující reklamace (ComplaintManager::save()). Zámek je reentrantní, takže vnořené volání vratky z přechodu je bezpečné.

Postup: co dělá apply()

  1. Pod zámkem znovu načte reklamaci. Cron expirace navíc ověří, že je pořád po lhůtě a v nefinálním stavu; jinak skončí no-op, ne chybou.
  2. Ověří přechodovou politiku: reklamace se skladovou vratkou (stock_document_id) smí jen do stavu s returnStock = 1. Jinam odmítne kódem stockAlreadyReturned.
  3. Vyrenderuje e-mail (mimo transakci).
  4. Má-li cílový stav returnStock, provede vratku. Ta je blokující: chybějící 4/complaint.returnWarehouseId nebo neúspěšný příjem přechod odmítne.
  5. V jedné transakci zapíše stav a vloží e-mail do fronty.
  6. Selže-li krok 5 po vratce, kterou vytvořil právě tento přechod, vratka se stornuje protidokladem (compensateReturn()) a stock_document_id se vynuluje.
Vratka Jak
co se vrací jen schválené položky (ComplaintItem::$approved), rozpadlé decomposeMany() na skladové karty
cena vážená skladová cena z výdejek původní objednávky; karta bez výdejky dostane cenu od Store
idempotence jednou zapsané stock_document_id = vratka proběhla, další volání je no-op
neúplný rozpad vratka se odmítne celá (unmappedItems) ještě před příjmem

Částečná vratka by se už nikdy nedokončila

Reklamace nese jediné stock_document_id. Kdyby vratka proběhla jen pro rozpadnutelné položky, další pokus by podle uloženého id skončil no-op a zbytek by se nevrátil ani po opravě mapování — a přechod by reklamaci uzavřel jako vyřízenou.

Když selže i storno, stock_document_id zůstává

Opakovaný přechod pak vratku přeskočí a dokončí jen stav, zboží už na skladě je. Obě selhání jdou do logu eshop.general a obsluha musí stav dohledat.

Cron expirace reklamaci, kterou přechodová politika do EXPIRED nepustí, počítá jako skipped. Dostane odstup přes CronFailureManager (fronta eshop.complaint.expire), aby opakovaně selhávající reklamace na čele výběru nezablokovaly ostatní.

Založení, vlastnictví a mazání#

Věc Jak
reklamace z frontu jen ComplaintManager::submitForUser(); entitu staví server ze snímku OrderItem, syrový payload se nehydratuje. Souběžné odeslání k jedné objednávce řadí zámek cms_eshop_complaint_order_<orderId>
volání api/eshop/complaint/submit jen interně přes bridge (akce nemá actionSubmit()). userId se bere z JWT, nikdy z payloadu. items a files jsou JSON řetězce: [{"orderItemId":1,"quantity":2}], soubory {name, path} (jen uvnitř kořene dropzone) nebo {name, data} v base64; nejvýš 5 souborů po 5 MB. Rate limit se započítá i za neúspěšný pokus, jinak by šlo zkoušet cizí orderItemId
potvrzení o přijetí e-mail stavu CREATED jde do fronty v transakci uložení. Selže-li render nebo fronta, reklamace se vrátí (submitFailed) a zákazník ji odešle znovu
vlastnictví (isOwnedBy()) zákazníkovi musí patřit reklamace i její objednávka; reklamace bez objednávky (SET NULL) neprojde
zápis přes Api (canUserWrite()) při založení musí order.id patřit žadateli; při úpravě navíc i případné nové order.id
smazání reklamaci se skladovou vratkou (i stornovanou) Api odmítne kódem complaintHasStockReturn; hromadné deleteAll() jde přes tutéž bránu

Potvrzení založené reklamace nepouštěj přes apply()

Reklamace je CREATED od vzniku, takže apply() na CREATED je no-op a e-mail by se nevyrenderoval. Proto ho řadí afterSave() přímo.

Doklad vratky ve skladu nemá cizí klíč na reklamaci

Odkaz source_module = 4, source_type = 'complaint', source_id je volný. Bez brány při mazání by zůstal zaúčtovaný doklad bez zdroje.

E-maily objednávky a reklamace#

E-maily se nikdy neposílají přímo. OrderEmailGenerator (a pro reklamace ComplaintEmailGenerator) je jen vyrenderuje do PreparedOrderEmail; volající je vloží do fronty přes EmailQueueManager::enqueue($email, $conn) ve své transakci.

E-mail Šablona Kdy
stavový OrderStatusText.emailSubject/emailBody cílový stav má send_email
obecný „stav změněn“ systémová ES_ORDER_CHANGED stav má send_default_email a stavový e-mail nevznikl — jinak by šly dva
expediční avízo ShipperText dopravce order.expeditionShipper stav má fill_expedition a dopravce sendEmail
kompenzační poukaz systémová ES_NEW_VOUCHER přechod vygeneroval voucher
reklamace ComplaintStatusText jen stavový, jiný zdroj není

Objednávka, která po založení zůstane v počátečním stavu, žádný přechod nemá; potvrzení jí zařadí OrderTransitionManager::enqueueCurrentStatusEmails() (volá ho checkout).

Pravidlo Důsledek
jazyk = order.language, jinak výchozí jazyk webu chybí-li text v tom jazyce, e-mail nevznikne; v jiném jazyce se nehledá
prázdná šablona nebo selhaný render → null do fronty nejde nic, chyba je v logu eshop.general
formát částek a dat podle lokalizace objednávky (PriceFormatContext::runWith(), DocumentLocale) přechod z adminu nebo cronu nepošle anglickému zákazníkovi české formáty

Proměnné šablon objednávky (OrderEmailGenerator::recapValues(); částky přicházejí naformátované v měně objednávky):

Proměnná Klíče
$order orderNumber, variableSymbol, totalPriceWithVat (splatná částka), totalBase, itemsTotal, rounding, roundingLine, withVat, taxNote, note
$items name, code, quantity, unit, unitPrice, totalPrice, fees (jen informativní)
$shipping name, price, pickupPoint
$payment name, price, isTransfer
$vouchers code, percents, discount
$vatRecap rate, base, vat, total (prázdné u objednávky bez DPH)
$bank account, iban, swift, bankName, variableSymbol, amount, missing
$status název stavu

V šabloně z databáze piš {$order['orderNumber']}, ne {$order.orderNumber}

Tečka je v Latte spojení řetězců: výsledek je „Array“ + „orderNumber“ a varování, zákazník dostane nesmysl. Totéž $complaint, $customer a ostatní pole.

$bank.missing musí šablona ošetřit

Převod bez účtu prodejce dá prázdné account i iban. Šablona to má říct textem, jinak zákazník dostane pokyn k platbě bez čísla účtu.

Naplánované úlohy#

Endpoint Co dělá Doporučený interval
/cron/eshop/price/recalc aktivuje naplánované ceny à 15 minut
/cron/eshop/order/expire ruší nezaplacené objednávky a uvolňuje rezervace à hodinu
/cron/eshop/order/invoice dogeneruje faktury, které nevznikly hned à 15 minut
/cron/eshop/complaint/expire posune reklamace po lhůtě denně
/cron/eshop/cart/cleanup maže opuštěné košíky denně v noci
/cron/eshop/feed/generate XML feedy pro srovnávače (?id= jen jeden feed) à hodinu
/cron/eshop/product-import/run spustí nejstarší import ve frontě à minutu
/cron/eshop/elastic/{sync,reindex} vyhledávání, viz Elasticsearch à 10 minut / denně v noci

Nespuštěná úloha nevyhodí chybu ani nic nezaloguje

Jen se tiše nic neděje. Pozná se to podle důsledku — třicet tisíc košíků v databázi, nezaplacené objednávky držící rezervace, chybějící faktura.

V přímém crontabu pouštěj order/invoice až po order/expire

Dohánění dokladů vystaví doklad každé objednávce, jejíž stav má createInvoice a která ho pro ten stav ještě nemá — tedy i té, která měla mezitím vypršet.

order/expire (OrderManager::expireUnpaid()) vybírá surovým SQL nestornované objednávky po reservation_expires_at, jejichž stav má allow_cancelling = 1, a každou převádí OrderTransitionManager::apply() do stavu s klíčem EXPIRED. Vlastní storno logiku nemá: uvolnění rezervace, vrácení voucheru i e-mail dělá přechod. Bez stavu EXPIRED v číselníku cron nic neudělá a vrátí jen chybu ve statistice.

Objednávku ve stavu bez allow_cancelling cron neruší

Výběr bere jen stavy s allow_cancelling = 1; ostatní by checkGating() stejně odmítl (transitionNotAllowed). Nezaplacená objednávka v takovém stavu tedy zůstane viset, dokud ji obsluha nevyřídí ručně. Její skladovou rezervaci mezitím uvolní cron skladu /cron/store/reservation/expire, takže zboží neblokuje.

Generování feedů z cronu a tlačítko „Vygenerovat teď“ v administraci (FeedEditComponent\Traits\GenerateTrait) drží týž zámek cron.eshop.feed.generate, takže nikdy neběží souběžně.

Import produktů z fronty#

/cron/eshop/product-import/run vezme nejstarší import ve stavu queued a spustí ho (ProductImportManager::run()). Jeden běh = jeden import; další čekající vezme příští minuta.

Věc Jak to funguje
zámek vlastní ProductImportPresenter::LOCK_NAME s TTL 900 s, obnovený po každé dávce importu; obecný zámek cron základu (300 s) by na dlouhý import nestačil
ruční spuštění „Spustit hned“ v administraci (ProductImportRunTrait) drží týž zámek — ruční a cronový běh se nepotkají. Jen do RUN_NOW_MAX_ITEMS (200) položek, větší import Api odmítne a patří do fronty
ruční „Označit jako selhalo“ handleMarkFailed(); Api ho povolí jen u running staršího než STALE_RUNNING_SECONDS, živý běh z administrace ukončit nejde
zaseknutý běh když proces spadne nebo ho ukončí časový limit FPM, finally neproběhne a import zůstane running. Cron proto před výběrem fronty, a jen když zámek nikdo nedrží, převede running starší než STALE_RUNNING_SECONDS (1800 s) na failed a zapíše to do logu importu
časový limit run() vypíná jen PHP max_execution_time; limit FPM nebo proxy obejít neumí

Zámek je fail-open

Když tabulka cms_cron_locks chybí nebo dotaz selže, import běží bez zámku a může se potkat s ručním spuštěním. Viz Cron presentery.

Stav importu#

state se mění jen metodami ProductImportManager (validate(), enqueue(), run(), setState()), nikdy generickým uložením. Povolené přechody (ALLOWED_TRANSITIONS):

Ze stavu Do
uploaded validated, invalid, cancelled
validated queued, cancelled, validated, invalid
invalid validated, invalid, cancelled
queued running, cancelled
running done, failed
failed validated, invalid

Přechod je jeden UPDATE … WHERE state = <původní>. Ze dvou souběžných volajících (cron a „Spustit hned“, dvojklik) projde jen jeden, druhý dostane VALIDATION_FAILED. Přechod do running zapíše started_time, který slouží jako token běhu: dokončení starým workerem (AND started_time = ?) ani ukončení zaseknutého běhu (started_time < hranice) nezasáhne běh, který job mezitím převzal.

Stav a počítadla jobu nezapisuj přes save()

beforeSave() při úpravě vrátí stav, počítadla, časy, logPath, soubor, formát i uživatele na hodnoty z databáze. Generický save() slouží jen nastavení jobu; cokoli jiného v něm tiše zmizí. Počítadla validace zapisuje updateValidationCounters(), průběh běhu surový UPDATE po každé dávce.

Po validaci a po běhu načti job znovu

Validátor i executor volají Api managery, které čistí EntityManager. Instance jobu z doby před nimi je odpojená a flush() na ní tiše neudělá nic.

Import v administraci#

Nahrání (ProductImportAddFormTrait) soubor přesune z dočasné složky dropzone do privateDir a přes bridge pošle jen metadata (fileName, filePath, format). Kanál meta['files'] s base64 by při 100MB souboru zbytečně zdvojnásobil paměťovou špičku. Po uložení se hned spustí zkušební validace (dry-run) a admin skončí na edit s reportem.

Věc Jak to funguje
limit velikosti XML 100 MB, CSV a JSON 20 MB. Dropzone zná jen jeden strop (100 MB), limit podle formátu kontroluje až moveUploadedImportFile(); větší soubor smaže a job nezaloží
fileName jméno vygenerované dropzone ({time}_{rand}.{ext}), ne původní jméno souboru
nastavení jobu pole sdílí add i edit (ProductImportSettingsFieldsTrait) včetně výchozích hodnot: autoCreateProducers zapnuto, autoCreateOptions a autoCreateCategories vypnuto
„Uložit nastavení a validovat znovu“ jen ve stavech uploaded, validated, invalid a failed (isProductImportSettingsEditable()). <settings> v souboru je jen návrh, rozhoduje nastavení jobu; rozdíly validátor vypíše do logu jako info
úklid po chybě cleanupMovedImportFile() smaže jen přesunutý soubor a jeho náhodný adresář, a jen když je prázdný

Importní složku nemaž rekurzivně

Kořen eshop/product-imports/RRRR/MM/DD sdílejí všechny joby téhož dne. Rekurzivní úklid by vzal soubory cizích importů, včetně těch, které čekají ve frontě.

Zápis importu#

Formát souboru a význam tagů pro administrátora popisuje Import produktů. Zápis orchestruje ImportExecutor::execute():

Krok Co se děje
1 ImportValidator::begin(): číselníky jako mapy id (ImportCodebooks), rozdíl <settings> proti nastavení jobu
2 po dávkách BATCH_SIZE (20) karet: validateChunk() → ImportProductWriter::write() pro každou kartu → em->clear() → $afterBatch (průběh na jobu, obnova zámku)
3 validateBundles() a finish() (vyhrazené štítky, cíle příslušenství, počty v logu)
4 druhý průchod příslušenství resolveAccessories() — kód smí mířit na sadu založenou později v souboru
5 bundly (ImportBundleWriter), až po všech kartách

Validátor i writer píšou do téhož řádkového logu (ImportLogWriter: JSONL v privateDir, na řádek objekt {phase, line, code, action, level, message, key?, value?}). Writer pravidla neposuzuje, dostává hotový verdikt: neznámou kategorii nebo jednotku tiše přeskočí a sadě nastaví importIncomplete. Sám loguje jen to, co selže až při zápisu.

Zápis jde vždy přes Api managery (hooky, validace i potlačení reindexu platí), nikdy SQL:

Data Kanál
karta, texty, kategorie ProductManager::save()
varianta ProductVariantManager::save()
sada, texty, parametry, štítky jediné ProductSetManager::save(), kolekce se mění na místě
ceny ProductSetManager::savePriceSnapshot() až po uložení sady
sklad ProductVariantManager::foundStockItemCard() + StockDocumentManager::adjust() (inventurní doklad)
galerie meta['images'] → ProductSetManager::processImages(); alt texty AssetManager::updateColumn('translations', …) sloučené se stávajícími jazyky
auto-založení výrobce, kategorie, volby parametru ProducerManager, ProductCategoryManager, ParameterManager

Postup: co dělá writer u nové karty

  1. Uloží kartu s activated = 0, pak varianty a sady (activated = 0).
  2. Po uložení sady zapíše ceny, sklad a obrázky — potřebují existující id.
  3. Až nakonec aktivuje sady a kartu podle souboru (výchozí 1).
  4. Při pádu kdekoli uprostřed smaže už založené sady, varianty a kartu přes managery (rollbackCreated()), vyřadí je z registrů běhu a všechny položky karty zapíše jako chybu. Nepovede-li se ani úklid, vypíše zbytky do logu jako varování k ručnímu smazání.

U existující karty se nic nevrací: částečně provedený zápis se zaloguje jako chyba.

Ceny, sklad a obrázky nad rámec návodu:

  • Nový cenový bod vzniká s publishedTime = teď jen pro osy (měna × fromAmount × skupina), které soubor zmínil a kde se hodnota liší od platné. Když se neliší nic, savePriceSnapshot() se nevolá — export a zpětný import řadu neprodlužují.
  • Nezmíněná původní cena a body se převezmou z platného bodu osy, nenulují se.
  • <amountWithVat> se přepočítá sazbou sady přečtenou až po jejím uložení, takže platí <vat> z téhož souboru.
  • mode="set" je fyzický stav včetně rezervací; stejné číslo podruhé = žádný pohyb. Záporný výsledek je tvrdá chyba položky a sklad se nezapíše vůbec. Validace nanečisto to nepozná, závisí na živém stavu.
  • Obrázek se nestahuje dvakrát: adresa se hledá v cache běhu a pak v Asset.meta.importSource.urlHash (sha256 adresy) z předchozích běhů. Nový obrázek jde base64 kanálem, asset založí manager v afterSave() a writer si id dohledá po uložení.
  • Sloučená galerie nad ImportRules::MAX_IMAGES_PER_VARIANT (20) se ořízne s měkkou chybou — validátor počítá jen položky souboru, ne výsledek sloučení.

Přes dávku ani přes adjust() nedrž žádnou entitu

Mezi dávkami běží em->clear() a StockDocumentManager::adjust() po commitu čistí identity mapu. Číselníky jsou proto jen mapy id a ceny i sklad pracují s id a čtením přes DBAL. Držená entita by po clearu byla odpojená a další uložení by s ní spadlo.

Volby parametrů se zakládají dřív, než se načte sada

autoCreateOptions běží jako předběžný průchod (preResolveParameterOptions()): všechny neznámé volby se založí před načtením nebo založením sady a zápis řádků parametrů pak jen čte slovníky z paměti, bez dotazů do databáze.

Kolekce sady měň na místě a při update ji načti s includes

Parametry, štítky, příslušenství i složení bundlu se mění přes add() / removeElement(), u složení setCount() na existujícím řádku. Výměna celé kolekce smaže znovu použitý řádek a smazání se založením řádku se stejným klíčem spadne na „ID hash … already present“. Sada pro update musí přijít s tags a parameterRels v includes, jinak je uložení grafu vynuluje.

Po neúspěšném uložení obnov EntityManager

Pád flushe zavře EntityManager pro zbytek procesu a odmítnuté uložení nechá v UoW přepsané kolekce. Writer proto po každém selhání volá recoverEntityManager() (clear a znovuotevření); bez toho by spadly i všechny další položky.

Výjimka z execute() neznamená, že se nic nezapsalo

Každé uložení managera je vlastní commit. ProductImportManager::run() předává vlastní ImportExecutionSummary, takže po pádu v něm zůstanou přijaté počty i touchedSetIds pro závěrečný reindex.

Bundl smí odkazovat jen na bundl dřív v souboru

Sada type = 2 potřebuje aspoň jednu položku při každém uložení, proto se složení převádí na id před prvním save(). Varianty jsou v registru kódů vždy, jiný bundl jen tehdy, když už byl zapsán. Odkaz na pozdější bundl položku nepřipojí a sada dostane importIncomplete.

Párování vidí i měkce smazané sady

ImportMatcher je neodfiltruje: ProductSet.code je unikátní i proti nim, takže „volný“ kód smazané sady by při zápisu kolidoval.

Čísla ze souboru ověřuj nad řetězci

ImportRules rozvine exponenciální zápis (1e2, 1.5E+3) posunem desetinné čárky a meze (WEIGHT_MAX_EXCLUSIVE, PRICE_MAX_EXCLUSIVE, STOCK_MAX_EXCLUSIVE, INT_UNSIGNED_MAX) porovnává bcmath nad řetězci. (float) '1e1000' je INF, z number_format() vyjde text inf a bcmath na exponentu skončí ValueError. Hodnota mimo rozsah je tvrdá chyba položky; ve striktním sql_mode by jinak zápis spadl na obecnou chybu databáze.

Cestu k souboru jobu skládej jen přes ImportFilePath::absolute()

filePath jobu jde přepsat obecným Api save. Platný je jen tvar z nahrání (eshop/product-imports/RRRR/MM/DD/<10 znaků a-z0-9>/<název>) a adresář jobu musí po realpath() ležet pod kořenem importů; jinak metoda vrátí null a soubor se nesmí dotknout. Vlastní skládání přes ltrim() pustí ../ a úklid jobu smaže soubor mimo privateDir.

CDATA v XML výstupu musí rozdělit ]]>

XMLWriter::writeCData() sekvenci ]]> uvnitř textu nerozdělí; jediný popis, který ji obsahuje, udělá z celého exportu neplatné XML a import ho odmítne. ProductXmlExporter::writeCDataSafe() text rozdělí do víc sekcí CDATA a parser ho složí zpět beze změny. Nový XML výstup s CDATA potřebuje totéž.

Export katalogu#

ProductXmlExporter píše katalog ve formátu importu: soubor projde beze změny týmž parserem, validátorem i XSD a dá se znovu naimportovat. Čte jen přes Api managery a zapisuje přes XMLWriter atomicky.

Věc Jak
rozsah karty zadané přímo nebo přes sady; nejvýš DEFAULT_LIMIT (500) karet, zbytek se ořízne a výsledek nese truncated
sekce volitelné (ALL_SECTIONS); bundly mají jen štítky, ceny a obrázky
obrázky fotka karty jako absolutní URL (src), galerie varianty jako assetId; nejvýš 20 na variantu, nad limit XML komentář

Editace sady v administraci#

Průvodce s atomickým odesláním — celý formulář se uloží najednou.

Tovární metody sady berou parametry POZIČNĚ

Prohození dvou parametrů se neprojeví chybou, ale špatnou jednotkou nebo množstvím. Sada se uloží, čísla budou nesmyslná a přijde se na to ze skladu.

Formulář sady se nesmí překreslovat po částech

Rozpracované nahrávání by zmizelo — podrobně Formuláře, oddíl o nahrávání souborů.

Módy komponenty a kudy se co ukládá#

Editaci obsluhuje komponenta ProductSetEdit, kterou hostí dva presentery:

Mód Kde Čím se liší
product-variant modál varianty v ProductPresenter bez pickerů produktu, varianty a složení — produkt dodá server; má záložku Sklad
bundle ProductSetPresenter typ je pevně BUNDLE, složení sady je první sekce
standalone žádný presenter ho dnes nepoužívá záložní mód s pickery produktu, varianty a typu

Tlačítko „Uložit“ neukládá všechno. Každá záložka má vlastní cestu:

Data Cesta Proč zvlášť
skaláry, texty, složení, příslušenství, štítky, poplatky, odkazy, parametry objektový graf api/eshop/product-set —
galerie $meta['images'] (ProductSetManager::processImages()) assetRels je v defaultSkipFields
ceník bridge savePriceSnapshot() / deletePriceSnapshot() prices je v defaultSkipFields, graf historii nepřepíše
feedy bridge FeedManager::saveParameterRelsForSet() data patří dvojici (parametr feedu × sada), viz Feedy pro srovnávače
sklad api/store/stock-document/receive a transfer doklady vlastní modul Sklad

Galerie má v $meta['images'] tři stavy: chybějící klíč galerii nechá, prázdný seznam smaže všechny obrázky, seznam je přesný nový stav v pořadí řádků. Řádek nese assetId (stávající obrázek) nebo image s name a data (nový upload, vznikne Asset s variantami). processImages() zapíše relace celé znovu (updateColumn(…, 'assetRels', …)) s order od 1 a první obrázek nastaví jako hlavní foto sady (asset). Obrázky, které ze seznamu vypadly, se po zápisu uvolní.

Parametry sady se ukládají celé: odeslané řádky jsou úplná pravda, prázdný seznam smaže všechny hodnoty. Nová vazba přebírá id uložené vazby téhož parametru (přednostně se stejnou hodnotou), takže uložení beze změny tabulku product_set_parameter_rel nezmění (ParameterFieldsTrait::applyProductSetParameterRels()).

Řádky parametrů nové varianty předvyplní bridge akce product-category/parameters-for-product (ProductCategoryManager::fetchParametersForProduct()) ze všech kategorií produktu. O zdroji rozhoduje každá kategorie zvlášť podle use_default_parameters:

use_default_parameters Parametry Pořadí
0 (vlastní cílení) vazby kategorie v cms_mod_eshop_category_parameter_rel uložené order
1 (výchozí) globální výchozí sada: default = 1, bez rodiče, aktivní lft

Parametr z víc kategorií se objeví jednou, s nejnižším pořadím.

Postup: co se stane při uložení nové varianty (FormTrait::saveProductVariantForm())

  1. Uloží se varianta, pak sada (typeId = 1) s odkazem na ni.
  2. Při duplikaci varianty se zkopíruje aktuálně platný cenový snímek zdroje — jen když admin v kroku 2 průvodce matici cen nevyplnil.
  3. Vyplněná matice z kroku 2 se zapíše jako první snímek (publishedTime = null = teď).
  4. Variantě se založí skladová karta (ProductVariantManager::foundStockItemCard()) a podle tabulky z kroku 3 se naskladní. Obojí jen s právy :Admin:Store:StockDocument:add a :Admin:Store:StockItem:add; bez nich se krok přeskočí a admin dostane varování.
  5. Hostitel přepne aktivní klíč (ProductPresenter::$activeSetEditVariantId) na nové id a překreslí snippet setEditHost.

Starší varianta bez karty ji dostane při první skladové akci v editaci (StockOperationTrait::ensureStockItemCard()). Selhání ceny, karty nebo naskladnění uložení varianty neshodí — skončí varováním.

stockItemId: null v meta varianty odpojí skladovou kartu

ProductVariantManager::save() bere přítomný klíč $meta['stockItemId'] jako mapování karty a null jako odpojení. stockItem je na Admin entitě #[Transient], takže tohle je jediný kanál, který vazbu mění. Kdo klíč pošle při každém uložení, odpojí kartu existující varianty pokaždé, když ji admin uloží. Posílej ho jen se skutečnou hodnotou.

Bez překreslení na nový klíč vznikne další varianta

Formulář v modálu nese v _do klíč komponenty z doby před prvním uložením. Když se po založení varianty nepřekreslí setEditHost, další „Uložit a pokračovat v editaci“ založí novou variantu znovu místo úpravy té právě vzniklé.

Dva snímky ve stejné sekundě se srazí

publishedTime = null znamená „teď“ s přesností na sekundy a druhý snímek se stejným časem Api odmítne kódem TIME_COLLISION. Proto se kopie ceny při duplikaci přeskočí, když admin cenu zadal sám — jinak by jeho cena neprošla a v ceníku by zůstala kopie.

Průvodce má novalidate

Povinné pole code je v prvním kroku, který JS při přechodu dál skryje. Nativní HTML5 validace by odeslání na skrytém poli zablokovala bez jakékoli hlášky. Povinnost proto drží klientská kontrola kroků v Default.latte (přepne na chybný krok) a serverové setRequired(). Editace skryté kroky nemá a nativní validaci si nechává.

Cyklus ve složení hlídá Api

Picker položek složení vyřadí sadu samotnou i všechny její předky (PickerTrait::productSetAncestorIds()). Závazná je ale až kontrola při uložení (SetCompositionService::wouldCreateCycle() v ProductSetManager) — modál výběru v gridu umí vyřadit jen jedno id.

Co hlídá uložení sady#

ProductSetManager::beforeSave() odmítne sadu dřív, než se zapíše, a odmítnutí vrátí srozumitelný Result místo pádu na databázovém omezení:

Pravidlo Kód
typeId je 1 (PRODUCT) nebo 2 (BUNDLE) VALIDATION_FAILED
code není prázdný a žádná jiná sada ho nemá (surový SELECT) VALIDATION_FAILED (prázdný) / DB_DUPLICATE_ENTRY
PRODUCT nemá položky složení a jeho varianta nepatří jiné sadě VALIDATION_FAILED / DB_DUPLICATE_ENTRY (obsazená varianta)
BUNDLE má aspoň jednu položku, nemá variantu, neobsahuje sám sebe ani cyklus VALIDATION_FAILED

Prázdný druh plnění doplní výchozí (SupplyKindManager::findFallbackId()), import zakládá sady bez něj. Cyklus testuje SetCompositionService surovým DBAL, protože find() v hooku by odpojil rozpracovaný graf.

Odmítnutí z beforeSave() musí vyprázdnit UnitOfWork

Hydrátor zapíše odeslané setItems a translations do spravovaných entit ještě před hookem a orphanRemoval už má naplánované mazání. Kdyby v UnitOfWork zůstaly, další flush v témže procesu by je zapsal a složení sady tiše smazal, i když admin dostal validační chybu. Proto každé odmítnutí jde přes reject(), které volá UnitOfWorkReset::abort(). Totéž dělá ShipperTariffManager::rejectImmutable() s ceníkem tarifu.

Postup: zapisuji cenový snímek#

Historie cen sady se zapisuje jen bridge metodami ProductSetManager; kolekce prices je v defaultSkipFields a graf ji nezmění.

  1. Nový snímek: savePriceSnapshot($setId, $publishedTime, $rows). $publishedTime je čas, jak ho zadal admin, v pražském čase; null nebo prázdný řetězec znamená „teď“. Uloží se převedený na UTC.
  2. Úprava snímku: navíc replaceTime = publishedTime původního snímku tak, jak přišel z výpisu (už v UTC). Jeho řádky se vyjmou a nahradí novými.
  3. Řádek je fromAmount, userGroupId, currencyId, price, originalPrice, points. Řádek bez číselné ceny se vynechá, duplicitní kombinace hranice × skupina × měna taky (vyhraje první).
  4. Smazání celého snímku: deletePriceSnapshot($setId, $publishedTime) s časem z výpisu.
  5. Kontroluj isSuccess() a kód v data['code']:
Kód Význam
OK zapsáno, přepočítaný i sloupec product_sets.price
NOT_FOUND sada neexistuje, nebo (u mazání) snímek s tím časem není
IMMUTABLE cílový nebo nahrazovaný čas je před dnešní půlnocí
TIME_COLLISION na cílový čas už existuje jiný snímek
VALIDATION zápis řady selhal
DENORM řada je zapsaná, ale přepočet product_sets.price selhal

Čas existujícího snímku už nepřeváděj

Čas z výpisu je identita řádku v UTC. Kdo ho pošle znovu jako pražský čas, posune ho o hodinu až dvě: mazání skončí NOT_FOUND a úprava původní snímek nenajde, takže ho nenahradí a skončí kolizí, nebo založí nový vedle něj.

DENORM je selhání, i když se ceny zapsaly

Katalog by dál řadil podle staré ceny. Admin pro tenhle kód ukazuje vlastní hlášku; sloupec srovná nejpozději cron /cron/eshop/price/recalc.

Feedy pro srovnávače#

Nastavení feedu je rozdělené podle toho, komu data patří:

Co Kde se edituje Jak se ukládá
základ feedu (název, adresa, tagy, jazyk, měna, limit položek) formulář feedu (FeedEditComponent) objektový graf; categoryRels je v FeedManager::$defaultSkipFields
strom parametrů (nejvýš 2 úrovně) formulář feedu FeedManager::saveParameterTree() v témže odeslání, až po uložení základu (potřebuje feedId)
hodnota pro kategorii (FeedProductCategoryRel) formulář kategorie FeedManager::saveCategoryRelsForCategory(): smaže a znovu vloží řádky jedné kategorie
ruční hodnota parametru pro sadu (FeedParameterRel) záložka „Feedy“ u sady FeedManager::saveParameterRelsForSet(): smaže a znovu vloží řádky jedné sady

Ruční hodnota se s automatickou (z akce parametru) kombinuje podle režimu dvojice parametr × sada (FeedParameterRel::$valueMode, vyhodnocuje FeedXmlGenerator):

Režim Jednoduchý uzel Opakovaný uzel (multiple)
change (výchozí) ruční hodnota nahradí automatickou; prázdné pole ji pustí jakákoli ruční hodnota zahodí všechna automatická opakování
prepend ruční automatická (spojené mezerou, prázdné části vynechané) ruční opakování před automatickými
append automatická ruční ruční opakování za automatickými
none uzel se pro sadu nevypíše vůbec totéž

Postup: jak záložka „Feedy“ skládá řádky (FeedFieldsTrait::collectFeedOverrideNodeRows())

  1. Každý parametr je multiplier feedMulti_<id>, jedna kopie = jedno opakování uzlu v XML. Neopakovaný parametr má jednu kopii bez tlačítek.
  2. Uzel s dětmi uloží i řádek rodiče s hodnotou = tag uzlu. Nese režim a počet opakování pro editor; generátor ho do XML nevypíše.
  3. Rodič a děti se párují podle pozice: prázdné dítě ve vyplněné kopii se uloží jako prázdný řetězec, úplně prázdná kopie se zahodí.
  4. Režim jiný než change bez vyplněné hodnoty dostane nosný řádek s prázdnou hodnotou.
  5. Režim má jen kořenový uzel; děti se ukládají s change a generátor jejich režim nečte. U neopakovaného uzlu s dětmi se prepend/append chová jako change.

Řádek, který záložka nevykreslí, smaže nejbližší uložení

Uložení je delete + insert toho, co formulář poslal. Když admin u parametru, který už má u sady víc řádků, vypne „vícenásobné použití“, záložka proto vykreslí všechny uložené řádky (maxCopies = počet řádků) s varováním a tlačítkem „Odebrat“. Do XML jde jen první.

Bez nosného řádku se none tiše ztratí

Pravidlo „prázdné pole = žádný řádek“ by režim nevyplněného uzlu zahodilo. Admin by viděl uloženo a feed by se nezměnil.

Posunutý řádek dítěte přesune hodnotu do jiného opakování

Opakování i bere i-tý řádek každého dítěte zvlášť. Vynechané prázdné dítě by rozpárovalo dvojice jako PARAM_NAME / VAL.

Přejmenovaný nebo odebraný uzel stromu ztratí ruční hodnoty

Formulář feedu volá saveParameterTree() při každém odeslání. Nezměněný strom se nezapisuje vůbec (id uzlů i ruční hodnoty ze záložky „Feedy“ u sad zůstanou). Změněný strom se smaže a založí znovu s novými id; cizí klíč cms_mod_eshop_feed_parameter_relations → feed_parameters má ON DELETE CASCADE, proto si metoda FeedParameterRel předem uloží a vrátí je na uzel se stejnou cestou (tagy předků a pořadí mezi stejnojmennými sourozenci, např. PARAM#1/VAL#0). Hodnoty uzlu, který přejmenuješ, odebereš nebo přesuneš pod jiného rodiče, zmizí. Počet zachovaných vrací data['keptValues'].

Destruktivní přestavby (saveParameterTree() a save…Rels…()) běží v jedné transakci (inRebuildTransaction()): když selže flush, smazané řádky se vrátí. Běží-li už vnější transakce (Api SaveTrait), vlastní nezakládají a výjimku nechají vlastníkovi transakce.

Generování feedu#

FeedXmlGenerator::generate() čte sady po dávkách 200 podle id a XML píše proudem do dočasného souboru, který na konci rename() atomicky přesune na feedsDir/<urlName>.xml. Srovnávač tak nikdy nestáhne useknutý soubor.

Věc Jak
které sady deleted = 0, activated = 1, publikační okno (SQL); pak se vyřadí sady se smazaným nebo neaktivním přímým produktem — stejně jako front
limit položek itemLimit počítá řádky dotazu před vyřazením neaktivních produktů, feed tedy může mít méně položek
produkt sady ProductSet.product, jinak přes variantu — přímý odkaz má jen část sad
kategorie hodnota namapovaná u kategorie pro feed, jinak přeložený název; víc kategorií spojí svislítkem s mezerami
odkaz na detail vždy s loc jazyka feedu a začátkem adresy ze site.baseUrl
režim ruční hodnoty z prvního (nejstaršího) řádku dvojice parametr × sada

Postup: přidávám akci parametru feedu

  1. Přidej klíč a closure fn(ProductSet $set, array $ctx) do actionMap(). Vrací skalár, nebo seznam pro opakovaný uzel.
  2. Všechno, na co closure sáhne, dej do FEED_INCLUDES.
  3. Klíč zapiš do cms_mod_eshop_feed_parameter_actions (sloupec action), jinak ho admin nevybere; neznámý klíč se chová jako input (prázdno).

Asociace mimo FEED_INCLUDES vrátí N+1

Bez nich dělal generátor kolem tří dotazů na položku; s nimi je počet dotazů konstantní. Žádné findBy ani DQL uvnitř akce.

Po první dávce je všechno odpojené

Po každé dávce běží em->clear(). Feed i dřív načtené entity jsou pak odpojené — nikdo po generování nesmí volat flush() nad entitami načtenými před ním.

Poplatky a vouchery v administraci#

Obě entity mají cenu „per měna“, ale každá s jinou sémantikou. Řádky měn generuje formulář pevnou smyčkou přes měny systému, ne dynamickým Multiplier.

Kolekce Klíč řádku Co dělá uložení Prázdné pole měny
ProductSetFeePrice (poplatek sady) poplatek × měna × publishedDatetime append-only: nový řádek s publishedDatetime = NOW() jen když se cena nebo DPH liší od platné žádná nová cena; stávající zůstává platit
VoucherPrice (voucher) voucher × měna upsert: řádek měny se přímo přepíše žádný řádek → voucher v té měně neplatí; existující řádek smaže orphanRemoval

Postup: měním cenu poplatku nebo prahy voucheru

  1. U poplatku pošli do uložené kolekce všechny dosavadní řádky beze změny a přidej jen nový (buildProductSetFeePrices()). Kolekce je vlastněná, uložení ji nahrazuje celou.
  2. Platnou cenu porovnávej s posledním řádkem s publishedDatetime <= NOW(); při shodě nový snímek nevytvářej.
  3. U voucheru vezmi stávající řádek měny (Voucher::getPriceForCurrency()) a přepiš ho; měnu bez min., max. i pevné slevy do kolekce nedávej vůbec.
  4. currentNumber voucheru z formuláře nečti ani nenastavuj — vrací se hodnota z DB.

Počítadlo uplatnění mění jen VoucherManager jedním atomickým UPDATE s podmínkou ve WHERE, bez zámku:

Metoda Podmínka Když neprojde
incrementUsage($voucherId) max_number = 0 (bez limitu) nebo current_number < max_number VALIDATION_FAILED: limit je vyčerpaný nebo voucher neexistuje
decrementUsage($voucherId) current_number > 0 vrátí úspěch; storno nesmí spadnout na nulovém počítadle nebo smazaném voucheru

Kontrola limitu při výpočtu objednávky není rezervace

Validace voucheru při sestavení objednávky jen čte stav. Mezi ní a uložením může limit vyčerpat jiná objednávka; rozhoduje až incrementUsage() při uložení a jeho neúspěch uložení odmítne.

Vynechaný řádek historie poplatku zmizí nadobro

Kdo do kolekce ProductSetFeePrice pošle jen novou cenu, orphanRemoval smaže celou dosavadní řadu — bez chyby a bez možnosti dohledat, za kolik se dřív prodávalo.

Vyprázdnění ceny poplatku cenu nezruší

Prázdné pole znamená „bez nové ceny“, ne „smazat“. Poplatek v té měně dál platí poslední cenou; formulář jinou cestu nemá.

Nula voucheru se při předvyplnění nesmí změnit na prázdno

Řádek měny, jehož jediná hodnota je minPrice = 0 („platí od nuly“), by se na editaci ukázal jako tři prázdná pole. Uložení beze změny by ho pak vynechalo a voucher by v té měně tiše přestal platit. Proto decimalOrNull() vrací 0.0, ne null.

Formulář objednávky v administraci#

Úprava objednávky (OrderFormTrait) neukládá generickým save() Admin manageru — ceny, DPH ani rozpad na sklad Admin spočítat neumí. Náhled i uložení jdou jedinou Api akcí api/eshop/order/build volanou přes ApiBridge a liší se jen příznakem persist. Stav se tímhle formulářem mění jen při založení (počáteční stav); každý další přechod jde přes samostatný OrderStatusChangeFormTrait → api/eshop/order/transition.

Tlačítko persist Co pošle a co udělá
Uložit, Použít true všechny řádky; řádek bez ceny spadne viditelně (linePriceRequired)
Přepočítat (recalculate) false všechny řádky; překreslí jen náhled orderPreview, ceny v řádcích nepřepisuje
shippingRefresh, paymentRefresh false jen řádky s cenou (onlyPricedLines()), aby předvyplnění dopravy nespadlo na neoceněné položce; doplní cenu a sazbu dopravy nebo platby
pickupRefresh false surový objekt widgetu v pickupPoint; ze snímku v odpovědi předvyplní šest polí výdejního místa

Pole s neobvyklým kontraktem:

Pole Kontrakt
allowPartial select se třemi stavy, ne checkbox. Prázdná volba = klíč v payloadu chybí a platí nastavení 4/order.partial.adminAllowed; allow / strict pošle true / false. Klíče jsou slova, protože číselný klíč '0' PHP převede na 0 a „vyžadovat plnou zásobu“ by splynulo s „nevybráno“
allowUnavailablePaymentMethod posílá se vždy true, admin je důvěryhodná cesta. Nedostupnou metodu (available = 0) Api ohlásí neblokujícím varováním paymentMethodUnavailable; výchozí false platí pro checkout
sellerId povinné. Na přidání a u objednávky bez snímku prodejce se předvyplní z 4/invoicer.sellerId, jinak z 7/invoice.default.sellerId, a admin dostane flash. Texty prodejce zůstávají ručně editovatelné
DPH pole na přidání existují jen u plátce — rozhoduje stav prodejce k dnešku (Seller::resolveUseVatOn()), ne nastavení 4/useVat. Na editaci jsou vždy a viditelnost určuje uložené withVat
customerContactId položky selectu se staví už při sestavení formuláře podle customerId: na POST z odeslaných dat (getPost()), na GET z uložené objednávky
pickupPoint* kontrolky se přidávají vždy, blok vykresluje šablona podle tarifu. pickupPointTariffId nese tarif, ke kterému výběr patří; server výběr pro jiný tarif zahodí
lines[].orderItemId id existující položky téže objednávky, kterou řádek upravuje; builder položku přenese (id, zmrazený rozpad na sklad, cenový snímek) místo založení nové. setId = 0 s orderItemId je položka, jejíž sada mezitím zmizela. Všechna id v payloadu musí být kladná celá čísla v kanonickém tvaru (OrderPayloadHelper::findInvalidIdPath()); '1.9' nebo '1e2' Api odmítne VALIDATION_FAILED s cestou pole, jinak by se po přetypování upravila cizí položka

Postup: přidávám pole nebo pomocné tlačítko do formuláře objednávky

  1. Hodnotu dej do payloadu v buildOrderPayload() — ten sdílí náhled i uložení.
  2. Pomocné AJAX tlačítko dostane setValidationScope([]) a obsluha čte hodnoty přes orderControlValues(), nikdy přes getValues() (důvod v Formulářích).
  3. Obsluha nastaví $this->orderPreview, chybu i varování a překreslí snippet, do kterého píše — jinak náhled zůstane prázdný a selhání je němé.
  4. Pole předvyplňované z asociace objednávky „na jednoho“ potřebuje tu asociaci v includes v OrderPresenter::actionEdit().
  5. Nové varování z Api přelož pod form.warnings.<kód>; uložení ho ukáže jako flash, který přežije přesměrování.

Editace bez includes asociací smaže položky objednávky

Hydrátor tenkou referenci „na jednoho“ bez include zahodí. Bez items.productSet se formulář vykreslí s prázdným productSetId, řádek se při uložení nespáruje s uloženou položkou a Api ho bere jako odebraný. Totéž shipper.tariff, paymentMethod.paymentMethod, gifts.gift a vouchers.voucher pro dopravu, platbu, dárky a vouchery.

Formulář změny stavu se odesílá bez AJAXu

Úspěch i chyba končí přesměrováním a flash se vykreslí až po plném načtení. Při AJAX odeslání by následný GET po redirect('this') nenesl žádný invalidovaný snippet — žádná hláška a na obrazovce starý stav. Tlačítko „Provést přechod“ se přidá jen tomu, kdo má :Admin:Eshop:Order:status, a výběr cílových stavů zrcadlí checkGating() serveru.

Co dělá order/build na serveru#

Akci obsluhuje OrderManager::getBuildData() (traita OrderBuildTrait); Api presenter ji jen přeposílá. Část hodnot nepřebírá z payloadu, ale počítá sama:

Hodnota Jak vznikne
buyerType z vybraného userTypeId (UserType::requiresCompanyData()); bez typu je firma, když je vyplněné customer.companyName nebo customer.regNo. Klíč buyerType v payloadu se nečte
customerId, customerContactId jen při persist = true. Ruční výběr vyhraje, když nese změnu: jiný zákazník, nebo týž zákazník s jinou adresou (OrderCustomerManager::manualSelection()). Jinak se páruje podle e-mailu (resolve()), ale jen když se e-mail změnil nebo jde o novou objednávku; prosté uložení beze změny vazbu nechá
snímek prodejce jen když payload nese klíč seller; chybějící klíč snímek nechá beze změny. Bez sellerId skončí uložení sellerRequired
počáteční stav statusId se čte jen u nové objednávky, bez něj uložení skončí statusRequired. U existující se stav tady nemění nikdy

Postup: pořadí kroků v getBuildData()

  1. Odmítne nekanonická id v payloadu (findInvalidIdPath()).
  2. Spočítá buyerType a vazbu na kartotéku. Obojí volá Api managery (UserTypeManager::find(), OrderCustomerManager), proto před načtením objednávky.
  3. Načte objednávku (find() s položkami, dopravou, platbou a platbami) nebo založí novou.
  4. Přepíše snímky, sestaví položky, dopravu a platbu (OrderSnapshotBuilder).
  5. Výdejní místo zapíše OrderPickupPointApplier až po buildShipperFromRow(), protože píše do téhož snímku OrderShipper, který builder právě přestavěl.
  6. Uplatní vouchery a dárky a přepočítá totály (OrderCalculator::recalculate()). Při persist = false tady skončí a vrátí náhled.
  7. Při persist = true a bez povoleného částečného plnění projde kontrolou zásoby (checkStrictStockGate()). K dostupnosti přičte vlastní aktivní rezervace objednávky, jinak by úprava množství narazila na svou starou rezervaci, kterou rereserve() uvolní až po uložení.
  8. Uloží přes OrderManager::save().

Za find() objednávky žádné volání, které může skončit abort()

Prosté Api čtení (find() / findAll()) identity mapu nečistí, čistí se jen na hranici operace a v UnitOfWorkReset (odmítnuté uložení abort(), dávky). Volání jiného manageru, které uvnitř ukládá, ale může skončit abort() a vyprázdnit identity mapu. Nad načtenou objednávkou by tím odpojilo shipper a flush by spadl na „another object of class OrderShipper was already present for the same ID“. Proto resolveBuyerType() i vazba na kartotéku běží před krokem 3 a číselník potřebný v kroku 4 a dál načti předem, nebo surovým DBAL.

Pickery formuláře objednávky#

Picker Hidden pole Co doplní
uživatel (OrderCustomerPickerTrait) userId snímek customer* přes fieldMap
zákazník z kartotéky (OrderCustomerLookupPickerTrait) customerId snímek customer* z prvního kontaktu; refresh customerRefresh pak serverem naplní select adres customerContactId
sada v řádku položek (OrderItemPickerTrait) productSetId refresh itemsPrefill doplní kód, jednotku, DPH a cenu z katalogu — jen řádku, který ještě cenu nemá
voucher v řádku vouchers (VoucherPickerTrait) voucherId (server ho nečte) textové pole code

Oba pickery zákazníka píšou do týchž polí

Jejich fieldMap cílí na stejný snímek customer*, takže pozdější výběr přepíše ten dřívější. Kontakt z kartotéky bez vyplněné trojice companyName/firstname/lastname nechá jméno prázdné — ze složeného name se nehádá a admin ho musí dopsat.

Voucher se uplatňuje podle kódu, ne podle id

VoucherRedemptionService validuje a snímkuje výhradně řetězec code (OrderFormTrait::orderPayloadVoucherCodes()). Proto je code zároveň zobrazované pole pickeru a dá se přepsat ručně. Našeptávač filtruje jen available = 1 a časové okno; vyčerpaný limit (currentNumber >= maxNumber) generické filtry neumí porovnat, takže se nabídne a odmítne ho až uložení.

Mazání produktu a sady z gridu#

Gridy produktů a sad mažou přes setDeleteMethod('deleteOrHide'). Záznam bez historie se smaže nadobro (produkt i s variantami a sadami, které by zůstaly prázdné); na který odkazuje objednávka, reklamace nebo dárek, se jen skryje (deleted = 1). Bránu historie drží Api (ProductHardDeleteManager::findBlockers(), u sady ProductSetManager::findHistoryBlockers()), Admin manager jen převede odmítnutí PRODUCT_DELETE_BLOCKED / PRODUCT_SET_DELETE_BLOCKED na měkké smazání. Id zpracovává po jednom, jinak by jeden produkt s historií zablokoval celou skupinu; když se něco jen skrylo, vrátí kód PRODUCT_DELETE_HIDDEN a grid ukáže varování.

Které sady zaniknou spolu s produktem, rozhoduje EmptiedSetResolver (volá ho ProductHardDeleteManager): prodejní karta mazané varianty vždy, balíček jen tehdy, když by po odebrání mazaných položek zůstal prázdný (rekurzivně, i balíček v balíčku). Balíček s jinou položkou zůstane a přijde jen o položku mazaného produktu.

Měkké smazání produktu (ProductManager::softDelete()) nastaví deleted = 1 produktu, jeho variantám a jejich prodejním kartám (typeId = 1) v jedné transakci surovým SQL a pak sady přeindexuje ve vyhledávání. Balíčky, které takovou kartu obsahují, nechá beze změny; vyřadit ji ze složení musí admin ručně.

Varianty se neukládají grafem produktu

variants je v ProductManager::$defaultSkipFields. Vazbu varianty na skladovou kartu hlídají hooky ProductVariantManager a hooky dítěte se při uložení grafu rodiče nespustí. Varianty proto jdou vlastním Api api/eshop/product-variant; poslané v grafu produktu se zahodí, takže je ani prázdný seznam nesmaže.

Statistiky v administraci#

Grafy kreslí jedna sdílená komponenta StatisticsChartControl, kterou parametrizuje StatisticsChartConfig. Používá ji dashboard StatisticsPresenter (8 grafů), statistiky produktu (ProductStatisticsControl, 3 grafy) a statistiky importu (ProductImportPresenter, graf běhů s vlastním apiPath). Data bere komponenta přímo přes ApiBridge z akce <apiPath>/<apiAction>, výchozí api/eshop/statistics.

Režim Kdo drží rozsah Jak
vlastní filtr (showFilter: true) každý graf zvlášť #[Persistent] $from, $to, $granularity; prostý formulář bez AJAXu uloží hodnoty a udělá redirect('this'). Nette přidá do odkazu persistentní parametry všech sourozeneckých komponent, takže filtr jednoho grafu ostatní neresetuje
řízený zvenčí (showFilter: false) stránka nebo nadřazená komponenta rozsah přijde v extraParams; ty v fetchData() přebijí vlastní stav grafu (sjednocení + drží levý operand)

Postup: přidávám graf

  1. Do Api presenteru přidej internalAction<Action>(); parametry from, to a podle potřeby granularity.
  2. Na stránce založ StatisticsChartConfig s apiAction v kebab-case a unikátním key (odděluje persistentní parametry instancí).
  3. Graf, který má ukazovat totéž období jako zbytek stránky, dostane showFilter: false a rozsah v extraParams.

Klíče extraParams se předávají jako pojmenované argumenty

ApiBridge::internalCall() volá Api metodu s ...$params. Klíč, který se nejmenuje přesně jako parametr internalAction<Action>(), shodí volání chybou neznámého pojmenovaného argumentu.

Graf řízený zvenčí si nesmí doplnit vlastní rozsah

Vyplněná persistentní vlastnost se liší od výchozí hodnoty, takže by ji Nette přidalo do každého odkazu na stránce. setConfig() proto u showFilter: false výchozí rozsah nenastavuje. Rozsah stránky drží ProductStatisticsControl ve vlastních persistentních vlastnostech, ne presenter — tam by se propsal do odkazů gridu i editace.

Odkud grafy berou čísla#

Data počítají dvě služby surovým SQL (fetchAllAssociative(), žádné entity): OrderStatisticsService pro dashboard a ProductStatisticsService pro statistiky produktu. Koše osy X, popisky a převod na řady ChartJS sdílí StatisticsSeriesTrait.

Pravidlo Proč
ceny, počty a názvy položek se čtou ze snímkových tabulek objednávky (order_items, order_shippers, order_payment_methods) přecenění nebo přejmenování v katalogu nesmí zpětně měnit historii. Názvy dopravců, plateb a stavů jdou z aktuálních číselníků jen jako popisky osy
stornované objednávky (is_cancelled = 1) se z tržeb a počtů vylučují storno není prodej. Výjimkou je orderCounts(), který storna ukazuje jako vlastní řadu stavu
statistiky produktu počítají jen prodejní karty jeho variant (type_id = 1) order_items balíčku nesou id balíčku, rozpad prodeje na složky by byl odhad
smazané sady a varianty ve statistice produktu zůstávají s příznakem deleted jejich objednávky jsou skutečná tržba, skrytí by čísla tiše zkrátilo
tržba produktu je mapa zkratka měny => částka, řady grafu jsou sada × měna price_with_vat je v měně své objednávky; součet korun a eur nic neznamená
souhrnná tabulka produktu skládá zobrazení, prodeje a sady třemi dotazy v PHP jeden dotaz se dvěma LEFT JOIN na různě zrnitá 1:N by hodnoty vynásobil

Dashboard orderRevenue() má řady po měnách

Tržba a průměrná objednávka mají vlastní řadu pro každou měnu objednávek (klíč revenue.<currencyId>, popisek se zkratkou měny, např. revenue Kč). Částky se nepřepočítávají: snímek objednávky nenese kurz platný v den objednávky a dnešní exchange_rate by přepsal minulé tržby. E-shop s jednou měnou vidí dvě řady jako dřív.

Tržby dopravců, plateb a nejprodávanějších položek jsou po měnách taky

shipperRevenue() a paymentMethodRevenue() mají řadu pro každý tarif nebo metodu a měnu objednávky (klíč <id>.<currencyId>, popisek např. DPD Kč). topProducts() nechá jednu řadu množství a tržbu rozdělí na revenue.<currencyId>. Zkratky měn dodává StatisticsPresenter přes CurrencyManager, stejně jako u orderRevenue().

Klíč koše musí vyjít stejně v SQL i v PHP

SQL ho počítá přes DATE_FORMAT (StatisticsSeriesTrait::bucketExpr(), týden %x-%v), osu staví StatisticsBucketLabels::build() (týden o-W), kterou používají i statistiky importu. Když se rozejdou, řada se do osy netrefí a graf je prázdný bez jediné chyby. Novou granularitu přidávej do StatisticsSeriesTrait::BUCKETS i do StatisticsBucketLabels naráz.

Košík a objednávka na frontu#

Nákup na webu obsluhují tři komponenty. Všechny injektují front CartManager (app/UI/Front/Eshop/Models/Managers/), který je jedinou viditelnou vrstvou modelu košíku; CartService, mappery a repository za ním nikdo jiný nevolá.

Komponenta Kde je Co dělá
Cart stránka košíku (Front:Eshop:Basket) počty, hromadné odebrání, slevový kód, pokračování do objednávky
CartWidget eshopová hlavička na každé stránce odznak s počtem kusů a rozbalovací mini košík
Checkout kroky objednávky (Front:Eshop:Order, parametr step) adresa a region, doprava a platba, rekapitulace a závazné odeslání

V úložišti košíku (session nebo DB, viz Košík) je jen setId, počet, kódy voucherů a texty od zákazníka. Ceny počítá vždy server: velký i mini košík a kroky 1–3 volají api/eshop/order/checkout s persist = false (CheckoutManager::preview(), jednou za request), závazně se ukládá až v kroku 3 s persist = true. Jediné násobení na frontu (bcmul ceny řádku) bere cenu za kus z odpovědi Api.

Vypnutý nákup: 4/catalog.only#

S hodnotou 1 běží e-shop jen jako katalog. CatalogOnlyGuardTrait::guardCatalogOnly() vrací u košíku a objednávky 404 (ne přesměrování, aby se stránky neindexovaly), signál addToCart! a signály mini košíku také 404 a CartWidget vykreslí jen prázdnou kotvu snippetu. Šablony skrývají „Do košíku“ podle proměnné $onlyCatalog.

Karta produktu potřebuje $onlyCatalog a $allowOutOfStockOrder

Obě proměnné plní ProductSetPresenter. Komponenta, která kartu (Templates/ProductSet/Items/_item.latte) vykresluje jinde, je musí nastavit sama, jako LastProductList. Jinak ladicí režim spadne na „Undefined variable“ a produkce kartu tiše ukáže jako u zapnutého nákupu.

Mini košík v hlavičce#

Panel mini košíku se plní až po prvním otevření. render() bez otevřeného panelu Api nevolá; obsah dotáhne signál loadDetail! (skript tématu assets/js/components/mini-cart.js). Přepočet košíku je plné ocenění na serveru a nesmí běžet při každém načtení každé stránky.

Snippet Co obsahuje Kdo ho překresluje
cartWidget celý widget „Do košíku“ z katalogu (AddToCartTrait, LastProductList)
cartCount jen číslo uvnitř odkazu signály panelu (increase!, decrease!, remove!, loadDetail!)
cartDetail obsah panelu signály panelu

Bez JS se panel neotevře, odznak je obyčejný odkaz do košíku a signály skončí přesměrováním do košíku.

Ze signálů panelu nepřekresluj cartWidget

Bootstrap drží instanci dropdownu na elementu přepínače. Výměna cartWidget pod otevřeným panelem ho vymění taky a panel se zákazníkovi pod rukama zavře. Ze stejného důvodu má panel data-bs-auto-close="outside".

Stránka košíku#

Změna počtu odešle formulář sama (data-autoupdate="submit", spouští jen prvky s data-autoupdate-trigger, takže zaškrtávátka řádků nic nepošlou). Tlačítko, které skript použije, je označené data-autoupdate-submitter (recalculate). Hodnoty se čtou z kontrolek, ne přes getValues(): pomocná tlačítka mají setValidationScope([]) (viz Formuláře).

Pokračování do objednávky zastaví blockedSetIds() s konkrétní hláškou: zásoba nepokryje požadovaný počet, sadu nejde objednat vůbec, nebo sada nemá cenu v měně košíku (to ví jen náhled Api, unpricedItems). Striktní rezervace by objednávku odmítla až při odeslání a bez vysvětlení, které zboží za to může.

Formulář košíku nepřepínej na naja

Košík nemá snippety a onSuccess končí redirect('this'). Naja by přesměrování následovala AJAXem, dostala HTML místo JSON (SyntaxError v konzoli) a nepřekreslila nic, ani souhrn, ani odznak v hlavičce. Nativní odeslání nechá přesměrování na prohlížeči.

Kroky objednávky#

Krok Formuláře Na co pozor
1 regionForm, addressForm typ zákazníka odvodí server ze zaškrtávátek „Nakupuji na firmu“ a „Jsem plátce DPH“ (OrderCheckoutManager::resolveUserTypeId()); délku IČ a DIČ hlídá sloupec dokladu (ColumnLimits::length(Invoice::class, …)), aby příliš dlouhá hodnota neshodila až vystavení dokladu
2 shippingForm položky radiolistů se staví z náhledu pro tarif z POSTu (Checkout::getSelectedTariffId()); metoda s available = false se nenabízí
3 summaryForm jednorázový token; doprava, platba a provozovna se berou jen ze session

Kroky se drží v rozpracovaném checkoutu (CartManager::mergeCheckout()). Krok je dostupný, jen když jsou vyplněné všechny předchozí (Checkout::isStepReachable()).

Region (krok 1). Předvolený je region lokalizace. Volba se ukládá spolu s lokalizací, na které vznikla; po přepnutí jazyka webu se zahodí (CheckoutRegionTrait, viz Ceny a měny). Při skutečné změně regionu onRegionSuccess() vymaže tariffId, paymentMethodId, branchId a pickupPoint: ceny dopravy a plateb jsou per měna a v novém regionu nemusí existovat.

Doprava a platba (krok 2). Formulář jede v data-autoupdate (jen označené prvky), změna dopravy překreslí nabídku plateb i souhrn. Bez JS projde obojí jedním odesláním, proto se položky plateb staví z tarifu v POSTu.

Položky radiolistů nestav ze session

Zákazník bez JS zvolí jiného dopravce i platbu najednou. Položky postavené z předchozího tarifu by odeslanou platbu neobsahovaly a Nette by ji jako neplatnou zahodil.

Skrytá pole výdejního místa vykresluj vždy

pickupPoint (surový JSON z widgetu dopravce) a pickupPointLabel musí být v šabloně i pro tarif bez výdejního místa, jinak se hodnota smaže. pickupPoint navíc potřebuje data-autoupdate-trigger; bez něj autoupdate change z widgetu zahodí, snippet checkoutStep se nepřekreslí a karta vybraného místa se neukáže. Chybějící místo hlásí krok 2 flash zprávou pickupPointRequired, ne addError(): chyba skrytého pole je formulářová a krok 2 formulářové chyby nevykresluje.

Závazné odeslání a token#

CartManager::issueToken() vydá token při zobrazení kroku 3, formulář ho nese ve skrytém poli.

  1. consumeToken() token spálí. Když už spálený je (dvojklik, F5, tlačítko zpět), přesměruje se na objednávku, kterou vyrobilo odeslání s tímhle tokenem (orderForToken(), mapa posledních TOKEN_ORDER_HISTORY = 5 tokenů). Když žádná nevznikla, zákazník jde do košíku s hláškou o vypršení.
  2. Teprve pak kontrola prázdného košíku (vydá nový token).
  3. CheckoutManager::place() s buildPayload(bindingSubmit: true).
  4. Úspěch: registerOrder() (vlastnictví pro hosta), rememberOrderForToken(), clearAfterOrder(); u platby přes bránu přesměrování na Front:System:Payment:show, jinak na návratovku Front:Eshop:OrderDone.
  5. Neúspěch: nový token a přesměrování podle errorCode.

Souběžné requesty téže session serializuje zámek PHP session, takže dvojklik oběma větvemi neprojde. Token i vlastnictví objednávek jsou v session i při storage: db.

Token vyhodnocuj před kontrolou prázdného košíku

Po úspěšné objednávce je košík vždy prázdný. Obrácené pořadí by nejčastější případ (F5 po dokončeném nákupu) poslalo na hlášku „prázdný košík“ místo vzniklé objednávky.

errorCode Kam se zákazník vrátí
insufficientStock do košíku, hláška vyjmenuje zboží z shortItems
shippingInvalid, shippingRequired, paymentInvalid, paymentRequired, pickupPointRequired krok 2
paymentRegionInvalid krok 1
voucherInvalid, emptyCart, itemsInvalid, noPriceInCurrency do košíku
ostatní (sellerRequired, nenapojený callback platby…) zůstane v kroku 3, obecná hláška bez technických detailů

Hlášku skládá CheckoutErrorMessage::resolve() z errorCode (u voucherů z reason a params) jako překladový klíč front.eshop.checkoutErrors.*. Api message je česká věta pro log a do jazyka webu nepatří.

Postup: přidávám nový chybový kód checkoutu

  1. Api vrátí kód v errorCode obálky Result.
  2. Doplň ho do CheckoutErrorMessage::CODES a klíč do překladů (app/Locale/eshop/*/front.*.neon). Neznámý kód spadne na obecnou hlášku failed.
  3. Má-li vést na konkrétní krok, přidej větev do SummaryFormTrait::handlePlaceFailure().

Katalog na frontu#

Co je vidět#

Viditelnost sady má jediné místo, ProductSetEligibilityFilters:

Metoda Kdo ji používá Podmínky
build(productsOnly: true) výpis, facety, varianty na detailu, LastProductList, sitemapa nesmazaná, aktivní, uvnitř okna publishTime/unpublishTime, typeId = 1 a vlastnící produkt nesmazaný a aktivní (karta bez produktu projde)
isEligible($set) guard detailu (404) totéž bez podmínky na typ, u typeId = 1 stav načteného produktu; detail balíčku je platný

Nový výpis produktů nestav z vlastních filtrů

Vlastní sada podmínek se s výpisem /eshop rozejde při první změně pravidel a na hlavní stranu prosákne nepublikovaný nebo deaktivovaný produkt.

Cena a dostupnost na kartě#

Cena ani dostupnost nejsou sloupce entity. findAll() vrátí sady s prázdnou cenou a karta spadne do „cena na dotaz“, dokud je někdo nedoplní dávkou: BatchDecorationTrait::decorateWithPriceAndAvailability() v presenteru, LastProductList vlastní decorateWithPrices(). Na celý výpis jsou to dvě bridge volání (current-prices, availability, viz Katalogová data přes bridge).

Na entitu se ukládá cena k zobrazení: základ, nebo základ × (1 + sazba) podle withVat každého řádku fetchCurrentPrices() (PriceDisplayHelper). currentPriceWithoutVat nese vždy základ. Šablony (buy-box.latte, Items/_item.latte) nic nepřepočítávají. Poplatky sady na detailu (resolveFeeCurrentPrices()) berou sazbu a chargeVat sady, ne vlastní sazbu z ceníku poplatku.

Jestli se sada smí objednat, rozhoduje ProductSet::isOrderable() (pro surová data isAvailabilityOrderable(), s počtem kusů isQuantityCovered()). Čtou ho šablony, signál addToCart! i košík:

availableSets Výsledek
null (varianta bez skladové karty, nerozpadnutelný balíček, neznámá sada) nikdy, ani se zapnutým 4/order.outOfStock.allowed; výpis ukáže „Dostupnost na dotaz“
<= 0 jen se zapnutým 4/order.outOfStock.allowed
> 0 ano; počet kusů musí availableSets pokrýt, pokud prodej bez zásoby není povolený

Schované tlačítko není brána

Odkaz ?do=addToCart&setId=… si složí kdokoli. handleAddToCart() proto ověřuje objednatelnost znovu přes fetchAvailability(); nový signál, který přidává do košíku, musí dělat totéž.

Postup: vypisuji produkty mimo /eshop#

Vzorem je LastProductList ({control frontEshopLastProductList}, :grid, 8, :deal, :news, :recommended).

  1. Filtry z ProductSetEligibilityFilters::build(true), includes shodné s ProductSetListTrait::LIST_INCLUDES (bez ratingThread by hodnocení na kartě dělalo N+1).
  2. Řazení s druhým klíčem id: produkty z hromadného vložení sdílejí insertTime a samotné řazení podle něj s limitem vrací pokaždé jiné řádky. Pole se jmenuje insertTime, ne insertDatetime jako u inzerátů.
  3. Dekorace cenou a dostupností (viz výš) a warmup ProductSetNameTranslator z načtených entit, jinak každý odkaz na detail stojí 3 dotazy.
  4. $onlyCatalog a $allowOutOfStockOrder do šablony.
  5. Vlastní handleAddToCart() v komponentě.
  6. Komponenta bez překladové domény: karta překládá plnými klíči (front.eshop.…).

Bez vlastního handleAddToCart() karta nesestaví odkaz

{link addToCart!} na kartě se váže na komponentu, která kartu vykresluje. V presenteru výpisu signál dodá AddToCartTrait, v komponentě ne: produkce dá href="#error", ladicí režim výjimku „Unknown signal 'addToCart'“.

„Akce týdne“ (renderDeal($lookback = 12)) vybírá podle štítku s saleFlag, ne podle slevy. Kandidáti jsou nejnovější sady s takovým štítkem (DB filtr tags.saleFlag = 1, distinct fáze dotazu brání duplicitám). Přednost má první s naplánovanou budoucí cenou (fetchNextPriceTimes(), jen ta má odpočet), jinak první kandidát; bez kandidáta je items = [] a šablona sekci schová.

Sekce „Novinky“ a „Doporučujeme“ jsou varianty téže komponenty řízené příznakem štítku, ne datem nebo ručním výběrem: {control frontEshopLastProductList:news, 4} bere nejnovější sady se štítkem ProductTag::$newFlag, :recommended se štítkem $recommendedFlag (DB filtr tags.newFlag / tags.recommendedFlag). Bez takového produktu se mřížka nevykreslí. Na úvodu /eshop je sdílená list.latte ukazuje nad výpisem jen tehdy, když ProductSetListTrait nastaví $showcase: první stránka, bez kategorie, bez hledání a bez filtrů parametrů. Hlavní strany šablon berou tytéž varianty; styl sekce je modules/eshop/_product_showcase.scss.

Drobečky, meta a hledací box#

Co Pravidlo
drobečky (Breadcrumb) výpis = page trail + trail kategorie, detail navíc název sady. popCurrentPage() se volá, jen když je modulový trail neprázdný; výpis bez kategorie (/eshop, hledání) by jinak přišel o položku „Eshop“. Balíček nemá produkt, a tedy ani kategorii: trail je jen název sady
meta detailu (MetaDataTrait) ProductSetText nemá meta pole, meta se skládá z názvu a popisu sady do $metaData DTO
hledací box (SearchBox) jen styl Mini; kategorie a parametry jsou v levém panelu. Odkaz na výpis nese vždy page = 1 a order = 0 (sentinel „řazení nezvoleno“, maska routy 200 …[/p-<page=1>o-<order=0>]); jiné order vypne relevanci z Elasticsearch
počty u kategorií (ProductCategoryList) jen na vyžádání argumentem withCount ({multicontrol frontEshopProductCategoryList-tiles:Tile null, true}); podstrom se sčítá z vlastních počtů jedním bridge voláním

{define metaTitle} s getMetaTitle() na textu sady stránku shodí

Metoda na ProductSetText neexistuje a ?-> chrání jen před null, ne před chybějící metodou. První volání z layoutu skončí „Call to undefined method“.

Počet u kategorie je orientační

Produkt zařazený do víc kategorií téhož podstromu se sečte vícekrát. Stránkovaný výpis počítá přes DISTINCT, takže číslo na dlaždici se s ním nemusí shodovat.

Filtry a hledání#

Facety mají vlastní pravidla: počty u voleb se zužují vším kromě vlastní facety, jinak by výběr druhé hodnoty téhož filtru ukazoval nulu. Katalog je zároveň 5. zdrojem Elasticsearch — s SQL fallbackem, když ES neběží.

Rozsahový filtr potřebuje round-trip přes adresu

Bez něj se hodnota ztratí mezi požadavky a filtr tiše nefiltruje — vrátí všechno a tváří se, že je nastavený.

Počty u voleb facet#

Počty („Sony 3“) a seznam parametrů, které se ve výpisu vyskytují, vrací ProductSetManager::fetchParameterValueCounts($filters) (bridge product-set/parameter-counts). Vstupem jsou tytéž filtry, jaké staví výpis (ProductSetEligibilityFilters, kategorie, aktivní facety); manager si podle nich dohledá id viditelných sad a teprve nad nimi počítá. Agregaci dělá Elasticsearch; bez platné odpovědi (zdroj vypnutý, výpadek, neúplné buckety) ji tiše převezme SQL, které je vždy přesné.

SQL agregace jde přes generovaný sloupec value_h (MD5(value)) a složený index ix_facet (parameter_id, value_h, product_set_id): odvozená tabulka seskupí jen z indexu a čitelnou hodnotu pro každou skupinu dohledá korelovaný poddotaz s LIMIT 1. Prázdný řetězec se vyřadí v poddotazu (value <> ''), protože MD5('') není NULL.

Pravidla viditelnosti nepiš do SQL agregace

Vlastní kopie podmínek by se s výpisem tiše rozešla při první změně a počty by neseděly s tím, co zákazník po kliknutí uvidí.

Dotaz nerozděluj na dva

Druhý dotaz s dvojicemi (parameter_id, value_h) IN (…) je pomalejší: nad zhruba stovkou dvojic MariaDB index zahodí a projde celou tabulku a další síťový round-trip stojí víc, než první krok ušetří. Ani MIN(value) v jednom dotazu nepomůže, vrací index zpět do řádků a ztratí seskupení jen z indexu.

value_h není v Doctrine entitě

Je to generovaný sloupec a ORM by se do něj pokusilo zapisovat. Filtry ParameterRelFilter / ParameterRelRangeFilter ho nepoužívají, jedou přes value.

Dokument prodejní karty v Elasticsearch#

Zdroj ProductSetDocumentSource (klíč eshop_product_set) indexuje jeden dokument na sadu a jazyk do indexu eshop_product_set_{alias}. Obecný kontrakt zdroje je v Elasticsearch, kapitola „Zdroj dokumentů“.

Co Jak
co se indexuje jen prodejní karty (typeId = 1), nesmazané a aktivní, s aktivním překladem. Balíček, smazaná a vypnutá karta = prázdný build = smazání z indexu
hledaná pole name, longName, shortDescription, description (HTML převedené na text), každé i s .folded; zrcadlí MySQL fallback FulltextFilter
úryvky jen name, karta ve výpisu nic jiného nevypisuje
parametry pro facety params.<parameterId> (keyword přes dynamic template) a pv s dvojicemi <parameterId>:<value>; syrové value z parameterRels, vynechané jen NULL a '', stejně jako v SQL agregaci. value2 se neindexuje, rozsahy filtruje databáze
publikační okno publishTime / unpublishTime jsou v dokumentu jen jako pole a předzužují dotaz při hledání (getVisibilityFilters()); podmínkou buildu nejsou
viditelnost produktu v dokumentu není (jen productId), hlídá ji databáze

Cena v dokumentu není a bez cronu tam být nesmí

Platná cena se mění nástupem published_time bez jakéhokoli zápisu, hook o tom neví. Řazení a filtr podle ceny proto zůstávají v databázi. Kdo cenu do dokumentu přidá, musí přidat i spuštění reindexu k nejbližšímu budoucímu published_time, jinak index tiše zestárne.

Publikační okno nepatří do buildDocuments() ani do expectedDocumentBounds()

Mění se časem, ne zápisem. Jako podmínka buildu by naplánovanou kartu dostal do indexu až noční reindex a monitoring by hlásil falešný drift pokaždé, když nějaká karta okno projde. Fasetové počty (EsSearcher::facetCounts()) předzúžení nečtou: dostávají id viditelných sad už vybraná databází.

Parametry v dokumentu nenormalizuj

ES agregace nahrazuje SQL a musí s ní souhlasit 1:1. Ořez, převod velikosti písmen nebo čištění osiřelých vazeb jen v dokumentu by vrátily jiné hodnoty, než podle kterých filtruje databáze (ParameterRelFilter jede přes syrové value): volba facety by ukázala jiný počet, než kolik sad pak výpis najde, a záleželo by na tom, jestli zrovna odpovídá ES, nebo SQL.

Kam sáhnout#

Chci Kde
vznik objednávky z košíku app/UI/Api/Eshop/Models/Managers/OrderCheckoutManager.php
změnu stavu app/UI/Api/Eshop/Models/Managers/OrderTransitionManager.php
přepočet částek app/UI/Api/Eshop/Models/Helpers/OrderCalculator.php
sestavení řádků ze serveru app/UI/Api/Eshop/Models/Services/OrderSnapshotBuilder.php
fakturu z objednávky app/UI/Api/Eshop/Models/Generators/OrderInvoiceGenerator.php
skladové efekty app/UI/Api/Eshop/Models/Managers/OrderStockManager.php
hooky uložení a mazání objednávky, vlastnictví app/UI/Api/Eshop/Models/Managers/OrderManager.php
stav a přechody importu app/UI/Api/Eshop/Models/Managers/ProductImportManager.php
ceny dopravy a příplatky plateb app/UI/Api/Eshop/Models/Services/ShippingPriceResolver.php, PaymentPriceResolver.php
ochranu historie ceníku tarifu app/UI/Api/Eshop/Models/Managers/ShipperTariffManager.php (beforeSave())
poplatky a vouchery v administraci app/UI/Admin/Eshop/Presenters/Traits/Forms/ProductSetFeeFormTrait.php, VoucherFormTrait.php
rozpad balíčku na skladové karty app/UI/Api/Eshop/Models/Services/SetCompositionService.php
editaci sady a varianty app/UI/Admin/Eshop/Components/ProductSetEditComponent/
ruční hodnoty feedu u sady app/UI/Admin/Eshop/Components/ProductSetEditComponent/Traits/FeedFieldsTrait.php
režimy hodnot ve feedu app/UI/Api/Eshop/Models/Generators/FeedXmlGenerator.php
formulář objednávky v administraci app/UI/Admin/Eshop/Presenters/Traits/Forms/OrderFormTrait.php, OrderStatusChangeFormTrait.php
validaci sady, galerii a cenové snímky app/UI/Api/Eshop/Models/Managers/ProductSetManager.php (beforeSave(), processImages(), savePriceSnapshot())
stavbu objednávky z formuláře administrace app/UI/Api/Eshop/Models/Managers/Traits/OrderBuildTrait.php
expiraci a dohánění dokladů z cronu app/UI/Api/Eshop/Models/Managers/Traits/OrderCronTrait.php
přílohy reklamace app/UI/Api/Eshop/Models/Managers/Traits/ComplaintManager/ComplaintFileTrait.php
grafy statistik app/UI/Admin/Eshop/Components/StatisticsChartComponent/
nahrání importu app/UI/Admin/Eshop/Presenters/Traits/Forms/ProductImportAddFormTrait.php
zápis importu app/UI/Api/Eshop/Models/Import/ImportExecutor.php, ImportProductWriter.php, ImportBundleWriter.php
pravidla a meze importu app/UI/Api/Eshop/Models/Import/ImportRules.php
číslo objednávky app/UI/Api/Eshop/Models/Generators/OrderNumberGenerator.php
e-maily objednávky a reklamace app/UI/Api/Eshop/Models/Generators/OrderEmailGenerator.php, ComplaintEmailGenerator.php
drivery výdejních míst app/UI/Api/Eshop/Carriers/
export katalogu app/UI/Api/Eshop/Models/Generators/ProductXmlExporter.php
cron úlohy app/UI/Cron/Eshop/Presenters/
trvalý košík a jeho slučování app/UI/Api/Eshop/Models/Managers/CartManager.php, volba úložiště config/Shared/eshopCart.neon
zaplacení objednávky z brány app/UI/Api/Eshop/Models/Managers/EshopPaymentHook.php
přechod stavu a vratku reklamace app/UI/Api/Eshop/Models/Managers/ComplaintTransitionManager.php, ComplaintStockManager.php
dílčí dobropis app/UI/Api/Eshop/Models/Managers/OrderCreditNoteManager.php
strom parametrů a ruční hodnoty feedu app/UI/Api/Eshop/Models/Managers/FeedManager.php
uplatnění voucheru na objednávce app/UI/Api/Eshop/Models/Services/VoucherRedemptionService.php
čísla grafů statistik app/UI/Api/Eshop/Models/Services/OrderStatisticsService.php, ProductStatisticsService.php, Traits/StatisticsSeriesTrait.php
dokument karty v Elasticsearch app/UI/Api/Eshop/Models/Search/ProductSetDocumentSource.php
košík, mini košík a kroky objednávky na webu app/UI/Front/Eshop/Components/CartComponent/, CartWidgetComponent/, CheckoutComponent/, společné Components/Traits/CartPreviewTrait.php
token, vlastnictví objednávek a rozpracovaný checkout na webu app/UI/Front/Eshop/Models/Managers/CartManager.php
hlášky chyb checkoutu app/UI/Front/Eshop/Models/Helpers/CheckoutErrorMessage.php
viditelnost a objednatelnost sady na webu app/UI/Front/Eshop/Models/Filters/ProductSetEligibilityFilters.php, Models/Entities/ProductSet.php (isOrderable())
cenu k zobrazení v katalogu app/UI/Front/Eshop/Presenters/Traits/Inits/BatchDecorationTrait.php, Models/Helpers/PriceDisplayHelper.php
výpis produktů mimo e-shop app/UI/Front/Eshop/Components/LastProductListComponent/

Navazující kapitoly: Sklad · Fakturace · Elasticsearch · Filtry · Naplánované úlohy

Nastavení cookies