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

Sklad#

Modul Store eviduje množství — sám nic neprodává. E-shop mu říká „rezervuj“, „potvrď“, „uvolni“; sklad na to odpovídá a vede o tom doklady. Kód: app/UI/Api/Store/.

Z čeho se modul skládá#

Entita Role
StockItem skladová karta — na ni ukazuje varianta produktu e-shopu (ProductVariant::$stockItem)
StockLevel množství na jednom skladu: quantity a reserved
StockBatch šarže — číslo, expirace, nákupní cena
StockDocument doklad (příjemka, výdejka, převodka, inventura)
StockMovement + StockMovementBatch pohyby a jejich rozpad na šarže
StockReservation rezervace
Warehouse, Supplier, Vacation číselníky a dovolené skladů
Služba K čemu
StockReader čtecí model dostupnosti a termínů dodání, bez zámků
StockRecalculator noční přepočet a self-heal
ReservationExpirer uvolňování prošlých rezervací
MarginReporter marže z nákupních a prodejních cen

Tři množství, prodává se podle dostupného#

quantity              fyzicky na skladě
reserved              drženo pro objednávky
dostupné = quantity − reserved

reserved je DENORMALIZOVANÉ — drží se uložené, nedopočítává se

Selhání uprostřed operace může nechat rezervaci bez objednávky. Fyzický stav pak sedí, dostupný ne — zboží je na skladě, ale nejde ho prodat. Nic to nehlásí; najde se to jako „proč to píše vyprodáno“. Proto ten noční přepočet.

Čtení dostupnosti je konstantní počet dotazů

StockReader agreguje přes sklady, karty, dodavatele i dovolené nezávisle na počtu položek, takže výpis celé kategorie e-shopu se dá zjistit levně. Kdo si dostupnost počítá po položkách ve smyčce, vyrobí N+1.

Množství a ceny: tři desetinná místa, half-up#

Skladová množství jsou decimal(10,3): quantity a reserved stavu, šarže, pohyby, párování pohybů na šarže, rezervace i min_quantity karty. Ceny pohybů (unit_price, unit_price_base, cogs_unit_price) mají také tři desetinná místa. Množství v e-shopu (košík, položky objednávky a reklamace) zůstává celočíselné.

DocumentService každé množství i cenu od volajícího zaokrouhlí jednou, na vstupu operace, half-up (od nuly) na 3 místa (roundQuantity(), roundPrice()). Kontrola dostupnosti, změna stavu, spotřeba šarží i řádek pohybu pak pracují se stejným zaokrouhleným číslem; 1.0009 se uloží jako 1.001, stejně jako koncept uložený z administrace. Kladnost se posuzuje na uložené hodnotě: 0.0004 je nula a odmítne se, 0.0005 projde jako 0.001.

Vstup Co služba udělá
text, exponent (1e2), číslo mimo rozsah sloupce InvalidArgumentException, nic se nezapíše (u inventury se odmítne jen řádek, důvod „neplatné číslo“)
záporná cena odmítne ji (toBase() hlídá každou oceněnou cestu: příjem, výdej, potvrzení rezervace, zaúčtování konceptu)
cena 0 platná cena, odlišná od chybějící (null)
součet stavu po příjmu nebo převodu mimo decimal(10,3) odmítne operaci dřív, než zapíše stav

Tvar a rozsah měří ColumnValue::fitsDecimal() nad mapováním StockMovement, stejně jako formuláře administrace (ColumnGate::decimal()). Hodnotu z pole s pravidlem Form::FLOAT posílej do skladu jako řetězec přes Decimal::fromFloat(), ne (string) $float: 0.00001 by jinak odešlo jako 1.0E-5 a maska ho odmítne.

Pro zobrazení množství slouží Latte filtr |quantity ('1.500' → „1,5“, '5.000' → „5“, viz Latte), v mřížkách administrace QuantityRenderer (CSV dostane strojový tvar s tečkou) a v PHP Quantity::format().

Termín dodání a dovolená#

StockReader počítá deliveryDays (za kolik dní se zboží odešle) deterministicky:

