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#
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#
- Zaveď
SourceRefpro svůj typ dokladu — modul, typ, id. Musí být stabilní napříč opakováními téže operace. - Rezervuj při vzniku závazku (
reserve()), s platností odpovídající tomu, jak dlouho závazek žije. - Rozhodni, jestli povolíš částečnou rezervaci. E-shopový checkout ji nepovoluje
(
allowPartial = false) — zákazník nesmí zaplatit nekryté zboží. - Při dokončení volej
confirm(), při zrušenírelease(). Nikdy nesahej naquantitypřímo. - 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
- Zavolej
cancel()na původní doklad a zkontroluj, že prošel (u příjemky selže, když už se z dodávky prodávalo). copyToConcept()původního dokladu vytvoří koncept se stejnými řádky.- 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í:
Š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é:
GET_LOCKpředbeginTransaction()—ReservationServicečeká 5 s, cron uvolňování nečeká vůbec a obsazený doklad přeskočí (dorovná ho příští běh),- práce v jedné DBAL transakci; při deadlocku (
SQLSTATE40001 / 1213) až tři pokusy, RELEASE_LOCKvefinally.
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.
Banner dovolené#
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
- Do každého
parts/*/header/breadcrumb.lattetématu vložte{control frontStoreVacationBanner}. - Ověřte, že každá hlavička (
parts/*/header/default.latte) a layouty@logged*.lattekončí includem některého z těch partialů. - 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
- Variantě bez karty zavolej
ProductVariantManager::foundStockItemCard($variantId). Varianta musí mít uloženou prodejní kartu (type = 1); jinak skončíVALIDATION_FAILED. - Existující kartu připoj uložením varianty s
['stockItemId' => $id]v$meta. - 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