Przejdź do głównej zawartości

Klienci do CRM i z powrotem

Dla: Programista

CRM i Prynt prowadzą tych samych klientów. Ten przepis synchronizuje ich w obie strony:

  • z Prynt do CRM co kilka minut pobiera klientów zmienionych od poprzedniego razu, razem z adresami, osobami kontaktowymi, tagami i notatkami;
  • z CRM do Prynt zapisuje klienta z CRM: zmienia tego, którego Prynt już zna, a nowego tworzy tylko raz;
  • aktywności z CRM, np. rozmowę z klientem, zapisuje w Prynt jako notatkę przy kliencie.
  • Klucz z zakresami clients:read i clients:write. Limit kredytowy i rabat klienta są dostępne dopiero z zakresem prices:read; ten przepis ich nie używa.
  • Klienta API z przepisu Obsługa błędów i ponowień. Kod niżej wysyła żądania przez niego.
  • Miejsce na znacznik synchronizacji, np. plik albo wiersz w bazie: jeden ciąg znaków z datą.
  • Pole w CRM na identyfikator klienta z Prynt (id), jeśli klienci powstają także w Prynt.

Każdy klient w Prynt może mieć Twój identyfikator, np. id rekordu w CRM, w polu externalRef. Podajesz go przy createClient albo updateClient, a filtr externalRef listy clients znajduje po nim klienta. Dzięki temu nie musisz przechowywać w CRM identyfikatorów z Prynt.

  • Należy do Twojej integracji. Inna integracja (np. sklep) ma własne identyfikatory tych samych klientów i nie widzi Twoich, więc dwie integracje nigdy sobie ich nie nadpiszą. W aplikacji Prynt identyfikator widać tylko w historii zmian klienta.
  • Jeden na klienta, unikalny w integracji. Identyfikator, który już ma inny klient, API odrzuca błędem CONFLICT z identyfikatorem tamtego klienta w komunikacie. null w updateClient usuwa identyfikator.
  • To nie jest zmiana klienta. Samo nadanie albo zmiana externalRef nie przesuwa Client.updatedAt, więc klient nie wraca przez to w kolejnej synchronizacji.

Client.updatedAt zmienia się przy każdej zmianie klienta i tego, co do niego należy: adresów, osób kontaktowych, notatek widocznych dla integracji i tagów. Filtr updatedFrom z sortowaniem UPDATED_AT rosnąco pobiera tylko klientów zmienionych od poprzedniego razu, w kolejności zmian.

  1. Pierwsza synchronizacja pobiera wszystkich klientów, także nieaktywnych: updatedFrom jest wtedy pusty.

  2. Zapisuj klienta w CRM po externalRef, jeśli go ma (klient przyszedł z CRM), a inaczej po id z Prynt. Zastąp w całości jego adresy, osoby kontaktowe, tagi i notatki: element usunięty w Prynt po prostu znika z listy, tak samo notatka, którą autor oznaczył jako prywatną — usuń ją też u siebie. Notatek przychodzi najwyżej 50 (clientNotes(first: 50)), od najpóźniejszej daty kontaktu (contactDate), nie od daty zapisu: gdy lista jest pełna, usuwaj tylko notatki z datą kontaktu późniejszą niż najwcześniejsza na liście, pozostałe zostaw.

  3. Kolejną porcję zaczynaj od updatedAt ostatniego klienta, nie od kolejnego numeru strony, 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.

Klient z isActive: false został w Prynt dezaktywowany (w aplikacji usunięcie klienta go dezaktywuje), a blockMode inny niż NONE znaczy, że drukarnia wstrzymała jego zamówienia albo wydanie towaru. Oba pola ustawia tylko drukarnia.

