Przejdź do głównej zawartości

Stany magazynowe do BI

Dla: Programista

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.

  • Klucz z zakresami warehouse:read i prices:read. prices:read daje 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łędem API_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).

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.

  1. Pierwsza synchronizacja pobiera wszystkie materiały: updatedFrom jest wtedy pusty.

  2. 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.unit to jednostka, w której drukarnia kupuje papier (np. KG), a stan papieru jest zawsze w arkuszach.

  3. Kolejną porcję zaczynaj od updatedAt ostatniego materiału, a po udanym końcu zapisz znacznik: najpóźniejszy updatedAt. 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ść 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 null to 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”).
  • quantityKg papieru to stan w kilogramach wpisany na karcie: Prynt nie przelicza go z arkuszy, więc może być null albo nieaktualny.

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), OTHER albo INVENTORY_ADJUSTMENT (różnica z zatwierdzonej inwentaryzacji);
  • quantityChange ze znakiem, w jednostce unit: dodatni, gdy stan wzrósł, ujemny, gdy spadł. quantityBefore i quantityAfter to 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: quantityAfter ruchu to quantityBefore następnego. Ruchy zaksięgowane w tej samej chwili mogą mieć occurredAt w 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 nim productId, 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) i stockTakeId (inwentaryzacja), z discrepancyReason przy różnicy z inwentaryzacji;
  • dokument dostawy przyjęcia: cena netto unitPrice za priceUnit w walucie currency, wartość netto netValue, dostawca supplierId, numer WZ albo faktury documentNumber i data dostawy z dokumentu deliveredAt (occurredAt to 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ą tu null. Te pola też nie zmieniają się po zaksięgowaniu.

Starsze ruchy folii zapisane bez rolek mają quantityBefore, quantityAfter i quantityChange równe null.

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:

  • RECEIPT to przyjęcia ręczne i dostawy z zamówień zakupu, czyli ruchy, które podniosły stan;
  • RETURN to 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 w type: 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 samo operationId dla jednej operacji i orderId dla 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 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.

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 CORRECTION od 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 quantityBefore nie jest quantityAfter poprzedniego) traktuj jak korektę o tę różnicę.
  • Inwentaryzacja ustawia stan na policzony: ruch INVENTORY_ADJUSTMENT zaczyna 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.

sync-stock.sh
#!/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.json
state=$(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>&1
papers=$(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"
Okno terminala
./sync-stock.sh >>magazyn.ndjson # wczytaj linie do hurtowni: stany po id, ruchy po id bez powtórek
  • Zakupy w hurtowni: lista purchaseOrders z filtrem updatedFrom daje zamówienia zakupu z pozycjami i cenami, a suppliers dostawcó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 (z archivedAt).
  • Przyjęcia z systemu dostawcy: przepis Zamówienie zakupu z systemu dostawcy zapisuje zamówienia zakupu i przyjmuje dostawy.