Krok Pravidlo
1. posun D_shift začne dnes; dokud padá do aktivní dovolené, skočí na její end_date + 1 (navazující okna se projdou znovu)
2. sklad se zbožím každý aktivní sklad s available > 0: D_w = D_shift, když má vacation_affected, jinak dnes; ship_w = D_w + delivery_days skladu
3. výsledek je-li zboží skladem, odeslání = min(ship_w); jinak D_shift + delivery_days dodavatele karty; bez dodavatele null („na dotaz“)

Warehouse::$vacationAffected (sloupec vacation_affected, výchozí 1) říká, jestli se skladu globální dovolená týká. Sklad s 0 expeduje i během dovolené. Dovolená posouvá den odeslání, ne přepravu: okno, které začne až po dni odeslání, odhad nezmění. labelWarehouseId je první sklad podle order se zbožím skladem — označuje zdroj, ne rychlost.

Minimum se bere z dat odeslání, ne z delivery_days

Při smíchaných skladech s vacation_affected a bez něj může být sklad s kratším delivery_days ve skutečnosti pomalejší. Kdo by vybíral nejrychlejší sklad podle delivery_days, ukáže zákazníkovi termín, který dovolená posune.

Rozhraní pro e-shop: čtyři operace#

Sklad nabízí navenek čtyři metody a všechny berou SourceRef — dvojici „který modul + jaký typ + které id“ (například objednávka č. 412 z e-shopu):

Metoda Co udělá
reserve($source, $lines, $allowPartial, $expiresAt) drží množství pro daný zdroj
confirm($source, $salePrices, $userId) promění rezervaci ve výdejku
release($source) rezervaci uvolní
post($documentId) potvrdí doklad a teprve tím pohne množstvím

SourceRef slouží zároveň jako klíč idempotence i jméno zámku (cms_store_src_<modul>_<typ>_<id>).

Opakované volání se stejným SourceRef je no-op, ne druhá rezervace

Právě proto se dá přechod stavu objednávky bezpečně zopakovat po pádu. Kdo si vymyslí vlastní klíč, tuhle vlastnost ztratí.

Pořadí zámků je vždy eshop → store

E-shop drží per-order zámek, sklad k němu přidá svůj nad zdrojem. Opačné pořadí by při souběhu vyrobilo uváznutí.

Postup: napojuji nový modul na sklad#

  1. Zaveď SourceRef pro svůj typ dokladu — modul, typ, id. Musí být stabilní napříč opakováními téže operace.
  2. Rezervuj při vzniku závazku (reserve()), s platností odpovídající tomu, jak dlouho závazek žije.
  3. Rozhodni, jestli povolíš částečnou rezervaci. E-shopový checkout ji nepovoluje (allowPartial = false) — zákazník nesmí zaplatit nekryté zboží.
  4. Při dokončení volej confirm(), při zrušení release(). Nikdy nesahej na quantity přímo.
  5. Prokliknij i cestu selhání: co se stane, když operace spadne mezi rezervací a potvrzením.

Přímý UPDATE množství obejde doklady i pohyby

Sklad pak sedí, ale nedá se dohledat proč. Každá změna množství musí projít dokladem — jinak inventura nemá s čím porovnávat.

Doklady#

Příjemka, výdejka, převodka, inventura. Dokud není doklad potvrzený (post()), s množstvím nedělá nic — dá se rozpracovat, opravit i zahodit.

Potvrzený doklad se neopravuje ani nemaže

Vystaví se storno a pak nový doklad. Smazání by rozešlo aktuální stav s historií pohybů — a rozdíl se objeví až při inventuře, bez stopy, odkud vznikl.

Koncept a zaúčtování#

Formulář dokladu v administraci (StockDocumentFormTrait) ukládá jen koncept jako objektový graf s vlastněnou kolekcí pohybů. Api StockDocumentManager::beforeSave() stav vždy přepíše na koncept a úpravu nebo smazání jiného než konceptu odmítne (stav čte SELECT … FOR UPDATE). Množstvím hýbe jedině post(); tlačítko „Uložit a zaúčtovat“ uloží koncept a hned zavolá post() přes bridge.

Typ Řádek v konceptu Co udělá post()
příjemka +q přijme; nekladné množství odmítne
výdejka −q vezme absolutní hodnotu a odepíše ze šarží, nebo z šarže vybrané na řádku (selected_batch_id)
převodka −q ze zdrojového skladu kladný řádek odmítne; ke zdrojovému řádku vytvoří +q na cílovém skladu
inventura rozdíl zapíše rozdíl

