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) |
- 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. - 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 jereserve()po commitu. - Chybějící prodejce je tvrdá chyba. Bez nastavení
4/invoicer.sellerIdobjednávka nevznikne — zákazník dostane srozumitelnou hlášku, technický detail jde do Tracy kanálueshop.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:
- Kontrola napojení platby. U metody s platební bránou ověří
OrderPaymentManager::checkCallbackWiring(), že callbackESHOP_ORDERmíří naEshopPaymentHook. Když ne, objednávka nevznikne. - Snímek (
buildOrder()): nejdřív kartotéka zákazníka (OrderCustomerManager), pak položky, doprava, platba a prodejce. - Kontrola zásoby před zápisem (
checkStrictStockGate(), kódinsufficientStock). Nastavení4/order.outOfStock.alloweduvolní jen nedostatek zásoby; položku bez skladové karty odmítne vždycky. - Uložení; rezervaci udělá
completedSavepo 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 dostanestockReserveFailed. Košík mu zůstane. - Platba se založí až teď a jen u metody s bránou — platba bez objednávky nevznikne.
- Přechod stavu mimo transakci (
apply()vnořenou odmítne). Když selže, objednávka zůstane vCREATEDa zákazník dostane varovánístatusTransitionFailed. - Objednávka, která zůstala v
CREATED, žádným přechodem neprošla. DokladORDERa potvrzovací e-mail jí proto vystaví a zařadí přímogenerateOrderDocumentForCreatedOrder(). 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.
- Stav nese příznaky akcí, které se při přechodu provedou — potvrdit sklad, stornovat rezervaci, vystavit fakturu, odeslat e-mail.
- 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. - 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
INSERTdocms_system_email_queue. Vrácený přechod vezme e-maily s sebou, odešle je až cron fronty. - Opakovaný přechod po pádu nevyrobí druhou sadu efektů. Hlídá to pět vrstev: zámek,
SourceRefve 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_updateda 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:
- 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á.
- Poslední voucher nese zaokrouhlovací rozdíl, takže součet řádků se rovná slevě v totálu.
- 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áž
maxVatjako v totálu). - 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.
- 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
- 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. - Postav položky. Vrácené
totalWeight(Σ váha × počet) je vstup výběru pásma dopravy. - 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. - 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í
OrderTaxManagerhned po stavbě. Důvěryhodná cesta bere sazbu z řádku a cenu s DPH dopočítá podlewithVatobjednávky (u neplátce je efektivní sazba 0 apriceWithVat*=price*), takžewithVatmusí 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:
- kód existuje a má
available = 1, start <= $at <= end,- limit
maxNumber > 0 && currentNumber >= maxNumber= vyčerpaný (0 = neomezeně); je to jen předběžná kontrola, pod souběhem rozhodne atomickéVoucherManager::incrementUsage(), - voucher má řádek ceny pro měnu objednávky,
- hodnota zboží s DPH po slevách leží v
[minPrice, maxPrice]toho řádku, - 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#
- 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ýmidmusí patřit editovanému tarifu, jinak uložení odmítne i budoucí řádek. - Upravovat jde jen generace od dnešní půlnoci dál a aktuálně platnou generaci každé řady.
- Vypnutí pásma nebo metody je nová generace s
active = 0/available = 0, ne smazání řádku — historie zůstává auditovatelná. - 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ýNULLjako jinou hodnotu. - Provozovny (
$branches,ShipperTariffBranch) se ukládají zvlášť (saveBranch()/deleteBranch()), ale kolekce musí být vEDIT_INCLUDEStarifu.
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
- Založ třídu
…Carrier extends AbstractCarriers unikátnímgetScriptName()(sloupecpickup_carriermá 45 znaků) agetLabel()pro select v administraci. - V
getWidgetConfig()vraťnull, chybí-li klíč, a jinakCarrierWidgetConfigs jedním ze tří mechanismů; jiný mechanismus front neumí. - V
normalizePickupPoint()vytáhni z payloadu jen pole s jasným významem a předej jebuildPickupPoint(). Nové DTO ve složceDtovyřaď zsearchvconfig/common.neon. - 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
- Vrať failure
Results['errorCode' => '<kód>']. - Pomůže-li opakování (výpadek, zámek), nic dalšího nedělej: cron
order/invoiceobjednávku zkusí znovu s odstupem. - Nepomůže-li, přidej kód do
OrderInvoiceGenerator::PERMANENT_ERROR_CODES. Cron pak posune watermarkinvoiced_statusa 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()
- 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.
- Ověří přechodovou politiku: reklamace se skladovou vratkou (
stock_document_id) smí jen do stavu sreturnStock = 1. Jinam odmítne kódemstockAlreadyReturned. - Vyrenderuje e-mail (mimo transakci).
- Má-li cílový stav
returnStock, provede vratku. Ta je blokující: chybějící4/complaint.returnWarehouseIdnebo neúspěšný příjem přechod odmítne. - V jedné transakci zapíše stav a vloží e-mail do fronty.
- Selže-li krok 5 po vratce, kterou vytvořil právě tento přechod, vratka se stornuje
protidokladem (
compensateReturn()) astock_document_idse 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.
| Š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
- Uloží kartu s
activated = 0, pak varianty a sady (activated = 0). - Po uložení sady zapíše ceny, sklad a obrázky — potřebují existující id.
- Až nakonec aktivuje sady a kartu podle souboru (výchozí 1).
- 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(sha256adresy) z předchozích běhů. Nový obrázek jde base64 kanálem, asset založí manager vafterSave()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())
- Uloží se varianta, pak sada (
typeId = 1) s odkazem na ni. - 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.
- Vyplněná matice z kroku 2 se zapíše jako první snímek (
publishedTime = null= teď). - Variantě se založí skladová karta (
ProductVariantManager::foundStockItemCard()) a podle tabulky z kroku 3 se naskladní. Obojí jen s právy:Admin:Store:StockDocument:adda:Admin:Store:StockItem:add; bez nich se krok přeskočí a admin dostane varování. - Hostitel přepne aktivní klíč (
ProductPresenter::$activeSetEditVariantId) na nové id a překreslí snippetsetEditHost.
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í.
- Nový snímek:
savePriceSnapshot($setId, $publishedTime, $rows).$publishedTimeje čas, jak ho zadal admin, v pražském čase;nullnebo prázdný řetězec znamená „teď“. Uloží se převedený na UTC. - Úprava snímku: navíc
replaceTime=publishedTimepůvodního snímku tak, jak přišel z výpisu (už v UTC). Jeho řádky se vyjmou a nahradí novými. - Řádek je
fromAmount,userGroupId,currencyId,price,originalPrice,points. Řádek bez číselné ceny se vynechá, duplicitní kombinace hranice × skupina × měna taky (vyhraje první). - Smazání celého snímku:
deletePriceSnapshot($setId, $publishedTime)s časem z výpisu. - Kontroluj
isSuccess()a kód vdata['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())
- Každý parametr je multiplier
feedMulti_<id>, jedna kopie = jedno opakování uzlu v XML. Neopakovaný parametr má jednu kopii bez tlačítek. - 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.
- 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í.
- Režim jiný než
changebez vyplněné hodnoty dostane nosný řádek s prázdnou hodnotou. - Režim má jen kořenový uzel; děti se ukládají s
changea generátor jejich režim nečte. U neopakovaného uzlu s dětmi seprepend/appendchová jakochange.
Řá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
- Přidej klíč a closure
fn(ProductSet $set, array $ctx)doactionMap(). Vrací skalár, nebo seznam pro opakovaný uzel. - Všechno, na co closure sáhne, dej do
FEED_INCLUDES. - Klíč zapiš do
cms_mod_eshop_feed_parameter_actions(sloupecaction), jinak ho admin nevybere; neznámý klíč se chová jakoinput(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
- 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. - Platnou cenu porovnávej s posledním řádkem s
publishedDatetime <= NOW(); při shodě nový snímek nevytvářej. - 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. currentNumbervoucheru 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
- Hodnotu dej do payloadu v
buildOrderPayload()— ten sdílí náhled i uložení. - Pomocné AJAX tlačítko dostane
setValidationScope([])a obsluha čte hodnoty přesorderControlValues(), nikdy přesgetValues()(důvod v Formulářích). - 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é. - Pole předvyplňované z asociace objednávky „na jednoho“ potřebuje tu asociaci v
includesvOrderPresenter::actionEdit(). - 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()
- Odmítne nekanonická id v payloadu (
findInvalidIdPath()). - Spočítá
buyerTypea vazbu na kartotéku. Obojí volá Api managery (UserTypeManager::find(),OrderCustomerManager), proto před načtením objednávky. - Načte objednávku (
find()s položkami, dopravou, platbou a platbami) nebo založí novou. - Přepíše snímky, sestaví položky, dopravu a platbu (
OrderSnapshotBuilder). - Výdejní místo zapíše
OrderPickupPointApplieraž pobuildShipperFromRow(), protože píše do téhož snímkuOrderShipper, který builder právě přestavěl. - Uplatní vouchery a dárky a přepočítá totály (
OrderCalculator::recalculate()). Připersist = falsetady skončí a vrátí náhled. - Při
persist = truea 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, kterourereserve()uvolní až po uložení. - 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
- Do Api presenteru přidej
internalAction<Action>(); parametryfrom,toa podle potřebygranularity. - Na stránce založ
StatisticsChartConfigsapiActionv kebab-case a unikátnímkey(odděluje persistentní parametry instancí). - Graf, který má ukazovat totéž období jako zbytek stránky, dostane
showFilter: falsea rozsah vextraParams.
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.
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íchTOKEN_ORDER_HISTORY= 5 tokenů). Když žádná nevznikla, zákazník jde do košíku s hláškou o vypršení.- Teprve pak kontrola prázdného košíku (vydá nový token).
CheckoutManager::place()sbuildPayload(bindingSubmit: true).- Úspěch:
registerOrder()(vlastnictví pro hosta),rememberOrderForToken(),clearAfterOrder(); u platby přes bránu přesměrování naFront:System:Payment:show, jinak na návratovkuFront:Eshop:OrderDone. - 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
- Api vrátí kód v
errorCodeobálkyResult. - Doplň ho do
CheckoutErrorMessage::CODESa klíč do překladů (app/Locale/eshop/*/front.*.neon). Neznámý kód spadne na obecnou hláškufailed. - 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).
- Filtry z
ProductSetEligibilityFilters::build(true), includes shodné sProductSetListTrait::LIST_INCLUDES(bezratingThreadby hodnocení na kartě dělalo N+1). - Řazení s druhým klíčem
id: produkty z hromadného vložení sdílejíinsertTimea samotné řazení podle něj s limitem vrací pokaždé jiné řádky. Pole se jmenujeinsertTime, neinsertDatetimejako u inzerátů. - Dekorace cenou a dostupností (viz výš) a warmup
ProductSetNameTranslatorz načtených entit, jinak každý odkaz na detail stojí 3 dotazy. $onlyCataloga$allowOutOfStockOrderdo šablony.- Vlastní
handleAddToCart()v komponentě. - 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