Wpisy
Dwa źródła danych, jeden sklep — synchronizacja WooCommerce z ERP i arkuszem katalogowym
Jak zsynchronizowałem sklep WooCommerce z kilkunastoma tysiącami SKU, gdy dane przychodzą z dwóch niezależnych systemów — i dlaczego import CSV w panelu admina tu nie wystarczy.
Pracuję przy sklepie online na WooCommerce — kilka–kilkanaście tysięcy SKU, promocje co tydzień, integracja z platformą dostawczą, dane z systemu kasowego i osobny plik katalogowy z biura. Jedno źródło prawdy? W teorii tak. W praktyce — nigdy.
Wiedziałem od razu, że jeden „universal importer” i import CSV z panelu admina przy tej skali odpadają — z wcześniejszych projektów e-commerce wiem, że WooCommerce API przy masowej aktualizacji meta i termów nie wytrzyma. Zamiast tego zaprojektowałem dwa niezależne pipeline’y, każdy pod inne źródło i inne zadanie. Poniżej opisuję architekturę oraz konkretne problemy: skąd się biorą, co psują i co wdrożyłem, żeby im zapobiec.
Kontekst: skala i architektura
Sklep, przy którym pracuję, ma kilkanaście tysięcy produktów. Cron synchronizacji odpala się co kilka–kilkanaście minut — w tym czasie musi się zgadzać kilka warstw danych naraz:
- Stany magazynowe z POS — klient widzi na stronie to, co jest fizycznie dostępne (albo świadomie ukryte w Archiwum).
- Ceny i promocje — w tym daty, wymogi omnibusowe i cena normatywna; rozjazd tu kończy się reklamacją albo kontrolą UOKiK.
- Katalog produktowy — nazwy, drzewo kategorii, tagi filtrujące, opisy, zdjęcia; to warstwa, którą klient przegląda przed dodaniem do koszyka.
- Widoczność na stronie vs. dostępność „na półce” — produkt może istnieć w ERP, ale nie być jeszcze w sprzedaży online (albo odwrotnie).
- Eksport do platform dostawczych — stany i ceny muszą być spójne z tym, co widzi klient w sklepie.
WooCommerce jest warstwą prezentacji i sprzedaży, ale nie jest źródłem prawdy o asortymencie. Źródła prawdy są dwa — i każde odpowiada za inny wycinek danych. Stąd decyzja o dwóch skryptach zamiast jednego.
Dwa źródła, dwa zadania
Pipeline’y działają równolegle — CSV nie zasila XML i odwrotnie. Oba piszą do tej samej bazy WooCommerce, ale każdy aktualizuje inne pola produktu.
1. Plik katalogowy CSV — „co w ogóle sprzedajemy”
Ten skrypt odpowiada za strukturę asortymentu — to, co biuro trzyma w arkuszu katalogowym i co trafia do sklepu jako opis, kategoria i widoczność w drzewie. Uruchamiam go rzadziej niż synchronizację stanów (katalog zmienia się wolniej niż stany na półce), ale każda zmiana dotyka setek lub tysięcy produktów naraz.
Co robi w praktyce:
- Pobranie i konwersja — plik leci z FTP w Windows-1250, konwertuję na UTF-8 zanim cokolwiek trafi do parsera; bez tego polskie znaki w kategoriach rozsypią się już na wejściu.
- Parsowanie — wiersze z separatorem
;, typowy eksport z Excela lub ERP; każdy wiersz to jeden produkt z GTIN, nazwą, ceną bazową i przypisaniem do kategorii. - Identyfikacja po GTIN — kod kreskowy jako główny klucz; SKU wewnętrzne bywa zmienne między systemami.
- Tworzenie i aktualizacja — nowe pozycje z sensowną ceną dostają kartę produktu; istniejące — odświeżone kategorie, tagi (bio, mrożone, 18+, vegan…), opisy, zdjęcia, marki.
- Archiwum — zniknięcie z pliku CSV nie kasuje produktu; trafia do kategorii Archiwum (soft delete), żeby URL i historia zamówień zostały.
- Przywracanie — powrót na stan w pliku katalogowym → wyciągam z Archiwum i przypisuję z powrotem kategorię zapisaną wcześniej w metadanych.
2. Eksport XML z systemu kasowego — „ile jest na półce i za ile”
Drugi skrypt to warstwa operacyjna — dane z kas, które zmieniają się często: stan, cena bieżąca, promocja, data końca akcji. Cron odpala go co kilka minut; pliki XML mają kilkadziesiąt MB, więc od razu zakładałem parsowanie strumieniowe, nie wczytanie całości do RAM.
Co robi w praktyce:
- Pobranie z FTP — osobny katalog i wzorzec plików niż w pipeline katalogowym; oba procesy mają własny lockfile, żeby nie pisały do bazy jednocześnie.
- Streaming XML —
XMLReaderzamiastSimpleXML/DOMDocument; plik czytam rekord po rekordzie, bez trzymania całego drzewa w pamięci. - Kodowanie — Latin II / CP1250 / ISO-8859-2 → UTF-8 blok po bloku; POS rzadko deklaruje encoding poprawnie w nagłówku.
- Aktualizacja handlowa — stan magazynowy, ceny, promocje, daty promocji, informacja omnibusowa, cena normatywna; to jedyne pola, które ten skrypt dotyka.
- Mapowanie promocji — symbole z kas (krótkie kody) → slugi atrybutów WooCommerce; surowy tekst z POS nie trafia wprost na front.
- Archiwum i przywracanie — brak stanu i ceny → Archiwum; stan > 0 → przywrócenie kategorii z zapisanego metadanych; nowy towar tylko w kasie → utworzenie produktu w WooCommerce jako fallback.
Podział odpowiedzialności
| Aspekt | Plik katalogowy CSV | Eksport XML (POS) |
|---|---|---|
| Tworzenie produktów | tak | tak (fallback) |
| Kategorie, tagi, opisy | tak | nie |
| Zdjęcia | tak | nie |
| Stan magazynowy | nie | tak |
| Ceny, promocje | nie | tak |
| Archiwizacja | brak w CSV | brak stanu/ceny |
Produkt może być w katalogu, ale niedostępny na stronie (POS: stan = 0) — i odwrotnie. To wynik dwóch niezależnych procesów biznesowych, nie błąd synchronizacji. Od początku zakładałem ten rozjazd i dlatego Archiwum + zapis _prod_cat to element projektu, nie łatanie po fakcie.
Problemy: przyczyny, skutki, przeciwdziałania
1. Wolny import przez WooCommerce API
Przyczyna: każda aktualizacja przez update_post_meta(), wp_set_object_terms() czy wc_get_product_id_by_sku() odpala hooki WooCommerce, invaliduje cache i przelicza term_taxonomy.count. Przy kilkunastu tysiącach SKU to setki tysięcy operacji w jednym przebiegu.
Skutki: import trwający godzinami, rosnące zużycie RAM, okno, w którym sklep jest obciążony zapisami do bazy — ryzyko timeoutów crona i kolizji z ruchem użytkowników.
Przeciwdziałanie: od początku planowałem warstwę batch SQL. Prototyp mapowania pól poszedł przez API (szybsze do weryfikacji logiki), produkcja — paczki po 200 produktów, INSERT ... ON DUPLICATE KEY UPDATE na postmeta, zbiorcze DELETE + INSERT na term_relationships, jeden UPDATE term_taxonomy.count na końcu, transakcje z trzema próbami i rollbackiem. Import schodzi z godziny do minut, bo omijam hooki WC tam, gdzie nie są potrzebne.
2. Pamięć w długotrwałym procesie PHP
Przyczyna: WordPress ładuje pełny runtime; każde get_post_meta() i lookup produktu dokłada do object cache. Przy iteracji po całym katalogu cache rośnie liniowo, aż skrypt uderza w memory_limit.
Skutki: losowe crashe w połowie importu, niedokończone transakcje, produkty zaktualizowane tylko częściowo.
Przeciwdziałanie: z wcześniejszych importów wiedziałem, że trzeba odciąć się od per-produktowych lookupów — preload metadanych jednym zapytaniem SQL, cache w tablicach PHP (meta, terms, post data), co 10 000 produktów wp_cache_flush() + gc_collect_cycles(). Do plików wejściowych — streaming (generator/yield, XMLReader), bo wczytanie 60 MB XML do pamięci to ten sam problem w innej formie.
3. Strefy czasowe promocji
Przyczyna: POS zapisuje daty promocji w czasie lokalnym (Europe/Warsaw), WooCommerce trzyma _sale_price_dates_* jako Unix timestamp w UTC. strtotime() z domyślną strefą UTC interpretuje lokalną datę jak UTC.
Skutki: promocje startują i kończą się ok. 2 godziny za wcześnie lub za późno — widoczne przy granicy północy i w okresie zmiany czasu, trudne do zdiagnozowania bez porównania timestampów.
Przeciwdziałanie: DateTimeImmutable z jawną strefą Europe/Warsaw → konwersja na timestamp. Znam ten wzorzec z wcześniejszych integracji promocji — tu wdrożyłem go od razu w warstwie parsowania XML, bez polegania na domyślnej strefie PHP.
4. Kodowanie polskich znaków
Przyczyna: CSV z ERP/Excela w Windows-1250, XML z POS w Latin II / ISO-8859-2. Źródła nie deklarują spójnie kodowania, a PHP domyślnie operuje na UTF-8.
Skutki: krzaki w kategoriach i opisach, odrzucenie wierszy przez walidację, rozjazd filtrów na froncie (tag „mrożone” nie trafia do produktu mimo poprawnej nazwy w ERP).
Przeciwdziałanie: wykrywanie kodowania z nagłówka XML + heurystyka dla CSV, konwersja strumieniowa blok po bloku (1 MB), atomowa podmiana pliku przez rename(). To standardowe podejście przy polskich integracjach — tu bez wyjątku.
5. Identyfikacja produktów między systemami
Przyczyna: POS operuje na SKU wewnętrznym, plik katalogowy na GTIN, czasem brakuje jednego identyfikatora, czasem w XML jest kilka kodów kreskowych na pozycję.
Skutki: duplikaty produktów, aktualizacja niewłaściwego wpisu, „zombie” bez powiązania między pipeline’ami.
Przeciwdziałanie: GTIN jako główny klucz łączenia (kod kreskowy zmienia się rzadziej niż SKU wewnętrzne), fallback na SKU, przy wielu EAN-ach w XML — pierwszy sensowny (≥ 8 znaków). Przed importem buduję indeks GTIN → post_id jednym zapytaniem, żeby nie szukać w pętli.
6. Promocje z nieustandaryzowanymi wartościami
Przyczyna: POS wysyła symbol promocji (krótki kod) lub opis tekstowy. WooCommerce oczekuje sluga atrybutu taksonomii — surowe dane trafiają wprost do pa_promo.
Skutki: atrybuty z pełnymi opisami marketingowymi zamiast filtrów, bałagan w warstwie prezentacji promocji, niemożność sensownego filtrowania na liście produktów.
Przeciwdziałanie: whitelist symboli z POS → ustandaryzowane slugi atrybutu; nieznany symbol → czyszczenie atrybutu zamiast zapisu śmieci. Mapowanie zdefiniowałem w konfiguracji skryptu, nie w logice ad hoc — wiedziałem z poprzednich wdrożeń, że surowe dane z kas zawsze wymagają warstwy normalizacji.
7. Równoległe uruchomienia importu
Przyczyna: cron co X minut + ręczne odpalanie podczas debugowania. Dwa procesy piszą jednocześnie do tych samych tabel.
Skutki: race condition, podwójne inserty, uszkodzone term_taxonomy.count, trudne do odtworzenia stany pośrednie.
Przeciwdziałanie: lockfile z flock(LOCK_EX | LOCK_NB) — drugi proces widzi lock i kończy się cicho. Stosuję ten sam wzorzec w obu skryptach; to pierwsza rzecz, którą dodaję do crona na produkcji.
8. Nadpisywanie danych z zewnętrznego PIM
Przyczyna: część produktów ma wzbogacone dane (zdjęcia, nazwy) z zewnętrznego dostawcy PIM. Import katalogowy domyślnie nadpisuje tytuł i obrazek.
Skutki: utrata jakościowych zdjęć i nazw, regresja po każdym cyklu synchronizacji CSV.
Przeciwdziałanie: flaga źródła na produkcie — jeśli ustawiona, pomijam tytuł i thumbnail w warstwie katalogowej. CSV aktualizuje tylko to, za co odpowiada (kategorie, tagi, opisy własne).
9. Nieaktualne ceny i stany po imporcie
Przyczyna: WooCommerce trzyma agregaty w transientach i tabelach lookup (wc_product_meta_lookup). Bezpośredni zapis do postmeta omija mechanizmy, które normalnie je odświeżają.
Skutki: front pokazuje stare ceny/stany mimo poprawnych danych w bazie, filtry po cenie zwracają błędne wyniki.
Przeciwdziałanie: na końcu każdego cyklu synchronizacji katalogu wywołuję:
wc_update_product_lookup_tables();
wc_scheduled_sales();
wc_delete_product_transients();
wc_delete_shop_order_transients();
To nie odkrycie — to obowiązkowy krok po każdym masowym imporcie poza API WooCommerce. Pomijanie tego kończy się supportem „sklep pokazuje złą cenę”.
10. Rozjazd katalogu i stanu magazynowego
Przyczyna: asortyment w pliku CSV (co jest w ofercie) ≠ stan w POS (co jest fizycznie dostępne). Dwa niezależne cykle biznesowe — biuro aktualizuje katalog, kasa — stany.
Skutki: produkt widoczny na stronie, ale niedostępny; produkt w kasie bez karty w sklepie; chaos w oczekiwaniach klienta i platform dostawczych.
Przeciwdziałanie: Archiwum jako soft delete (produkt zostaje w bazie, traci widoczność), zapis oryginalnej kategorii w _prod_cat przed archiwizacją, automatyczne przywrócenie przy powrocie stanu, tworzenie produktu z POS gdy towar pojawił się tylko w kasie. Hard delete odrzuciłem świadomie — URL-e, historia zamówień i SEO zostają nienaruszone.
Architektura techniczna (uproszczona)
Oba pipeline’y startują niezależnie — wspólny jest tylko cel (baza WooCommerce) i wzorzec zapisu (batch SQL); to dwa równoległe strumienie danych.
Wspólne elementy obu skryptów:
- Bootstrap WordPress przez
wp-load.phpbez motywu — potrzebuję funkcji WC i dostępu do bazy, nie frontu. - Lockfile (
flock) — cron co X minut plus ręczne odpalenia; bez locka dwa procesy nadpiszą sobie dane. - Logowanie do pliku +
error_logprzy błędach — przy kilkunastu tysiącach SKU „cicho padło w połowie” to koszmar do debugowania. - Archiwizacja plików po przetworzeniu (zip + upload na FTP) — żeby wiedzieć, który eksport był ostatni i móc odtworzyć stan w razie incydentu.
Co dalej
Synchronizacja działa, ale wymaga utrzymania — monitoring opóźnień między kasą a widocznością online, spójność cen w koszyku po imporcie, push stanów do platform zewnętrznych. Większość pułapek opisanych wyżej znam z wcześniejszych wdrożeń; tu chodziło o to, żeby od razu zaplanować architekturę pod skalę i dwa źródła, zamiast łatać jeden importer.
Jeśli prowadzisz sklep WooCommerce z kilkoma źródłami danych i czujesz, że „import CSV” to za mało — prawdopodobnie masz rację.