Než cokoli zapíše, post() projde všechny řádky: řádek s nulovým množstvím (a u převodky kladný řádek) odmítne celý doklad. U příjemky a výdejky odmítne i řádek s cenou, která není nezáporné číslo v rozsahu sloupce (typicky záporná cena uložená dřív, než ji formulář začal hlídat). Vyhodí InvalidDraftLineException s důvodem invalidUnitPrice, číslem řádku (pořadí vložení, jak ho ukazuje formulář přes StockDocument::getMovementsInLineOrder()), štítkem karty KÓD — Název a hodnotou. Doklad zůstane koncept, opraví se a zaúčtuje znovu.

Kladný řádek v konceptu převodky by převod zdvojil

Typicky jde o kopii zaúčtované převodky. Zaúčtování by ji provedlo jako přesun cíl → cíl a rozbilo žurnál pohybů. Proto ho post() odmítne a koncept převodky smí nést jen −q.

Inventura v administraci#

Obrazovka stock-document/inventory (StockInventoryTrait) ukáže karty vybraného skladu s aktuálním množstvím a polem „napočteno“ a pošle řádky do adjust():

Vstup Co se stane
prázdné pole karta se přeskočí (částečná inventura), nikdy neznamená napočteno 0
číslo s exponentem nebo mimo rozsah stock_levels.quantity řádek se přeskočí ještě v administraci, ostatní projdou, správce dostane varování
množství se od vykreslení změnilo (skrytý snímek ≠ stav pod zámkem) adjust() řádek odmítne („přepočítejte“), ostatní projdou
napočteno = stav žádný pohyb
manko / přebytek manko se odepíše ze šarží, přebytek založí šarži bez čísla v průměrné ceně

Počty se čtou ze surového POST (counted[id], snapshot[id]), ne z kontrolek Nette: počet karet na stránce se mění a formulář drží jen CSRF, poznámku a tlačítko.

Napočteno a snímek nesmí projít jen přes is_numeric()

Ten pustí i 1e21, na kterém bcadd() spadne s ValueError. Výjimka shodí celou transakci adjust(), tedy celou dávku inventury, ne jeden řádek. Tvar a rozsah měří ColumnValue::isDecimalShape() a fitsDecimal() nad StockLevel::quantity.

Storno#

DocumentService::cancel() přijme jen zaúčtovaný doklad. Vystaví protidoklad stejného typu s opačnými množstvími (source_type = 'storno', zdroj převzatý z originálu), originál přepne na zrušený a oba doklady provážou storno_document_id. Po stornu se kartám znovu spočítá průměrná i poslední nákupní cena — karta si tak nenechá cenu zrušené dodávky.

Typ Co storno udělá Kdy ho odmítne
výdejka vrátí množství do původních šarží podle párování originálu —
příjemka odepíše jen ze šarží, které založila právě tahle příjemka část dodávky už je vydaná nebo převedená; nebo by dostupné kleslo pod nulu a záporný stav není povolený
převodka provede převod opačným směrem dostupné na cílovém skladu je menší než převedené množství a záporný stav není povolený
inventura zapíše opačné rozdíly šarže z inventurního přebytku už je (i jen zčásti) vydaná; nebo by dostupné kleslo pod nulu a záporný stav není povolený
protidoklad (storno) — vždy: storno storna se nepovolí, chybné storno se napraví novým zadáním původní operace

Opravu zaúčtovaného dokladu dělá copyToConcept(): zkopíruje hlavičku i pohyby do nového konceptu (bez čísla a stavu, storno se z něj stane manual), ten se upraví a zaúčtuje.

Postup: opravuji chybně zaúčtovaný doklad

  1. Zavolej cancel() na původní doklad a zkontroluj, že prošel (u příjemky selže, když už se z dodávky prodávalo).
  2. copyToConcept() původního dokladu vytvoří koncept se stejnými řádky.
  3. Koncept oprav a zaúčtuj post().

Chyby skladových operací#

Operace volá administrace a e-shop přes interní bridge (internalActionReceive(), internalActionCancel() …). Externí HTTP akce téhož presenteru (actionReceive(), actionSave() …) vrací vždy PERMISSION_DENIED; sklad se zvenku jen čte. Výjimku z operace převede StoreFailure:

