# Mit Ihrem ERP synchronisieren

> Eine vollständige Nachtprodukt- und Kundensynchronisation sowie ein Bestellabholer, der neue Bestellungen in Ihr eigenes System zieht.

Dieser Leitfaden erstellt die beiden Jobs, die die meisten Lieferanten benötigen:

1. **Nächtlicher Sync:** Produkte und Kunden aus Ihrem ERP in TradeCatalog übertragen.
2. **Bestellpoller:** Alle paar Minuten neue Bestellungen in Ihr ERP abrufen und als bestätigt markieren.

Sie benötigen einen **Lese- und Schreib-API-Schlüssel**. Siehe [Authentifizierung](/docs/api/authentication).

## Wie der nächtliche Sync funktioniert

- Produkte werden anhand der SKU und Kunden anhand der Kontonummer übereinstimmen, daher ist es harmlos, den Sync zweimal auszuführen.
- Neue Produkte werden sofort zu ihrem Listenpreis live geschaltet.
- Geänderte Listenpreise **werden nicht** live geschaltet. Sie sammeln sich in Entwürfen für Preisanpassungen. Jeden Morgen öffnet jemand **Preisanpassungen** in der App, prüft die Vorschau und Warnungen, wählt ein Datum aus und veröffentlicht. Kunden können per E-Mail ihre eigenen neuen Preise erhalten.
- `brand`, `category`, `manufacturerRef` und `description` werden wie gesendet geschrieben, daher senden Sie immer das vollständige Produkt.
- Die `email` und `discountBp` der Kunden werden ebenfalls wie gesendet geschrieben: `null` löscht sie.

**Senden Sie nur das, was sich geändert hat:** Wenn Ihr ERP Ihnen sagen kann, welche Produkte seit dem letzten Lauf geändert wurden, senden Sie nur diese. Weniger Anfragen und weniger Produkte, die in jedem Entwurf gescannt werden müssen.

## Nächtlicher Sync (Node.js)

Benötigt Node.js 20 oder höher, für die eingebaute `fetch`. Ersetzen Sie die beiden `fromErp`-Funktionen durch Lesevorgänge aus Ihrem System.

```js
// sync.mjs — run nightly: TRADECATALOG_API_KEY=tc_live_… node sync.mjs
const BASE = 'https://tradecatalog.app/api/v1'
const KEY = process.env.TRADECATALOG_API_KEY
const sleep = (seconds) => new Promise((r) => setTimeout(r, seconds * 1000))

async function api(method, path, body, attempt = 0) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      Authorization: `Bearer ${KEY}`,
      'Content-Type': 'application/json',
    },
    body: body && JSON.stringify(body),
  })
  if ((res.status === 429 || res.status >= 500) && attempt < 5) {
    await sleep(Number(res.headers.get('Retry-After')) || 2 ** attempt)
    return api(method, path, body, attempt + 1)
  }
  const json = await res.json()
  if (!res.ok) {
    const detail = json.error.issues
      ?.map((i) => `${i.path}: ${i.message}`)
      .join('; ')
    throw new Error(`${method} ${path}: ${json.error.message} ${detail ?? ''}`)
  }
  return json
}

// Replace these with reads from your ERP. Money in pence, discounts in basis points.
async function productsFromErp() {
  return [
    {
      sku: 'HX-8510',
      name: 'Hex bolt M10 x 50 zinc',
      listPrice: 1950,
      unitLabel: 'Box of 100',
      brand: 'Northgate',
      category: 'Bolts',
      availability: 'in_stock',
    },
  ]
}
async function customersFromErp() {
  return [
    {
      accountCode: 'OAK01',
      name: 'Oakfield Joinery',
      email: 'accounts@oakfieldjoinery.co.uk',
      discountBp: 500,
    },
  ]
}

const batches = (rows, size = 100) =>
  Array.from({ length: Math.ceil(rows.length / size) }, (_, i) =>
    rows.slice(i * size, i * size + size),
  )

const { data: workspace } = await api('GET', '/workspace')
console.log(`Syncing into ${workspace.name} (${workspace.currency})`)

for (const customers of batches(await customersFromErp())) {
  const { data } = await api('PUT', '/customers', { customers })
  console.log(`Customers: ${data.created} new, ${data.updated} updated`)
}

// Every batch adds its price changes to the same draft.
let priceChangeId
for (const products of batches(await productsFromErp())) {
  const { data } = await api('PUT', '/products', { products, priceChangeId })
  console.log(`Products: ${data.created} new, ${data.updated} updated`)
  priceChangeId = data.priceChangeId ?? priceChangeId
  if (data.unknownCustomers.length)
    console.warn('Unknown customers in onlyFor:', data.unknownCustomers)
}

if (priceChangeId) {
  const { data } = await api('GET', `/price-changes/${priceChangeId}`)
  console.log(
    `Draft price change: ${data.counts.up} up, ${data.counts.down} down,` +
      ` ${data.warnings.length} warnings. Publish it in TradeCatalog → Price changes.`,
  )
}
```

