Einzelfehler je Bestellposition (Schema a) */ public $errors = []; /** @var array Feldfehler der formalen Pruefung (Schema b) */ public $fieldErrors = []; /** @var mixed Unveraenderte Antwort der API (dekodiert bzw. Rohtext) */ public $raw; public function __construct(string $meldung, int $status, $raw = null) { parent::__construct($meldung, $status); $this->status = $status; $this->raw = $raw; } /** * True bei „Funktion fuer Ihr Konto nicht freigeschaltet". * HTTP 403 heisst hier in aller Regel NICHT „Token ungueltig". */ public function istNichtFreigeschaltet(): bool { return $this->status === 403 || $this->errorCode === 'API_NOT_ENABLED'; } /** True, wenn dieselbe Bestellnummer innerhalb von 24 Stunden schon einging. */ public function istDoublette(): bool { return $this->status === 409 || $this->errorCode === 'DUPLICATE_ORDER'; } } /* ===================================================================== * Client * ===================================================================*/ final class HegaApiClient { public const BASIS_TEST = 'https://test.api.hega.net'; public const BASIS_LIVE = 'https://api.hega.net'; /** Sekunden vor dem tatsaechlichen Ablauf, ab denen erneuert wird. */ private const TOKEN_PUFFER_SEK = 120; /** Maximale Anzahl Versuche je Aufruf (inkl. Erstversuch). */ private const MAX_VERSUCHE = 3; /** @var string */ private $baseUrl; /** @var string */ private $apiMail; /** @var string */ private $password; /** @var string */ private $kundennummer; /** @var int */ private $timeout; /** @var callable|null */ private $logger; /** @var string|null */ private $accessToken; /** @var string|null */ private $refreshToken; /** @var int Unix-Zeit des tatsaechlichen Access-Token-Ablaufs (JWT-Claim `exp`) */ private $accessTokenAblauf = 0; /** * @param string $baseUrl self::BASIS_TEST oder self::BASIS_LIVE * @param string $apiMail Zugangskennung (Login-Feld `apimail`) * @param string $password Passwort * @param string $kundennummer Ihre HEGA-Kundennummer (Login-Feld `kdnnr`) * @param int $timeout Sekunden je HTTP-Aufruf * @param callable|null $logger fn(string $zeile): void — optional */ public function __construct( string $baseUrl, string $apiMail, string $password, string $kundennummer, int $timeout = 60, ?callable $logger = null ) { if (!function_exists('curl_init')) { throw new RuntimeException('Die PHP-Erweiterung ext-curl wird benoetigt.'); } $this->baseUrl = rtrim($baseUrl, '/'); $this->apiMail = $apiMail; $this->password = $password; $this->kundennummer = $kundennummer; $this->timeout = $timeout; $this->logger = $logger; } /* ----------------------------------------------------------------- * Bestellung * ---------------------------------------------------------------*/ /** * Vorab-Pruefung (Dry-Run): prueft Adresse, Artikel und Mengen, * legt aber NICHTS an. * * Funktioniert bewusst auch, bevor die Bestellfunktion fuer Ihr Konto * final freigeschaltet ist — ideal, um Ihr Feldmapping zu testen. * * @return array{valid:bool,errorCount:int,errors:array} */ public function orderValidate(array $bestellung): array { $body = $this->request('POST', '/api/DSOrder/OrderValidate/', $bestellung)['body']; return is_array($body) ? $body : ['valid' => false, 'errorCount' => 1, 'errors' => []]; } /** * Legt eine Dropshipping-Bestellung an. * * @return string Bestaetigungstext der API * @throws HegaApiException bei fachlichen Fehlern (400/403/409) */ public function orderAdd(array $bestellung): string { $body = $this->request('POST', '/api/DSOrder/OrderAdd/', $bestellung)['body']; return is_string($body) ? $body : (string)json_encode($body, JSON_UNESCAPED_UNICODE); } /** * `orderAdd()` mit sicherer Behandlung von Timeouts. * * Das Anlegen ist nicht vollstaendig idempotent: Bricht die Verbindung * ab, kann die Bestellung trotzdem angekommen sein. Statt blind zu * wiederholen fragen wir erst den Status der Bestellnummer ab — so * entstehen keine Doubletten. */ public function orderAddSafe(array $bestellung): string { $bestellnummer = (string)($bestellung['bestellnummer'] ?? ''); try { return $this->orderAdd($bestellung); } catch (HegaApiException $e) { // Doublette: die Bestellung liegt bereits vor — kein Fehlerfall. if ($e->istDoublette()) { return 'Bereits vorhanden: ' . $bestellnummer; } // Verbindungsabbruch oder Serverfehler: erst nachsehen, dann entscheiden. if ($e->status === 0 || $e->status >= 500) { $this->log('OrderAdd unklar (Status ' . $e->status . ') — pruefe Bestellnummer ...'); if ($this->orderStatus($bestellnummer) !== []) { return 'Bereits angekommen: ' . $bestellnummer; } return $this->orderAdd($bestellung); // sicher nicht angekommen } throw $e; } } /** * Status einer Bestellung ueber Ihre eigene Bestellnummer. * Leeres Array = nicht gefunden. * * Bei Teillieferungen kommen mehrere Eintraege zurueck. Hinweis: * `menge` und `orderMenge` sind erst ab den Status `Dispatched` / * `Invoiced` aussagekraeftig — vorher stimmen sie zwangslaeufig * ueberein und taugen nicht als Hinweis auf eine gekuerzte Menge. */ public function orderStatus(string $bestellnummer): array { if ($bestellnummer === '') { return []; } try { $body = $this->request('GET', '/api/DSOrder/Customer/' . rawurlencode($bestellnummer), null, [], true)['body']; return is_array($body) ? $body : []; } catch (HegaApiException $e) { if ($e->status === 404) { return []; } throw $e; } } /* ----------------------------------------------------------------- * Artikel & Bestand * ---------------------------------------------------------------*/ /** * Einzelnen Artikel abrufen — vor allem wegen `anzahlartve` * (Stueck je Verkaufseinheit) fuer die VE-Umrechnung. * * @return array|null null = fuer Ihr Konto nicht bestellbar (HTTP 404) */ public function artikel(string $artikelnummer, bool $kompakt = true): ?array { $pfad = '/api/DSArtikelDaten/' . ($kompakt ? 'light/' : '') . rawurlencode($artikelnummer); try { $body = $this->request('GET', $pfad, null, [], true)['body']; if (!is_array($body)) { return null; } // Manche Endpunkte antworten mit einer Liste aus einem Element. if (isset($body[0]) && is_array($body[0])) { return $body[0]; } return $body; } catch (HegaApiException $e) { if ($e->status === 404) { return null; } throw $e; } } /** * Warenbestand eines Artikels. * * `bestandstatus` bitte nur auf die Werte 0 und 1 auswerten. * `verfuegbar_ab` mit dem Datum 01.01.1899 bedeutet „kein * abweichendes Datum bekannt". */ public function bestand(string $artikelnummer): ?array { try { $body = $this->request('GET', '/api/DSArtikelBestand/' . rawurlencode($artikelnummer), null, [], true)['body']; return is_array($body) ? $body : null; } catch (HegaApiException $e) { if ($e->status === 404) { return null; } throw $e; } } /** * Sortiment seitenweise abrufen (kompakte Variante) — schonender als * ein Vollabzug in einer einzigen Antwort. * * WICHTIG: Durch die kontospezifische Freischaltung kann eine Seite * WENIGER als `pageSize` Artikel enthalten. Bei einer „kurzen" Seite * darf deshalb NICHT abgebrochen werden — es wird weitergezaehlt, bis * die Summe den Wert aus dem Header `X-Total-Count` erreicht. * * @return array Alle gelieferten Artikel */ public function artikelDatenKompaktAlle(int $pageSize = 500): array { $alle = []; $seite = 1; $gesamt = null; // aus dem Header X-Total-Count (nur auf Seite 1 gesetzt) $maxSeiten = 10000; // Notbremse gegen Endlosschleifen do { $antwort = $this->request('GET', '/api/DSArtikelDaten/light', null, [ 'page' => $seite, 'pageSize' => $pageSize, ], true); if ($gesamt === null && isset($antwort['headers']['x-total-count'])) { $gesamt = (int)$antwort['headers']['x-total-count']; $this->log('Sortiment gesamt: ' . $gesamt . ' Artikel'); } $seitenDaten = is_array($antwort['body']) ? $antwort['body'] : []; $alle = array_merge($alle, $seitenDaten); $this->log(sprintf('Seite %d: %d Artikel (Summe %d)', $seite, count($seitenDaten), count($alle))); if ($seitenDaten === []) { break; // leere Seite = wirklich zu Ende } $seite++; } while (($gesamt === null || count($alle) < $gesamt) && $seite <= $maxSeiten); return $alle; } /* ----------------------------------------------------------------- * Belege * ---------------------------------------------------------------*/ /** * Beleg-PDF als Binaerdaten. * * ACHTUNG: Diese beiden Endpunkte laufen als einzige MIT `/api/v1/` * im Pfad. Es werden nur Belege der letzten 12 Monate geliefert. * * @param string $art 'DeliveryNote' (Lieferschein) oder 'Invoice' (Rechnung) */ public function belegPdf(string $art, string $nummer): string { $antwort = $this->request( 'GET', '/api/v1/' . $art . '/' . rawurlencode($nummer) . '/pdf', null, [], true, true ); return (string)$antwort['body']; } /* ----------------------------------------------------------------- * Authentifizierung * ---------------------------------------------------------------*/ /** Meldet an und legt Access- und Refresh-Token ab. */ public function login(): void { $antwort = $this->httpAufruf('POST', '/api/Account/login', [ 'apimail' => $this->apiMail, 'password' => $this->password, 'kdnnr' => $this->kundennummer, ], ['api-version' => '1.0'], false); if ($antwort['status'] !== 200 || !is_array($antwort['body']) || empty($antwort['body']['accessToken'])) { throw $this->baueFehler($antwort, 'Login fehlgeschlagen'); } $this->uebernehmeToken($antwort['body']); $this->log('Login erfolgreich (Kundennummer ' . ($antwort['body']['kundenNummer'] ?? '?') . ')'); } /** Meldet ab und gibt das Refresh-Token zurueck. */ public function logout(): void { if ($this->accessToken !== null) { $this->httpAufruf('POST', '/api/Account/logout', null, ['api-version' => '1.0'], true); } $this->accessToken = null; $this->refreshToken = null; $this->accessTokenAblauf = 0; } /** * Sorgt dafuer, dass ein gueltiger Access-Token vorliegt. * * Reihenfolge: noch frisch → nichts tun · laeuft demnaechst ab → * erneuern · Erneuerung nicht moeglich → neuer Login. */ private function stelleTokenSicher(): void { if ($this->accessToken !== null && time() < $this->accessTokenAblauf - self::TOKEN_PUFFER_SEK) { return; } if ($this->accessToken !== null && $this->refreshToken !== null && time() < $this->accessTokenAblauf) { // Die Erneuerung setzt einen noch gueltigen Access-Token voraus. if ($this->tokenErneuern()) { return; } } $this->login(); } /** * Erneuert den Access-Token. * * Kann regulaer fehlschlagen — etwa wenn die Sitzung serverseitig * nicht mehr bekannt ist (z. B. nach einer Wartung). Der Aufrufer * faellt dann automatisch auf einen neuen Login zurueck; das ist * ein normaler Betriebszustand und kein Fehler. */ private function tokenErneuern(): bool { $antwort = $this->httpAufruf( 'POST', '/api/Account/refresh-token', ['refreshToken' => $this->refreshToken], ['api-version' => '1.0'], true ); if ($antwort['status'] === 200 && is_array($antwort['body']) && !empty($antwort['body']['accessToken'])) { $this->uebernehmeToken($antwort['body']); $this->log('Access-Token erneuert'); return true; } $this->log('Erneuerung nicht moeglich (Status ' . $antwort['status'] . ') — neuer Login folgt'); return false; } /** * Uebernimmt die Token aus einer Login- bzw. Erneuerungs-Antwort. * * Der Ablauf wird bewusst NICHT aus dem Feld `expiresAt` gelesen — * dieses bezieht sich auf das Refresh-Token und liegt spaeter als der * Ablauf des Access-Tokens. Massgeblich ist allein der Claim `exp` * im JWT selbst. */ private function uebernehmeToken(array $body): void { $this->accessToken = (string)$body['accessToken']; $this->refreshToken = isset($body['refreshToken']) ? (string)$body['refreshToken'] : null; $exp = self::jwtAblauf($this->accessToken); // Konservativer Ersatzwert, falls sich das Tokenformat einmal aendert. $this->accessTokenAblauf = $exp !== null ? $exp : (time() + 25 * 60); } /** * Liest den Claim `exp` aus einem JWT. * Ohne Signaturpruefung — die Gueltigkeit prueft ausschliesslich der Server. */ private static function jwtAblauf(string $jwt): ?int { $teile = explode('.', $jwt); if (count($teile) !== 3) { return null; } $b64 = strtr($teile[1], '-_', '+/'); $b64 .= str_repeat('=', (4 - strlen($b64) % 4) % 4); $roh = base64_decode($b64, true); if ($roh === false) { return null; } $daten = json_decode($roh, true); if (!is_array($daten) || !isset($daten['exp'])) { return null; } return (int)$daten['exp']; } /* ----------------------------------------------------------------- * HTTP-Schicht * ---------------------------------------------------------------*/ /** * Aufruf inkl. Token-Verwaltung, Wiederholungen und Fehlerauswertung. * * @param bool $idempotent Nur bei true wird nach Timeout/5xx wiederholt. * Fuer das Anlegen von Bestellungen IMMER false! * @param bool $binaer true = Antwort nicht als JSON auswerten (PDF/CSV) * * @return array{status:int,body:mixed,headers:array} * @throws HegaApiException */ public function request( string $methode, string $pfad, ?array $body = null, array $query = [], bool $idempotent = false, bool $binaer = false ): array { $this->stelleTokenSicher(); $query = array_merge(['api-version' => '1.0'], $query); $wartezeit = 1; for ($versuch = 1; $versuch <= self::MAX_VERSUCHE; $versuch++) { $antwort = $this->httpAufruf($methode, $pfad, $body, $query, true, $binaer); $status = $antwort['status']; // 401 — Sitzung abgelaufen: einmal neu anmelden und erneut versuchen. if ($status === 401 && $versuch < self::MAX_VERSUCHE) { $this->log('HTTP 401 — melde neu an'); $this->accessToken = null; $this->login(); continue; } // 429 — Rate-Limit: warten und erneut versuchen. if ($status === 429 && $versuch < self::MAX_VERSUCHE) { $pause = isset($antwort['headers']['retry-after']) ? max(1, (int)$antwort['headers']['retry-after']) : $wartezeit; $this->log('HTTP 429 (Rate-Limit) — warte ' . $pause . ' s'); sleep($pause); $wartezeit *= 2; continue; } // Verbindungsabbruch (0) oder Serverfehler (5xx): // nur bei nachweislich wiederholbaren Aufrufen erneut senden. if (($status === 0 || $status >= 500) && $idempotent && $versuch < self::MAX_VERSUCHE) { $this->log('Status ' . $status . ' — Wiederholung in ' . $wartezeit . ' s'); sleep($wartezeit); $wartezeit *= 2; continue; } if ($status < 200 || $status >= 300) { throw $this->baueFehler($antwort); } return $antwort; } throw new HegaApiException( 'Aufruf nach ' . self::MAX_VERSUCHE . ' Versuchen nicht erfolgreich: ' . $methode . ' ' . $pfad, 0 ); } /** * Ein einzelner HTTP-Aufruf ohne Wiederholungslogik. * * @return array{status:int,body:mixed,headers:array,fehler:string} */ private function httpAufruf( string $methode, string $pfad, ?array $body, array $query, bool $mitToken, bool $binaer = false ): array { $url = $this->baseUrl . $pfad . ($query !== [] ? '?' . http_build_query($query) : ''); $header = ['Accept: application/json']; if ($mitToken && $this->accessToken !== null) { $header[] = 'Authorization: Bearer ' . $this->accessToken; } $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_CUSTOMREQUEST => $methode, CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_TIMEOUT => $this->timeout, CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_SSL_VERIFYPEER => true, // bitte niemals abschalten CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_ENCODING => '', // gzip/deflate zulassen ]); if ($body !== null) { // Hinweis zur Kodierung: Umlaute und das "ss" im Feldnamen // "strasse" duerfen von json_encode() als \uXXXX maskiert werden — // die API dekodiert das korrekt. Voraussetzung ist, dass die // uebergebenen Strings UTF-8 sind. Wer aus einer ISO-8859-1-Quelle // liest, wandelt vorher um (mb_convert_encoding(...)), sonst // liefert json_encode() `false`. $json = json_encode($body); if ($json === false) { throw new HegaApiException( 'Anfrage konnte nicht als JSON kodiert werden (vermutlich kein UTF-8): ' . json_last_error_msg(), 0 ); } $header[] = 'Content-Type: application/json'; curl_setopt($ch, CURLOPT_POSTFIELDS, $json); } curl_setopt($ch, CURLOPT_HTTPHEADER, $header); $roh = curl_exec($ch); $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $headerLen = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE); $curlFehler = curl_error($ch); curl_close($ch); if ($roh === false) { return ['status' => 0, 'body' => null, 'headers' => [], 'fehler' => $curlFehler]; } return [ 'status' => $status, 'body' => $binaer ? substr($roh, $headerLen) : self::jsonOderText(substr($roh, $headerLen)), 'headers' => self::headerParsen(substr($roh, 0, $headerLen)), 'fehler' => '', ]; } /** * Zerlegt den Header-Block. Bei Weiterleitungen/`100 Continue` koennen * mehrere Bloecke enthalten sein — spaetere Werte gewinnen. * * @return array Feldnamen in Kleinbuchstaben */ private static function headerParsen(string $rohHeader): array { $ergebnis = []; $zeilen = preg_split('/\r?\n/', $rohHeader); foreach (($zeilen !== false ? $zeilen : []) as $zeile) { $pos = strpos($zeile, ':'); if ($pos !== false) { $ergebnis[strtolower(trim(substr($zeile, 0, $pos)))] = trim(substr($zeile, $pos + 1)); } } return $ergebnis; } /** Dekodiert JSON; ist die Antwort kein JSON, kommt der Rohtext zurueck. */ private static function jsonOderText(string $rohBody) { if ($rohBody === '') { return null; } $daten = json_decode($rohBody, true); return json_last_error() === JSON_ERROR_NONE ? $daten : $rohBody; } /** * Baut aus einer Fehlerantwort eine `HegaApiException` und wertet * dabei BEIDE Fehlerschemas aus (siehe Kommentar an der Klasse). */ private function baueFehler(array $antwort, string $praefix = 'API-Fehler'): HegaApiException { $status = (int)$antwort['status']; $body = $antwort['body']; if ($status === 0) { return new HegaApiException( $praefix . ' — keine Verbindung: ' . (string)($antwort['fehler'] ?? ''), 0 ); } if (is_array($body)) { // Schema (a): fachlicher Fehler mit Fehlercode if (isset($body['errorCode'])) { $einzelfehler = (isset($body['errors']) && is_array($body['errors'])) ? $body['errors'] : []; $text = (string)($body['message'] ?? ''); if ($einzelfehler !== []) { $text .= ' (' . count($einzelfehler) . ' fehlerhafte Position(en))'; } $ex = new HegaApiException( sprintf('%s (HTTP %d) [%s] %s', $praefix, $status, (string)$body['errorCode'], $text), $status, $body ); $ex->errorCode = (string)$body['errorCode']; $ex->errors = $einzelfehler; return $ex; } // Schema (b): formale Feldpruefung if (isset($body['title'], $body['errors']) && is_array($body['errors'])) { $felder = []; foreach ($body['errors'] as $feld => $meldungen) { $felder[] = $feld . ': ' . implode(' / ', (array)$meldungen); } $ex = new HegaApiException( sprintf('%s (HTTP %d) Feldpruefung: %s', $praefix, $status, implode(' | ', $felder)), $status, $body ); $ex->fieldErrors = $body['errors']; return $ex; } if (isset($body['message'])) { return new HegaApiException( sprintf('%s (HTTP %d) %s', $praefix, $status, (string)$body['message']), $status, $body ); } } if (is_string($body) && $body !== '') { return new HegaApiException(sprintf('%s (HTTP %d) %s', $praefix, $status, $body), $status, $body); } return new HegaApiException(sprintf('%s (HTTP %d)', $praefix, $status), $status, $body); } private function log(string $zeile): void { if ($this->logger !== null) { call_user_func($this->logger, '[HEGA] ' . $zeile); } } } /* ===================================================================== * Demo — laeuft nur beim direkten Aufruf ueber die Kommandozeile: * * php HEGA-PHP-Client.php * * Zugangsdaten bitte nicht im Quelltext hinterlegen, sondern z. B. aus * Umgebungsvariablen lesen (siehe unten). * ===================================================================*/ if (PHP_SAPI === 'cli' && isset($_SERVER['SCRIPT_FILENAME']) && realpath($_SERVER['SCRIPT_FILENAME']) === realpath(__FILE__)) { $client = new HegaApiClient( HegaApiClient::BASIS_TEST, // erst Test, spaeter BASIS_LIVE getenv('HEGA_API_MAIL') ?: 'filiale@example.com', getenv('HEGA_API_PASSWORT') ?: '***', getenv('HEGA_KUNDENNUMMER') ?: '1234567', 60, function (string $zeile) { fwrite(STDERR, $zeile . PHP_EOL); } ); // Beispielbestellung. Die Feldnamen sind exakt so zu verwenden — // das Strassenfeld heisst in der API tatsaechlich "straße". $bestellung = [ 'bestellnummer' => 'BESTELL-2026-0001', // Ihre Referenz, max. 20 Zeichen 'vorname' => 'Maria', 'nachname' => 'Schmidt', 'straße' => 'Musterweg', 'hausnummer' => '42a', 'postleitzahl' => '50667', 'ort' => 'Köln', 'laenderkennzeichen' => 'DE', // Grossbuchstaben, case-sensitive 'versender' => 'DPD', // "DHL" oder "DPD", case-sensitive 'emailadresse' => 'maria.schmidt@example.com', // bei DPD Pflicht (alternativ mobilnummer) 'dsorderzeiledto' => [ ['artnr' => '12345', 'menge' => 2], // Menge in VE ['artnr' => '9110378', 'menge' => 12, 'mengeneinheit' => 'Stueck'], // HEGA rechnet auf VE um ], ]; try { // 1) Immer zuerst der Dry-Run — meldet alle Probleme auf einmal. $pruefung = $client->orderValidate($bestellung); if (empty($pruefung['valid'])) { echo 'Bestellung fehlerhaft (' . (int)($pruefung['errorCount'] ?? 0) . " Problem(e)):\n"; foreach ((array)($pruefung['errors'] ?? []) as $fehler) { printf( " - [%s] %s %s\n", $fehler['errorCode'] ?? '?', $fehler['message'] ?? '', isset($fehler['details']['Position']) ? '(Position ' . $fehler['details']['Position'] . ')' : '' ); } exit(1); } // 2) Erst danach wirklich anlegen. echo $client->orderAddSafe($bestellung) . PHP_EOL; // 3) Status abfragen — Identifikation ueber Ihre eigene Bestellnummer. foreach ($client->orderStatus((string)$bestellung['bestellnummer']) as $zeile) { if (!is_array($zeile)) { continue; } printf( " Status: %s Lieferschein: %s Tracking: %s\n", $zeile['status'] ?? '?', $zeile['lsnr'] ?? '-', $zeile['trackingid'] ?? '-' ); } $client->logout(); } catch (HegaApiException $e) { fwrite(STDERR, $e->getMessage() . PHP_EOL); if ($e->istNichtFreigeschaltet()) { fwrite(STDERR, "Hinweis: HTTP 403 bedeutet in der Regel, dass die Funktion fuer Ihr Konto\n" . "noch nicht freigeschaltet ist — bitte wenden Sie sich an it@hega.net.\n"); } foreach ($e->errors as $fehler) { fwrite(STDERR, ' - ' . ($fehler['errorCode'] ?? '?') . ': ' . ($fehler['message'] ?? '') . PHP_EOL); } foreach ($e->fieldErrors as $feld => $meldungen) { fwrite(STDERR, ' - Feld ' . $feld . ': ' . implode(' / ', (array)$meldungen) . PHP_EOL); } exit(1); } }