Przejdź do treści
ShopentlyLearn

Jak wyeksportować zamówienia

Wyeksportuj zamówienia z Shopently API dla zakresu dat. Przejdź przez kolejne strony wyników, korzystając z cURL, PHP lub Pythona.

Przebieg i rezultat

Ustaw CREATED_FROM i CREATED_TO jako daty ISO 8601 ze strefą czasową. Skrypt eksportuje zamówienia utworzone w wybranym przedziale, przechodząc po wszystkich kursorach. To nie jest synchronizacja zmian: API nie udostępnia filtra updated_since. Odpowiedzi mogą zawierać dane klientów, więc zapisuj wynik w chronionym miejscu.

Parametry

  • SHOPENTLY_SHOP_URL — Adres zasobu.
  • SHOPENTLY_ACCESS_TOKEN — Krótkotrwały token uwierzytelniający Bearer.
  • CREATED_FROM — Początek zakresu filtrowania według daty utworzenia.
  • CREATED_TO — Koniec zakresu filtrowania według daty utworzenia.

PHP 8.3 (64-bit, ext-curl, JSON) · Python 3.12 + requests · cURL 8 + jq 1.7.

Kompletny przykład

cURL

#!/bin/sh
# POSIX shell, curl 8+, jq 1.7. Credentials stay in environment variables.
set -eu
: "${SHOPENTLY_SHOP_URL:?}"
base=${SHOPENTLY_SHOP_URL%/}
case "$base" in https://*) ;; *) echo 'Use an HTTPS origin' >&2; exit 1;; esac
authority=${base#https://}
case "$authority" in ''|*/*|*\?*|*\#*|*@*) echo 'Use an HTTPS origin' >&2; exit 1;; esac
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT HUP INT TERM
chmod 700 "$work"
encode() { printf '%s' "$1" | jq -sRr @uri; }
api() {
    method=$1; path=$2; body=${3-}; key=${4-}
    scheme=Bearer; credential=${SHOPENTLY_ACCESS_TOKEN-}
    if [ "$path" = /api/v1/auth/token ]; then scheme=ApiKey; credential=${SHOPENTLY_API_KEY-}; fi
    [ -n "$credential" ] || { echo 'Missing credential' >&2; return 1; }
    set -- --disable --globoff --silent --show-error --proto '=https' --connect-timeout 5 --max-time 30 \
        --request "$method" --header "Authorization: $scheme $credential" \
        --dump-header "$work/headers" --output "$work/body" --write-out '%{http_code}'
    if [ -n "$key" ]; then set -- "$@" --header "Idempotency-Key: $key"; fi
    if [ -n "$body" ]; then set -- "$@" --header 'Content-Type: application/json' --data-raw "$body"; fi
    if [ "$method" = GET ]; then set -- "$@" --retry 2 --retry-max-time 60; fi
    status=$(curl "$@" --url "$base$path") || return 1
    case "$status" in
        2??) if [ -s "$work/body" ]; then jq -e . "$work/body"; else printf 'null\n'; fi ;;
        *) printf 'HTTP %s\n' "$status" >&2
           jq -c '.error | {code, fields}' "$work/body" >&2 2>/dev/null || true
           return 1 ;;
    esac
}

query='limit=100'
: "${CREATED_FROM:?}" "${CREATED_TO:?}"
query="$query&created_from=$(encode "$CREATED_FROM")&created_to=$(encode "$CREATED_TO")"
seen='[]'
while :; do
    page=$(api GET "/api/v1/orders?$query")
    printf '%s' "$page" | jq -r '.data[].id | @uri' > "$work/order-ids"
    while IFS= read -r order_id; do
        detail=$(api GET "/api/v1/orders/$order_id")
        printf '%s' "$detail" | jq -c '.data'
    done < "$work/order-ids"
    more=$(printf '%s' "$page" | jq -r '.meta.pagination.has_more')
    [ "$more" = true ] || break
    cursor=$(printf '%s' "$page" | jq -er '.meta.pagination.next_cursor')
    if printf '%s' "$seen" | jq -e --arg cursor "$cursor" 'index($cursor) != null' >/dev/null; then
        echo 'Repeated cursor' >&2; exit 1
    fi
    seen=$(printf '%s' "$seen" | jq -c --arg cursor "$cursor" '. + [$cursor]')
    query="limit=100&cursor=$(encode "$cursor")"
    query="$query&created_from=$(encode "$CREATED_FROM")&created_to=$(encode "$CREATED_TO")"
done

PHP

<?php
declare(strict_types=1);
set_exception_handler(static function (Throwable $error): void {
    fwrite(STDERR, $error->getMessage() . PHP_EOL);
    exit(1);
});
// PHP 8.3+, 64-bit, ext-curl. Credentials stay in environment variables.
function env(string $name): string {
    $value = getenv($name);
    if ($value === false || $value === '' || strpbrk($value, "\r\n") !== false) {
        throw new RuntimeException('Invalid environment variable: ' . $name);
    }
    return $value;
}
$base = rtrim(env('SHOPENTLY_SHOP_URL'), '/');
$origin = parse_url($base);
if (!$origin || ($origin['scheme'] ?? '') !== 'https' || empty($origin['host']) ||
    isset($origin['user']) || isset($origin['pass']) || isset($origin['path']) ||
    isset($origin['query']) || isset($origin['fragment']) || PHP_INT_SIZE !== 8) {
    throw new RuntimeException('Use a 64-bit PHP runtime and an HTTPS origin');
}
class ApiError extends RuntimeException {
    public function __construct(public int $status, public array $headers, public array $error) {
        parent::__construct('HTTP ' . $status . '; code=' . ($error['code'] ?? '') .
            '; request_id=' . ($headers['x-request-id'] ?? ''));
    }
}
function api(string $method, string $path, array $query = [], ?array $body = null, ?string $key = null, bool $apiKey = false): ?array {
    global $base;
    $credential = env($apiKey ? 'SHOPENTLY_API_KEY' : 'SHOPENTLY_ACCESS_TOKEN');
    $headers = ['Authorization: ' . ($apiKey ? 'ApiKey ' : 'Bearer ') . $credential];
    if ($key !== null) $headers[] = 'Idempotency-Key: ' . $key;
    if ($body !== null) $headers[] = 'Content-Type: application/json';
    $url = $base . $path . ($query ? '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986) : '');
    $responseHeaders = [];
    $h = curl_init($url);
    curl_setopt_array($h, [
        CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers,
        CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false,
        CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 30,
        CURLOPT_HEADERFUNCTION => static function ($h, string $line) use (&$responseHeaders): int {
            if (str_contains($line, ':')) {
                [$name, $value] = explode(':', $line, 2);
                $responseHeaders[strtolower(trim($name))] = trim($value);
            }
            return strlen($line);
        },
    ]);
    if ($body !== null) {
        $json = $apiKey && $body === [] ? '{}' : json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
        curl_setopt($h, CURLOPT_POSTFIELDS, $json);
    }
    $raw = curl_exec($h);
    $status = curl_getinfo($h, CURLINFO_RESPONSE_CODE);
    $error = curl_error($h);
    curl_close($h);
    if ($raw === false) throw new RuntimeException('Transport error: ' . $error);
    if ($status < 200 || $status >= 300) {
        $data = json_decode($raw, true);
        throw new ApiError($status, $responseHeaders, is_array($data) ? ($data['error'] ?? []) : []);
    }
    return $raw === '' ? null : json_decode($raw, true, 512, JSON_THROW_ON_ERROR | JSON_BIGINT_AS_STRING);
}
function output(mixed $value): void {
    echo json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), PHP_EOL;
}
function readOrderApi(string $path, array $query = []): array {
    $deadline = hrtime(true) / 1e9 + 60;
    for ($attempt = 0; $attempt < 3; $attempt++) {
        try {
            return api('GET', $path, $query);
        } catch (ApiError $error) {
            if ($attempt === 2 || !in_array($error->status, [408, 429, 500, 502, 503, 504], true)) throw $error;
            $delay = 2 ** $attempt;
            $retry = $error->headers['retry-after'] ?? '';
            if ($retry !== '') {
                if (preg_match('/^[0-9]+$/D', $retry) === 1) $delay = (float)$retry;
                elseif (($date = strtotime($retry)) !== false) $delay = max(0, $date - time());
            }
            if ($delay > $deadline - hrtime(true) / 1e9) throw $error;
            usleep((int)($delay * 1000000));
        }
    }
    throw new RuntimeException('GET retry limit reached');
}
$query = ['limit' => 100, 'created_from' => env('CREATED_FROM'), 'created_to' => env('CREATED_TO')];
$seen = [];
do {
    $page = readOrderApi('/api/v1/orders', $query);
    foreach ($page['data'] as $record) {
        $detail = readOrderApi('/api/v1/orders/' . rawurlencode($record['id']));
        output($detail['data']);
    }
    $pagination = $page['meta']['pagination'];
    if (!$pagination['has_more']) break;
    $cursor = $pagination['next_cursor'];
    if (!$cursor || isset($seen[$cursor])) throw new RuntimeException('Missing or repeated cursor');
    $seen[$cursor] = true;
    $query['cursor'] = $cursor;
} while (true);

Python

"""Run with Python 3.12 and requests. Credentials stay in environment variables."""
import json
import os
import time
from urllib.parse import quote, urlsplit
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
import requests

def env(name):
    value = os.environ[name]
    if not value or "\r" in value or "\n" in value:
        raise ValueError("Invalid environment variable: " + name)
    return value

base = env("SHOPENTLY_SHOP_URL").rstrip("/")
origin = urlsplit(base)
if origin.scheme != "https" or not origin.hostname or origin.username or origin.password or origin.path or origin.query or origin.fragment:
    raise ValueError("SHOPENTLY_SHOP_URL must be an HTTPS origin")

class ApiError(RuntimeError):
    def __init__(self, response):
        self.status = response.status_code
        self.request_id = response.headers.get("X-Request-ID", "")
        self.retry_after = response.headers.get("Retry-After", "")
        try:
            self.error = response.json().get("error", {})
        except (ValueError, AttributeError):
            self.error = {}
        super().__init__(f"HTTP {self.status}; code={self.error.get('code', '')}; request_id={self.request_id}")

def api(method, path, *, query=None, body=None, key=None, api_key=False):
    credential = env("SHOPENTLY_API_KEY" if api_key else "SHOPENTLY_ACCESS_TOKEN")
    headers = {"Authorization": ("ApiKey " if api_key else "Bearer ") + credential}
    if key:
        headers["Idempotency-Key"] = key
    if body is not None:
        headers["Content-Type"] = "application/json"
    response = requests.request(method, base + path, params=query, headers=headers,
        data=json.dumps(body, ensure_ascii=False, allow_nan=False).encode() if body is not None else None,
        timeout=(5, 30), allow_redirects=False)
    if not 200 <= response.status_code < 300:
        raise ApiError(response)
    return response.json() if response.content else None

def segment(value):
    return quote(value, safe="")

def read(path, query=None):
    deadline = time.monotonic() + 60
    for attempt in range(3):
        try:
            return api("GET", path, query=query)
        except ApiError as error:
            if attempt == 2 or error.status not in {408, 429, 500, 502, 503, 504}:
                raise
            delay = 2 ** attempt
            if error.retry_after:
                try:
                    delay = int(error.retry_after)
                except ValueError:
                    try:
                        delay = (parsedate_to_datetime(error.retry_after) - datetime.now(timezone.utc)).total_seconds()
                    except (ValueError, TypeError):
                        pass
            delay = max(0, delay)
            if delay > deadline - time.monotonic():
                raise
            time.sleep(delay)

query = {"limit": 100}
query.update(created_from=env("CREATED_FROM"), created_to=env("CREATED_TO"))
seen = set()
while True:
    page = read("/api/v1/orders", query)
    for record in page["data"]:
        detail = read("/api/v1/orders/" + segment(record["id"]))
        print(json.dumps(detail["data"], ensure_ascii=False, allow_nan=False))
    pagination = page["meta"]["pagination"]
    if not pagination["has_more"]:
        break
    cursor = pagination["next_cursor"]
    if not cursor or cursor in seen:
        raise RuntimeError("Missing or repeated cursor")
    seen.add(cursor)
    query["cursor"] = cursor

Jak to działa

Lista i szczegóły zamówienia zawierają delivery.dates. Daty YYYY-MM-DD są planowane, nie potwierdzają nadania. Bieżące daty mają pierwszeństwo; dla starszych zamówień używana jest zapisana estymacja. Wyczyszczone lub nieznane wartości to null. Szczegóły zawierają też wszystkie pozycje i zdjęcia produktów.

Prawidłowe identyfikatory statusów pobierz z /api/v1/order-statuses. Status zamówienia i status płatności mają oddzielne endpointy PATCH i uprawnienia. Zmiana zapisanego statusu płatności nie obciąża karty ani nie zleca zwrotu u operatora płatności.

delivery.dates: planned_dispatch_on, dispatch_timezone, estimated_delivery_from, estimated_delivery_to. Daty wysyłki i dostawy: YYYY-MM-DD; null. Strefa czasowa wysyłki: IANA.

Zamówienie · Podsumowania

Kwoty i stawki podatku pochodzą z zapisanego zamówienia, z uwzględnieniem korekt pozycji. Brakująca historia ma wartość null; nie zastępują jej bieżące stawki z katalogu. Kwoty pozostają tekstami dziesiętnymi.

  • tax_country_code · Kraj podatkowy · Zapisana migawka zamówienia · ISO 3166-1 alpha-2; null.
  • lines[].unit_* · Jedna sztuka · Przed rabatami; lines[].original_total_* · Przed rabatami; lines[].total_* · Po rabatach.
  • lines[].discount_* · Rabaty; manual_discount · Rabat ręczny; percentage "10" = 10%.
  • delivery.net / tax / gross · Po rabatach; delivery.original_* · Przed rabatami; delivery.discount_* · Rabaty.
  • tax_rate: "0.23" = 23%; "0" ≠ null. Nieznane: null; znane zero: 0.
  • totals · Podsumowania; tax_breakdown.groups · Grupy podatkowe; Nieprzypisany podatek = order.tax − Σ groups.tax; ±.

Lista zwraca podsumowania. Dla każdego wyniku pobierz GET /api/v1/orders/{order_id}, aby wyeksportować pełne rozliczenie. Ponawianie GET jest ograniczone i uwzględnia Retry-After. Eksport według daty utworzenia nie synchronizuje późniejszych korekt.

payment.amount to łączna kwota zamówienia. Status płatności nie określa kwoty wpłaconej, zwróconej ani pozostałej do zapłaty.