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, sklad a čárový kód
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.
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.
Ceník se needituje, přidává se nový snímek
Změna staré ceny přepíše historii — a přepočet staré objednávky pak vyjde jinak, než jak byla zaplacená.
Naplánovaná cena potřebuje cron
/cron/eshop/price/recalc běží à 15 minut. Delší interval znamená, že se sleva
spustí později, než měla — a nikdo to nehlásí.
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, říkají dva příznaky prodejce Invoiceru
(cms_mod_invoicer_sellers): use_vat (plátcovství) a show_prices_with_vat
(zobrazovací přepínač). Dopočítává se jen u plátce se zapnutým přepínačem;
PriceDisplayHelper::vatNoteMode() vrací null pro neplátce — pak se o DPH v šabloně
nesmí zmínit vůbec.
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.
Denormalizovaný sloupec product_sets.price je ZÁKLAD
Řazení ceny (o-2/o-3) i všechno, co nad tím sloupcem filtruje, pracuje se
základem — ne s číslem, které vidí zákazník. Při jediné sazbě je pořadí stejné, při
míchaných sazbách se rozejde.
Starší snímky objednávek se PD nepřepočítaly
Uložená čísla se jen reinterpretovala. Snímky jsou immutable, takže objednávky z doby před etapou PD nejsou dotčené — nesnaž se je „opravit“.
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::getBuildData()) |
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.
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.
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 jdou do fronty až po commitu — aby se neodeslaly k přechodu, který se nakonec neuložil.
- Idempotence je ve třech vrstvách: zámek,
SourceRefve skladu (opakovaná rezervace týchž řádků je no-op) a lookup existující faktury podle(orderId, invoiceTypeId).
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í.
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í (ceil na celé jednotky měny) |
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 záporný výsledek se neořezává
Dárek je zdarma. Proti zneužití voucherů stojí validace minimálního košíku, ne ořezání totálu na nulu.
Sklad#
Napojení je přes sadu. Rezervace vzniká při objednávce, odpis při přechodu do stavu s příznakem skladu. Podrobně Sklad.
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
Když je rounding > 0, přidá se na fakturu extra řádek „Zaokrouhlení“. Invoicer
sám zaokrouhlení nepočítá.
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ě).
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 | à hodinu |
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.
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ů.
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ý.
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 |
| cron úlohy | app/UI/Cron/Eshop/Presenters/ |
Navazující kapitoly: Sklad · Fakturace · Elasticsearch · Filtry · Naplánované úlohy