Výjimka error message
InsufficientStockException insufficientStock text výjimky (pro obsluhu)
ReservationShortfallException reservationShortfall text výjimky
InvalidDraftLineException invalidDraftLine text výjimky; navíc reason a params (line, item, value), ze kterých administrace přeloží hlášku form.messages.post.<reason> s číslem řádku a kartou
cokoli jiného (RuntimeException, DBAL, TypeError …) storeOperationFailed obecná věta s krátkým ref; plný text jen v log/store.log

Důvod odmítnutí z RuntimeException se k obsluze nedostane

Většina kontrol v DocumentService (storno storna, spotřebovaná dodávka, doklad v jiném stavu) hází RuntimeException. Správce pak vidí jen „Store operation failed. Reference: …“ a důvod se dohledává v logu podle ref. Kdo chce hlášku pro obsluhu, musí hodit doménovou výjimku ze složky Models/Exceptions/ a doplnit ji do StoreFailure.

Pohyby jsou snímky#

Pohyb ukládá název položky a zůstatek v okamžiku pohybu.

Přejmenování karty nezmění staré pohyby

Je to záměr — doklad má ukazovat, co na něm stálo tehdy. Stejná logika jako u faktury.

Šarže#

Odepisuje se metodou FEFO (první expiruje, první ven) s FIFO jako záložním pořadím. Pod zámkem stavu se čtou šarže s quantity > 0 v pořadí:

ORDER BY (expiration_date IS NULL), expiration_date, received_datetime, stock_batch_id

Šarže bez expirace jdou až na konec. Každá spotřebovaná šarže dostane párování (stock_movement_batches) a pohyb nese cogs_unit_price jako vážený průměr cen spotřebovaných šarží. Řádek výdejky s vybranou šarží (IssueLine.batchId) bere jen z ní a šarže musí patřit stejné kartě a skladu.

Kontrolu přeprodeje vypíná nastavení stock.negative.allowed (modul 10). Při 1 může stav skladu klesnout pod nulu: co šarže nepokryjí, zůstane bez párování a ocení se poslední nákupní cenou. Po každé operaci se ověřuje, že součet šarží = max(stav, 0). Příjem na záporný stav proto nejdřív umoří dluh a šarži založí jen z části nad nulou (max(0, starý + q) − max(0, starý)).

Prošlá šarže se sama nevyřadí

Zůstane v evidenci a FEFO ji odepíše jako první. Cron /cron/store/batch/expiry-alert jen upozorní; vyřazení je ruční výdejka.

Hlídka expirace (StockBatchManager::watchExpiry()) vybere živé šarže s expirací do stock.batch.expiryAlertDays dní (výchozí 30), i ty už prošlé (záporné daysLeft). Nález zapíše do logu store; e-mail (šablona ST_BATCH_EXPIRY_ALERT) pošle jen na platnou adresu v stock.lowStock.email — stejnou, na kterou chodí hlídka minimální zásoby. Bez adresy jen loguje.

Adresa z nastavení nemá vlastní jazyk ani lokalizaci. Oba e-maily (ST_BATCH_EXPIRY_ALERT i souhrn karet pod minimem z StockItemManager::watchLowStock()) se proto vykreslí ve výchozím jazyce webu a čísla dostanou oddělovač výchozí lokalizace: množství manager předformátuje přes Quantity::format() („1,5“ v češtině, „1.5“ v angličtině) a render běží v PriceFormatContext::runWith(), takže |quantity i |priceFormat v šabloně e-mailu použijí stejnou lokalizaci. V šabloně stačí vypsat hotový řetězec, např. {$item['available']}.

Rezervace a souběh#

Rezervace vzniká se závazkem a má platnost; po ní se uvolní cronem.

Platnost rezervace musí být sladěná s expirací objednávky

Uvolní-li se rezervace dřív, než vyprší objednávka, zboží se může prodat podruhé — a druhý zákazník zaplatí něco, co už není. Opačný nepoměr je jen neefektivita: zboží zbytečně dlouho blokované.

Cron uvolňování běží à 5 minut

/cron/store/reservation/expire. Delší interval znamená, že zboží zůstává blokované po vypršení — a projeví se to jako „vyprodáno“, ne jako chyba.