Zapis klienta z CRM to upsert w trzech krokach:

  1. Szukaj po externalRef, czyli po id rekordu w CRM.

  2. Firmę bez takiego identyfikatora szukaj po NIP. Drukarnia mogła ją już dodać w Prynt. NIP wysyłaj jako same cyfry (Prynt porównuje go dosłownie, więc 521-300-00-01 to inny numer), a że filtr taxIdentificationNumber szuka fragmentu numeru, porównaj cały NIP u siebie. Znalezionej firmie updateClient dopisuje Twój externalRef. NIP w Prynt jest unikalny: drugiego klienta z tym samym NIP nie utworzysz, a firma połączona już z innym rekordem CRM to duplikat w CRM — kod zgłasza wtedy błąd, zamiast przełączać identyfikator między rekordami.

  3. Nikogo nie znalazłeś: utwórz klienta mutacją createClient, z adresem siedziby i nagłówkiem Idempotency-Key z id rekordu w CRM. Ponowienie nie utworzy drugiego klienta.

Zasady zapisu:

  • Zmiana to łatka. W updateClient pole pominięte zostaje bez zmian, a null czyści pole, które może być puste (opis pola w referencji to podaje). Kod niżej pomija puste pola z CRM, więc niczego w Prynt nie czyści. Jeśli Twój CRM jest źródłem prawdy, wysyłaj null dla pól, które w nim wyczyszczono.
  • Rodzaj klienta kod wysyła tylko przy tworzeniu. CRM zna zwykle firmę i osobę, a drukarnia mogła zapisać klienta jako jednoosobową działalność (SOLE_PROPRIETOR). Dlatego kod podaje type tylko w createClient, a updateClient bez type zostawia rodzaj z Prynt. API przyjmuje type także w updateClient: jeśli go zmieniasz, wyczyść pola nazwy, które przestają pasować.
  • Warunki handlowe ustala drukarnia. Integracja nie podaje limitu kredytowego, rabatu, cennika, terminu ani formy płatności, podmiotu wystawiającego faktury, waluty, blokady, aktywności, opiekunów ani uwag zespołu o kliencie; takiego pola nie ma w jej schemacie. Nowy klient z API dostaje domyślny termin płatności (14 dni), a resztę warunków drukarnia uzupełnia na karcie klienta w Prynt.
  • Klient usunięty w Prynt (isActive: false) nie przyjmuje zmian z API: ani on, ani jego kontakty, adresy, notatki i tagi. Przywrócić go może tylko drukarnia; kod zgłasza wtedy błąd.
  • Adres siedziby z CRM kod zapisuje przy tworzeniu klienta, a przy zmianie aktualizuje adres typu HEADQUARTERS mutacją updateClientAddress (łatka: tylko pola, które CRM podaje) albo dodaje go mutacją createClientAddress, gdy klient go nie ma. Kod pocztowy musi pasować do kraju adresu (polski: 00-000). Inne adresy i osoby kontaktowe zmieniasz tymi samymi mutacjami oraz createClientContact i updateClientContact — kod niżej ich nie wysyła.
  • Telefon klienta i adresu ma w Prynt najwyżej 15 znaków (osoby kontaktowej: 20): wysyłaj go bez spacji i nawiasów (+48221002030). Kod robi to za Ciebie, tak samo jak zamienia NIP na same cyfry.
  • Tagi nadajesz i zdejmujesz mutacjami assignClientTag i removeClientTag albo podajesz cały zestaw w tagIds przy updateClient. Listę tagów prowadzi drukarnia: pobierzesz ją zapytaniem clientTags.

Rozmowę, spotkanie albo e-mail z CRM zapisujesz w Prynt mutacją createClientNote z typem CALL, MEETING, EMAIL, NOTE albo OTHER. Notatka integracji jest zawsze widoczna dla zespołu drukarni, a jej autorem jest integracja. Zmienić możesz tylko notatki zapisane przez Twoją integrację. Prywatne notatki pracowników drukarni nie są widoczne w API, a przy notatkach zespołu authorName jest pusty (null): API nie podaje nazwisk pracowników.

  • Zapisz id notatki przy aktywności w CRM i wysyłaj każdą aktywność do Prynt tylko raz (pushActivity). Klucz idempotencji crm-notatka-<id aktywności> chroni przed duplikatem tylko przez 24 godziny.
  • Zmianę aktywności wysyłaj mutacją updateClientNote z zapisanym id (pushActivityChange), nie ponownym createClientNote: ten sam klucz idempotencji z inną treścią API odrzuca błędem API_IDEMPOTENCY_KEY_REUSED, a po 24 godzinach powstałaby druga notatka.

