Blog

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 — XMLReader zamiast SimpleXML/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.php bez 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_log przy 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ę.