Platnost se určí v ReservationService::resolveExpires(): explicitní $expiresAt, jinak NOW() + reservation.ttlMinutes z databáze, při 0 rezervace nevyprší. Aplikace běží v čase Europe/Prague, databázová relace v UTC a vypršení porovnává expires_datetime < NOW() v databázi. $expiresAt proto předávej jako DateTimeImmutable v čase aplikace; na UTC ho převede právě tohle jediné místo.

Vlastní zápis expires_datetime posune platnost o hodinu až dvě

Kdo čas zapíše mimo resolveExpires() (přímým SQL nebo formátem bez převodu), uloží pražský čas do UTC sloupce. Rezervace pak vyprší o 1–2 h později, než má, a zboží zůstane blokované.

Potvrzení rezervace#

confirm($source, $salePrices, $userId) promění aktivní rezervace zdroje v jednu zaúčtovanou výdejku (číslo VYD-…) a rezervace přepne na potvrzené, vše v jedné transakci pod zámkem zdroje. Výdejka nemá sklad v hlavičce — rezervace mohla brát z několika skladů a skutečnost je v pohybech. Výsledek je ConfirmResult:

Výsledek Význam
success, alreadyConfirmed = false výdejka vznikla teď (documentId, number)
success, alreadyConfirmed = true zdroj už výdejku má; vrátí se ta stávající beze změny
errorCode = 'noActiveReservations' nic k vydání (rezervace chybí nebo právě vypršely) — sklad nic nevydá
errorCode = 'sourceLocked' zámek zdroje se do 5 s nepodařilo získat

Nejvýš jednu zaúčtovanou výdejku na zdroj hlídá i databáze (uq_stock_documents_confirm). Rezervace prošlé v okamžiku potvrzení se pod zámkem nechají vypršet a do výdejky nejdou.

