Hurtownia danych (BI) potrzebuje z magazynu dwóch rzeczy: aktualnych stanów papieru, farb i folii, z których policzy wartość magazynu i braki, oraz ruchów magazynowych, z których policzy zużycie, przyjęcia i straty w dowolnym okresie. Ten przepis pobiera przy pierwszym uruchomieniu wszystko, a przy kolejnych, np. co 5–15 minut, tylko to, co się zmieniło.
Czego potrzebujesz
Dział zatytułowany „Czego potrzebujesz”- Klucz z zakresami
warehouse:readiprices:read.prices:readdaje ceny zakupu z kart materiałów, z których liczysz wartość magazynu, i ceny przyjęć w ruchach. Bez niego usuń z zapytań pola cen (pricePerSheet,pricePerKg,pricePerM2,unitPrice,netValue): zapytanie z nimi API odrzuca błędemAPI_SCOPE_MISSING. - Klienta API z przepisu Obsługa błędów i ponowień. Kod niżej wysyła żądania przez niego.
- Miejsce na cztery znaczniki synchronizacji: papier, farby, folie i ruchy, np. plik JSON albo wiersz w bazie.
- Dwie tabele w hurtowni: stany (jeden wiersz na materiał, zastępowany przy każdej zmianie) i ruchy (tylko dopisywane).
Stany: migawka zastępowana przy zmianie
Dział zatytułowany „Stany: migawka zastępowana przy zmianie”Listy papers, paints i foils mają filtr updatedFrom i sortowanie UPDATED_AT. updatedAt
materiału przesuwa każda zmiana karty i każda zmiana stanu: przyjęcie, wydanie, dostawa z
zamówienia zakupu, zużycie w zadaniu produkcyjnym, korekta stanu na karcie i korekta z
zatwierdzonej inwentaryzacji. Materiał, którego stan się zmienił, przychodzi więc zawsze w kolejnej
porcji, z nowym stanem. Zapis karty w Prynt bez zmiany żadnego pola updatedAt nie przesuwa.
-
Pierwsza synchronizacja pobiera wszystkie materiały:
updatedFromjest wtedy pusty. -
Zapisuj materiał po
id: wstaw wiersz albo zastąp go w całości. Kod dokłada do każdego wiersza rodzaj (kind), jednostkę stanu (stockUnit) i stan w tej jednostce (stockQuantity), więc hurtownia ma jeden kształt dla trzech list. Nazwy celowo nie pokrywają się z polami API:Paper.unitto jednostka, w której drukarnia kupuje papier (np.KG), a stan papieru jest zawsze w arkuszach. -
Kolejną porcję zaczynaj od
updatedAtostatniego materiału, a po udanym końcu zapisz znacznik: najpóźniejszyupdatedAt. Następna synchronizacja zaczyna od znacznika minus 5 minut. Szczegóły algorytmu: Paginacja, filtry i synchronizacja.
Każdy materiał ma własną jednostkę stanu, w której przychodzą też jego ruchy:
| Materiał | Stan | Jednostka (unit ruchu) |
Dokładność |
|---|---|---|---|
| Papier | quantitySheets |
SHEETS (arkusze) |
całe arkusze |
| Farba | quantityGrams |
GRAMS (gramy) |
0,001 g |
| Folia | numberOfRolls |
ROLLS (rolki) |
0,01 rolki: część rolki się liczy |
Stan nigdy nie spada poniżej zera. Papier ma też fscQuantitySheets, czyli część stanu z
certyfikatem FSC, a minimumStock każdego materiału to stan minimalny w jego jednostce: poniżej
niego Prynt pokazuje materiał jako kończący się (null: stanu minimalnego nie ustawiono).
Papier tylko do wyceny. Lista papers zawiera też papiery, których stanu drukarnia nie
prowadzi, a kalkulacja je wycenia: mają isStock: false, a klucz ich nie przyjmie ani nie wyda
(błąd PAPER_PRICING_ONLY). Flaga niczego nie zeruje: stan i ruchy zapisane wcześniej zostają, a
pracownik w aplikacji może taki papier dalej przyjąć i wydać. W hurtowni zapisz flagę isStock i
pomiń te papiery w wartości magazynu albo pobieraj tylko papier na stanie filtrem isStock: true. Synchronizacja
przyrostowa bez tego filtra widzi też papier przestawiony na „tylko do wyceny” — jako zmianę
isStock, a nie jako znikający wiersz.
Jeśli hurtownia potrzebuje tylko części kartoteki, zawęź listy filtrami zamiast filtrować po
pobraniu: papier po gramaturze (grammageMin, grammageMax — ta sama wartość w obu daje jedną
gramaturę), stanie w arkuszach (quantitySheetsMin: 1 to papier naprawdę na stanie,
quantitySheetsMax), certyfikacie (isFsc) i dostawcy (supplierId); farby po stanie w gramach
(quantityGramsMin, quantityGramsMax) i dostawcy; folie po liczbie rolek (numberOfRollsMin,
numberOfRollsMax) i dostawcy. Wszystkie granice są włącznie, a pominięty filtr (albo null)
niczego nie zawęża. supplierId to preferowany dostawca z karty materiału; starsze papiery z
dostawcą wpisanym tylko tekstem (supplierName) go nie mają.
Wartość magazynu
Dział zatytułowany „Wartość magazynu”Wartość policz z ceny zakupu na karcie materiału, tak jak pole Est. wartość na karcie w Prynt:
| Materiał | Wartość |
|---|---|
| Papier | quantitySheets × pricePerSheet; bez ceny za arkusz: quantityKg × pricePerKg |
| Farba | quantityGrams / 1000 × pricePerKg |
| Folia | numberOfRolls × metersPerRoll × widthMm / 1000 × pricePerM2 |
- Cena jest w walucie materiału (
currency). Przelicz ją na swoją walutę raportową, zanim zsumujesz magazyn. - Cena
nullto brak ceny, nie zero. Pokaż taki materiał osobno, np. jako „bez wyceny”, zamiast zaniżać wartość magazynu. - To wycena po bieżącej cenie z karty, którą wpisuje drukarnia; przyjęcie ani dostawa z zamówienia zakupu jej nie zmienia. Ceny z poszczególnych dostaw ma każdy ruch przyjęcia (niżej: „Koszt zakupów w okresie”).
quantityKgpapieru to stan w kilogramach wpisany na karcie: Prynt nie przelicza go z arkuszy, więc może byćnullalbo nieaktualny.
Ruchy: dziennik, tylko dopisywany
Dział zatytułowany „Ruchy: dziennik, tylko dopisywany”Lista stockMovements zbiera ruchy papieru, farb i folii w jedną listę. Ilości i czas ruchu po
zapisaniu się nie zmieniają, dlatego czytasz go od chwili, w której go zaksięgowano: filtr
occurredFrom i sortowanie OCCURRED_AT rosnąco, z tym samym zapasem 5 minut. Ruch pobrany drugi
raz rozpoznasz po id: pomiń go.
Każdy ruch ma:
type:RECEIPT(przyjęcie, także dostawa z zamówienia zakupu),PRODUCTION(wydanie na produkcję, także zużycie w zadaniu),WASTE(odpad),RETURN(zwrot do dostawcy),CORRECTION(korekta: wydanie jako korekta, stan zmieniony ręcznie na karcie materiału, w górę lub w dół, albo stan wpisany przy zakładaniu karty, od 0),OTHERalboINVENTORY_ADJUSTMENT(różnica z zatwierdzonej inwentaryzacji);quantityChangeze znakiem, w jednostceunit: dodatni, gdy stan wzrósł, ujemny, gdy spadł.quantityBeforeiquantityAfterto stan przed ruchem i po nim. Prynt odczytuje stan pod blokadą materiału, więc ruchy jednego materiału układają się w łańcuch:quantityAfterruchu toquantityBeforenastępnego. Ruchy zaksięgowane w tej samej chwili mogą miećoccurredAtw innej kolejności niż łańcuch — kolejność w łańcuchu wyznaczają ilości;- powiązania:
orderId(zamówienie, na które wydano materiał) i w nimproductId,elementId,operationId(produkt, element i operacja tego zamówienia; więcej w podrozdziale „Filtry” niżej),purchaseOrderId(zamówienie zakupu, z którego przyszła dostawa) istockTakeId(inwentaryzacja), zdiscrepancyReasonprzy różnicy z inwentaryzacji; - dokument dostawy przyjęcia: cena netto
unitPricezapriceUnitw waluciecurrency, wartość nettonetValue, dostawcasupplierId, numer WZ albo fakturydocumentNumberi data dostawy z dokumentudeliveredAt(occurredAtto chwila zaksięgowania). Przyjęcie ręczne ma to, co podano przy nim; dostawa z zamówienia zakupu — cenę i jednostkę pozycji, walutę i dostawcę zamówienia. Wydania, korekty, inwentaryzacje, przyjęcia bez ceny i wszystkie przyjęcia sprzed wersji 0.33.0 mają tunull. Te pola też nie zmieniają się po zaksięgowaniu.
Starsze ruchy folii zapisane bez rolek mają quantityBefore, quantityAfter i quantityChange
równe null.
Filtry: okres, powiązanie i typ
Dział zatytułowany „Filtry: okres, powiązanie i typ”Zużycie w okresie, np. w miesiącu, policzysz w hurtowni albo pobierzesz wprost: occurredFrom i
occurredTo (granica wyłączna) z filtrem materialType, materialId, orderId, productId,
elementId albo operationId. Ruch musi spełnić wszystkie podane filtry. Przyjęcia jednego
dostawcy wybierze supplierId, a przyjęcia z jednego dokumentu — documentNumber (cały numer, bez
rozróżniania wielkości liter; fragment numeru niczego nie znajdzie).
Filtry orderId, productId, elementId i operationId porównują identyfikatory zapisane w
ruchu. Od wersji 0.32.0 wydanie zapisuje cały łańcuch: wydanie na samą operację ma też jej element,
produkt i zamówienie, więc znajdziesz je każdym z tych filtrów. Starsze ruchy mają tylko
identyfikatory, które podano przy wydaniu — zużycie produktu sprzed 0.32.0 pobierz więc także po
orderId i rozdziel u siebie.
Filtr type wybiera ruchy jednego typu, a typeIn dowolnego z listy (gdy lista zawiera choć jeden
typ, type się nie liczy; pusta lista niczego nie zawęża). Liczy się ten sam type, który widzisz
w odpowiedzi:
RECEIPTto przyjęcia ręczne i dostawy z zamówień zakupu, czyli ruchy, które podniosły stan;RETURNto zwroty do dostawcy, w tym starsze ruchy folii zapisane bez rolek;- korekty (
CORRECTION) i różnice z inwentaryzacji (INVENTORY_ADJUSTMENT) mają własne typy, nawet gdy podniosły stan — nie ma ich wtype: RECEIPT.
Każdy ruch ma jeden typ, więc totalCount dla typeIn: [RECEIPT, RETURN] to suma totalCount
obu typów osobno.
Przykłady:
filter: { productId: "…", typeIn: [PRODUCTION, WASTE, OTHER] }— zużycie materiału na produkt zamówienia: wydania na produkcję, odpad i wydania z innego powodu zaksięgowane na ten produkt. Te same typy liczy kafelek Zużycie (30 dni) na przeglądzie magazynu w Prynt. Tak samooperationIddla jednej operacji iorderIddla całego zamówienia.filter: { type: RECEIPT, occurredFrom: "…", occurredTo: "…" }— przyjęcia w okresie, także dostawy z zamówień zakupu.
Koszt zakupów w okresie i ostatnia cena zakupu
Dział zatytułowany „Koszt zakupów w okresie i ostatnia cena zakupu”Koszt zakupów w okresie to suma netValue ruchów z filtrem
{ type: RECEIPT, occurredFrom: "…", occurredTo: "…" } (z prices:read), osobno dla każdej
waluty (currency). Wartość przyjęcia z zamówienia zakupu to ilość z dokumentu dostawy razy cena
pozycji z chwili dostawy, a nie wartość zamówienia: późniejsza zmiana ceny pozycji ruchu nie zmienia.
Ruch przyjęcia z netValue równym null nie ma wyceny, więc pokaż go osobno, a nie jako zero. Tak
jest, gdy przyjęto go bez ceny albo przed wersją 0.33.0, a także gdy ktoś przyjął go w aplikacji z
ceną w jednostce, w której materiału się nie liczy (np. za paletę): cena i jej jednostka są wtedy w
ruchu (unitPrice, priceUnit), ale wartości nie da się policzyć. Dostawy z zamówienia zakupu,
którą dokładność stanu zaokrągla do zera (kilka metrów długiej rolki folii), w ruchach w ogóle nie
ma: jej cena zostaje tylko w pozycji zamówienia (PurchaseOrder.items.unitPrice).
Ostatnia cena zakupu materiału to unitPrice pierwszego ruchu z filtrem
{ materialId: "…", type: RECEIPT } posortowanego po OCCURRED_AT malejąco, który ma cenę.
Czytaj ją razem z priceUnit: jednostka ceny nie musi być jednostką stanu (unit) — farba
wyceniona za kilogram (priceUnit: "kg") jest na stanie w gramach (unit: GRAMS), a folia
wyceniona za metr (priceUnit: "m") w rolkach. Cena na karcie materiału (pricePerKg i inne) to
decyzja drukarni i przyjęcie jej nie zmienia.
Ruchy i stany: uzgadniaj z migawką
Dział zatytułowany „Ruchy i stany: uzgadniaj z migawką”Suma quantityChange od początku nie musi dać bieżącego stanu, więc stan bierz z migawki stanów,
a ruchy traktuj jako wyjaśnienie zmian:
- Stan początkowy karty (wpisany przy zakładaniu materiału) jest od wersji 0.34.0 ruchem
CORRECTIONod 0 z uwagą (notes) „Stan początkowy karty”, więc łańcuch nowego materiału zaczyna się od zera. Materiały założone wcześniej takiego ruchu nie mają: ich łańcuch zaczyna się od stanu początkowego. - Zmiany sprzed wersji 0.28.0 zapisane ręcznie na karcie materiału nie mają ruchów. Od 0.28.0
każda zmiana stanu — także ręczna korekta na karcie, zużycie w zadaniu i inwentaryzacja — jest
ruchem. Przerwę w łańcuchu (ruch, którego
quantityBeforenie jestquantityAfterpoprzedniego) traktuj jak korektę o tę różnicę. - Inwentaryzacja ustawia stan na policzony: ruch
INVENTORY_ADJUSTMENTzaczyna się od stanu w chwili zatwierdzenia, więc wydania z czasu liczenia też są w łańcuchu.
Rezerwacji papieru pod zamówienia i dziennika zużycia narzędzi API nie udostępnia. Inwentaryzacje
odczytasz listą stockTakes; to, co zmieniła zatwierdzona inwentaryzacja, przychodzi jako ruchy
INVENTORY_ADJUSTMENT.
Funkcja synchronizacji przyjmuje znaczniki z poprzedniego razu (pusty obiekt przy pierwszym), zapis stanu i zapis ruchu, a zwraca nowe znaczniki. Zapisz je dopiero po udanym końcu: przerwana synchronizacja powtórzy się wtedy od poprzednich.
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. Każdy stan i ruch
wypisuje jako jedną linię JSON ({"stock": …} albo {"movement": …}), a znaczniki trzyma w pliku
stan-synchronizacji-magazynu.json.
#!/usr/bin/env bash# sync-stock.sh — jedna synchronizacja hurtowni (curl, jq; prynt-call.sh z przepisu „Obsługa błędów# i ponowień”): stany papieru, farb i folii zmienione od poprzedniej synchronizacji i nowe ruchy# magazynowe. Każdy rekord wypisuje jako linię JSON — {"stock": …} albo {"movement": …} — którą# zapisujesz u siebie po `id`: ./sync-stock.sh >>magazyn.ndjson# Znaczniki trzyma w pliku stan-synchronizacji-magazynu.json i zapisuje je dopiero na końcu.set -euo pipefail
state_file=stan-synchronizacji-magazynu.jsonstate=$(cat "$state_file" 2>/dev/null || echo '{}')body=$(mktemp) result=$(mktemp)trap 'rm -f "$body" "$result"' EXIT
# Ruchy są tylko dopisywane: czytamy je od chwili zdarzenia (occurredFrom), nie od zmiany.movements_query='query NewStockMovements($from: DateTime, $page: Int!) { stockMovements( filter: { occurredFrom: $from } sort: { field: OCCURRED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id occurredAt materialType materialId type unit quantityBefore quantityAfter quantityChange orderId purchaseOrderId stockTakeId unitPrice priceUnit currency netValue supplierId deliveredAt documentNumber } pageInfo { hasNextPage } }}'
# Stany: papier w arkuszach, farby w gramach, folie w rolkach — z cenami zakupu (prices:read).papers_query='query ChangedPapers($from: DateTime, $page: Int!) { papers( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id code name quantitySheets quantityKg minimumStock pricePerSheet pricePerKg currency updatedAt } pageInfo { hasNextPage } }}'paints_query='query ChangedPaints($from: DateTime, $page: Int!) { paints( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id code name quantityGrams minimumStock pricePerKg currency updatedAt } pageInfo { hasNextPage } }}'foils_query='query ChangedFoils($from: DateTime, $page: Int!) { foils( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id code name numberOfRolls metersPerRoll widthMm minimumStock pricePerM2 currency updatedAt } pageInfo { hasNextPage } }}'
# Pobiera listę $1 zapytaniem $2 od znacznika $4 (minus 5 minut zapasu) w kolejności pola czasu $3;# każdy rekord przepuszcza przez filtr jq $5 i wypisuje. Na stdout na końcu: nowy znacznik.pull() { local field=$1 query=$2 time=$3 since=$4 row=$5 from=null latest=$4 page=1 last if [[ -n $since ]]; then from=$(jq -n --arg since "$since" '$since | capture("^(?<time>[^.]+?)(?<ms>\\.[0-9]+)?Z$") | ((.time + "Z") | fromdateiso8601 - 300 | todateiso8601 | rtrimstr("Z")) + (.ms // ".000") + "Z"') fi while :; do jq -n --arg query "$query" --argjson from "$from" --argjson page "$page" \ '{ query: $query, variables: { from: $from, page: $page } }' >"$body" ./prynt-call.sh "$body" >"$result" jq -c --arg field "$field" ".data[\$field].items[] | $row" "$result" >&3 # Czasy z API mają jeden format (np. 2026-10-05T10:41:02.000Z), więc porównujemy je jako tekst. latest=$(jq -r --arg latest "$latest" --arg field "$field" --arg time "$time" \ '[$latest, .data[$field].items[][$time]] | map(select(. != "")) | max // ""' "$result") [[ $(jq -r --arg field "$field" '.data[$field].pageInfo.hasNextPage' "$result") == true ]] || break last=$(jq -c --arg field "$field" --arg time "$time" '.data[$field].items[-1][$time]' "$result") # Dalej czytamy od czasu ostatniego rekordu, nie od kolejnego numeru strony; kolejną stronę # bierzemy tylko wtedy, gdy cała strona ma ten sam czas. if [[ $last == "$from" ]]; then page=$((page + 1)) else from=$last page=1 fi done echo "$latest"}
cursor() { jq -r --arg name "$1" '.[$name] // ""' <<<"$state"; }
exec 3>&1papers=$(pull papers "$papers_query" updatedAt "$(cursor papers)" \ '{ stock: (. + { kind: "PAPER", stockUnit: "SHEETS", stockQuantity: .quantitySheets }) }')paints=$(pull paints "$paints_query" updatedAt "$(cursor paints)" \ '{ stock: (. + { kind: "PAINT", stockUnit: "GRAMS", stockQuantity: .quantityGrams }) }')foils=$(pull foils "$foils_query" updatedAt "$(cursor foils)" \ '{ stock: (. + { kind: "FOIL", stockUnit: "ROLLS", stockQuantity: .numberOfRolls }) }')movements=$(pull stockMovements "$movements_query" occurredAt "$(cursor movements)" '{ movement: . }')
jq -n --arg papers "$papers" --arg paints "$paints" --arg foils "$foils" --arg movements "$movements" \ '{ papers: $papers, paints: $paints, foils: $foils, movements: $movements } | with_entries(select(.value != ""))' >"$state_file"./sync-stock.sh >>magazyn.ndjson # wczytaj linie do hurtowni: stany po id, ruchy po id bez powtórek// sync-stock.mjs — Node.js 18+, klient API z przepisu „Obsługa błędów i ponowień”import { pryntRequest } from './prynt-client.mjs';
// Zapas na zmiany zapisane tuż przed poprzednią synchronizacją, a widoczne dopiero po niej.const OVERLAP_MS = 5 * 60 * 1000;
// Stany: papier w arkuszach, farby w gramach, folie w rolkach — z cenami zakupu (prices:read).const STOCK_LISTS = { papers: `query ChangedPapers($from: DateTime, $page: Int!) { papers( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id code name quantitySheets quantityKg minimumStock pricePerSheet pricePerKg currency updatedAt } pageInfo { hasNextPage } }}`, paints: `query ChangedPaints($from: DateTime, $page: Int!) { paints( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id code name quantityGrams minimumStock pricePerKg currency updatedAt } pageInfo { hasNextPage } }}`, foils: `query ChangedFoils($from: DateTime, $page: Int!) { foils( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id code name numberOfRolls metersPerRoll widthMm minimumStock pricePerM2 currency updatedAt } pageInfo { hasNextPage } }}`,};
// Ruchy są tylko dopisywane: czytamy je od chwili zdarzenia (occurredFrom), nie od zmiany.const NEW_MOVEMENTS = `query NewStockMovements($from: DateTime, $page: Int!) { stockMovements( filter: { occurredFrom: $from } sort: { field: OCCURRED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id occurredAt materialType materialId type unit quantityBefore quantityAfter quantityChange orderId purchaseOrderId stockTakeId unitPrice priceUnit currency netValue supplierId deliveredAt documentNumber } pageInfo { hasNextPage } }}`;
/** * Wiersz stanu dla hurtowni: ilość zawsze w jednostce stanu materiału (`stockQuantity`, * `stockUnit` — nazwy spoza schematu, bo `Paper.unit` to jednostka zakupu, nie stanu). */const stockRow = { papers: (paper) => ({ ...paper, kind: 'PAPER', stockUnit: 'SHEETS', stockQuantity: paper.quantitySheets, }), paints: (paint) => ({ ...paint, kind: 'PAINT', stockUnit: 'GRAMS', stockQuantity: paint.quantityGrams, }), foils: (foil) => ({ ...foil, kind: 'FOIL', stockUnit: 'ROLLS', stockQuantity: foil.numberOfRolls, }),};
const later = (a, b) => (a === null || Date.parse(b) > Date.parse(a) ? b : a);const minusOverlap = (since) => since === null ? null : new Date(Date.parse(since) - OVERLAP_MS).toISOString();
/** * Pobiera listę `field` od `since` (minus zapas) w kolejności `time` i przekazuje każdy rekord do * `save`. Zwraca najpóźniejszy czas, jaki widziała — znacznik do następnej synchronizacji. */async function pull(query, field, time, since, save) { let from = minusOverlap(since); let latest = since; for (let page = 1; ; ) { const data = await pryntRequest({ query, variables: { from, page } }); const list = data[field]; for (const item of list.items) { await save(item); latest = later(latest, item[time]); } if (!list.pageInfo.hasNextPage) return latest; const last = list.items.at(-1)[time]; // Dalej czytamy od czasu ostatniego rekordu, a nie od kolejnego numeru strony; kolejną stronę // bierzemy tylko wtedy, gdy cała strona ma ten sam czas. if (last === from) { page += 1; } else { from = last; page = 1; } }}
/** * Jedna synchronizacja hurtowni: stany papieru, farb i folii zmienione od poprzedniej * (`saveStock`, zapis po `id`: wstaw albo zaktualizuj) i nowe ruchy (`saveMovement`, zapis po `id`: * ruch pobrany drugi raz pomijasz). `state` to znaczniki z poprzedniej synchronizacji (`{}` przy * pierwszej); zwraca nowe — zapisz je dopiero po udanym końcu. */export async function syncStock(state, saveStock, saveMovement) { const next = {}; for (const [field, query] of Object.entries(STOCK_LISTS)) { const save = (item) => saveStock(stockRow[field](item)); next[field] = await pull(query, field, 'updatedAt', state[field] ?? null, save); } next.movements = await pull( NEW_MOVEMENTS, 'stockMovements', 'occurredAt', state.movements ?? null, saveMovement, ); return next;}import { readFile, writeFile } from 'node:fs/promises';import { syncStock } from './sync-stock.mjs';
const STATE = 'stan-synchronizacji-magazynu.json';const state = JSON.parse(await readFile(STATE, 'utf8').catch(() => '{}'));const next = await syncStock( state, async (stock) => upsertStock(stock), // Twoja funkcja: wstaw albo zastąp wiersz po stock.id async (movement) => insertMovement(movement), // Twoja funkcja: pomiń ruch, który już masz);await writeFile(STATE, JSON.stringify(next));# sync_stock.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
# Zapas na zmiany zapisane tuż przed poprzednią synchronizacją, a widoczne dopiero po niej.OVERLAP = timedelta(minutes=5)
# Stany: papier w arkuszach, farby w gramach, folie w rolkach — z cenami zakupu (prices:read).STOCK_LISTS = { "papers": """query ChangedPapers($from: DateTime, $page: Int!) { papers( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id code name quantitySheets quantityKg minimumStock pricePerSheet pricePerKg currency updatedAt } pageInfo { hasNextPage } }}""", "paints": """query ChangedPaints($from: DateTime, $page: Int!) { paints( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id code name quantityGrams minimumStock pricePerKg currency updatedAt } pageInfo { hasNextPage } }}""", "foils": """query ChangedFoils($from: DateTime, $page: Int!) { foils( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id code name numberOfRolls metersPerRoll widthMm minimumStock pricePerM2 currency updatedAt } pageInfo { hasNextPage } }}""",}
# Ruchy są tylko dopisywane: czytamy je od chwili zdarzenia (occurredFrom), nie od zmiany.NEW_MOVEMENTS = """query NewStockMovements($from: DateTime, $page: Int!) { stockMovements( filter: { occurredFrom: $from } sort: { field: OCCURRED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id occurredAt materialType materialId type unit quantityBefore quantityAfter quantityChange orderId purchaseOrderId stockTakeId unitPrice priceUnit currency netValue supplierId deliveredAt documentNumber } pageInfo { hasNextPage } }}"""
# Wiersz stanu dla hurtowni: ilość zawsze w jednostce stanu materiału (stockQuantity, stockUnit —# nazwy spoza schematu, bo Paper.unit to jednostka zakupu, nie stanu).STOCK_ROWS = { "papers": lambda paper: { **paper, "kind": "PAPER", "stockUnit": "SHEETS", "stockQuantity": paper["quantitySheets"], }, "paints": lambda paint: { **paint, "kind": "PAINT", "stockUnit": "GRAMS", "stockQuantity": paint["quantityGrams"], }, "foils": lambda foil: { **foil, "kind": "FOIL", "stockUnit": "ROLLS", "stockQuantity": foil["numberOfRolls"], },}
def _instant(value): return datetime.fromisoformat(value.replace("Z", "+00:00"))
def _iso(instant): return instant.astimezone(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def _pull(query, field, time, since, save): """Pobiera listę `field` od `since` (minus zapas) w kolejności `time` i przekazuje każdy rekord do `save`. Zwraca najpóźniejszy czas, jaki widziała — znacznik do następnej synchronizacji.""" start = None if since is None else _iso(_instant(since) - OVERLAP) latest, page = since, 1 while True: result = prynt_request(query, {"from": start, "page": page})[field] for item in result["items"]: save(item) if latest is None or _instant(item[time]) > _instant(latest): latest = item[time] if not result["pageInfo"]["hasNextPage"]: return latest last = result["items"][-1][time] # Dalej czytamy od czasu ostatniego rekordu, nie od kolejnego numeru strony; kolejną # stronę bierzemy tylko wtedy, gdy cała strona ma ten sam czas. if last == start: page += 1 else: start, page = last, 1
def sync_stock(state, save_stock, save_movement): """Jedna synchronizacja hurtowni: stany papieru, farb i folii zmienione od poprzedniej (`save_stock`, zapis po `id`) i nowe ruchy (`save_movement`, ruch pobrany drugi raz pomijasz). `state` to znaczniki z poprzedniej synchronizacji ({} przy pierwszej); zwraca nowe — zapisz je dopiero po udanym końcu.""" following = {} for field, query in STOCK_LISTS.items(): row = STOCK_ROWS[field] following[field] = _pull( query, field, "updatedAt", state.get(field), lambda item, row=row: save_stock(row(item)) ) following["movements"] = _pull( NEW_MOVEMENTS, "stockMovements", "occurredAt", state.get("movements"), save_movement ) return followingimport jsonfrom pathlib import Path
from sync_stock import sync_stock
state_file = Path("stan-synchronizacji-magazynu.json")state = json.loads(state_file.read_text()) if state_file.exists() else {}# upsert_stock i insert_movement: Twoje funkcje zapisu w hurtowni (po id)next_state = sync_stock(state, upsert_stock, insert_movement)state_file.write_text(json.dumps(next_state))<?php// SyncStock.php — PHP 8.1+, klient API z przepisu „Obsługa błędów i ponowień”require_once __DIR__ . '/PryntClient.php';
final class SyncStock{ // Zapas na zmiany zapisane tuż przed poprzednią synchronizacją, a widoczne dopiero po niej. private const OVERLAP = 'PT5M';
// Stany: papier w arkuszach, farby w gramach, folie w rolkach — z cenami zakupu (prices:read). private const STOCK_LISTS = [ 'papers' => <<<'GRAPHQL' query ChangedPapers($from: DateTime, $page: Int!) { papers( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id code name quantitySheets quantityKg minimumStock pricePerSheet pricePerKg currency updatedAt } pageInfo { hasNextPage } } } GRAPHQL, 'paints' => <<<'GRAPHQL' query ChangedPaints($from: DateTime, $page: Int!) { paints( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id code name quantityGrams minimumStock pricePerKg currency updatedAt } pageInfo { hasNextPage } } } GRAPHQL, 'foils' => <<<'GRAPHQL' query ChangedFoils($from: DateTime, $page: Int!) { foils( filter: { updatedFrom: $from } sort: { field: UPDATED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id code name numberOfRolls metersPerRoll widthMm minimumStock pricePerM2 currency updatedAt } pageInfo { hasNextPage } } } GRAPHQL, ];
// Ruchy są tylko dopisywane: czytamy je od chwili zdarzenia (occurredFrom), nie od zmiany. private const NEW_MOVEMENTS = <<<'GRAPHQL' query NewStockMovements($from: DateTime, $page: Int!) { stockMovements( filter: { occurredFrom: $from } sort: { field: OCCURRED_AT, direction: ASC } pagination: { page: $page, pageSize: 100 } ) { items { id occurredAt materialType materialId type unit quantityBefore quantityAfter quantityChange orderId purchaseOrderId stockTakeId unitPrice priceUnit currency netValue supplierId deliveredAt documentNumber } pageInfo { hasNextPage } } } GRAPHQL;
// Wiersz stanu dla hurtowni: ilość zawsze w jednostce stanu materiału (stockQuantity, // stockUnit — nazwy spoza schematu, bo Paper.unit to jednostka zakupu, nie stanu). private const STOCK_ROWS = [ 'papers' => ['PAPER', 'SHEETS', 'quantitySheets'], 'paints' => ['PAINT', 'GRAMS', 'quantityGrams'], 'foils' => ['FOIL', 'ROLLS', 'numberOfRolls'], ];
public function __construct(private readonly PryntClient $prynt = new PryntClient()) { }
/** * Jedna synchronizacja hurtowni: stany papieru, farb i folii zmienione od poprzedniej * ($saveStock, zapis po `id`) i nowe ruchy ($saveMovement, ruch pobrany drugi raz pomijasz). * $state to znaczniki z poprzedniej synchronizacji ([] przy pierwszej); zwraca nowe — zapisz * je dopiero po udanym końcu. */ public function sync(array $state, callable $saveStock, callable $saveMovement): array { $next = []; foreach (self::STOCK_LISTS as $field => $query) { [$kind, $unit, $quantity] = self::STOCK_ROWS[$field]; $next[$field] = $this->pull($query, $field, 'updatedAt', $state[$field] ?? null, fn (array $item) => $saveStock( ['kind' => $kind, 'stockUnit' => $unit, 'stockQuantity' => $item[$quantity]] + $item)); } $next['movements'] = $this->pull(self::NEW_MOVEMENTS, 'stockMovements', 'occurredAt', $state['movements'] ?? null, $saveMovement); return $next; }
/** * Pobiera listę $field od $since (minus zapas) w kolejności $time i przekazuje każdy rekord do * $save. Zwraca najpóźniejszy czas, jaki widziała — znacznik do następnej synchronizacji. */ private function pull(string $query, string $field, string $time, ?string $since, callable $save): ?string { $from = $since === null ? null : (new DateTimeImmutable($since)) ->sub(new DateInterval(self::OVERLAP)) ->setTimezone(new DateTimeZone('UTC')) ->format('Y-m-d\TH:i:s.v\Z'); $latest = $since; $page = 1; while (true) { $list = $this->prynt->request($query, ['from' => $from, 'page' => $page])[$field]; foreach ($list['items'] as $item) { $save($item); if ($latest === null || new DateTimeImmutable($item[$time]) > new DateTimeImmutable($latest)) { $latest = $item[$time]; } } if (!$list['pageInfo']['hasNextPage']) { return $latest; } $last = end($list['items'])[$time]; // Dalej czytamy od czasu ostatniego rekordu, nie od kolejnego numeru strony; kolejną // stronę bierzemy tylko wtedy, gdy cała strona ma ten sam czas. if ($last === $from) { $page++; } else { $from = $last; $page = 1; } } }}<?phprequire __DIR__ . '/SyncStock.php';
$stateFile = __DIR__ . '/stan-synchronizacji-magazynu.json';$state = is_file($stateFile) ? json_decode(file_get_contents($stateFile), true) : [];$next = (new SyncStock())->sync( $state, fn (array $stock) => upsertStock($stock), // Twoja funkcja: wstaw albo zastąp wiersz po id fn (array $movement) => insertMovement($movement), // Twoja funkcja: pomiń ruch, który już masz);file_put_contents($stateFile, json_encode($next));Co dalej
Dział zatytułowany „Co dalej”- Zakupy w hurtowni: lista
purchaseOrdersz filtremupdatedFromdaje zamówienia zakupu z pozycjami i cenami, asuppliersdostawców z cennikami; synchronizujesz je tak samo jak stany. - Wykrojniki i matryce (
dieCuts,matrices) też mająupdatedFrom; przy synchronizacji lista zwraca także narzędzia zarchiwizowane od poprzedniego razu (zarchivedAt). - Przyjęcia z systemu dostawcy: przepis Zamówienie zakupu z systemu dostawcy zapisuje zamówienia zakupu i przyjmuje dostawy.