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.
Czego potrzebujesz
Dział zatytułowany „Czego potrzebujesz”- Klucz z zakresami
clients:readiclients:write. Limit kredytowy i rabat klienta są dostępne dopiero z zakresemprices: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.
Własny identyfikator: externalRef
Dział zatytułowany „Własny identyfikator: externalRef”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
CONFLICTz identyfikatorem tamtego klienta w komunikacie.nullwupdateClientusuwa identyfikator. - To nie jest zmiana klienta. Samo nadanie albo zmiana
externalRefnie przesuwaClient.updatedAt, więc klient nie wraca przez to w kolejnej synchronizacji.
Z Prynt do CRM
Dział zatytułowany „Z Prynt do CRM”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.
-
Pierwsza synchronizacja pobiera wszystkich klientów, także nieaktywnych:
updatedFromjest wtedy pusty. -
Zapisuj klienta w CRM po
externalRef, jeśli go ma (klient przyszedł z CRM), a inaczej poidz 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. -
Kolejną porcję zaczynaj od
updatedAtostatniego klienta, nie od kolejnego numeru strony, 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.
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.
Z CRM do Prynt
Dział zatytułowany „Z CRM do Prynt”Zapis klienta z CRM to upsert w trzech krokach:
-
Szukaj po
externalRef, czyli po id rekordu w CRM. -
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-01to inny numer), a że filtrtaxIdentificationNumberszuka fragmentu numeru, porównaj cały NIP u siebie. Znalezionej firmieupdateClientdopisuje TwójexternalRef. 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. -
Nikogo nie znalazłeś: utwórz klienta mutacją
createClient, z adresem siedziby i nagłówkiemIdempotency-Keyz id rekordu w CRM. Ponowienie nie utworzy drugiego klienta.
Zasady zapisu:
- Zmiana to łatka. W
updateClientpole pominięte zostaje bez zmian, anullczyś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łajnulldla 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 podajetypetylko wcreateClient, aupdateClientbeztypezostawia rodzaj z Prynt. API przyjmujetypetakże wupdateClient: 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
HEADQUARTERSmutacją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 orazcreateClientContactiupdateClientContact— 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
assignClientTagiremoveClientTagalbo podajesz cały zestaw wtagIdsprzyupdateClient. Listę tagów prowadzi drukarnia: pobierzesz ją zapytaniemclientTags.
Aktywności z CRM jako notatki
Dział zatytułowany „Aktywności z CRM jako notatki”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
idnotatki przy aktywności w CRM i wysyłaj każdą aktywność do Prynt tylko raz (pushActivity). Klucz idempotencjicrm-notatka-<id aktywności>chroni przed duplikatem tylko przez 24 godziny. - Zmianę aktywności wysyłaj mutacją
updateClientNotez zapisanymid(pushActivityChange), nie ponownymcreateClientNote: ten sam klucz idempotencji z inną treścią API odrzuca błędemAPI_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):
{ "id": "crm-1042", "kind": "company", "name": "Wydawnictwo Lipowe sp. z o.o.", "nip": "521-300-00-01", "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.
#!/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 notatceset -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./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// sync-clients.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;
// 25 klientów na stronę: z adresami, kontaktami, tagami i notatkami mieści się w limicie kosztu.const 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 } }}`;
const FIND_CLIENT = `query FindCrmClient($ref: String!) { clients(filter: { externalRef: $ref }) { items { id isActive addresses { id type } } }}`;
const FIND_BY_NIP = `query FindClientByNip($nip: String!) { clients(filter: { taxIdentificationNumber: $nip }) { items { id taxIdentificationNumber externalRef isActive addresses { id type } } }}`;
const CREATE_CLIENT = `mutation CreateCrmClient($input: CreateClientInput!) { createClient(input: $input) { id externalRef updatedAt }}`;
const UPDATE_CLIENT = `mutation UpdateCrmClient($id: ID!, $input: UpdateClientInput!) { updateClient(id: $id, input: $input) { id externalRef updatedAt }}`;
const ADD_HEADQUARTERS = `mutation AddCrmHeadquarters($clientId: ID!, $input: ClientAddressInput!) { createClientAddress(clientId: $clientId, input: $input) { id }}`;
const UPDATE_HEADQUARTERS = `mutation UpdateCrmHeadquarters($id: ID!, $input: UpdateClientAddressInput!) { updateClientAddress(addressId: $id, input: $input) { id }}`;
const ADD_NOTE = `mutation AddCrmNote($input: CreateClientNoteInput!) { createClientNote(input: $input) { id }}`;
const UPDATE_NOTE = `mutation UpdateCrmNote($id: ID!, $input: UpdateClientNoteInput!) { updateClientNote(id: $id, input: $input) { id }}`;
const NOTE_TYPES = { call: 'CALL', meeting: 'MEETING', email: 'EMAIL' };
const later = (a, b) => (a === null || Date.parse(b) > Date.parse(a) ? b : a);
/** NIP jako same cyfry: Prynt porównuje NIP dosłownie, więc tak go zapisujemy i szukamy. */const nipDigits = (nip) => nip?.replaceAll(/\D/g, '');
/** Telefon bez spacji, nawiasów i myślników: Prynt przyjmuje najwyżej 15 znaków. */const compactPhone = (phone) => phone?.replaceAll(/[\s()-]/g, '');
/** API odrzuca jawny null w polach, które go nie przyjmują: puste pola pomijamy. */function withoutEmpty(value) { return Object.fromEntries( Object.entries(value).filter(([, item]) => item !== null && item !== undefined && item !== ''), );}
/** * Pobiera klientów zmienionych od `since` (ISO 8601; `null` przy pierwszej synchronizacji: * wszyscy) i przekazuje każdego do `save`, które zapisuje go w CRM: po `externalRef`, gdy klient * przyszedł z CRM, inaczej po `id` z Prynt. `save` zastępuje w CRM adresy, osoby kontaktowe, tagi * i notatki klienta tym, co przyszło: czego nie ma na liście, to w Prynt usunięto (notatkę także * oznaczono jako prywatną). Notatek przychodzi najwyżej 50, najnowsze: przy pełnej liście usuwaj * tylko notatki nowsze od najstarszej z listy. Zwraca znacznik do następnej synchronizacji — * najpóźniejszy `updatedAt`, jaki widziała; zapisz go dopiero po udanym końcu. */export async function syncClients(since, save) { let from = since === null ? null : new Date(Date.parse(since) - OVERLAP_MS).toISOString(); let latest = since; for (let page = 1; ; ) { const { clients } = await pryntRequest({ query: CHANGED_CLIENTS, variables: { from, page } }); for (const client of clients.items) { await save(client); latest = later(latest, client.updatedAt); } if (!clients.pageInfo.hasNextPage) return latest; const last = clients.items.at(-1).updatedAt; // 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) { page += 1; } else { from = last; page = 1; } }}
/** * Klient z CRM jako pola klienta w Prynt, wspólne dla tworzenia i zmiany; jego id z CRM to * `externalRef`. Bez `type`: CRM zna firmę i osobę, a drukarnia mogła zapisać klienta jako * jednoosobową działalność (`SOLE_PROPRIETOR`) — zmiana nie może tego nadpisać. */export function clientFields(crmClient) { const company = crmClient.kind === 'company'; return withoutEmpty({ legalName: company ? crmClient.name : undefined, firstName: company ? undefined : crmClient.firstName, lastName: company ? undefined : crmClient.lastName, taxIdentificationNumber: nipDigits(crmClient.nip), email: crmClient.email, phone: compactPhone(crmClient.phone), externalRef: crmClient.id, });}
/** Nowy klient w Prynt: rodzaj, pola klienta i adres siedziby. */export function newClientInput(crmClient) { return { type: crmClient.kind === 'company' ? 'COMPANY' : 'INDIVIDUAL', ...clientFields(crmClient), ...withoutEmpty({ ...crmClient.address }), };}
/** * Klient w Prynt: po identyfikatorze z CRM, a firma bez niego — po całym NIP. Firmę połączoną * już z innym rekordem CRM odrzuca: to duplikat w CRM (NIP w Prynt jest unikalny), który trzeba * scalić, inaczej oba rekordy nadpisywałyby jednego klienta. */async function findClient(crmClient) { const { clients } = await pryntRequest({ query: FIND_CLIENT, variables: { ref: crmClient.id } }); if (clients.items.length > 0) return clients.items[0]; const nip = nipDigits(crmClient.nip); if (!nip) return null; const byNip = await pryntRequest({ query: FIND_BY_NIP, variables: { nip } }); // Filtr szuka fragmentu numeru: bierzemy tylko klienta z całym tym samym NIP. const found = byNip.clients.items.find((client) => client.taxIdentificationNumber === nip); if (found?.externalRef && found.externalRef !== crmClient.id) { throw new Error( `NIP ${nip} ma w Prynt klient ${found.id} połączony z rekordem CRM ${found.externalRef}: scal duplikaty w CRM.`, ); } return found ?? null;}
/** Adres siedziby z CRM na istniejącym kliencie: zmienia adres HEADQUARTERS albo go dodaje. */async function pushHeadquarters(client, crmClient) { const address = withoutEmpty({ ...crmClient.address }); if (Object.keys(address).length === 0) return; const headquarters = client.addresses.find((item) => item.type === 'HEADQUARTERS'); if (headquarters) { await pryntRequest({ query: UPDATE_HEADQUARTERS, variables: { id: headquarters.id, input: address }, }); return; } await pryntRequest({ query: ADD_HEADQUARTERS, variables: { clientId: client.id, // Kraj jak przy createClient: Polska, gdy CRM go nie podaje. input: { type: 'HEADQUARTERS', name: 'Siedziba', country: 'Polska', ...address }, }, idempotencyKey: `crm-siedziba-${crmClient.id}`, });}
/** * Zapisuje klienta z CRM w Prynt (upsert): zmienia istniejącego — znalezionego po `externalRef` * albo po NIP, któremu dopisuje `externalRef` — razem z adresem siedziby, albo tworzy nowego. * Klucz idempotencji z id w CRM: ponowienie nie utworzy drugiego klienta. Klienta usuniętego w * Prynt (`isActive: false`) nie zmienia: API by to odrzuciło, przywraca go tylko drukarnia. */export async function pushClient(crmClient) { const existing = await findClient(crmClient); if (!existing) { const data = await pryntRequest({ query: CREATE_CLIENT, variables: { input: newClientInput(crmClient) }, idempotencyKey: `crm-klient-${crmClient.id}`, }); return data.createClient; } if (!existing.isActive) { throw new Error(`Klient ${existing.id} jest w Prynt usunięty: przywrócić go może drukarnia.`); } const data = await pryntRequest({ query: UPDATE_CLIENT, variables: { id: existing.id, input: clientFields(crmClient) }, }); await pushHeadquarters(existing, crmClient); return data.updateClient;}
/** Aktywność z CRM jako pola notatki w Prynt. */function noteFields(activity) { return { type: NOTE_TYPES[activity.kind] ?? 'NOTE', content: activity.text, contactDate: activity.at, };}
/** * Zapisuje nową aktywność z CRM (rozmowę, spotkanie, e-mail) jako notatkę klienta w Prynt. * Zapisz zwrócone `id` przy aktywności w CRM, a jej zmianę wyślij przez `pushActivityChange`: * klucz idempotencji chroni przed drugą notatką tylko przez 24 godziny. */export async function pushActivity(clientId, activity) { const data = await pryntRequest({ query: ADD_NOTE, variables: { input: { clientId, ...noteFields(activity) } }, idempotencyKey: `crm-notatka-${activity.id}`, }); return data.createClientNote;}
/** Zmienia notatkę `noteId` (zapisaną wcześniej przez `pushActivity`) po zmianie aktywności w CRM. */export async function pushActivityChange(noteId, activity) { const data = await pryntRequest({ query: UPDATE_NOTE, variables: { id: noteId, input: noteFields(activity) }, }); return data.updateClientNote;}import { readFile, writeFile } from 'node:fs/promises';import { pushActivity, pushActivityChange, pushClient, syncClients } from './sync-clients.mjs';
// Z Prynt do CRMconst MARKER = 'ostatnia-synchronizacja-klientow.txt';const since = (await readFile(MARKER, 'utf8').catch(() => '')).trim() || null;const next = await syncClients(since, async (client) => { await saveInCrm(client); // Twoja funkcja: zapisz po client.externalRef albo client.id});if (next) await writeFile(MARKER, next);
// Z CRM do Prynt: crmClient i activity to rekordy z Twojego CRMconst client = await pushClient(crmClient);if (activity.pryntNoteId) { await pushActivityChange(activity.pryntNoteId, activity); // aktywność zmieniona w CRM} else { const note = await pushActivity(client.id, activity); await saveNoteId(activity.id, note.id); // Twoja funkcja: zapisz id notatki przy aktywności}# sync_clients.py — Python 3.9+, klient API z przepisu „Obsługa błędów i ponowień”import refrom 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)
# 25 klientów na stronę: z adresami, kontaktami, tagami i notatkami mieści się w limicie kosztu.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 }}"""
NOTE_TYPES = {"call": "CALL", "meeting": "MEETING", "email": "EMAIL"}
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 nip_digits(nip): """NIP jako same cyfry: Prynt porównuje NIP dosłownie, więc tak go zapisujemy i szukamy.""" return re.sub(r"\D", "", nip) if nip else None
def compact_phone(phone): """Telefon bez spacji, nawiasów i myślników: Prynt przyjmuje najwyżej 15 znaków.""" return re.sub(r"[\s()-]", "", phone) if phone else None
def without_empty(value): """API odrzuca jawny null w polach, które go nie przyjmują: puste pola pomijamy.""" return {key: item for key, item in value.items() if item not in (None, "")}
def sync_clients(since, save): """Pobiera klientów zmienionych od `since` (ISO 8601; None przy pierwszej synchronizacji: wszyscy) i przekazuje każdego do `save`, które zapisuje go w CRM: po `externalRef`, gdy klient przyszedł z CRM, inaczej po `id` z Prynt. `save` zastępuje w CRM adresy, osoby kontaktowe, tagi i notatki klienta tym, co przyszło: czego nie ma na liście, to w Prynt usunięto (notatkę także oznaczono jako prywatną). Notatek przychodzi najwyżej 50, najnowsze: przy pełnej liście usuwaj tylko notatki nowsze od najstarszej z listy. Zwraca znacznik do następnej synchronizacji (najpóźniejszy `updatedAt`); zapisz go dopiero po udanym końcu.""" start = None if since is None else _iso(_instant(since) - OVERLAP) latest, page = since, 1 while True: clients = prynt_request(CHANGED_CLIENTS, {"from": start, "page": page})["clients"] for client in clients["items"]: save(client) if latest is None or _instant(client["updatedAt"]) > _instant(latest): latest = client["updatedAt"] if not clients["pageInfo"]["hasNextPage"]: return latest last = clients["items"][-1]["updatedAt"] # 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 == start: page += 1 else: start, page = last, 1
def client_fields(crm_client): """Klient z CRM jako pola klienta w Prynt, wspólne dla tworzenia i zmiany; jego id z CRM to `externalRef`. Bez `type`: CRM zna firmę i osobę, a drukarnia mogła zapisać klienta jako jednoosobową działalność (SOLE_PROPRIETOR) — zmiana nie może tego nadpisać.""" company = crm_client.get("kind") == "company" return without_empty({ "legalName": crm_client.get("name") if company else None, "firstName": None if company else crm_client.get("firstName"), "lastName": None if company else crm_client.get("lastName"), "taxIdentificationNumber": nip_digits(crm_client.get("nip")), "email": crm_client.get("email"), "phone": compact_phone(crm_client.get("phone")), "externalRef": crm_client["id"], })
def new_client_input(crm_client): """Nowy klient w Prynt: rodzaj, pola klienta i adres siedziby.""" return { "type": "COMPANY" if crm_client.get("kind") == "company" else "INDIVIDUAL", **client_fields(crm_client), **without_empty(crm_client.get("address") or {}), }
def _find_client(crm_client): """Klient w Prynt: po identyfikatorze z CRM, a firma bez niego — po całym NIP. Firmę połączoną już z innym rekordem CRM odrzuca: to duplikat w CRM (NIP w Prynt jest unikalny).""" found = prynt_request(FIND_CLIENT, {"ref": crm_client["id"]})["clients"]["items"] if found: return found[0] nip = nip_digits(crm_client.get("nip")) if not nip: return None by_nip = prynt_request(FIND_BY_NIP, {"nip": nip})["clients"]["items"] # Filtr szuka fragmentu numeru: bierzemy tylko klienta z całym tym samym NIP. client = next((item for item in by_nip if item["taxIdentificationNumber"] == nip), None) if client and client["externalRef"] and client["externalRef"] != crm_client["id"]: raise ValueError( f"NIP {nip} ma w Prynt klient {client['id']} połączony z rekordem CRM " f"{client['externalRef']}: scal duplikaty w CRM." ) return client
def _push_headquarters(client, crm_client): """Adres siedziby z CRM na istniejącym kliencie: zmienia adres HEADQUARTERS albo go dodaje.""" address = without_empty(crm_client.get("address") or {}) if not address: return headquarters = next((item for item in client["addresses"] if item["type"] == "HEADQUARTERS"), None) if headquarters: prynt_request(UPDATE_HEADQUARTERS, {"id": headquarters["id"], "input": address}) return prynt_request( ADD_HEADQUARTERS, # Kraj jak przy createClient: Polska, gdy CRM go nie podaje. {"clientId": client["id"], "input": {"type": "HEADQUARTERS", "name": "Siedziba", "country": "Polska", **address}}, idempotency_key=f"crm-siedziba-{crm_client['id']}", )
def push_client(crm_client): """Zapisuje klienta z CRM w Prynt (upsert): zmienia istniejącego — znalezionego po `externalRef` albo po NIP, któremu dopisuje `externalRef` — razem z adresem siedziby, albo tworzy nowego. Klucz idempotencji z id w CRM: ponowienie nie utworzy drugiego klienta. Klienta usuniętego w Prynt (isActive: false) nie zmienia: przywrócić go może drukarnia.""" existing = _find_client(crm_client) if existing is None: data = prynt_request( CREATE_CLIENT, {"input": new_client_input(crm_client)}, idempotency_key=f"crm-klient-{crm_client['id']}", ) return data["createClient"] if not existing["isActive"]: raise ValueError(f"Klient {existing['id']} jest w Prynt usunięty: przywrócić go może drukarnia.") data = prynt_request(UPDATE_CLIENT, {"id": existing["id"], "input": client_fields(crm_client)}) _push_headquarters(existing, crm_client) return data["updateClient"]
def _note_fields(activity): """Aktywność z CRM jako pola notatki w Prynt.""" return { "type": NOTE_TYPES.get(activity["kind"], "NOTE"), "content": activity["text"], "contactDate": activity["at"], }
def push_activity(client_id, activity): """Zapisuje nową aktywność z CRM (rozmowę, spotkanie, e-mail) jako notatkę klienta w Prynt. Zapisz zwrócone `id` przy aktywności w CRM, a jej zmianę wyślij przez push_activity_change: klucz idempotencji chroni przed drugą notatką tylko przez 24 godziny.""" data = prynt_request( ADD_NOTE, {"input": {"clientId": client_id, **_note_fields(activity)}}, idempotency_key=f"crm-notatka-{activity['id']}", ) return data["createClientNote"]
def push_activity_change(note_id, activity): """Zmienia notatkę `note_id` (zapisaną wcześniej przez push_activity) po zmianie aktywności.""" data = prynt_request(UPDATE_NOTE, {"id": note_id, "input": _note_fields(activity)}) return data["updateClientNote"]from pathlib import Path
from sync_clients import push_activity, push_activity_change, push_client, sync_clients
# Z Prynt do CRM; save_in_crm: Twoja funkcja, zapisuje po externalRef albo idmarker = Path("ostatnia-synchronizacja-klientow.txt")since = (marker.read_text().strip() or None) if marker.exists() else Nonenext_since = sync_clients(since, save_in_crm)if next_since: marker.write_text(next_since)
# Z CRM do Prynt: crm_client i activity to rekordy z Twojego CRM jako słownikiclient = push_client(crm_client)if activity.get("pryntNoteId"): push_activity_change(activity["pryntNoteId"], activity) # aktywność zmieniona w CRMelse: note = push_activity(client["id"], activity) save_note_id(activity["id"], note["id"]) # Twoja funkcja: zapisz id notatki przy aktywności<?php// SyncClients.php — PHP 8.1+, klient API z przepisu „Obsługa błędów i ponowień”require_once __DIR__ . '/PryntClient.php';
final class SyncClients{ // Zapas na zmiany zapisane tuż przed poprzednią synchronizacją, a widoczne dopiero po niej. private const OVERLAP = 'PT5M';
// 25 klientów na stronę: z adresami, kontaktami, tagami i notatkami mieści się w limicie kosztu. private const CHANGED_CLIENTS = <<<'GRAPHQL' 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 } } } GRAPHQL;
private const FIND_CLIENT = <<<'GRAPHQL' query FindCrmClient($ref: String!) { clients(filter: { externalRef: $ref }) { items { id isActive addresses { id type } } } } GRAPHQL;
private const FIND_BY_NIP = <<<'GRAPHQL' query FindClientByNip($nip: String!) { clients(filter: { taxIdentificationNumber: $nip }) { items { id taxIdentificationNumber externalRef isActive addresses { id type } } } } GRAPHQL;
private const CREATE_CLIENT = <<<'GRAPHQL' mutation CreateCrmClient($input: CreateClientInput!) { createClient(input: $input) { id externalRef updatedAt } } GRAPHQL;
private const UPDATE_CLIENT = <<<'GRAPHQL' mutation UpdateCrmClient($id: ID!, $input: UpdateClientInput!) { updateClient(id: $id, input: $input) { id externalRef updatedAt } } GRAPHQL;
private const ADD_HEADQUARTERS = <<<'GRAPHQL' mutation AddCrmHeadquarters($clientId: ID!, $input: ClientAddressInput!) { createClientAddress(clientId: $clientId, input: $input) { id } } GRAPHQL;
private const UPDATE_HEADQUARTERS = <<<'GRAPHQL' mutation UpdateCrmHeadquarters($id: ID!, $input: UpdateClientAddressInput!) { updateClientAddress(addressId: $id, input: $input) { id } } GRAPHQL;
private const ADD_NOTE = <<<'GRAPHQL' mutation AddCrmNote($input: CreateClientNoteInput!) { createClientNote(input: $input) { id } } GRAPHQL;
private const UPDATE_NOTE = <<<'GRAPHQL' mutation UpdateCrmNote($id: ID!, $input: UpdateClientNoteInput!) { updateClientNote(id: $id, input: $input) { id } } GRAPHQL;
private const NOTE_TYPES = ['call' => 'CALL', 'meeting' => 'MEETING', 'email' => 'EMAIL'];
public function __construct(private readonly PryntClient $prynt = new PryntClient()) { }
/** API odrzuca jawny null w polach, które go nie przyjmują: puste pola pomijamy. */ private static function withoutEmpty(array $value): array { return array_filter($value, fn ($item) => $item !== null && $item !== ''); }
/** NIP jako same cyfry: Prynt porównuje NIP dosłownie, więc tak go zapisujemy i szukamy. */ private static function nipDigits(?string $nip): ?string { return $nip === null ? null : preg_replace('/\D/', '', $nip); }
/** Telefon bez spacji, nawiasów i myślników: Prynt przyjmuje najwyżej 15 znaków. */ private static function compactPhone(?string $phone): ?string { return $phone === null ? null : preg_replace('/[\s()-]/', '', $phone); }
/** * Pobiera klientów zmienionych od $since (ISO 8601; null przy pierwszej synchronizacji: * wszyscy) i przekazuje każdego do $save, które zapisuje go w CRM: po `externalRef`, gdy * klient przyszedł z CRM, inaczej po `id` z Prynt. $save zastępuje w CRM adresy, osoby * kontaktowe, tagi i notatki klienta tym, co przyszło: czego nie ma na liście, to w Prynt * usunięto (notatkę także oznaczono jako prywatną). Notatek przychodzi najwyżej * 50, najnowsze: przy pełnej liście usuwaj tylko notatki nowsze od najstarszej * z listy. Zwraca znacznik do następnej synchronizacji (najpóźniejszy `updatedAt`); zapisz go * dopiero po udanym końcu. */ public function sync(?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) { $clients = $this->prynt->request(self::CHANGED_CLIENTS, ['from' => $from, 'page' => $page])['clients']; foreach ($clients['items'] as $client) { $save($client); if ($latest === null || new DateTimeImmutable($client['updatedAt']) > new DateTimeImmutable($latest)) { $latest = $client['updatedAt']; } } if (!$clients['pageInfo']['hasNextPage']) { return $latest; } $last = end($clients['items'])['updatedAt']; // 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) { $page++; } else { $from = $last; $page = 1; } } }
/** * Klient z CRM jako pola klienta w Prynt, wspólne dla tworzenia i zmiany; jego id z CRM to * `externalRef`. Bez `type`: CRM zna firmę i osobę, a drukarnia mogła zapisać klienta jako * jednoosobową działalność (SOLE_PROPRIETOR) — zmiana nie może tego nadpisać. */ public static function clientFields(array $crmClient): array { $company = ($crmClient['kind'] ?? null) === 'company'; return self::withoutEmpty([ 'legalName' => $company ? ($crmClient['name'] ?? null) : null, 'firstName' => $company ? null : ($crmClient['firstName'] ?? null), 'lastName' => $company ? null : ($crmClient['lastName'] ?? null), 'taxIdentificationNumber' => self::nipDigits($crmClient['nip'] ?? null), 'email' => $crmClient['email'] ?? null, 'phone' => self::compactPhone($crmClient['phone'] ?? null), 'externalRef' => $crmClient['id'], ]); }
/** Nowy klient w Prynt: rodzaj, pola klienta i adres siedziby. */ public static function newClientInput(array $crmClient): array { return [ 'type' => ($crmClient['kind'] ?? null) === 'company' ? 'COMPANY' : 'INDIVIDUAL', ...self::clientFields($crmClient), ...self::withoutEmpty($crmClient['address'] ?? []), ]; }
/** * Klient w Prynt: po identyfikatorze z CRM, a firma bez niego — po całym NIP. Firmę połączoną * już z innym rekordem CRM odrzuca: to duplikat w CRM (NIP w Prynt jest unikalny). */ private function findClient(array $crmClient): ?array { $found = $this->prynt->request(self::FIND_CLIENT, ['ref' => $crmClient['id']])['clients']['items']; if ($found !== []) { return $found[0]; } $nip = self::nipDigits($crmClient['nip'] ?? null); if ($nip === null || $nip === '') { return null; } $byNip = $this->prynt->request(self::FIND_BY_NIP, ['nip' => $nip])['clients']['items']; // Filtr szuka fragmentu numeru: bierzemy tylko klienta z całym tym samym NIP. foreach ($byNip as $client) { if ($client['taxIdentificationNumber'] !== $nip) { continue; } if ($client['externalRef'] !== null && $client['externalRef'] !== $crmClient['id']) { throw new RuntimeException( "NIP {$nip} ma w Prynt klient {$client['id']} połączony z rekordem CRM " . "{$client['externalRef']}: scal duplikaty w CRM." ); } return $client; } return null; }
/** Adres siedziby z CRM na istniejącym kliencie: zmienia adres HEADQUARTERS albo go dodaje. */ private function pushHeadquarters(array $client, array $crmClient): void { $address = self::withoutEmpty($crmClient['address'] ?? []); if ($address === []) { return; } foreach ($client['addresses'] as $headquarters) { if ($headquarters['type'] === 'HEADQUARTERS') { $this->prynt->request(self::UPDATE_HEADQUARTERS, ['id' => $headquarters['id'], 'input' => $address]); return; } } $this->prynt->request( self::ADD_HEADQUARTERS, // Kraj jak przy createClient: Polska, gdy CRM go nie podaje. ['clientId' => $client['id'], 'input' => ['type' => 'HEADQUARTERS', 'name' => 'Siedziba', 'country' => 'Polska', ...$address]], "crm-siedziba-{$crmClient['id']}", ); }
/** * Zapisuje klienta z CRM w Prynt (upsert): zmienia istniejącego — znalezionego po * `externalRef` albo po NIP, któremu dopisuje `externalRef` — razem z adresem siedziby, albo * tworzy nowego. Klucz idempotencji z id w CRM: ponowienie nie utworzy drugiego klienta. * Klienta usuniętego w Prynt (isActive: false) nie zmienia: przywrócić go może drukarnia. */ public function push(array $crmClient): array { $existing = $this->findClient($crmClient); if ($existing === null) { return $this->prynt->request( self::CREATE_CLIENT, ['input' => self::newClientInput($crmClient)], "crm-klient-{$crmClient['id']}", )['createClient']; } if (!$existing['isActive']) { throw new RuntimeException("Klient {$existing['id']} jest w Prynt usunięty: przywrócić go może drukarnia."); } $updated = $this->prynt->request( self::UPDATE_CLIENT, ['id' => $existing['id'], 'input' => self::clientFields($crmClient)], )['updateClient']; $this->pushHeadquarters($existing, $crmClient); return $updated; }
/** Aktywność z CRM jako pola notatki w Prynt. */ private static function noteFields(array $activity): array { return [ 'type' => self::NOTE_TYPES[$activity['kind']] ?? 'NOTE', 'content' => $activity['text'], 'contactDate' => $activity['at'], ]; }
/** * Zapisuje nową aktywność z CRM (rozmowę, spotkanie, e-mail) jako notatkę klienta w Prynt. * Zapisz zwrócone `id` przy aktywności w CRM, a jej zmianę wyślij przez pushActivityChange: * klucz idempotencji chroni przed drugą notatką tylko przez 24 godziny. */ public function pushActivity(string $clientId, array $activity): array { return $this->prynt->request( self::ADD_NOTE, ['input' => ['clientId' => $clientId, ...self::noteFields($activity)]], "crm-notatka-{$activity['id']}", )['createClientNote']; }
/** Zmienia notatkę $noteId (zapisaną wcześniej przez pushActivity) po zmianie aktywności. */ public function pushActivityChange(string $noteId, array $activity): array { return $this->prynt->request( self::UPDATE_NOTE, ['id' => $noteId, 'input' => self::noteFields($activity)], )['updateClientNote']; }}<?phprequire __DIR__ . '/SyncClients.php';
$sync = new SyncClients();
// Z Prynt do CRM$marker = __DIR__ . '/ostatnia-synchronizacja-klientow.txt';$since = is_file($marker) ? (trim(file_get_contents($marker)) ?: null) : null;$next = $sync->sync($since, function (array $client): void { saveInCrm($client); // Twoja funkcja: zapisz po externalRef albo id});if ($next !== null) { file_put_contents($marker, $next);}
// Z CRM do Prynt: $crmClient i $activity to rekordy z Twojego CRM jako tablice$client = $sync->push($crmClient);if (isset($activity['pryntNoteId'])) { $sync->pushActivityChange($activity['pryntNoteId'], $activity); // aktywność zmieniona w CRM} else { $note = $sync->pushActivity($client['id'], $activity); saveNoteId($activity['id'], $note['id']); // Twoja funkcja: zapisz id notatki przy aktywności}Co dalej
Dział zatytułowany „Co dalej”- 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:readfiltremclientIdlistyorders, a z zakresemclients:readlista zamówień pokaże też pełnego klienta w poluclient. - Kupujący ze sklepu jako klienci w Prynt: przepis Zamówienie ze sklepu internetowego zakłada klienta, jeśli go nie ma.