warnings nese, co prošlo, ale je potřeba vědět. Každé varování je kód, buď holý řetězec (noActiveReservations), nebo strukturované StockWarning {code, params}, nikdy hotová věta. Parametry karty skládá StockWarning::itemParams(): itemId, itemCode, itemName a štítek item ve tvaru KÓD — Název (bez kódu i názvu #id). Konzument přeloží <prefix>.<code> s parametry v aktuálním jazyce; přechod stavu objednávky v administraci k tomu má klíče statusForm.warnings.<code>.

Kód Kdy
salePriceMissing karta vydána bez prodejní ceny — výdejka má náklady, ale ne tržbu; report marží takový výdej do tržby, nákladů ani marže nezapočte a ukáže jen jejich počet
salePriceInvalid (+ value) zadaná prodejní cena není nezáporné číslo v prostém zápisu, zahodí se; cena 0 je platná a projde
issuedWithoutBatchCoverage (+ warehouseId, unpaired, quantity) fyzický stav mezitím klesl pod rezervaci (třeba inventurním mankem) — vydá se celé množství, stav skladu jde do minusu a nepokrytá část se ocení poslední nákupní cenou; nastavení záporného stavu se tu neuplatní, zboží bylo rezervované

Štítek karty doplní ReservationService jedním dotazem pro všechna varování potvrzení. Kdo varování předává dál, zachová i strukturovaný tvar (StockWarning::parse()); filtr jen na řetězce by ztratil kartu.

noActiveReservations neřeš tichým vydáním

Chyba znamená, že pro objednávku už nic drženo není. Volající musí rozhodnout: znovu rezervovat, nebo vydat ručně s kontrolou zásoby. Kdo chybu přejde, expeduje zboží, které mohlo být mezitím prodané jinému zákazníkovi.

Zápisy nad jedním dokladem serializuje advisory zámek SourceRef::lockName() (cms_store_src_<modul>_<typ>_<id>) ze sdílené SourceLockTransactionTrait. Používají ji i e-shop, fakturace a katalog firem. Pořadí je pevné:

  1. GET_LOCK před beginTransaction() — ReservationService čeká 5 s, cron uvolňování nečeká vůbec a obsazený doklad přeskočí (dorovná ho příští běh),
  2. práce v jedné DBAL transakci; při deadlocku (SQLSTATE 40001 / 1213) až tři pokusy,
  3. RELEASE_LOCK ve finally.

Zámek přežije rollback i opakování po deadlocku, takže všechny pokusy běží pod stejnou serializací.

Zámek a transakce musí jet na stejném spojení

GET_LOCK je zámek MySQL relace. Na jiném spojení by transakci nechránil a dva zápisy téhož dokladu by se proplétly. Proto traita vyžaduje jediný přístup connection() pro obojí.

Pořadí skladů#

Sloupec order skladu je priorita čerpání; sklady jsou plochý seznam, ne strom. Šipky ve výpisu (WarehousePresenter::actionMoveUp() / actionMoveDown()) najdou souseda v pořadí order ASC, id ASC a pošlou jedno volání api/store/warehouse/swap-order. To v jedné transakci zamkne oba řádky (FOR UPDATE), ověří, že order sedí s tím, co správce viděl ve výpisu, a zapíše obě hodnoty. Když se mezitím změnily, nezapíše nic a vrátí INVALID_STATE.

Prohození dvěma updateColumns() nechá pořadí rozbité

Jsou to dva samostatné požadavky: selže-li druhý, první změna zůstane, a dva souběžné přesuny skončí duplicitním order.

Mazání skladu a karty#

Co Smazání odmítne Proč
sklad WAREHOUSE_DELETE_BLOCKED, když na něm je množství nebo rezervace, nebo na něj ukazuje jakýkoli doklad či pohyb stavy a rezervace mají ON DELETE CASCADE (zmizel by živý sklad), doklady SET NULL (zaúčtované doklady by ztratily sklad)
karta když má kladný stav, rezervaci nebo šarže stavy i šarže mají ON DELETE CASCADE

Sklad s jakoukoli historií tedy smazat nejde nikdy. Vyřazuje se vypnutím (activated = 0): rezervace z něj přestanou čerpat a nepočítá se do termínu dodání.

Kód karty je jedinečný. StockItemManager::beforeSave() ho kontroluje surovým dotazem (DB_DUPLICATE_ENTRY); souběžné vložení zachytí index uq_stock_items_code jako DB_ERROR.

Marže#

MarginReporter::report($from, $to, $stockItemId) (bridge internalActionMarginReport()) počítá nad pohyby zaúčtovaných dokladů podle posted_datetime v polootevřeném intervalu [od 00:00, den po „do“ 00:00), takže poslední den se započte celý. Administrace pošle jen platná data ve tvaru RRRR-MM-DD; neexistující den nebo „od“ po „do“ odmítne hláškou a report nevolá.

Řádek Z čeho
tržba −q × unit_price_base výdejek se stavem zaúčtováno, bez protidokladů (source_type = 'storno'), jen pohyby s prodejní cenou
náklady (COGS) −q × cogs_unit_price týchž pohybů
marže tržba − náklady
výdeje bez ceny unpricedMovementCount: pohyby bez unit_price_base, do tří řádků výše nevstupují
náklad odhadnut costEstimatedMovementCount a costEstimated: pohyby s cenou, které odepsaly šarži bez nákupní ceny
inventurní rozdíly zaúčtované inventury: množství = součet q, hodnota = součet q × cena (manko záporně, přebytek kladně)

Cenu nese každý inventurní pohyb v jiném sloupci: manko má cogs_unit_price (FEFO spotřeba šarží) a unit_price_base prázdnou, přebytek má cenu nové šarže (průměrná, jinak poslední nákupní) v unit_price_base a cogs_unit_price prázdnou. Hodnota proto bere COALESCE(cogs_unit_price, unit_price_base).

Stornovaný doklad (výdejka i inventura) má stav zrušeno a jeho protidoklad (source_type = 'storno') se vyřadí filtrem, takže z přehledu zmizí celý. Zrušená inventura tedy do inventurních rozdílů nepřidá nic. Převodky se nepočítají, i když nesou −q s cenou.

Prodejní cenu výdejky objednávky skládá OrderStockManager po slevách: sleva řádku a zákaznická sleva se odečtou po řádcích, slevový kupon se rozpočítá mezi řádky poměrem k jejich ceně (zbytek zaokrouhlení nese poslední řádek). Tržba v reportu tedy odpovídá ceně zboží v objednávce bez DPH, dopravy a platby. Příjemka bez ceny ocení šarži průměrnou (jinak poslední) nákupní cenou karty; šarže zůstane bez ceny jen u karty, která žádnou nákupní cenu ještě nemá. Takovou šarži výdej ocení průměrem oceněných šarží téhož výdeje (žádná oceněná = náklad 0), proto report u takových pohybů hlásí odhadnutý náklad.

Hlášku „expedujeme od …“ počítá jen StockReader::getVacationNotice(); front ji bere přes bridge api/store/vacation/banner (VacationRepository::fetchBanner()). Vrací výluky, které dnes běží nebo začnou do $daysBefore dnů, a ke každé:

Pole Hodnota
shipsFrom end_date + 1, posunuté přes navazující výluky (stejná smyčka jako termín dodání)
deliveryDays nejkratší delivery_days aktivních skladů s vacation_affected = 1; bez takového skladu null
deliveryEstimate shipsFrom + deliveryDays, nebo null

Je to globální hláška, ne dostupnost konkrétní karty. Data jdou ven jako řetězce Y-m-d.

Front nesmí výpočet opakovat

Vlastní filtr „aktivní dovolené“ nebo dopočet data v šabloně se rozejde s algoritmem termínu dodání (normalizace na půlnoc, řetězení oken) a banner pak slíbí jiné datum, než jaké ukáže košík.

Předstih bere komponenta z nastavení Store vacation.banner.leadDays (modul 10, v administraci Nastavení skladu). Chybějící nebo nekladná hodnota znamená 3 dny — nastavení není povinné.

Kde se banner vykresluje#

Banner patří modulu Store, ale ukazuje se na celém webu. Komponenta frontStoreVacationBanner sedí v drobečkových partialech tématu parts/{shared,eshop,blog,bazaar,comcat,discussion}/header/breadcrumb.latte: každá hlavička modulu končí svým partialem a @logged.latte / @logged_form.latte vkládají blogový, takže jsou pokryté i „Moje objednávky“ a „Reklamace“.

Po odpovědi Api komponenta dotáhne entity vybraných výluk jedním findAll() s include(['translations']), výluku bez aktivního překladu v jazyce stránky přeskočí a seřadí je podle pořadí z Api (findAll() pořadí nezaručuje). Větu hlášky skládá a překládá už v PHP, data formátuje podle lokalizace stránky. Bez deliveryEstimate hláška uvede jen datum expedice.

Výluka bez aktivního překladu se na webu neukáže

getTranslation() příznak active_translation nefiltruje, komponenta ho proto kontroluje sama a takovou výluku vynechá. Dovolená založená jen v češtině tak na anglické verzi webu banner nemá — zákazník se o ní dozví až z termínu dodání v košíku.

Postup: banner v novém tématu

  1. Do každého parts/*/header/breadcrumb.latte tématu vložte {control frontStoreVacationBanner}.
  2. Ověřte, že každá hlavička (parts/*/header/default.latte) a layouty @logged*.latte končí includem některého z těch partialů.
  3. Komponentu do hlaviček nepřidávejte zvlášť — na stránce by se banner ukázal dvakrát.