Kunden zuerst, damit Produkte mit `onlyFor` sie finden können.

Führen Sie es aus cron, einer geplanten Aufgabe oder dem Job-Runner Ihres ERP aus:

```text
0 2 * * *  cd /opt/tradecatalog && TRADECATALOG_API_KEY=tc_live_… node sync.mjs >> sync.log 2>&1
```

## Bestellpoller (Node.js)

Alle paar Minuten neue Bestellungen abrufen, jede an Ihr ERP weiterleiten und als bestätigt markieren. Der Käufer erhält eine E-Mail, dass seine Bestellung bestätigt wurde.

```js
// orders.mjs — run every 5 minutes. Copy BASE, KEY, sleep and api() from sync.mjs.
async function sendToErp(order) {
  // Create the sales order in your system. Use order.reference as its external
  // reference, and check it first so a retry can't create it twice.
  console.log(
    `${order.reference} for ${order.customer.accountCode}: ${order.lines.length} lines`,
  )
}

const { data: newOrders } = await api('GET', '/orders?status=new')
for (const summary of newOrders.reverse()) {
  const { data: order } = await api('GET', `/orders/${summary.id}`)
  await sendToErp(order)
  await api('PATCH', `/orders/${order.id}`, { status: 'acknowledged' })
}
```

Älteste Bestellungen werden zuerst bearbeitet (`reverse()`), weil die Liste neu zuerst ist.

Wenn der ERP-Schritt erfolgreich ist, aber der `PATCH` fehlschlägt, wird die Bestellung beim nächsten Mal immer noch `new`. Deshalb sollte `sendToErp` vor dem Erstellen von etwas auf `order.reference` prüfen.

Später, wenn eine Bestellung versandt wird, markieren Sie sie als versandt mit den Versanddetails im Hinweis:

```js
await api('PATCH', `/orders/${orderId}`, {
  status: 'dispatched',
  note: 'DPD tracking 1554 2210 0931',
})
```

## Bestellpoller (Python)

Derselbe Poller mit [requests](https://requests.readthedocs.io):

```python
# orders.py: run every 5 minutes. TRADECATALOG_API_KEY=tc_live_… python orders.py
import os
import time

import requests

BASE = "https://tradecatalog.app/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['TRADECATALOG_API_KEY']}"


def api(method, path, body=None, attempt=0):
    res = session.request(method, BASE + path, json=body, timeout=30)
    if (res.status_code == 429 or res.status_code >= 500) and attempt < 5:
        time.sleep(int(res.headers.get("Retry-After", 2**attempt)))
        return api(method, path, body, attempt + 1)
    data = res.json()
    if not res.ok:
        raise RuntimeError(f"{method} {path}: {data['error']['message']}")
    return data


def send_to_erp(order):
    # Create the sales order in your system; skip it if order["reference"] exists.
    print(order["reference"], order["customer"]["accountCode"], len(order["lines"]), "lines")


for summary in reversed(api("GET", "/orders?status=new")["data"]):
    order = api("GET", f"/orders/{summary['id']}")["data"]
    send_to_erp(order)
    api("PATCH", f"/orders/{order['id']}", {"status": "acknowledged"})
```

## Geld und Codes

- `listPrice`, `unitPrice`, `lineTotal` und `total` sind in Kleineinheiten (Pence für GBP). Durch 100 teilen, um Pfund zu erhalten.
- Bestellpreise sind so festgelegt: vor Steuern, wenn Ihr `taxLabel` „ex …“ sagt (Ihr ERP fügt die Umsatzsteuer beim Rechnen hinzu), einschließlich Steuern, wenn es „inc …“ sagt.
- Verwenden Sie die Kundenkontonummern Ihres ERP als TradeCatalog-Kontonummern, damit beide Systeme ohne Tabellenabfrage übereinstimmen.

## Grenzen für Planung

- 100 Zeilen pro `PUT`, und 120 Anfragen pro Minute pro Schlüssel. Ein Katalog mit 10.000 Produkten ist 100 Anfragen, etwa eine Minute.
- `GET /orders` deckt die 200 neuesten passenden Bestellungen ab. Pollen Sie häufig genug, dass weniger als 200 neue Bestellungen zwischen den Durchläufen ankommen.
- Das Veröffentlichen von Preisanpassungen, Rabattregeln und vereinbarten Preisen erfolgt in der App.
