Search by

liquiddesign / eshop-doryo-api

info@lqd.cz

Čtecí API e-shopu pro Doryo — objednávky, zákazníci, produkty, sklad a faktury v jednotné projekci

Package info

github.com/liquiddesign/eshop-doryo-api

pkg:composer/liquiddesign/eshop-doryo-api

Statistics

Installs: 156

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 1

v1.12.0 2026-10-12 11:18 UTC

README

Čtecí API e-shopu pro Doryo — objednávky, zákazníci, produkty, sklad a faktury v jednotné projekci, ve stejném tvaru, v jakém je vydávají ERP konektory.

Balík staví na liquiddesign/eshop a nepotřebuje od shopu nic než konfiguraci. Rozdíly mezi verzemi 2.0–2.2 řeší uvnitř: repozitáře si bere přes DIConnection::findRepository(), chybějící tabulka nebo relace je null, ne chyba, a /v1/meta/capabilities řekne, co daný shop reálně vede.

Se sloupci, které mezi verzemi přibyly, se počítá zvlášť — chybějící sloupec v podmínce dotaz neshodí, jen se podmínka vynechá (Codebooks::hasColumn()). Týká se to eshop_product.deletedTs a eshop_price.hidden, které jsou až od eshopu 2.1; shop na 2.0 produkty měkce nemaže a ceny neskrývá, takže tam ty podmínky nedávají smysl. Běží to i na StORM 1.1 — balík z něj používá jen API, které je v 1.1 i 2.0 shodné.

Eshop 2.2 (ověřeno na Forbaby) se liší ještě jinde a balík s tím počítá: sazbu DPH produktu vede po zemích (eshop_productvatrate, země z vatCountry), u položky košíku nemá čas vložení, dodavatel nemá isImportActive a vazba zákazník–obchodník může být jen v tabulce M:N, bez sloupce eshop_customer.fk_merchant.

Jen ke čtení. Žádný endpoint balíku nemění data; jiná metoda než GET/HEAD vrací 405. Zápis si může přidat projekt vlastním endpointem, viz Vlastní endpointy z projektu.

Instalace

composer require liquiddesign/eshop-doryo-api
extensions:
    doryoApi: DoryoApi\Bridges\DoryoApiDI

doryoApi:
    shopName: 'Můj shop'
    shopUrl: 'https://muj-shop.cz'
    # null = token se vezme z proměnné prostředí DORYO_API_TOKEN; bez tokenu je API vypnuté
    token: null

Služby, routy i mapování presenteru si balík zaregistruje sám. Zbývá jediné, co musí udělat projekt: pustit požadavek přes svou bránu na front. Projekty postavené na Base\Application mají v config/environments.neon seznam frontAccess.exclude — přidej do něj DoryoApi:Api, jinak brána odmítne požadavek dřív, než se dostane na token.

Dvě věci, na které se naráží na klasickém serveru:

  • Adresa shopu. shopUrl bývá v repu s produkční adresou; testovací server ji přepíše env proměnnou DORYO_API_SHOP_URL (má přednost před configem), třeba SetEnv ve vhostu. Bez toho rozcestník i odkazy na produkty ukazují na produkci, i když data jdou z testu.

  • Apache s PHP přes FastCGI/CGI hlavičku Authorization do PHP nepředává a API pak vrací 401 Chybí hlavička Authorization, i když je token správně. Do vhostu dej CGIPassAuth On (Apache ≥ 2.4.13), nebo do .htaccess:

    RewriteCond %{HTTP:Authorization} .
    RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]

    Balík si hlavičku z HTTP_AUTHORIZATION i REDIRECT_HTTP_AUTHORIZATION přečte sám.

Konfigurace

klíč výchozí k čemu
prefix doryo-api prefix cesty; routy se registrují podle něj
token null Bearer token; null = z env DORYO_API_TOKEN
shopUrl null veřejná adresa shopu v odkazech; env DORYO_API_SHOP_URL ji přepíše
allowIps [] whitelist IP/CIDR; prázdné = stačí platný token
currency CZK měna, ve které se vydávají částky bez vazby na ceník
defaultPricelists [] ceníky pro veřejnou cenu; prázdné = ceníky výchozí skupiny zákazníků
defaultCustomerGroup null skupina, ze které se ceníky vezmou; null = výchozí po registraci
orderStates viz níž mapa normalizovaný stav → stavy shopu
invoicePaymentTracked true eviduje shop úhrady faktur? kde je vede ERP, dej false
vatCountry CZ země, jejíž sazbu DPH produktu API bere (eshop 2.2 je vede po zemích; starší volbu ignorují)
merchantScope.orders placed objednávky obchodníka: placed = které zadal, customers = jeho zákazníků (a které zadal)
merchantScope.subordinates false obchodník vidí i zákazníky přímých podřízených (eshop_merchant_nxn_subordinate)
merchantScope.customerGroups false k zákazníkům obchodníka patří i ti z jeho skupin zákazníků
customerPrices false vydávat ceny konkrétního zákazníka (vědomá výjimka, viz níž)
defaultLimit 200 kolik záznamů vrátí seznam nebo report bez limit
maxLimit 1000 strop parametru limit
defaultWindowMonths 6 výchozí okno seznamů a reportů bez data; shop se statisíci objednávek si ho zkrátí
maxWindowMonths 24 nejdelší okno, které API přijme
userfilesUrl null veřejná adresa složky s obrázky; null = adresa shopu + /userfiles
productUrlMask null odkaz na produkt, když ho shop nemá ve stránkách: produkt/{id} ({id}, {code})
extensions [] služby implementující DoryoApi\Extension\DoryoApiExtension