Napojení na e-shop#

Skladová karta patří variantě produktu: cizí klíč drží ProductVariant::$stockItem (sloupec cms_mod_store_stock_items_stock_item_id v product_variants) a je to jediný zdroj pravdy. Objednávka i reklamace pracují se sadou; na karty ji rozloží SetCompositionService (prodejní karta varianty → její skladová karta, balíček → karty jeho listů). Podrobně E-shop.

Pravidlo Kde
jedna karta patří nejvýš jedné variantě; druhé mapování odmítne DB_DUPLICATE_ENTRY ProductVariantManager::beforeSave(), surový SELECT
mapování se mění jen klíčem $meta['stockItemId'] při uložení varianty: chybějící klíč = beze změny, null nebo 0 = odpojit, neexistující karta = ITEM_NOT_FOUND ProductVariantManager::save()
karta nese zpětné zrcadlo external_module = 4, external_id = id varianty; zapisuje a maže ho jen afterSave() varianty cms_mod_store_stock_items
novou kartu pro variantu zakládá jen foundStockItemCard(): kód a jednotka ze sady typu PRODUCT, název z jejího textu; karta se stejným kódem se použije znovu ProductVariantManager
smazání varianty kartu uvolní (zrcadlo smaže); odmítne ho PRODUCT_DELETE_BLOCKED, když na prodejní kartu ukazuje objednávka, reklamace nebo dárek, nebo je skladová karta v objednávce ProductVariantManager::beforeDelete() / afterDelete()

