Sdílené principy

DROP = systém kdy marketplace (Alza, Mall, …) přijme objednávku od zákazníka, ale fakturuje a expeduje Vapol. Apivapol funguje jako most mezi marketplace API a Karatem:

  1. Příjem objednávky — buď push (marketplace zavolá náš REST endpoint), nebo pull (apivapol periodicky polluje API).
  2. Lokální záznam — DB tabulky api_{drop}_orders + items + address.
  3. Karat insertuser_imp_w3_obj_h + user_imp_w3_obj_p (Doctrine entity W3ImpObjednavkaHlav).
  4. Karat confirm — rezervace zboží + nastavení user_typ_expedice = '{drop}-drop-0' (literál na který Karat reaguje, viz feedback_preserve_sova_literals).
  5. Marketplace confirm — vrátíme marketplace náš Karat doklad / shipment number.
  6. Tracking — sync status doručení (DPD, Ulozenka, …) zpět do marketplace.
  7. Billing — měsíční CSV exporty pro účtárny.

Pouze Alza: Karat literál Waitting for Confirm v poznámce (překlep tt záměrný — Karat na něj reaguje) drží objednávku ve stavu „čeká na potvrzení" do samostatné Confirm fáze, která ho přepíše na „Potvrzeno". Mall confirmuje hned v témže cyklu, takže poznámku nepoužívá (nechává prázdnou).

Překlad chyb dopravců do CZ (create-shipment)

Skladníci při createShipment dostávali z API dopravců (DPD, PPL, GLS, Packeta, UPS, FedEx, Geis, Messenger, InPost) chyby v angličtině a nerozuměli jim. CarrierErrorTranslator je překládá do věcné české hlášky, aniž by měnil existující API kontrakt.

Fallback řetězec
  1. Static pattern (~0.1 ms, v PHP kódu) — 39 patternů napříč 9 dopravci v Services/ErrorTranslation/Patterns/.
  2. DB cache — tabulka carrier_error_translation (PG), source = 'ai' | 'manual', hash normalizuje dynamické části (UUID, ID, timestampy).
  3. AI (Google Gemini) — jen když allowAi = true (pouze createShipment akce) a rate limit OK; výsledek se uloží do cache.
  4. Raw fallback — když vše selže, vrátí se originál s prefixem dopravce + Tracy log.
Odpověď

Nový field errorCz na BaseResultEntity (setError path), nebo exception context ['errorCz' => …, 'source' => …] u throw-pattern controllerů — BC zachována.

AI backend (Gemini)
  • Klient App\Services\Ai\GeminiClient (implementuje AiTextClient) — veřejné Generative Language API (generativelanguage.googleapis.com) + API klíč, ne Vertex AI / ADC (SovaNG běží on-prem, ne na GCP).
  • Config v local.neon: parameters.ai.geminiApiKey (prázdný = AI vypnutá, běží jen static + cache + raw), model v ai.geminiModel.
  • Rate limit 6 AI volání / hodinu (server-wide, přes COUNT nad carrier_error_translation WHERE source='ai').
  • AnthropicClient (Claude) zůstává v kódu jako autowired: false pro případný návrat.