Obchodník (merchantId). Kolego dosazuje obchodníkovi s rolí „vidí jen svoje" parametr merchantId. U seznamů (zákazníci, objednávky, faktury, reporty) je to filtr, u cest podle id (/v1/customers/{id}/…, /v1/orders/{id}[/history|/shipments], /v1/invoices/{id}[/payment], i by-number) ověření vlastnictví — cizí záznam je 404. Komu co patří, řídí merchantScope; Forbaby ho má nastavený jako admin (orders: customers, podřízení i skupiny).

Měny v reportech. Shop, který prodává ve víc měnách, počítá tržby, top produkty a růst zákazníků jen z nákupů v jedné měně — currency, jinak výchozí currency z configu — a odpověď nese currency a currencyNote s tím, co vynechal. Shop s jedinou měnou se nefiltruje.

Výchozí mapa stavů je new: [open], processing: [received], delivered: [finished], cancelled: [canceled]. Stavy shipped a returned eshop nerozlišuje; shop, který si je vede po svém, si mapu přepíše.

Co API vydává

Kořen /{prefix} vrací rozcestník (odkaz na openapi.json, health a capabilities) — kdo si adresu otevře v prohlížeči, dostane odpověď API, ne stránkovou 404 shopu.

Zbytek je na /{prefix}/v1/…, seznamy v obálce { items, nextCursor, hasMore }, částky jako řetězec s měnou ({"amount": "12500.00", "currency": "CZK"}), chyby v application/problem+json česky. /{prefix}/openapi.json popisuje endpointy pro introspekci.

  • zákazníci — seznam, detail, jejich objednávky, faktury, souhrn, odebírané položky
  • objednávky — seznam a detail s položkami, historie změn, zásilky a balíky
  • faktury — seznam a detail, diagnostika úhrady
  • produkty a sklad — katalog s parametry, sklad s rozpadem po skladech, diagnostika viditelnosti a obrázků
  • ceny — ceníky, ceny z ceníku a (volitelně) ceny konkrétního zákazníka
  • reporty — tržby, top produkty, růst a pokles zákazníků, pohledávky, churn, pokrytí zásob, expedice, hodnocení, importy, zdraví katalogu, doklady bez protějšku
  • orientace — meta/capabilities, meta/codebooks, categories, suppliers, search

Aby odpověď nešla přečíst špatně

Odpověď čte model, ne člověk, takže tři věci nejdou nechat na domýšlení:

Prázdný seznam řekne, jestli za tím není jen výchozí okno. Seznamy a reporty bez zadaného rozsahu berou posledních defaultWindowMonths měsíců. Když se okno vzalo z výchozí hodnoty, odpověď to přizná v window — a je-li výsledek prázdný, přidá i note:

{
  "items": [],
  "nextCursor": null,
  "hasMore": false,
  "window": { "from": "2026-03-04", "to": "2026-09-04", "params": ["createdFrom", "createdTo"], "defaulted": true },
  "note": "Prázdné nemusí znamenat, že záznamy nejsou: bez createdFrom a createdTo se bere posledních 6 měsíců…"
}

Bez toho nejde rozeznat „zákazník nic neodebral" od „data jsou starší, než kam výchozí okno sahá" — obojí je items: []. Když si rozsah zadáš sám, window ani note v odpovědi nejsou; víš, na co ses ptal, a nemá smysl tím ujídat kontext.

Překlep v názvu parametru je 400, ne ticho. ?zakaznik=… nebo ?CreatedFrom=… by se jinak zahodily a vrátila by se nefiltrovaná data, která vypadají jako odfiltrovaná. Chyba vyjmenuje, co daný endpoint zná:

Neznámý parametr zakaznik. Tenhle endpoint zná: createdFrom, createdTo, cursor, customerId,
limit, q, status… Úplný popis je v /openapi.json.