Postup: napojuji variantu na kartu z kódu

  1. Variantě bez karty zavolej ProductVariantManager::foundStockItemCard($variantId). Varianta musí mít uloženou prodejní kartu (type = 1); jinak skončí VALIDATION_FAILED.
  2. Existující kartu připoj uložením varianty s ['stockItemId' => $id] v $meta.
  3. Zrcadlo external_* nepiš ručně; srovná ho uložení varianty.

Zrcadlo na kartě není vazba

external_module / external_id je jen pomůcka, aby sklad našel vlastníka karty, a jsou to #[ExternalReadonly] pole. Kdo vazbu čte nebo mění přes ně, rozejde se se skutečným cizím klíčem na variantě; při kolizi sklad jen zaloguje AMBIGUOUS.

Hledání skladové operace na produktu nic nenajde

Produkt kartu nemá, mají ji jen jeho varianty. Doklady a rezervace se vážou na kartu a na zdroj (SourceRef, například objednávku).

Naplánované úlohy#

Endpoint Co dělá Doporučený interval
/cron/store/reservation/expire uvolní prošlé rezervace à 5 minut
/cron/store/batch/expiry-alert upozorní na blížící se expiraci šarží denně ráno
/cron/store/stock/recount noční přepočet a self-heal denormalizovaných hodnot denně v noci
/cron/store/stock/low-stock-alert souhrnný e-mail o kartách pod minimem (jednou na kartu, low_stock_notified_at) denně ráno

Bez nočního přepočtu se rozdíl v reserved nikdy nesrovná

Denormalizovaná hodnota se opraví jedině tímhle během. Nespuštěná úloha se neprojeví chybou — jen postupně přibývá zboží, které je na skladě a přesto se tváří jako nedostupné.

Co noční přepočet opraví a co jen ohlásí#

/cron/store/stock/recount volá StockRecalculator (přes StockLevelManager::recountLevels() a StockItemManager::recountItemPrices()). Projde vždy celou tabulku po stránkách (stock.recount.batchSize, výchozí 200) a každý řádek opraví v samostatné krátké transakci. Zdrojem pravdy je žurnál pohybů nekonceptních dokladů.

Hodnota Co přepočet udělá
stock_levels.quantity přepočítá ze součtu pohybů a rozdíl opraví
stock_levels.reserved přepočítá z aktivních rezervací a rozdíl opraví
chybějící řádek stock_levels u karty se šaržemi nebo pohyby založí ho znovu a zaloguje jako chybu
průměrná a poslední nákupní cena karty přepočítá a rozdíl opraví
součet šarží proti stavu jen zaloguje — šarži nejde bezpečně opravit, chybí její původní množství
zůstatek uložený v pohybu jen zaloguje — pohyby se nemění
aktivní rezervace starší než reservation.alert.staleDays (výchozí 30 dní) jen zaloguje

Alarmy se jen zapisují do logu store

Nikoho neupozorní. Rozjetá šarže nebo zapomenutá rezervace se projeví až nesedícím skladem — log log/store.log je proto potřeba po nočním běhu číst.

Kam sáhnout#

Chci Kde
rozhraní rezervací app/UI/Api/Store/Models/Managers/StockReservationManager.php
potvrzování dokladů app/UI/Api/Store/Models/Managers/StockDocumentManager.php
zaúčtování a inventuru app/UI/Api/Store/Models/Services/DocumentService.php → post(), adjust()
formulář dokladu a inventuru v administraci app/UI/Admin/Store/Presenters/Traits/Forms/StockDocumentFormTrait.php, Traits/StockInventoryTrait.php
čtení dostupnosti a termínů app/UI/Api/Store/Models/Services/StockReader.php
přepočet a self-heal app/UI/Api/Store/Models/Services/StockRecalculator.php
potvrzení rezervace a platnost app/UI/Api/Store/Models/Services/ReservationService.php → confirm(), resolveExpires()
odpověď operace při chybě app/UI/Api/Store/Presenters/Traits/StoreFailure.php
marže app/UI/Api/Store/Models/Services/MarginReporter.php
banner dovolené na webu app/UI/Front/Store/Components/VacationBannerComponent/VacationBanner.php
klíč idempotence a zámku app/UI/Api/Store/Models/DTO/SourceRef.php
cron úlohy app/UI/Cron/Store/Presenters/

Navazující kapitoly: E-shop · Fakturace · Naplánované úlohy · Životní cyklus manageru

Nastavení cookies