Model se nastavuje aliasem gemini-flash-lite-latest, ne pinned verzí — Google blokuje gemini-2.5-flash-lite pro nové projekty (404 „no longer available to new users"). Volání má thinkingBudget: 0 (textová transformace, ne uvažování) a hard timeout 8 s, aby AI nezpomalila odpověď skladovému systému. Po změně configu je nutné smazat temp/cache (zkompilovaný DIC).

Push-styled (Alza volá naše REST endpointy). Partneři rozlišeni přes customer_id: 1017086 (VAPOL, Karat path), 74 (BIORUZE, L4U path) a 100 (SPINERGO, L4U path). VAPOL má vlastní Karat instanci; ostatní jdou přes L4U / log4u.cz. Větvení řídí AlzaOrder::isVapol() — vše non-VAPOL automaticky L4U cestou.

Endpointy (API V1, prefix /api/v1)
  • POST /drop/alza/order/{order} — Insert: lokální DB + Karat doklad + rezervace + poznámka „Waitting for Confirm".
  • POST /drop/alza/order/{order}/confirm — Confirm: dispatch v Karatu + přepis poznámky na „Potvrzeno".
  • DELETE /drop/alza/order/{cancelOrder} — Storno objednávky.
  • POST /drop/alza/order/{order}/extend — Prodloužení platnosti rezervace.
  • GET /drop/alza/order/{order}/delivery-address?customerId=X — Doručovací adresa objednávky (data 1:1 se starou sovou): deliveryAddress klíčovaný orderId (10 polí adresy + 6 z objednávky) + errorCode/errorMessage. Nahrazuje sova /api/vapol/alza/{doklad}/orderDeliveryAddress; data skládají modely (AlzaOrderAddress::toApiArray + AlzaOrder::getDeliveryApiExtraFields).
Zpětná kompatibilita starých sova URL

Po přepojení DNS sova.vapol.cz → apivapol volá Alza dál staré URL, na které je nakonfigurovaná: /api/alza/order/… (stará sova, Nette Restful). Tvar cesty za order je shodný s novým Apitte API, liší se jen prefix. BcUrlRewriteDispatcher přepíše cestu před routingem:

  • /api/alza/order/{…}/api/v1/drop/alza/order/{…} (insert / confirm / cancel / extend)

Rozsah je jen Alza order endpointy (Alza push). Staré /api/vapol/* (shipment / delivery / tracking) jsou interní WMS, ne Alza — mimo BC. Nelze řešit přes Apache rewrite (Apitte matchuje cestu z REQUEST_URI, interní rewrite ji nezmění) ani request decoratorem (běží až po routingu) — proto custom dispatcher. Query string (?token=) zůstává zachovaný.

Bezpečnost: AlzaController ?token= čte, ale nevaliduje (parita se sovou, která měla checkToken fakticky vypnutý). Auth řeší RequestAuthenticationDecorator — je zaregistrovaný (handler.before), ale scoped jen na Alza push cestu /api/v1/drop/alza/order (ALZA_PROTECTED_PATH_PREFIX); partnerské feedy/obrázky i ostatní drop platformy zůstávají nedotčené. Vynucování řídí master přepínač parameters.alza.authEnabledaktuálně false = decorator je no-op, nic neblokuje (parita se stávajícím stavem).

Po zapnutí (authEnabled=true) projde request z IP v parameters.alza.allowedIps nebo s hlavičkou vapol-baseauth; host .local je vždy povolen (smoke/testy). Otevřený úkol: před zapnutím ověřit reálné produkční IP Alzy (zdroj = tabulka partner_ip v sova DB pro partner.partnerID Alza partnerů), doplnit je do allowedIps (hodnoty v common.neon jsou zatím neověřené placeholdery, prod override v local.neon) a přepnout na true. Testy: tests/Alza/testAlzaAuthDecorator.php, tests/Alza/testAlzaBcUrlRewrite.php.

Klíčové soubory
  • app/Api/Module/V1/Drop/AlzaController.php
  • app/Api/Dispatcher/BcUrlRewriteDispatcher.php (BC staré sova URL → Apitte, registrace api.core.dispatcher v services.neon)
  • app/Api/Module/V1/Decorators/RequestAuthenticationDecorator.php (scoped auth Alza push; config parameters.alza.authEnabled/allowedIps v common.neon + local.neon)
  • app/Model/Service/Drop/AlzaManager.php
  • app/Services/RemoteApi/AlzaApiService.php
  • app/Model/Context/LocalTables/AlzaOrder.php (konstanty ALZA_DROP_*, LOCAL_STATE_*, DELIVERY_ACTION_*)
  • app/Presenters/AlzaDropPresenter.php (admin grid + detail)
Local state číselník (api_alza_orders.local_state)

Jednotný číselník v AlzaOrder::LOCAL_STATE_META — pouze tyto hodnoty se zapisují. Static getLocalStates() vrací mapping klíč → CZ label pro grid filter + badge renderer.

KonstantaHodnotaPopis
LOCAL_STATE_CREATEDcreatedPo Insert — Karat doklad vytvořen
LOCAL_STATE_CONFIRMEDconfirmedPo Confirm — Karat dispatch OK
LOCAL_STATE_SHIPMENT_CREATEDsccreateShipment OK
LOCAL_STATE_SHIPMENT_DEPARTUREsdshipmentDeparture OK — odesláno
LOCAL_STATE_SHIPMENT_DELETEDshipmentDeleteddeleteShipment OK
LOCAL_STATE_DELIVERY_REJECTEDdeliveryRejectedAlza delivery Rejected
LOCAL_STATE_DELIVERY_CANCELEDdeliveryCanceledAlza delivery Canceled
LOCAL_STATE_STORNOstornocancelOrder z Alzy
LOCAL_STATE_UNAVAIL_ITEMSunavailableFatal: nedostupné položky
LOCAL_STATE_SD_ERRORsd_errorFatal: shipmentDeparture selhal
LOCAL_STATE_SHIP_CANCEL_ERRORship_cancel_errorFatal: deleteShipment selhal
LOCAL_STATE_ERRORerrorFatal: generic (dispatch/import fail)

DELIVERY_ACTION_REJECTED / _CANCELED (hodnoty Rejected / Canceled) NEJSOU local_state — jsou to inbound tokens z Alza deliveryResult API. AlzaOrder::setDeliveryResult() je mapuje na interní LOCAL_STATE_DELIVERY_REJECTED/_CANCELED. Alza posílá delivery feedback bez customer_id v URL — AlzaManager::deliveryResultAction partnera odvodí z objednávky (customer_id), takže Alza token (supplierId + secret) je partner-aware pro VAPOL i L4U partnery.

Cron / diagnostika
  • /console/alza-avail/avail?type=update&partner=… — availability sync (VAPOL: přes KaratAvailabilitySource z w_SOVA_availability_v2, BIORUZE + SPINERGO: přes L4U). Zdroj vybírá AlzaAvailPresenter::pickSourceForPartner (VAPOL → Karat, ostatní → L4U).
  • /console/alza-test-vapol/insert-and-confirm?nomen=…&skipStorno=1 — E2E test proti ostré Karat DB (Insert + Confirm + volitelně Storno)
Ruční storno (admin detail)

Tlačítko „Stornovat" na AlzaDropPresenter detailu → handleStornoAlzaManager::adminStornoOrder. Routuje podle partnera: VAPOL = storno v Karatu (procedura user_o_obj_storno_op, guard canCancelKaratOrder) + lokální storno stav; L4U partneři (BIORUZE, SPINERGO) jdou přes L4U (ne Karat) → jen lokální storno, L4U ručně. Do Alza API neposílá nic. Tlačítko je partner-aware (jiný text pro VAPOL vs L4U partnery).

Idempotence + email notifikace

Opakovaný order/insert pro stejnou existující objednávku vrátí errorCode 0 (success), ne 409. Sověžij chování 1:1. Fatal state → email na monitor@vapol.cz přes DropErrorNotifier (viz Sdílená vrstva níže), error_notified_ts guarduje před opakovaným spamem.

Historický fix (2026-07-10): confirmAlzaOrder při chybě aktualizace lokální objednávky/adresy dělal return $e (Throwable), ale AlzaController::confirmOrder návratovou hodnotu nekontroluje → Alze se vracelo HTTP 200 success, přestože confirm interně selhal a objednávka zůstala ve error stavu. Fix: vyhodit ClientErrorException, kterou controller přemapuje na 400. Zároveň odstraněny leftover bdump() z produkčních Alza/import cest (leak HMAC tokenu při zapnutém debug módu).

Pull-styled. Apivapol pollá Allegro REST API (OAuth2 token refresh). DPD CZ shipment pro objednávky z polské Allegro.

Klíčové soubory
  • app/Model/Service/AllegroManager.php
  • app/Services/RemoteApi/AllegroApiService.php
  • app/ConsoleModule/Presenters/AllegroSyncOrders*Presenter.php
  • app/Presenters/AllegroPresenter.php (admin)
Cron actions
  • console-allegro-sync-orders — import nových objednávek
  • console-allegro-sync-offers — sync stavů offer
  • console-allegro-sync-orders-shipping — shipment + invoice
  • console-allegro-messaging — komunikace s zákazníky
Ruční / maintenance akce
  • console/allegro-sync-offers/end-duplicate-offers?file=…&dryRun=1&limit=N&hash=… — jednorázové hromadné trvalé ukončení nabídek (publication.status = ENDED). Čte seznam offer ID z textového souboru (jedno ID na řádek, cesta relativní k app rootu, typicky temp/allegro/…). dryRun=1 jen zaloguje, nevolá API; limit=N zpracuje prvních N (test batch). Loguje per-nabídku do SyncDropLog (action end_duplicate_offers).

Typ_expedice allegro-drop-0, sove Allegro pattern zachován.
ENDED je na Allegru nevratný (nabídku nelze reaktivovat, jen naklonovat) — proto postup dry-run → malý limit → plný běh. Backend: konstanta ApiOfferContext::PUB_STATUS_ENDED + AllegroManager::endOffer($offerId) (PATCH /sale/product-offers/{offerId}, jen podle ID, bez předchozího GET nabídky).

Kaufland Global Marketplace. Specifikum: 1 Kaufland order může obsahovat víc units (per-sku samostatné objednávky), které sloučíme do 1 Karat dokladu.

Klíčové soubory
  • app/Model/Service/KauflandManager.php
  • app/Services/RemoteApi/KauflandApiService.php
  • app/Model/Context/LocalTables/KauflandOrderUnitOrder.php (konstanta KOD_CENIKU)
  • app/ConsoleModule/Presenters/Kaufland*Presenter.php

Typ_expedice kaufland-drop-0, typ_uhrady PK, kod_dopravy D01.

Historický bug (2026-07-02): variable shadowing v KauflandOrdersPresenter::actionDownloadOrders — vnější $ordersUnits (all nedokladované) přepisoval vnitřní $orderUnits (aktuální group), takže všechny units napříč všemi Kaufland orders dostávaly stejný karat_doklad + www_doklad. Fix: použít $orderUnits uvnitř foreach cyklu. Detekce obětí: SELECT www_doklad, COUNT(DISTINCT kaufland_order_id) FROM kaufland_order_unit GROUP BY www_doklad HAVING COUNT(DISTINCT kaufland_order_id) > 1.

Decathlon (Mirakl) drop. Pull objednávek + push nabídek (cena + quantity). Jeden Mirakl endpoint, jednotlivé trhy se rozlišují per-country CLIENT_KEY + shop_id.

Multi-market (CZ · HU · SK)

Aktivní trhy dává DecathlonManager::getActiveCountries() — presentery (sync offers i download orders) přes ně iterují a slučují výsledky:

TrhShopCeník / kanál (Karat)DPHkod_dpStav
CZdefault (bez shop_id)KOD_CENIKU_CZ / ON-12221 %D01aktivní
HUSHOP_HU_IDKOD_CENIKU_HU / ON-32227 %D21aktivní
SKSHOP_SK_IDKOD_CENIKU_SK / ON-22223 %D31za přepínačem

SK přepínač: parameters.decathlon.skEnabled (common.neon, default false) — dokud je vypnutý, SK se nesynchronizuje (getActiveCountries() vrací jen CZ+HU). Zapnout až po dodání Mirakl SK credentials (CLIENT_KEY_SK + SHOP_SK_ID v DecathlonApiService, zatím placeholdery) a Karat SK partnera (PARTNER_ID_DECATHLON_SK_FOR_CENIK, ceník musí být v EUR). Poté skEnabled=true (ideálně local.neon) + smazat temp/cache (nový constructor param manageru).

Nabídky se klasifikují na zemi podle měny (ApiOfferContext::getCountryCode): HUF→HU, EUR→SK, jinak CZ (CZ shop je výhradně CZK). carrier_code hlášený Decathlonu (Mirakl) je oddělený od Karat kod_dp — pro SK zatím placeholder DPDSK (TODO ověřit u Decathlonu).

Cron actions
*/10 * * * *decathlon-sync-orders/download-orders — import objednávek do Karatu (iteruje aktivní trhy)
0 * * * *decathlon-sync-offers/sync-offers — push cen + quantity (POST /offers), delete neplatných ve web3
Klíčové soubory
  • app/Model/Service/DecathlonManager.php (getActiveCountries, $partnerIds, skEnabled)
  • app/Services/RemoteApi/DecathlonApiService.php (per-shop getShopId/getClientKey, CARRIER_CODES)
  • app/ConsoleModule/Presenters/DecathlonSyncOffersPresenter.php + DecathlonSyncOrdersPresenter.php
  • app/Model/Context/Decathlon/Orders/ApiOrderContext.php (hasValidCountry, getShippingCarrierCode)
  • app/Model/Context/Decathlon/Offers/ApiOfferContext.php (getCountryCode dle měny)
  • app/Presenters/DecathlonPresenter.php (admin)

Typ_expedice decathlon-drop-0.

Mall Slovenia. Pull pattern (apivapol je klient, polluje Mall API). MPAPI SDK NEPOUŽÍVÁM — vlastní tenký REST klient.

Klíčové konstanty
  • MallOrder::MALL_SI_PARTNER_ID = '01034818' (Karat fakturační partner)
  • MALL_DROP_RADA_SI = 'ML', MALL_DROP_DENIK = '31'
  • MALL_DROP_EXPEDITION_TYPE = 'mall-drop-0'
  • MALL_KOD_DP_DPD = 'D21', MALL_KOD_DP_ULOZENKA = 'DM1' (kód dopravy do Karatu podle dopravce)
  • MALL_WWW_DOKLAD_PREFIX = 'MLL'
  • MALL_TRACKING_URL_BASE_BY_DELIVERY_METHOD (base URL pro tracking odkaz; zatím prázdné — doplnit reálné SI URL)

Dopravce se odvozuje z Mall delivery_method přes MALL_DELIVERY_METHOD_TO_CARRIER (translateDeliveryMethodToKarat()): Mall.si standard 54 = DPD → kod_dp D21, Uloženka → DM1, neznámé/chybějící → default D01. check-delivery tracking sleduje jen namapované dopravce (nenamapovaný delivery_method = objednávka bez sledování).

REST endpointy (pro sklad Lora / WMS)
  • GET /api/v1/drop/mall/order/{doklad}/status (MallController) — aktuální stav Mall objednávky živě z Mall API. Vstup = Karat doklad (nebo Mall order_id). Vrací {orderStatus: <celé tělo Mall odpovědi>, errorCode, errorMessage} (data 1:1 se sovou). errorCode -1 = neplatná objednávka → HTTP 400; 1 = Mall neodpověděl (orderStatus=false) → HTTP 200. Nahrazuje sova /api/vapol/mall-order/{doklad}/orderStatus.
  • GET /api/v1/drop/mall/order/{order}/delivery-address?customerId=X (MallController) — doručovací adresa objednávky. Vrací identickou strukturu jako Alza EP (deliveryAddress[ORDER] = 16 polí + errorCode + errorMessage), aby ji Lora četla 1:1 pro obě platformy. Data skládají modely MallOrderAddress::toApiArray() (10 adresních polí, mapováno na Alza klíče: company→company_name, name→address_name, street→street_with_number, note='') + MallOrder::getDeliveryApiExtraFields() (6 order-level polí: shipping_carrier_identification z delivery_method, cod, currency, payment_vs=purchase_id, shipment_value=null). errorCode -1 = objednávka bez Karat dokladu → HTTP 400; -2 = adresa nenalezena → HTTP 400; 0 → HTTP 200. POZOR: na rozdíl od Alzy se customerId neporovnává (Mall customer_id = ID kupujícího, ne partner) — sova to řešila stejně. Replikace sova ApiVapolModule\MallPresenter::actionReadOrderDeliveryAddress.
  • POST /api/v1/drop/mall/order/{doklad}/set-status (MallController) — sklad Lora mění stav objednávky na Mallu (status a/nebo confirmed; např. status=shipping = vyskladňuje se, ještě neodesláno → bez trackingu). PUT do Mall, lokální stav nemění (srovná ho status-sync cron). Zapisuje audit do sys_logu objednávky. Port sova MallOrderPresenter::actionReadSetOrderStatus.
  • POST /api/v1/drop/general/set-order-tracking-number — shipping-push (viz níže).
Push stavů do Mall

Do Mall se stav objednávky propisuje na těchto místech (jinak apivapol stavy jen čte):

KdyCo se pošleOdkud
Po importu (cron / ruční)confirmed=trueMallManager::importOneOrderFromDetail (krok 5)
Storno (cron)confirmed=truecancelOneOrder
Expedice (shipped)status=shipped + tracking_number (+ tracking_url)MallManager::setOrderTrackingNumber
Doručení / vrácení (cron)status=delivered|returned (+ delivered_at)applyTrackingStatusUpdate (check-delivery)
Ruční adminstatus + confirmed=trueadminConfirmOrderToMall / ruční nástroje

Shipping-push (shipped + tracking) je vstupní bod volaný externím expedičním systémem přes REST: POST /api/v1/drop/general/set-order-tracking-number s {drop:'mall', karat_doklad, tracking_number, carrier} (sdílený GenerallController). Teprve tímto se objednávka dostane do shipped, což je předpoklad, aby ji začal sledovat check-delivery cron (filtr status='shipped'). Port sova MallOrderPresenter::actionReadSetOrderTrackingNumber.

Obě LORA cesty (set-status i set-order-tracking-number) zapisují audit do sys_logu objednávky (title setStatus/setTracking), takže v detailu je vidět, co a kdy sklad volal — i když set-status sám lokální stav nemění.

Ruční nástroje

/mall-drop/manual (odkaz „Mall — ruční nástroje" v menu) — admin formulář: (1) ruční import jedné objednávky podle order_id s krokovým logem (status nechat nevyplněný = potvrdit jen confirmed; stejné jako cron), (2) stažení a pretty-print JSON detailu objednávky z Mall (read-only).

Ruční storno: tlačítko na MallDropPresenter detailu → handleStornoMallManager::adminStornoOrder — storno v Karatu (procedura user_o_obj_storno_op, guard canCancelKaratOrder) + lokální cancelled. Na Mall API neposílá nic (na rozdíl od cron cancelOneOrder, který pushuje confirmed=true). Storno lze i z Karat detailu (KaratOrderPresenter, čistě op_zahlavi).

Admin grid & detail

/mall-drop/ grid: sloupec Stav (local_state badge — odlišný od Mall status open/shipped/…), řádek objednávky ve fatal stavu (local_state='error') je červený (table-danger), příznaky confirmed/reserved/delivered jako barevné ANO/ne badge, Karat doklad na jednom řádku (nowrap). V detailu je položkové Item ID proklik na nomen detail přes globální Offcanvas drawer (_nomen_link.latte/karat-product/drawer).

Transakční bezpečnost importu

importOneOrderFromDetail je idempotentní resumable automat řízený flagy (karat_doklad / reserved / confirmed): Karat insert → confirm → teprve pak MALL confirmed=true (pořadí brání „confirmed v Mall bez Karatu"). Anti-duplicita: ERP guard KaratOrderManager::getKaratDokladByWwwDoklad (unikátní www_doklad = MLL+orderId, ne op_zahlavi.objednavka — ta je cross-platform kolizní). Souběh cronů řeší PG advisory lock (MallManager::runOrderSyncLocked).

Cron actions
IntervalAkce
*/5api-get-unconfirmed-orders — import nových
*/10api-get-cancelled-orders — sync zrušení
*/15api-update-availability?type=update — dostupnost
*/30sync-order-status-by-mall — Mall status sync
0 * * * *check-delivery — DPD/Ulozenka tracking
0 2 * * *api-update-availability?type=full — full produkt sync
0 3 * * 1check-products-platnost?dryRun=1 — cleanup neplatných (report)
0 6 1 * *generate-order-list-csv — měsíční billing (Mall)
0 6 2 * *generate-account-order-list-csv — billing pro účtárnu

Nasazení: crony běží pod uživatelem vapol, definice v repu v souboru mall.cron (vkládá se přes crontab -e, ne crontab mall.cron — to by přepsalo celý crontab). Log jde do /home/vapol/cron/apivapol-cron.logNE do /var/log (to vlastní root, cron pod vapol tam nezapíše a celý řádek pak selže ještě před spuštěním curl). Hash secret z ConsoleBasePresenter::HASH_URL. Všechny order-sync akce serializuje advisory lock.

Klíčové soubory
  • app/Model/Service/MallManager.php (orchestrace)
  • app/Services/RemoteApi/MallApiService.php (REST klient)
  • app/Services/RemoteApi/UlozenkaApiService.php (SI tracking)
  • app/Model/Context/LocalTables/MallOrder.php (konstanty)
  • app/ConsoleModule/Presenters/ConsoleMallPresenter.php (10 actions)
  • app/Presenters/MallDropPresenter.php (admin grid + detail + ruční nástroje + storno)
  • app/Api/Module/V1/Controllers/Drop/MallController.php (order status endpoint pro Loru)
  • app/Api/Module/V1/Controllers/Drop/GenerallController.php (shipping-push endpoint, větev mall)
BaseApiContext quirk

Mall response {result, paging, data}BaseApiContext::setValues bug, který přemapuje data na prázdné pole. Workaround: MallApiService::extractIds() / extractData() / extractPaging() přes getOrigValues().

Sdílená vrstva drop platforem

Namespace App\Services\Drop\ — reuse infrastruktura pro Allegro / Alza / Mall / Decathlon / Kaufland (viz feedback_drop_shared_interfaces memory). Cíl: nová drop platforma stačí implementovat interface, nemusí duplikovat email pattern, fatal-state handling, grid renderer.

TřídaÚčel
DropOrderInterface Kontrakt pro drop order entity. Implementují AllegroOrder, AlzaOrder, MallOrder, DecathlonOrder, KauflandOrderUnitOrder. Metody: getDropPlatformName(), getOrderExternalId(), getPartnerLabel(), hasFatalError(), getFatalStateLabel/Short(), getRecommendedAction(), getDetailUrl(), getErrorNotifiedTs(), markErrorNotified(), clearErrorNotified(), isProcessed() + getProcessedLabel() (expedováno+ = „hotovo").
DropErrorNotifier Jednotný email pipeline. Odesílá na monitor@vapol.cz (from it@vapol.cz) když hasFatalError() a error_notified_ts je NULL. Po odeslání volá markErrorNotified() → idempotency guard proti spamu. Selhání mailer nesmí shodit business flow (try/catch + Tracy log).
DropSummary + DropSummaryProviderInterface Value object + kontrakt pro uniform „Total/OK/Cancel/Error" summary za N dní. Managery vrací DropSummary z getUnifiedSummary($days=30). Používá se v grid pill row nad každou drop stránkou + na Drop Dashboardu.
DropOrderSearchService Cross-platform search po karat_doklad / external ID / www_doklad / tracking_number (Alza pozor: cs_shipmentNumber, ne tracking_number). Vrací DropOrderReference[] — shallow objekty pro Dashboard search.
DropOrderRepository UNION SQL nad 5 tabulkami: findTodayOrders($limit=30), findFatalInbox($days=14, $limit=50). Používá Dashboard. Cross-platform SQL patří sem, ne do drop-specific manageru (feedback_no_sql_in_presenter).

Backward compat: Decathlon + Kaufland manageOrderError zachován v původním tvaru (odlišný email pattern it@vapol.czmonitor@vapol.cz + Kaufland má 2h cache rate limit) — nedotčeno kvůli produkční pipeline. Allegro + Alza + Mall managery volají DropErrorNotifier přes tenký wrapper manageOrderError() (Mall: fatal jen local_state='error', volán v error cestě importu).

Fatal state guard v DB

Sloupec error_notified_ts (timestamp) přidán na allegro_order, api_alza_orders, decathlon_order, kaufland_order_unit, api_mall_orders. Partial index pro efektivní inbox query:

CREATE INDEX api_alza_orders_fatal_unsent_idx
    ON api_alza_orders (created_ts DESC)
    WHERE local_state IN ('error','sd_error','ship_cancel_error','unavailable')
      AND error_notified_ts IS NULL;
Jednotný stavový vizuál (grid + detail)

Napříč všemi 5 platformami stejné barevné signály řízené DropOrderInterface: červený řádek = hasFatalError() (vyžaduje ruční zásah), zelený řádek = isProcessed() (expedováno+, hotovo). Grid: sdílený setRowCallback (fatal má přednost před hotovo). Detail: fatal alert-danger + sdílený zelený banner _drop_processed_banner.latte. Pill row / dashboard „OK" počet (DropSummary.ok) = počet expedovaných = počet zelených řádků.

Definice „expedováno+" per platforma: Alza local_state='sd'; Mall status shipped/delivered; Allegro vapol_state SENT/PICKED_UP; Kaufland status sent/sent_and_autopaid; Decathlon vapol_state/decathlon_state SHIPPED/RECEIVED/CLOSED.

Sync Drop Log product/availability sync

Centrální per-nomen log synchronizací skladu / nabídek na marketplace (ne objednávek — ty má fatal inbox výše). Umožňuje dohledat „co se stalo s tímto nomen při posledním sync běhu" napříč platformami bez grepování Tracy souborů. Zapisuje service SyncDropLogger do tabulky sync_drop_log.

Model běhu

Každý sync běh má sync_run_id (startRun(dropName, action)), pod kterým se per-nomen zapisují záznamy (logOk / logNoChange / logSkipped / logFailed / logDeactivated). Na konci summarize(runId) vrátí počty per status. Statusy: ok, no_change, skipped, failed, deactivated.

Zapojené platformy + akce
drop_nameKde se logujeaction
allegroAllegroSyncOffersPresentersync_offers_full / …
kauflandKauflandProductsPresenterupdate_inventory / update_inventory_hourly_{store}
mallMallManager::updateMallProductsFull (per-nomen) / updateMallAvailabilityIncremental (per-položka batch)products_full / availability_update
alzaAlzaAvailPresenter (per-položka batch podle výsledku sendAvailability)availability_full / availability_update
decathlonDecathlonSyncOffersPresenter (per-nabídka; delete → deactivated, jinak ok; výsledek se loguje až podle batch update dané země)sync_offers

Množina platforem = SyncDropLog::getDrops() (řídí filtr v gridu i dlaždice v dashboardu). Batch cesty (Mall availability, Alza) nemají per-nomen API výsledek, takže se stav celé dávky promítne na každou položku. Prázdný běh (nic ke změně) žádné záznamy nezapíše.

UI + retence
  • GET /sync-drop-log/ (SyncDropLogPresenter) — DataGrid všech záznamů, filtr podle platformy / statusu / nomen.
  • Dashboard /drop-dashboard/ „sync status" — poslední běh per platforma (getLastRunPerDrop(): ok/failed/skipped/no_change).
  • Detail nomen — timeline sync událostí napříč platformami (getHistoryForNomen()).
  • Retence: cron SyncDropLogCleanupPresenter (cleanupOlderThan($days=90)).
Klíčové soubory
  • app/Model/Service/SyncDropLogger.php (zápis + summarize + dashboard/timeline query)
  • app/Model/Context/LocalTables/SyncDropLog.php (konstanty DROP_*, STATUS_*, getDrops)
  • app/Presenters/SyncDropLogPresenter.php (grid + detail)

Nomen lookup — detail produktu

KaratProductPresenter — samostatná diagnostika „proč mi obj padla na tomto nomen". 3 casey: (A) je ve w_web3_nomenklatury — Doctrine detail, (B) není ve web3 ale je v Karatu — raw dba.nomenklatura (SELECT *, 3-sloupcový layout), (C) neexistuje nikde — alert-danger.

Endpointy
  • GET /karat-product/ — search form (Nette Form component, redirect na detail)
  • GET /karat-product/detail/{id} — full-page detail (banner + body)
  • GET /karat-product/drawer/{id} — AJAX fragment (bez layoutu, setLayout(false)) pro Bootstrap Offcanvas
Klíčové soubory
  • app/Presenters/KaratProductPresenter.php
  • app/Presenters/templates/KaratProduct/_detail_body.latte (sdílený body — 3 case)
  • app/Presenters/templates/KaratProduct/detail.latte (full-page)
  • app/Presenters/templates/KaratProduct/drawer.latte (fragment)
  • app/Model/Karat/KaratService.php: getNomenklaturaRow($code) + getKartyRows($code)
  • app/Model/Service/KaratProductManager.php: getNomenFromKaratRaw($code) + getKartyRows($code)
Sklad JOIN
  • Case A (Web3): $web3->getSklad() (Doctrine LAZY) — dispKusu, datumDostupnosti, typSkladu, userPotvrzeneDod
  • Case B (Karat): SELECT * FROM dba.karty WHERE KARTA = ? per SKLAD ID — stav_rt real-time zůstatek, rezerva, objednáno
Global nomen drawer (Bootstrap 5 Offcanvas)

@layout.latte obsahuje globální #nomenDrawer Offcanvas + JS handler. Klik na libovolný odkaz s .js-nomen-drawer class → fetch('/karat-product/drawer/{code}') → inject do drawer body. Ctrl+/Cmd+/Shift+/middle-click → standardní <a href> navigace na full-page (fallback pro sdílení URL). Tracy Debug Bar je odstřižen z AJAX response na klientu.

Použití v templates: {include _nomen_link.latte, code => $item->getCode(), enabled => $localOrder->isVapol()} (shared include, backward compat přes $enabled=false → plain <code> bez linku). Aktivní v: AlzaDrop/detail.latte (jen VAPOL obj), SyncDropLogPresenter grid.

Deprecated metody

Web3Nomenklatura::getRezervovavoCount() a getActualCount() volaly neexistující getCartItems() (legacy relace w_web3_cart_items odstraněna). Označeno @deprecated, vrací bezpečné hodnoty (0 / dispKusu). Rezervace se v novém apivapol nedrží ve web3 — pokud potřebuješ real-time rezervace, čti z Karat op_polozky.

Karat objednávka — detail

KaratOrderPresenter — detail Karat objednávky (dba.op_zahlavi + dba.op_polozky). Vstup je Karat doklad nebo číslo objednávky z marketplace — KaratService::resolveDoklad přeloží na doklad (match na op_zahlavi.doklad / op_zahlavi.objednavka, nejnovější shoda). Analogicky k Nomen lookup: jeden _detail_body.latte sdílí full-page i AJAX drawer.

Endpointy
  • GET /karat-order/ — search form (doklad / číslo objednávky, Nette Form → redirect na detail)
  • GET /karat-order/detail/{id} — full-page detail (banner + body)
  • GET /karat-order/drawer/{id} — AJAX fragment (bez layoutu) pro Bootstrap Offcanvas
  • handleStorno — ruční storno jen v Karatu (op_zahlavi, procedura user_o_obj_storno_op, guard canCancelKaratOrderByDoklad); neovlivňuje marketplace ani lokální drop evidenci
Sekce detailu
  • Hlavička & stavy — kurátorovaná pole z op_zahlavi (doklad, objednávka, typ_expedice, user_vyskladnit, vyřízeno, storno, doklad_vl/fv, cena).
  • Položkyop_polozky (nomen s drawer linkem, množství obj/vyd, ceny, DPH, sklad).
  • Lokální drop záznam — zpětný proklik do drop detailu přes DropOrderSearchService::search($doklad).
  • Raw op_zahlavi — sbalený dump všech non-null sloupců hlavičky (3-sloupcový layout).
  • Importní záznamy (staging) — sbalený raw dump user_imp_w3_obj_h + user_imp_w3_obj_p, čistě informativní: z jakého API importu doklad vznikl. Dohledání přes sloupec doklad (= Karat doklad, viz krok „Karat insert" v drop flow nahoře); staging řádky se po importu nemažou. U objednávek vzniklých mimo import flow zůstane prázdné.
Klíčové soubory
  • app/Presenters/KaratOrderPresenter.php
  • app/Presenters/templates/KaratOrder/_detail_body.latte (sdílený body — full-page i drawer)
  • app/Model/Service/KaratOrderManager.php: getOrderDetail($input)
  • app/Model/Karat/KaratService.php: resolveDoklad, getOrderHeaderByDoklad, getOrderItemsByDoklad, getImportHeaderByDoklad, getImportItemsByDoklad

Dotazy na op_zahlavi/op_polozky i staging tabulky jsou raw PDO (SELECT *, WITH (NOLOCK), RTRIM(doklad) = :doklad) v KaratService — žádné SQL v presenteru (feedback_no_sql_in_presenter).

Drop Dashboard

/drop-dashboard/ — hlavní přehledová obrazovka pro drop sekci. Snapshot přes všech 5 platforem bez nutnosti procházet 5 gridů samostatně.

Sekce
  1. Agregate stats — 5 karet s brand barvami platforem (Allegro #FF5A00, Alza #00A884, Mall #E5006D, Decathlon #0082C3, Kaufland #E10915). Stat pilly „Celkem 30 dní / OK / Cancel / Chyby" per platforma přes getUnifiedSummary(30).
  2. Dnešní objednávky — UNION přes 5 tabulek (DropOrderRepository::findTodayOrders(30)), řazeno DESC.
  3. Fatal error inbox — obj s hasFatalError() max 14 dní zpět (findFatalInbox(14, 50)). Deep-link na detail obj.
  4. Sync status — poslední úspěšný sync per drop_name z sync_drop_log (SyncDropLogger::getLastRunPerDrop()).
Search bar

Nette Form component (createComponentSearchForm) → onSuccess redirect na ?q=…. Cross-platform lookup přes DropOrderSearchService::search($q) po karat_doklad, external ID, www_doklad, tracking_number. Alza tracking je v cs_shipmentNumber, ne tracking_number.

Sjednocené UI napříč gridy

Sdílený Latte include templates/_drop_pill_row.latte — 4 pill boxy (Total/OK/Cancel/Chyby) nad každým drop gridem. Renderuje se z $unifiedSummary = $manager->getUnifiedSummary(30). Platform-specific rozpad (Allegro error kategorie, Alza per-partner) je pod tím v každém default.latte samostatně.

Nomen
Otevřít celou stránku ↗

Načítání...

Karat objednávka
Otevřít celou stránku ↗

Načítání...

Partner
Otevřít celou stránku ↗

Načítání...