Kód produktu se hledá ve všech podobách, v jakých ho člověk píše. Shop má kód rozdělený na code a subCode a každý je skládá jinak: v databázi je 37214 + 1, na dokladu z K2 37214.01, v jiném shopu 37214.1. Kdyby se filtry ptaly jen na code, kód z faktury by nenašel nic. Hledá se proto přes syrový kód, kód s podkódem doplněným na dvě místa i bez doplnění, kód dodavatele a katalogovou podobu bez dodavatelského prefixu — a zadaná hodnota se sama roztáhne na podobu s vodicí nulou i bez ní. Když si shop vede celý kód ve vlastním sloupci eshop_product.fullCode (Levior), bere se i ten. Platí to pro code/codes i pro fulltext q a /v1/search.

Nejdřív se zeptej, co shop vede

GET /v1/meta/capabilities řekne u každé domény, jestli ji shop používá, kolik má záznamů a kdy do ní naposled něco přibylo. Bez toho nejde poznat rozdíl mezi „dnes nic" a „tohle se tu nepoužívá" — a modelu se pak snadno stane, že si domyslí odpověď.

Kontrola se neptá jen na existenci řádků, ale na použitelné řádky. Ověřeno v praxi: shop měl 65 tisíc řádků recenzí, ve kterých nebylo ani jedno vyplněné hodnocení — byly to odeslané žádosti o hodnocení, ne recenze.

Co API nikdy nevydá

Odpověď skládá mapper pole po poli, nikdy toArray() entity. Ven nejdou hesla ani hashe, tokeny, nákupní ceny a marže, interní poznámky adminů ani platební údaje. Osobní údaje zákazníků ano — Doryo je pseudonymizuje na své straně a cenzurovat je tady by API znehodnotilo.

Jediná vědomá výjimka je customerPrices. Ceny konkrétního zákazníka jsou obchodní tajemství a ve výchozím stavu jsou vypnuté (endpoint vrací 403); bez nich ale nejde sestavit cenová nabídka, tak ať je to rozhodnutí shopu, ne balíku.

Rozšíření o vlastní pole

final class MojeRozsireni implements DoryoApi\Extension\DoryoApiExtension
{
    public function extendOrder(Eshop\DB\Order $order, array &$out): void
    {
        $out['eshop']['channel'] = 'web';
    }
    // extendCustomer(), extendProduct()
}
doryoApi:
    extensions: [@mojeRozsireni]

Rozšíření smí přidávat jen do klíče eshop — standardní pole mapper po zavolání vrátí zpátky, aby se nedal rozbít kontrakt s Doryo.

Indexy

Seznamy i reporty jdou oknem podle data objednávky a eshop na eshop_order.createdTs index nemá. Bez něj každé volání projde celou tabulku objednávek; na shopu se statisíci objednávek to jsou vteřiny navíc u každého reportu. Balík do schématu nesahá — index založ v projektu shopu (migrace):

ALTER TABLE eshop_order ADD INDEX eshop_order_createdTs (createdTs);

Že chybí, hlásí GET /v1/meta/health v poli warnings.

Reporty nad položkami (top-products, replenishment, sales?groupBy=category|producer) jdou jedním dotazem vedeným od objednávek (STRAIGHT_JOIN), ne přes seznam id nákupů v PHP — ten na čtyřiceti tisících objednávek za půl roku trval déle, než klient čekal. I tak platí: bez from a to se počítá celé výchozí okno; kdo se ptá na poslední týdny, má je zadat.

reports/churn hledá kandidáty (kdo objednával a od hranice nečinnosti mlčí) poddotazem jen nad objednávkami a nákupy a obrat sčítá až jim — do 1.7.0 se cena každé objednávky (čtyři korelované poddotazy) počítala všem zákazníkům za 24 měsíců a teprve pak se řadilo, takže menší limit nepomohl. Okno „mívali objednávky" je výchozí okno reportů před hranicí nečinnosti a from ho posouvá; merchantId zúží report na zákazníky jednoho obchodníka (sloupec i vazební tabulka M:N).

Reporty sales a top-products berou category a producer (id, kód nebo název; kategorie i s podkategoriemi): tržba se pak počítá z položek objednávek, ne z ceny objednávky, a odpověď nese filter s tím, co se našlo. „Kteří zákazníci berou nože" je jedno volání (groupBy=customer&category=Nože), stejně jako vývoj po měsících; do 1.8.1 to z API poskládat nešlo. S filtrem rozhoduje velikost množiny produktů: do pěti produktů (typicky product) jde dotaz od položek přes index eshop_cartitem(fk_product) a je rychlý i za celý rok; větší množina (kategorie, výrobce) jde od objednávek okna se spojením na materializovanou množinu produktů — na kopii Levioru 5 s za 3 měsíce, kdežto od položek 50–68 s (Nože: 162 produktů, ale 279 tisíc řádků položek za všechny roky). Cena roste s oknem, ne s velikostí kategorie — zadej období. Že index chybí, hlásí /v1/meta/health.