Notatka zapisana przez integrację wraca w kolejnej synchronizacji z Prynt do CRM. Rozpoznasz ją po id, które zwróciła mutacja, albo po authorName równym nazwie Twojej integracji.

Każdy wariant przyjmuje klienta z CRM w takim kształcie (dopasuj go do swojego CRM):

klient-crm.json
{
"id": "crm-1042",
"kind": "company",
"name": "Wydawnictwo Lipowe sp. z o.o.",
"nip": "521-300-00-01",
"email": "[email protected]",
"phone": "+48 (22) 100 20 30",
"address": {
"street": "Lipowa",
"buildingNumber": "7",
"zip": "00-950",
"city": "Warszawa",
"country": "Polska"
}
}

Osoba prywatna ma "kind": "person" oraz firstName i lastName zamiast name i nip. Aktywność z CRM to obiekt z polami id, kind (call, meeting, email), text i at (data i czas ISO 8601); zapisany w CRM identyfikator notatki z Prynt kod przykładowy nazywa pryntNoteId.

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. Pobieranie wypisuje każdego klienta jako jedną linię JSON, a znacznik trzyma w pliku ostatnia-synchronizacja-klientow.txt.

sync-clients.sh
#!/usr/bin/env bash
# sync-clients.sh — klienci między CRM a Prynt (curl, jq; prynt-call.sh z przepisu „Obsługa błędów
# i ponowień”).
# ./sync-clients.sh pull >>klienci.ndjson klienci zmienieni od poprzedniej synchronizacji,
# jedna linia JSON na klienta (znacznik w pliku
# ostatnia-synchronizacja-klientow.txt)
# ./sync-clients.sh push klient-crm.json zapis klienta z CRM w Prynt (upsert)
# ./sync-clients.sh note <id klienta> aktywnosc.json nowa aktywność z CRM jako notatka
# ./sync-clients.sh note-change <id notatki> aktywnosc.json zmiana tej aktywności w notatce
set -euo pipefail
# 25 klientów na stronę: z adresami, kontaktami, tagami i notatkami mieści się w limicie kosztu.
# Notatek przychodzi najwyżej 50, najnowsze: przy pełnej liście usuwaj w CRM tylko notatki nowsze
# od najstarszej z listy.
changed_clients='query ChangedClients($from: DateTime, $page: Int!) {
clients(
filter: { updatedFrom: $from }
sort: { field: UPDATED_AT, direction: ASC }
pagination: { page: $page, pageSize: 25 }
) {
items {
id externalRef updatedAt type displayName legalName firstName lastName
taxIdentificationNumber email phone isActive blockMode
addresses { id type name street buildingNumber zip city country isDefault }
contacts { id firstName lastName email phone role isPrimary isActive }
tags { name }
clientNotes(first: 50) { id type content contactDate authorName }
}
pageInfo { hasNextPage }
}
}'
find_client='query FindCrmClient($ref: String!) {
clients(filter: { externalRef: $ref }) { items { id isActive addresses { id type } } }
}'
find_by_nip='query FindClientByNip($nip: String!) {
clients(filter: { taxIdentificationNumber: $nip }) {
items { id taxIdentificationNumber externalRef isActive addresses { id type } }
}
}'
create_client='mutation CreateCrmClient($input: CreateClientInput!) {
createClient(input: $input) { id externalRef updatedAt }
}'
update_client='mutation UpdateCrmClient($id: ID!, $input: UpdateClientInput!) {
updateClient(id: $id, input: $input) { id externalRef updatedAt }
}'
add_headquarters='mutation AddCrmHeadquarters($clientId: ID!, $input: ClientAddressInput!) {
createClientAddress(clientId: $clientId, input: $input) { id }
}'
update_headquarters='mutation UpdateCrmHeadquarters($id: ID!, $input: UpdateClientAddressInput!) {
updateClientAddress(addressId: $id, input: $input) { id }
}'
add_note='mutation AddCrmNote($input: CreateClientNoteInput!) {
createClientNote(input: $input) { id }
}'
update_note='mutation UpdateCrmNote($id: ID!, $input: UpdateClientNoteInput!) {
updateClientNote(id: $id, input: $input) { id }
}'
body=$(mktemp) result=$(mktemp)
trap 'rm -f "$body" "$result"' EXIT
# Wysyła operację $1 ze zmiennymi $2 (JSON); odpowiedź zostaje w pliku $result.
# Mutację tworzącą wywołuj z IDEMPOTENCY_KEY=… przed nazwą funkcji.
request() {
jq -n --arg query "$1" --argjson variables "$2" '{ query: $query, variables: $variables }' >"$body"
./prynt-call.sh "$body" >"$result"
}
pull() {
local cursor_file=ostatnia-synchronizacja-klientow.txt since from latest page last
since=$(cat "$cursor_file" 2>/dev/null || true)
from=null # wartość JSON zmiennej $from: null przy pierwszej synchronizacji (wszyscy klienci)
if [[ -n $since ]]; then
# 5 minut zapasu na zmiany zapisane tuż przed poprzednią synchronizacją, a widoczne po niej.
from=$(jq -n --arg since "$since" '$since | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601 - 300 | todateiso8601')
fi
latest=$since page=1
while :; do
request "$changed_clients" "$(jq -n --argjson from "$from" --argjson page "$page" '{ from: $from, page: $page }')"
jq -c '.data.clients.items[]' "$result"
# Czasy z API mają jeden format (np. 2026-10-05T10:41:02.000Z): porównujemy je jako tekst.
latest=$(jq -r --arg latest "$latest" \
'[$latest, .data.clients.items[].updatedAt] | map(select(. != "")) | max // ""' "$result")
[[ $(jq -r '.data.clients.pageInfo.hasNextPage' "$result") == true ]] || break
last=$(jq -c '.data.clients.items[-1].updatedAt' "$result")
# Dalej czytamy od czasu ostatniego klienta, nie od kolejnego numeru strony; kolejną stronę
# bierzemy tylko wtedy, gdy cała strona ma ten sam czas zmiany.
if [[ $last == "$from" ]]; then
page=$((page + 1))
else
from=$last page=1
fi
done
if [[ -n $latest ]]; then echo "$latest" >"$cursor_file"; fi
}
push() {
local crm=$1 fields ref nip client id linked address hq
# Pola klienta w Prynt, wspólne dla tworzenia i zmiany; jego id z CRM to externalRef. Bez type:
# drukarnia mogła zapisać klienta jako SOLE_PROPRIETOR. NIP jako same cyfry, telefon bez spacji
# i nawiasów. Puste pola pomijamy: API odrzuca jawny null.
fields=$(jq '{
legalName: (if .kind == "company" then .name else null end),
firstName: (if .kind == "company" then null else .firstName end),
lastName: (if .kind == "company" then null else .lastName end),
taxIdentificationNumber: (.nip // null | if . then gsub("[^0-9]"; "") else null end),
email: .email,
phone: (.phone // null | if . then gsub("[\\s()-]"; "") else null end),
externalRef: .id
} | with_entries(select(.value != null and .value != ""))' "$crm")
address=$(jq '(.address // {}) | with_entries(select(.value != null and .value != ""))' "$crm")
ref=$(jq -r .id "$crm") nip=$(jq -r '.taxIdentificationNumber // ""' <<<"$fields")
request "$find_client" "$(jq -n --arg ref "$ref" '{ ref: $ref }')"
client=$(jq -c '.data.clients.items[0] // empty' "$result")
if [[ -z $client && -n $nip ]]; then
request "$find_by_nip" "$(jq -n --arg nip "$nip" '{ nip: $nip }')"
# Filtr szuka fragmentu numeru: bierzemy tylko klienta z całym tym samym NIP.
client=$(jq -c --arg nip "$nip" \
'[.data.clients.items[] | select(.taxIdentificationNumber == $nip)][0] // empty' "$result")
linked=""
[[ -z $client ]] || linked=$(jq -r '.externalRef // ""' <<<"$client")
if [[ -n $linked && $linked != "$ref" ]]; then
echo "NIP $nip ma w Prynt klient $(jq -r .id <<<"$client") połączony z rekordem CRM $linked: scal duplikaty w CRM." >&2
exit 1
fi
fi
if [[ -z $client ]]; then
# Nowy klient z adresem siedziby; klucz idempotencji z id w CRM: ponowienie nie utworzy drugiego.
IDEMPOTENCY_KEY="crm-klient-$ref" request "$create_client" "$(jq -n --slurpfile crm "$crm" \
--argjson fields "$fields" --argjson address "$address" \
'{ input: ({ type: (if $crm[0].kind == "company" then "COMPANY" else "INDIVIDUAL" end) } + $fields + $address) }')"
jq '.data.createClient' "$result"
return
fi
id=$(jq -r .id <<<"$client")
if [[ $(jq -r .isActive <<<"$client") != true ]]; then
echo "Klient $id jest w Prynt usunięty: przywrócić go może drukarnia." >&2
exit 1
fi
request "$update_client" "$(jq -n --arg id "$id" --argjson input "$fields" '{ id: $id, input: $input }')"
jq '.data.updateClient' "$result"
# Adres siedziby z CRM: zmiana adresu HEADQUARTERS albo nowy, gdy klient go nie ma.
if [[ $address != "{}" ]]; then
hq=$(jq -r '[.addresses[] | select(.type == "HEADQUARTERS")][0].id // ""' <<<"$client")
if [[ -n $hq ]]; then
request "$update_headquarters" "$(jq -n --arg id "$hq" --argjson input "$address" '{ id: $id, input: $input }')"
else
IDEMPOTENCY_KEY="crm-siedziba-$ref" request "$add_headquarters" "$(jq -n --arg id "$id" \
--argjson address "$address" '{ clientId: $id, input: ({ type: "HEADQUARTERS", name: "Siedziba", country: "Polska" } + $address) }')"
fi
fi
}
# Aktywność z CRM jako pola notatki w Prynt.
note_fields() {
jq '{
type: ({ call: "CALL", meeting: "MEETING", email: "EMAIL" }[.kind] // "NOTE"),
content: .text,
contactDate: .at
}' "$1"
}
# Nowa aktywność jako notatka; zapisz zwrócone id przy aktywności w CRM, a jej zmianę wyślij
# przez note-change: klucz idempotencji chroni przed drugą notatką tylko przez 24 godziny.
note() {
local client=$1 activity=$2
IDEMPOTENCY_KEY="crm-notatka-$(jq -r .id "$activity")" request "$add_note" \
"$(jq -n --arg client "$client" --argjson note "$(note_fields "$activity")" '{ input: ({ clientId: $client } + $note) }')"
jq '.data.createClientNote' "$result"
}
note_change() {
local id=$1 activity=$2
request "$update_note" "$(jq -n --arg id "$id" --argjson input "$(note_fields "$activity")" '{ id: $id, input: $input }')"
jq '.data.updateClientNote' "$result"
}
case ${1:-} in
pull) pull ;;
push) push "$2" ;;
note) note "$2" "$3" ;;
note-change) note_change "$2" "$3" ;;
*)
echo "Użycie: $0 pull | push klient-crm.json | note <id klienta> aktywnosc.json | note-change <id notatki> aktywnosc.json" >&2
exit 2
;;
esac
Okno terminala
./sync-clients.sh pull >>klienci.ndjson # wczytaj linie do CRM, zapisując po externalRef albo id
./sync-clients.sh push klient-crm.json # klient z CRM do Prynt
./sync-clients.sh note 6c2e4a1b-7d3f-4e5a-9b8c-0d1e2f3a4b5c aktywnosc.json # wypisuje id notatki
./sync-clients.sh note-change 3b8f1c2d-4e5a-4b6c-8d7e-9f0a1b2c3d4e aktywnosc.json
  • Klienci usunięci w Prynt nie znikają z API: są dezaktywowani (isActive: false) i wracają w synchronizacji jak każda zmiana.
  • Zamówienia klienta pobierzesz z zakresem orders:read filtrem clientId listy orders, a z zakresem clients:read lista zamówień pokaże też pełnego klienta w polu client.
  • Kupujący ze sklepu jako klienci w Prynt: przepis Zamówienie ze sklepu internetowego zakłada klienta, jeśli go nie ma.