Zmiany na żywo w ekranach integracji
Dla: Programista
Przykłady na tej stronie używają adresu . Zmień adres
Ekran integracji — tablica hali, panel handlowca albo nakładka na Prynt otwarta przez cały dzień — powinien pokazać zmianę z Prynt kilka sekund po niej, bez przeładowania strony. API nie wysyła zmian samo: klucz nie otwiera subskrypcji GraphQL ani WebSocketa, a Prynt nie wywołuje adresów Twojej aplikacji. Ten przepis robi to jednym pollerem w backendzie integracji: co 15 sekund jedno żądanie pyta osiem list naraz, co się w nich zmieniło, a Twój backend wysyła zmiany do przeglądarek.
Jak to działa
Dział zatytułowany „Jak to działa”-
Jeden poller w backendzie integracji, nie w przeglądarce. Klucz zostaje na serwerze: żądanie z kluczem wysłane przez przeglądarkę API odrzuca (zobacz Uwierzytelnianie). Limit żądań jest wspólny dla całej integracji, więc jeden poller obsługuje wszystkich użytkowników Twoich ekranów.
-
Co 10–15 sekund jedna sonda zmian: jedno zapytanie z kilkoma listami, z każdej tylko identyfikatory i czasy zmiany (
id,updatedAt) rekordów zmienionych w ostatnich minutach. Sonda porównuje je z tym, co widziała poprzednio, i zwraca tylko nowe zmiany. -
Szczegóły tylko zmienionych rekordów pobiera drugie zapytanie, np.
order(id:). Gdy nic się nie zmieniło, poller nie wysyła nic więcej. -
Zmiany do przeglądarek wysyła Twój backend własnym kanałem: Server-Sent Events albo WebSocketem. Przeglądarki nigdy nie pytają Prynt same.
Czego potrzebujesz
Dział zatytułowany „Czego potrzebujesz”- Klucz z zakresem odczytu każdej listy sondy. Sonda z tego przepisu pyta o zamówienia i
operacje (
orders:read), zadania hali (production:read), klientów (clients:read), papier, farby i folie (warehouse:read) oraz faktury sprzedaży (invoices:read). Usuń z niej listy, których Twoje ekrany nie pokazują: gdy klucz nie ma zakresu jednej z list, API odrzuca całe zapytanie błędemAPI_SCOPE_MISSING. - Klienta API z przepisu Obsługa błędów i ponowień. Kod niżej wysyła żądania przez niego.
- Kanał do przeglądarek w Twoim backendzie: Server-Sent Events albo WebSocket.
- Zegar serwera zsynchronizowany z czasem światowym (NTP). Okno sondy liczysz od chwili według zegara Twojego serwera.
Sonda zmian
Dział zatytułowany „Sonda zmian”Każda lista sondy ma ten sam kształt: filtr updatedFrom, sortowanie UPDATED_AT rosnąco, stronę
po 100 rekordów i same pola proste. Pełne zapytanie z ośmioma listami jest w
kodzie niżej; dwie pierwsze wyglądają tak:
query ChangesProbe($from: DateTime!, $page: Int!) { orders( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } operations( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } # … tak samo tasks, clients, papers, paints, foils i productInvoices}- Okno, nie znacznik. Każda sonda pyta o zmiany z ostatnich 5 minut (
from= chwila sondy minus 5 minut) i porównuje je z tym, co już widziała, poidiupdatedAt. Każdą zmianę zgłasza więc raz, a nie gubi zmiany zapisanej tuż przed poprzednią sondą i widocznej w API dopiero po niej. Rekordy, które wypadły z okna, sonda zapomina, więc jej pamięć nie rośnie. - Kolejna strona tylko przy wielu zmianach. Gdy któraś lista ma w oknie więcej niż
100 zmienionych rekordów (np. po masowej zmianie statusów), sonda pyta o kolejną
stronę wszystkich list naraz; liczbę stron listy podaje
pageInfo.totalPages. Listy, które już się skończyły, zwracają pustą stronę. - Najwyżej 3 strony jednej listy. Lista, której zmiany zajmują w oknie więcej stron (burza
zmian, np. inwentaryzacja albo masowa zmiana), dostaje w wyniku sondy
nullzamiast identyfikatorów, a sonda nie pyta o jej kolejne strony, dopóki burza nie wyjdzie z okna. Zobacz Burza zmian. - Koszt. Pola proste nic nie kosztują, więc każda lista kosztuje 3 punkty bez względu na
rozmiar strony: cała sonda to 24 z 200 punktów
limitu złożoności. Szczegółów nie dokładaj do sondy:
pole obiektowe w
itemskosztuje tyle razy, ile rekordów ma strona. - Jedno żądanie. Kilka list w jednym zapytaniu to jedno żądanie limitu, a nie osiem.
Ile żądań to kosztuje
Dział zatytułowany „Ile żądań to kosztuje”Domyślny limit integracji to 120 żądań na minutę (Limity API). Poller z tego przepisu zużywa z niego niewiele:
| Co | Jak często | Żądań na minutę |
|---|---|---|
| Sonda zmian | co 15 sekund | 4, przy wielu zmianach do 12 |
| Szczegóły zmienionych rekordów | tylko po zmianie | zwykle kilka |
Słowniki (machines, referenceData, billingEntities, currencies, vatRates, clientTags) |
co kilka minut albo gdy rekord wskaże nieznany identyfikator | poniżej 1 |
| Pełne przejście list (usunięte rekordy) | raz na dobę, poza godzinami pracy drukarni | — |
Nie odpytuj częściej niż co 10 sekund: każda sonda zużywa limit także wtedy, gdy nic się nie zmieniło, a ekran człowieka nie zyska na świeżości liczonej w pojedynczych sekundach.
Burza zmian
Dział zatytułowany „Burza zmian”Sonda czyta najwyżej 3 strony jednej listy, czyli 300 rekordów zmienionych w oknie 5 minut. Przy wielu zmianach zużywa więc najwyżej 3 żądania na sondę, 12 na minutę. Większej burzy (np. inwentaryzacji 3000 kart albo masowej zmiany) nie czyta do końca: 30 stron na sondę to 120 żądań na minutę przez 5 minut, a domyślny limit integracji to 120 żądań na minutę — zapisy i zapytania o szczegóły dostawałyby wtedy odpowiedź 429.
Lista z burzą dostaje w wyniku sondy:
nullw pierwszej sondzie burzy: sonda nie wie, co się w niej zmieniło, więc każ przeglądarkom załadować od nowa ekrany, które pokazują tę listę, tak jak po starcie pollera;- pustą listę w kolejnych sondach, dopóki burza nie wyjdzie z okna — o kolejne strony tej listy sonda wtedy nie pyta;
nullw pierwszej sondzie po burzy: zmian tej listy z czasu burzy sonda nie zgłosiła, więc załaduj te ekrany jeszcze raz.
Pozostałe listy działają w tym czasie normalnie: burza w magazynie nie zatrzymuje tablicy hali.
Który zegar co pokazuje
Dział zatytułowany „Który zegar co pokazuje”Sonda widzi rekord, gdy przesunął się jego updatedAt. Co go przesuwa, opisuje pole updatedAt w
referencji API, a szczegóły — Paginacja, filtry i
synchronizacja:
| Lista | updatedAt przesuwa |
|---|---|
orders |
każda zmiana zamówienia i tego, co w nim jest: produktów, elementów, operacji, płatności, kosztów dodatkowych, plików, kontaktu, dostaw i danych do faktury, a także każda zmiana statusu zadania na hali |
operations |
zmiana operacji (ilość, czas, maszyna, pozycja w elemencie, wykonana ilość, zakończenie) i każda zmiana statusu jej zadania; zmiana zamówienia, produktu ani elementu go nie przesuwa |
tasks |
każda zmiana zadania: rozpoczęcie, wstrzymanie, wznowienie, zakończenie, anulowanie, także zmiana, której API nie pokazuje |
clients |
zmiana klienta, jego kontaktów, adresów, tagów i notatek widocznych dla integracji |
papers, paints, foils |
zmiana karty materiału i każdy ruch stanu: przyjęcie, wydanie, dostawa z zamówienia zakupu, korekta inwentaryzacji |
productInvoices |
zmiana faktury i jej dokumentu (PDF) |
Sonda zgłasza rekord, gdy przesunął się jego updatedAt, także wtedy, gdy zmieniło się pole,
którego Twoje ekrany nie pokazują. Jedna zmiana na hali pokazuje się w trzech listach naraz:
zadanie, jego operacja i zamówienie. Odśwież te ekrany, które pokazują dane z każdej z nich.
Tak samo możesz dodać do sondy inne listy z filtrem updatedFrom: purchaseInvoices,
purchaseOrders, suppliers, dieCuts, matrices i stockTakes: dopisz listę do zapytania
i jej nazwę do nazw list w kodzie (PROBED_LISTS, LISTS albo lists). Ruchy magazynowe
(stockMovements) nie zmieniają się po zapisaniu i mają filtr occurredFrom oraz sortowanie
OCCURRED_AT; w zapytaniu sondy pobierz je z aliasem updatedAt: occurredAt, a w kodzie dopisz
stockMovements do nazw list jak każdą inną listę. Zwykle wystarczą jednak listy materiałów:
każdy ruch przesuwa updatedAt papieru, farby albo folii.
Czego sonda nie widzi
Dział zatytułowany „Czego sonda nie widzi”- Usuniętych rekordów list. Usunięcie produktu albo operacji przesuwa
updatedAtzamówienia, więc zgłosi je listaorders. Usunięty rekord samej listy (np. zamówienie, operacja, karta materiału, faktura) nie ma jużupdatedAt, więc sonda go nie zgłosi, a jego szczegóły nie wrócą (order(id:)zwracanull) — wtedy usuń go z ekranu. Kopię danych u siebie porządkuj pełnym przejściem listy raz na dobę. - Usunięcia klienta. Klient usunięty w Prynt nie znika, tylko zostaje dezaktywowany: sonda
zgłosi go jak zwykłą zmianę, a jego szczegóły wrócą z
isActive: false, nie jakonull. Po tej fladze zdejmij go z ekranu (zobacz Klienci do CRM i z powrotem). - Pojedynczych zmian listy w czasie burzy zmian. Gdy zmiany jednej listy
zajmują w oknie więcej niż 3 strony, sonda zwraca dla niej
nulli do końca burzy nie zgłasza jej pojedynczych zmian. Ekrany tej listy odświeżysz wtedy w całości, a pozostałe listy działają normalnie. - Gotowości operacji do startu.
Operation.readyToStartzmienia się, gdy hala skończy inną operację elementu, bez ruchuupdatedAttej operacji. Przesuwa się wtedyupdatedAtzamówienia: po zmianie zamówienia odśwież wszystkie jego operacje. - Słowników.
machines,referenceData,billingEntities,currencies,vatRatesiclientTagsnie mają zegara zmian: odświeżaj je co kilka minut albo gdy rekord wskaże identyfikator, którego jeszcze nie znasz. - Nowych pól po wydaniu Prynt. Pole dodane do istniejącego typu nie przesuwa
updatedAtstarszych rekordów. Jeśli Twoje ekrany pokazują je z kopii danych u siebie, po wydaniu, które je wprowadza, pobierz te listy pełnym przejściem. Nowe pola zapowiada historia zmian API. - Zmian sprzed startu pollera. Sonda pamięta tylko to, co widziała, i patrzy 5 minut wstecz. Po starcie pollera (także po restarcie albo przerwie dłuższej niż 5 minut) każ przeglądarkom załadować ekrany od nowa.
Sonda pamięta, co już widziała, więc trzymaj jeden jej egzemplarz przez cały czas działania
backendu. Każde wywołanie zwraca identyfikatory rekordów nowych albo zmienionych od poprzedniego,
po liście, np. {"orders":["…"],"operations":[],…}. null zamiast identyfikatorów listy znaczy
burzę zmian: załaduj wtedy od nowa ekrany, które pokazują tę listę. To, co
widziała, sonda zapamiętuje dopiero po udanym końcu, więc zmiany z sondy przerwanej błędem zgłosi
następna.
Przykłady pokazują adres zastępczy. Wpisz adres swojej drukarni, żeby go podmienić.
Wymaga curl, jq i skryptu prynt-call.sh z przepisu
Obsługa błędów i ponowień w tym samym katalogu. Jedno uruchomienie
to jedna sonda: wypisuje zmiany jako jedną linię JSON (lista z wartością null to burza zmian),
a to, co widziała, trzyma w pliku sonda-zmian.json.
#!/usr/bin/env bash# changes-probe.sh — jedna sonda zmian (curl, jq; prynt-call.sh z przepisu „Obsługa błędów# i ponowień”): identyfikatory rekordów ośmiu list, które są nowe albo zmieniły się od poprzedniej# sondy, jako jedna linia JSON, np. {"orders":["…"],"operations":[],…}. Uruchamiaj ją co 15 sekund,# zawsze po zakończeniu poprzedniej:# while :; do ./changes-probe.sh >>zmiany.ndjson; sleep 15; done# null zamiast identyfikatorów znaczy burzę zmian w tej liście (więcej niż max_pages stron w oknie):# sonda nie wie, co się w niej zmieniło, więc załaduj od nowa ekrany, które ją pokazują. Lista# dostaje null w pierwszej sondzie burzy i w pierwszej po niej, a w trakcie — pustą listę.# Czasy zmian rekordów z okna i listy z burzą trzyma w pliku sonda-zmian.json, zapisywanym dopiero# po udanej sondzie. Chwilę sondy (ISO 8601, UTC) możesz podać argumentem; domyślnie teraz według# zegara Twojego serwera.set -euo pipefail
state_file=sonda-zmian.jsonsaved=$(cat "$state_file" 2>/dev/null || echo '{}')body=$(mktemp) result=$(mktemp)trap 'rm -f "$body" "$result"' EXIT
# Sonda zmian: osiem list w jednym żądaniu, z każdej tylko identyfikator i czas zmiany rekordów# zmienionych od `from`. Pola proste nic nie kosztują, więc całe zapytanie kosztuje 3 punkty na# listę (24 z 200) mimo stron po 100 rekordów. Usuń listy, których Twoje ekrany nie pokazują:# klucz potrzebuje zakresu odczytu każdej listy z zapytania.query='query ChangesProbe($from: DateTime!, $page: Int!) { orders( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } operations( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } tasks( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } clients( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } papers( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } paints( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } foils( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } productInvoices( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } }}'# Listy sondy, w kolejności z zapytania.lists='["orders", "operations", "tasks", "clients", "papers", "paints", "foils", "productInvoices"]'
# Najwięcej stron jednej listy w sondzie: 300 rekordów zmienionych w oknie. Przy większej burzy# zmian (masowa zmiana, inwentaryzacja) sonda nie pyta o kolejne strony tej listy, dopóki burza nie# wyjdzie z okna, i zwraca dla niej null: inaczej przez 5 minut każda sonda zużywałaby limit żądań# na strony tej burzy.max_pages=3
# Okno sondy: zmiany z ostatnich 5 minut. Zapas łapie zmiany zapisane tuż przed poprzednią sondą,# a widoczne w API dopiero po niej, i drobne różnice zegarów.now=${1:-$(date -u +%Y-%m-%dT%H:%M:%S.000Z)}from=$(jq -nr --arg now "$now" '$now | capture("^(?<time>[^.]+?)(?<ms>\\.[0-9]+)?Z$") | ((.time + "Z") | fromdateiso8601 - 300 | todateiso8601 | rtrimstr("Z")) + (.ms // ".000") + "Z"')
# Jedna strona odpowiedzi: lista z więcej niż max_pages stronami to burza (jej rekordy pomijamy),# a rekord pozostałych jest zmianą, gdy sonda nie widziała go jeszcze z tym czasem zmiany; każdy# zgłasza raz. `pages` to liczba stron najdłuższej listy bez burzy.page_step='.pages = 0 | reduce $lists[] as $list (.; $data[$list] as $answer | (if $answer.pageInfo.totalPages > $max and (.flooded | any(.[]; . == $list) | not) then .flooded += [$list] else . end) | if .flooded | any(.[]; . == $list) then . else .pages = ([.pages, $answer.pageInfo.totalPages] | max) | reduce $answer.items[] as $item (.; (if .seen[$list][$item.id] != $item.updatedAt and (.changes[$list] | any(.[]; . == $item.id) | not) then .changes[$list] += [$item.id] else . end) | .seen[$list][$item.id] = $item.updatedAt) end)'
# Koniec sondy: lista z burzą dostaje null w jej pierwszej sondzie (potem pustą listę) i zachowuje# czasy zmian sprzed burzy; pozostałe zapominają rekordy sprzed okna (nie wrócą w następnej sondzie# z tym samym czasem zmiany), a lista tuż po burzy dostaje null. Czasy z API mają jeden format# (np. 2026-10-05T10:41:02.000Z), więc porównujemy je jako tekst.finish='reduce $lists[] as $list (.; ($saved.flooded // [] | any(.[]; . == $list)) as $was | if .flooded | any(.[]; . == $list) then .changes[$list] = (if $was then [] else null end) | .seen[$list] = ($saved.seen[$list] // {}) else .seen[$list] |= with_entries(select(.value >= $from)) | if $was then .changes[$list] = null else . end end)'
state=$(jq -n --argjson lists "$lists" --argjson saved "$saved" \ '{ seen: (reduce $lists[] as $list ($saved.seen // {}; .[$list] //= {})), changes: (reduce $lists[] as $list ({}; .[$list] = [])), flooded: [], pages: 1 }')page=1# Kolejna strona tylko wtedy, gdy któraś lista bez burzy ma w oknie więcej niż 100 rekordów.while ((page <= $(jq -r '.pages' <<<"$state"))); do jq -n --arg query "$query" --arg from "$from" --argjson page "$page" \ '{ query: $query, variables: { from: $from, page: $page } }' >"$body" ./prynt-call.sh "$body" >"$result" state=$(jq -c --argjson lists "$lists" --argjson max "$max_pages" --argjson state "$state" \ ".data as \$data | \$state | $page_step" "$result") page=$((page + 1))done
# Plik stanu zapisujemy dopiero po udanej sondzie: przerwana nie oznacza rekordów jako widzianych.state=$(jq -c --argjson lists "$lists" --argjson saved "$saved" --arg from "$from" "$finish" \ <<<"$state")jq -c '{ seen, flooded }' <<<"$state" >"$state_file"jq -c '.changes' <<<"$state"# Kolejna sonda 15 sekund po zakończeniu poprzedniej; nieudaną pomija i próbuje dalej.while :; do ./changes-probe.sh >>zmiany.ndjson || echo "sonda nieudana" >&2 sleep 15done// changes-probe.mjs — Node.js 18+, klient API z przepisu „Obsługa błędów i ponowień”import { pryntRequest } from './prynt-client.mjs';
// Okno sondy: zmiany z ostatnich 5 minut. Zapas łapie zmiany zapisane tuż przed poprzednią sondą, a// widoczne w API dopiero po niej, i drobne różnice zegarów.const WINDOW_MS = 5 * 60 * 1000;
// Najwięcej stron jednej listy w sondzie: 300 rekordów zmienionych w oknie. Przy większej burzy// zmian (masowa zmiana, inwentaryzacja) sonda nie pyta o kolejne strony tej listy, dopóki burza nie// wyjdzie z okna, i zwraca dla niej `null`: inaczej przez 5 minut każda sonda zużywałaby limit// żądań na strony tej burzy.const MAX_PAGES = 3;
// Sonda zmian: osiem list w jednym żądaniu, z każdej tylko identyfikator i czas zmiany rekordów// zmienionych od `from`. Pola proste nic nie kosztują, więc całe zapytanie kosztuje 3 punkty na// listę (24 z 200) mimo stron po 100 rekordów. Usuń listy, których Twoje ekrany nie pokazują:// klucz potrzebuje zakresu odczytu każdej listy z zapytania.const PROBE = `query ChangesProbe($from: DateTime!, $page: Int!) { orders( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } operations( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } tasks( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } clients( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } papers( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } paints( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } foils( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } productInvoices( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } }}`;
/** Listy sondy, w kolejności z zapytania. */export const PROBED_LISTS = [ 'orders', 'operations', 'tasks', 'clients', 'papers', 'paints', 'foils', 'productInvoices',];
/** * Sonda zmian w backendzie integracji. Każde `next()` to jedno żądanie do Prynt (gdy któraś lista * ma w oknie więcej niż 100 zmienionych rekordów — najwyżej trzy) i zwraca identyfikatory rekordów * nowych albo zmienionych od poprzedniej sondy, po liście: `{ orders: ['…'], operations: [], … }`. * Sonda pamięta czasy zmian rekordów z okna, więc każdą zmianę zgłasza raz. * * `null` zamiast identyfikatorów znaczy burzę zmian w tej liście (więcej niż `MAX_PAGES` stron w * oknie): sonda nie wie, co się w niej zmieniło, więc załaduj od nowa ekrany, które ją pokazują. * Lista dostaje `null` w pierwszej sondzie burzy i w pierwszej po niej, a w trakcie — pustą listę. */export class ChangesProbe { #seen = Object.fromEntries(PROBED_LISTS.map((list) => [list, new Map()])); /** Listy, w których trwa burza zmian. */ #flooded = new Set();
/** Jedna sonda. `now` to chwila sondy według zegara Twojego serwera (domyślnie teraz). */ async next(now = new Date()) { const from = new Date(now.getTime() - WINDOW_MS).toISOString(); // Sonda zapisuje na kopii i przyjmuje ją dopiero na końcu: gdy żądanie kolejnej strony się nie // uda, następna sonda zgłosi także zmiany z poprzednich stron. const seen = Object.fromEntries(PROBED_LISTS.map((list) => [list, new Map(this.#seen[list])])); const changes = Object.fromEntries(PROBED_LISTS.map((list) => [list, []])); const flooded = new Set(); // Kolejna strona tylko wtedy, gdy któraś lista bez burzy ma w oknie więcej niż 100 rekordów. for (let page = 1, pages = 1; page <= pages; page += 1) { const data = await pryntRequest({ query: PROBE, variables: { from, page } }); pages = 0; for (const list of PROBED_LISTS) { const { items, pageInfo } = data[list]; if (pageInfo.totalPages > MAX_PAGES) flooded.add(list); if (flooded.has(list)) continue; pages = Math.max(pages, pageInfo.totalPages); const known = seen[list]; for (const { id, updatedAt } of items) { if (known.get(id) !== updatedAt && !changes[list].includes(id)) changes[list].push(id); known.set(id, updatedAt); } } } return this.#settle(changes, seen, flooded, from); }
/** * Koniec udanej sondy: lista z burzą dostaje `null` w pierwszej sondzie burzy (potem pustą listę) * i zachowuje czasy zmian sprzed niej; pozostałe przyjmują to, co sonda widziała, a lista tuż po * burzy dostaje `null`. */ #settle(changes, seen, flooded, from) { for (const list of PROBED_LISTS) { const wasFlooded = this.#flooded.has(list); if (flooded.has(list)) { changes[list] = wasFlooded ? [] : null; } else { this.#seen[list] = forgetBefore(seen[list], from); if (wasFlooded) changes[list] = null; } } this.#flooded = flooded; return changes; }}
/** Rekord sprzed okna nie wróci w następnej sondzie z tym samym czasem zmiany: zapominamy go. */function forgetBefore(seen, from) { // Czasy z API mają jeden format (np. 2026-10-05T10:41:02.000Z), więc porównujemy je jako tekst. for (const [id, updatedAt] of seen) if (updatedAt < from) seen.delete(id); return seen;}import { ChangesProbe } from './changes-probe.mjs';
const probe = new ChangesProbe();
async function tick() { try { const changes = await probe.next(); for (const [list, ids] of Object.entries(changes)) { // Twoje funkcje: reload — każ przeglądarkom załadować od nowa ekrany tej listy (burza zmian), // publish — pobierz szczegóły rekordów i wyślij je przeglądarkom (SSE, WebSocket). if (ids === null) await reload(list); else if (ids.length > 0) await publish(list, ids); } } catch (error) { console.error('Sonda zmian nieudana:', error.message); // kolejna za 15 sekund } setTimeout(tick, 15_000); // kolejna sonda 15 sekund po zakończeniu poprzedniej}
tick();# changes_probe.py — Python 3.9+, klient API z przepisu „Obsługa błędów i ponowień”from datetime import datetime, timedelta, timezone
from prynt_client import prynt_request
# Okno sondy: zmiany z ostatnich 5 minut. Zapas łapie zmiany zapisane tuż przed poprzednią sondą, a# widoczne w API dopiero po niej, i drobne różnice zegarów.WINDOW = timedelta(minutes=5)
# Najwięcej stron jednej listy w sondzie: 300 rekordów zmienionych w oknie. Przy większej burzy# zmian (masowa zmiana, inwentaryzacja) sonda nie pyta o kolejne strony tej listy, dopóki burza nie# wyjdzie z okna, i zwraca dla niej None: inaczej przez 5 minut każda sonda zużywałaby limit# żądań na strony tej burzy.MAX_PAGES = 3
# Sonda zmian: osiem list w jednym żądaniu, z każdej tylko identyfikator i czas zmiany rekordów# zmienionych od `from`. Pola proste nic nie kosztują, więc całe zapytanie kosztuje 3 punkty na# listę (24 z 200) mimo stron po 100 rekordów. Usuń listy, których Twoje ekrany nie pokazują:# klucz potrzebuje zakresu odczytu każdej listy z zapytania.PROBE = """query ChangesProbe($from: DateTime!, $page: Int!) { orders( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } operations( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } tasks( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } clients( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } papers( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } paints( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } foils( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } productInvoices( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } }}"""
# Listy sondy, w kolejności z zapytania.PROBED_LISTS = [ "orders", "operations", "tasks", "clients", "papers", "paints", "foils", "productInvoices",]
def _iso(instant): return instant.astimezone(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
class ChangesProbe: """Sonda zmian w backendzie integracji. Każde `next()` to jedno żądanie do Prynt (gdy któraś lista ma w oknie więcej niż 100 zmienionych rekordów — najwyżej trzy) i zwraca identyfikatory rekordów nowych albo zmienionych od poprzedniej sondy, po liście: {"orders": ["…"], "operations": [], …}. Sonda pamięta czasy zmian rekordów z okna, więc każdą zmianę zgłasza raz.
None zamiast identyfikatorów znaczy burzę zmian w tej liście (więcej niż MAX_PAGES stron w oknie): sonda nie wie, co się w niej zmieniło, więc załaduj od nowa ekrany, które ją pokazują. Lista dostaje None w pierwszej sondzie burzy i w pierwszej po niej, a w trakcie — pustą listę."""
def __init__(self): self._seen = {name: {} for name in PROBED_LISTS} self._flooded = set() # listy, w których trwa burza zmian
def next(self, now=None): """Jedna sonda. `now` to chwila sondy według zegara Twojego serwera (domyślnie teraz).""" start = _iso((now or datetime.now(timezone.utc)) - WINDOW) # Sonda zapisuje na kopii i przyjmuje ją dopiero na końcu: gdy żądanie kolejnej strony się # nie uda, następna sonda zgłosi także zmiany z poprzednich stron. seen = {name: dict(records) for name, records in self._seen.items()} changes = {name: [] for name in PROBED_LISTS} flooded = set() # Kolejna strona tylko wtedy, gdy któraś lista bez burzy ma w oknie ponad 100 rekordów. page, pages = 1, 1 while page <= pages: data = prynt_request(PROBE, {"from": start, "page": page}) pages = 0 for name in PROBED_LISTS: total = data[name]["pageInfo"]["totalPages"] if total > MAX_PAGES: flooded.add(name) if name in flooded: continue pages = max(pages, total) for item in data[name]["items"]: record_id, updated_at = item["id"], item["updatedAt"] if seen[name].get(record_id) != updated_at and record_id not in changes[name]: changes[name].append(record_id) seen[name][record_id] = updated_at page += 1 return self._settle(changes, seen, flooded, start)
def _settle(self, changes, seen, flooded, start): """Koniec udanej sondy: lista z burzą dostaje None w pierwszej sondzie burzy (potem pustą listę) i zachowuje czasy zmian sprzed niej; pozostałe przyjmują to, co sonda widziała, a lista tuż po burzy dostaje None.""" for name in PROBED_LISTS: was_flooded = name in self._flooded if name in flooded: changes[name] = [] if was_flooded else None else: self._seen[name] = _forget_before(seen[name], start) if was_flooded: changes[name] = None self._flooded = flooded return changes
def _forget_before(seen, start): """Rekord sprzed okna nie wróci w następnej sondzie z tym samym czasem zmiany: zapominamy go. Czasy z API mają jeden format (np. 2026-10-05T10:41:02.000Z), więc porównujemy je jako tekst.""" return {record_id: updated_at for record_id, updated_at in seen.items() if updated_at >= start}import time
from changes_probe import ChangesProbe
probe = ChangesProbe()while True: try: for name, ids in probe.next().items(): # reload i publish: Twoje funkcje — ekrany tej listy od nowa (burza zmian) albo # szczegóły rekordów do przeglądarek (SSE, WebSocket) if ids is None: reload(name) elif ids: publish(name, ids) except Exception as error: # kolejna sonda za 15 sekund print("Sonda zmian nieudana:", error) time.sleep(15) # kolejna sonda 15 sekund po zakończeniu poprzedniej<?php// ChangesProbe.php — PHP 8.1+, klient API z przepisu „Obsługa błędów i ponowień”require_once __DIR__ . '/PryntClient.php';
final class ChangesProbe{ // Okno sondy: zmiany z ostatnich 5 minut. Zapas łapie zmiany zapisane tuż przed poprzednią // sondą, a widoczne w API dopiero po niej, i drobne różnice zegarów. private const WINDOW = 'PT5M';
// Najwięcej stron jednej listy w sondzie: 300 rekordów zmienionych w oknie. Przy większej // burzy zmian (masowa zmiana, inwentaryzacja) sonda nie pyta o kolejne strony tej listy, dopóki // burza nie wyjdzie z okna, i zwraca dla niej null: inaczej przez 5 minut każda sonda // zużywałaby limit żądań na strony tej burzy. private const MAX_PAGES = 3;
// Sonda zmian: osiem list w jednym żądaniu, z każdej tylko identyfikator i czas zmiany rekordów // zmienionych od `from`. Pola proste nic nie kosztują, więc całe zapytanie kosztuje 3 punkty na // listę (24 z 200) mimo stron po 100 rekordów. Usuń listy, których Twoje ekrany nie pokazują: // klucz potrzebuje zakresu odczytu każdej listy z zapytania. private const PROBE = <<<'GRAPHQL' query ChangesProbe($from: DateTime!, $page: Int!) { orders( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } operations( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } tasks( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } clients( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } papers( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } paints( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } foils( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } productInvoices( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id updatedAt } pageInfo { totalPages } } } GRAPHQL;
// Listy sondy, w kolejności z zapytania. public const LISTS = [ 'orders', 'operations', 'tasks', 'clients', 'papers', 'paints', 'foils', 'productInvoices', ];
/** @var array<string, array<string, string>> czas zmiany każdego rekordu z okna, po liście */ private array $seen;
/** @var array<string, true> listy, w których trwa burza zmian */ private array $flooded = [];
public function __construct(private readonly PryntClient $prynt = new PryntClient()) { $this->seen = array_fill_keys(self::LISTS, []); }
/** * Jedna sonda: każde wywołanie to jedno żądanie do Prynt (gdy któraś lista ma w oknie więcej * niż 100 zmienionych rekordów — najwyżej trzy). Zwraca identyfikatory rekordów nowych albo * zmienionych od poprzedniej sondy, po liście: ['orders' => ['…'], 'operations' => [], …]. * Sonda pamięta czasy zmian rekordów z okna, więc każdą zmianę zgłasza raz. $now to chwila * sondy według zegara Twojego serwera (domyślnie teraz). * * null zamiast identyfikatorów znaczy burzę zmian w tej liście (więcej niż MAX_PAGES stron * w oknie): sonda nie wie, co się w niej zmieniło, więc załaduj od nowa ekrany, które ją * pokazują. Lista dostaje null w pierwszej sondzie burzy i w pierwszej po niej, a w trakcie — * pustą listę. */ public function next(?DateTimeImmutable $now = null): array { $from = ($now ?? new DateTimeImmutable()) ->sub(new DateInterval(self::WINDOW)) ->setTimezone(new DateTimeZone('UTC')) ->format('Y-m-d\TH:i:s.v\Z'); // Sonda zapisuje na kopii i przyjmuje ją dopiero na końcu: gdy żądanie kolejnej strony się // nie uda, następna sonda zgłosi także zmiany z poprzednich stron. $seen = $this->seen; $changes = array_fill_keys(self::LISTS, []); $flooded = []; // Kolejna strona tylko wtedy, gdy któraś lista bez burzy ma w oknie ponad 100 rekordów. for ($page = 1, $pages = 1; $page <= $pages; $page++) { $data = $this->prynt->request(self::PROBE, ['from' => $from, 'page' => $page]); $pages = 0; foreach (self::LISTS as $list) { $total = $data[$list]['pageInfo']['totalPages']; if ($total > self::MAX_PAGES) { $flooded[$list] = true; } if (isset($flooded[$list])) { continue; } $pages = max($pages, $total); foreach ($data[$list]['items'] as ['id' => $id, 'updatedAt' => $updatedAt]) { $known = $seen[$list][$id] ?? null; if ($known !== $updatedAt && !in_array($id, $changes[$list], true)) { $changes[$list][] = $id; } $seen[$list][$id] = $updatedAt; } } } return $this->settle($changes, $seen, $flooded, $from); }
/** * Koniec udanej sondy: lista z burzą dostaje null w pierwszej sondzie burzy (potem pustą * listę) i zachowuje czasy zmian sprzed niej; pozostałe przyjmują to, co sonda widziała, * a lista tuż po burzy dostaje null. */ private function settle(array $changes, array $seen, array $flooded, string $from): array { foreach (self::LISTS as $list) { $wasFlooded = isset($this->flooded[$list]); if (isset($flooded[$list])) { $changes[$list] = $wasFlooded ? [] : null; } else { $this->seen[$list] = self::forgetBefore($seen[$list], $from); if ($wasFlooded) { $changes[$list] = null; } } } $this->flooded = $flooded; return $changes; }
/** * Rekord sprzed okna nie wróci w następnej sondzie z tym samym czasem zmiany: zapominamy go. * Czasy z API mają jeden format (np. 2026-10-05T10:41:02.000Z), więc porównujemy je jako tekst. * * @param array<string, string> $records * @return array<string, string> */ private static function forgetBefore(array $records, string $from): array { return array_filter($records, fn (string $updatedAt) => strcmp($updatedAt, $from) >= 0); }}<?phprequire __DIR__ . '/ChangesProbe.php';
$probe = new ChangesProbe();while (true) { try { foreach ($probe->next() as $list => $ids) { // reload i publish: Twoje funkcje — ekrany tej listy od nowa (burza zmian) albo // szczegóły rekordów do przeglądarek (SSE, WebSocket) if ($ids === null) { reload($list); } elseif ($ids !== []) { publish($list, $ids); } } } catch (Throwable $error) { error_log('Sonda zmian nieudana: ' . $error->getMessage()); // kolejna za 15 sekund } sleep(15); // kolejna sonda 15 sekund po zakończeniu poprzedniej}Szczegóły zmienionych rekordów
Dział zatytułowany „Szczegóły zmienionych rekordów”Sonda podaje tylko, co się zmieniło. Szczegóły pobierz drugim zapytaniem z polami, które
pokazują Twoje ekrany, np. zamówienie ze stanem produkcji (zakresy orders:read i
production:read):
query ScreenOrder($id: ID!) { order(id: $id) { id displayId status completionDate updatedAt production { status percent } products { id name status } }}- Pobierz szczegóły raz i wyślij je wszystkim przeglądarkom, które pokazują ten rekord. Nie każ każdej przeglądarce pytać Prynt osobno: limit żądań jest wspólny dla całej integracji.
- Przy wielu zmianach jednej listy (np. po masowej zmianie) zamiast osobnych
order(id:)pobierz tę listę stronami zupdatedFromrównym początkowi okna sondy i polami ekranu — tak jak w przepisie Status produkcji dla klienta. - Operacje zamówienia pobierzesz jednym zapytaniem
operations(filter: { orderId: … })albo w drzewie zamówienia (products { elements { operations { … } } }).
Co dalej
Dział zatytułowany „Co dalej”- Kopia danych u siebie zamiast pytania Prynt o szczegóły: przepisy Zamówienia do księgowości i Stany magazynowe do BI pokazują pełną synchronizację przyrostową, a sonda z tego przepisu może ją wywoływać tylko wtedy, gdy coś się zmieniło.
- Ekran przy maszynie: przepis Status produkcji dla klienta pokazuje listę operacji maszyny i to, które z nich hala może już zacząć.