Dál (1.9.0) sales a top-products berou merchantId (nákupy zákazníků obchodníka — sloupec i vazební tabulka, ne kdo objednávku zadal) a customerId; sales navíc product (id nebo kód, holý kód zahrne podkódy), srovnání compare=lastYear|previous nebo compareFrom+compareTo (každý řádek dostane compare, change, changePercent; měsíce, týdny a dny se u lastYear párují o rok posunutým klíčem, u previous podle pořadí) a groupBy=newVsReturning (nový = první objednávka zákazníka padá do období; řádky nesou customers). Srovnání je druhý stejný dotaz, „noví vs. stálí" jeden průchod objednávkami navíc — na velkém shopu zadej období.

Cesty zákazníka podle id (/v1/customers/{id}, /orders, /invoices, /summary, /products, /prices) berou merchantId jako ověření vlastnictví: zákazník, který obchodníkovi nepatří, je 404, jako by neexistoval. Odpovědi těch cest vlastníka nenesou, takže Kolego jinak „obchodník vidí jen své zákazníky" u cest podle id neuhlídá — broker mu merchantId dosadí sám (od 1.8.1).

Vlastní endpointy z projektu

Balík nese jen to, co má každý shop na liquiddesign/eshop stejné. Co je jen tenhle projekt (zápis do jeho administrace, jeho vlastní doména), si přidá projekt sám: služba implementující DoryoApi\Endpoint\Endpoint se zaregistruje v endpoints: a balík ji přidá do routeru i do openapi.json.

final class AdminEndpoint implements
    DoryoApi\Endpoint\Endpoint,
    DoryoApi\Endpoint\MethodAware,
    DoryoApi\OpenApi\SpecificationPart
{
    /** @return array<string, string> vzor cesty => jméno obsluhy */
    public function getRoutes(): array
    {
        return ['v1/admin/pages' => 'pages', 'v1/admin/forms/{presenter}/{form}' => 'form'];
    }

    /** @return array<string, array<string>> bez téhle metody umí endpoint jen GET/HEAD */
    public function getMethods(): array
    {
        return ['v1/admin/forms/{presenter}/{form}' => ['GET', 'POST']];
    }

    /** @param array<string, string> $params */
    public function form(array $params, DoryoApi\Http\Query $query): DoryoApi\Http\Response
    {
        // $query->getMethod() rozliší čtení od zápisu, $query->getBody() je rozparsované JSON tělo
        return new DoryoApi\Http\Response(['ok' => true]);
    }

    /** @return array<string, mixed> */
    public function getOpenApiPaths(): array
    {
        return ['/v1/admin/pages' => ['get' => ['summary' => 'Mapa administrace', ...]]];
    }

    /** @return array<string, mixed> */
    public function getOpenApiComponents(): array
    {
        return [];
    }
}
services:
    adminEndpoint: App\DoryoApi\AdminEndpoint

doryoApi:
    endpoints: [@adminEndpoint]

Pravidla:

  • Výchozí stav je jen ke čtení. Bez MethodAware vrátí cokoliv jiného než GET/HEAD 405. Metody se hlásí per vzor cesty, takže jedna cesta může být jen ke čtení a druhá i k zápisu.
  • Pořadí kontrol je token → routa → metoda. Neznámá cesta je 404 i u POSTu, známá cesta se špatnou metodou 405 se seznamem toho, co umí.
  • Tělo se u zápisových metod rozparsuje z JSONu (neplatný JSON = 400 problem+json) a předá se v Query::getBody(). Signatura obsluh zůstává method(array $params, Query $query): Response.
  • Popis do openapi.json dodá endpoint přes SpecificationPart. Vestavěné cesty a schémata mají přednost — projekt smí přidávat, ne přepisovat. Schémata těla piš inline: parser v Doryo resolvuje $ref jen u parametrů, u schémat těla ne.
  • Chyby se vracejí přes DoryoApi\Http\ApiException (400, 401, 403, 404, 405, …), aby odpověď byla application/problem+json jako ve zbytku API.
  • Log volání nese metodu a u zápisu jen velikost těla, nikdy jeho obsah.

Testy

php tests/smoke.php https://muj-shop.cz/doryo-api <token>

Devadesát kontrol přes HTTP: autentizace, 405 na zápisové metody, tvar obálek, částky jako řetězce, stránkování kurzorem, stropy a validace parametrů, ceny, diagnostika, reporty, whitelist polí a platnost OpenAPI. Jede to schválně po drátě, ne přes DI kontejner — testuje se i routa a autentizace.