跳至內容
ShopentlyLearn

匯出訂單

匯出訂單. 參數: CREATED_FROM, CREATED_TO. 程式碼範例: cURL, PHP, Python.

參數

  • SHOPENTLY_SHOP_URL — 資源 URL。
  • SHOPENTLY_ACCESS_TOKEN — 短期 Bearer 憑據。
  • CREATED_FROM — 建立時間篩選的起點。
  • CREATED_TO — 建立時間篩選的終點。

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

完整範例

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

工作原理

訂單列表和詳細資料均包含 delivery.dates。YYYY-MM-DD 格式的日期為計畫日期,不代表已出貨。目前日期優先;舊訂單使用已儲存的估計。已清除或未知的值為 null。詳細資料還包含所有訂單項目和商品圖片。

從 /api/v1/order-statuses 取得有效狀態識別碼。訂單狀態和支付狀態使用不同的 PATCH 端點及權限。修改記錄中的支付狀態不會從銀行卡扣款,也不會通過支付服務商發起退款。

delivery.dates: planned_dispatch_on, dispatch_timezone, estimated_delivery_from, estimated_delivery_to. 出貨及預計送達日期: YYYY-MM-DD; null. 出貨時區: IANA.

訂單 · 合計

金額和稅率取自已儲存的訂單,包括訂單項目的更正。缺少的歷史資料為 null,不會用目前商品目錄的稅率補齊。金額始終維持為十進位字串。

  • tax_country_code · 課稅國家 · 已儲存的訂單資料 · ISO 3166-1 alpha-2; null.
  • lines[].unit_* · 單件 · 折扣前; lines[].original_total_* · 折扣前; lines[].total_* · 折扣後.
  • lines[].discount_* · 折扣; manual_discount · 手動折扣; percentage "10" = 10%.
  • delivery.net / tax / gross · 折扣後; delivery.original_* · 折扣前; delivery.discount_* · 折扣.
  • tax_rate: "0.23" = 23%; "0" ≠ null. 未知:null;已知零值:0.
  • totals · 合計; tax_breakdown.groups · 稅務分組; 未分配稅額 = order.tax − Σ groups.tax; ±.

清單傳回摘要。針對每個結果取得 GET /api/v1/orders/{order_id},以匯出完整的金額明細。GET 重試次數有限,並遵循 Retry-After。依建立日期匯出不會同步之後的更正。

payment.amount 是訂單總額。付款狀態無法確定已付、已退款或待付金額。