Przejdź do głównej zawartości

Zmiany na żywo w ekranach integracji

Dla: Programista

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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  • 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łędem API_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.

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, po id i updatedAt. 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 null zamiast 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 items kosztuje tyle razy, ile rekordów ma strona.
  • Jedno żądanie. Kilka list w jednym zapytaniu to jedno żądanie limitu, a nie osiem.

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.

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:

  • null w 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;
  • null w 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.

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.

  • Usuniętych rekordów list. Usunięcie produktu albo operacji przesuwa updatedAt zamówienia, więc zgłosi je lista orders. 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:) zwraca null) — 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 jako null. 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 null i 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.readyToStart zmienia się, gdy hala skończy inną operację elementu, bez ruchu updatedAt tej operacji. Przesuwa się wtedy updatedAt zamówienia: po zmianie zamówienia odśwież wszystkie jego operacje.
  • Słowników. machines, referenceData, billingEntities, currencies, vatRates i clientTags nie 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 updatedAt starszych 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.

changes-probe.sh
#!/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.json
saved=$(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"
Okno terminala
# 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 15
done

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 z updatedFrom ró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 { … } } }).
  • 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ąć.