/** * ===================================================================== * HEGA GmbH - Portal-API - Node.js-/TypeScript-Referenzclient * ===================================================================== * * Lauffaehiges Beispiel fuer die Anbindung an die HEGA Portal-API. * Bewusst OHNE npm-Abhaengigkeiten - benutzt wird nur, was Node selbst * mitbringt (globales `fetch`). Es ist kein `npm install` noetig, die * Datei laeuft auch in abgeschotteten Umgebungen ohne Registry-Zugang. * * Voraussetzungen : Node.js 18 oder neuer (geprueft mit 24.18) * Dokumentation : https://api.hega.net/docs (Swagger UI) * https://api.hega.net/docs/v1/docs.json (OpenAPI-Spec) * Handbuch : https://api.hega.net/HEGA-Anbindung-Handbuch.pdf * Support : it@hega.net * * Sie duerfen diese Datei frei kopieren und an Ihre Beduerfnisse * anpassen. Sie ist als Startpunkt gedacht, nicht als fertige * Integration - Protokollierung, Wiederaufsetzen und Fehler-Reporting * richten sich nach Ihrem System. * * --- Warum die Endung `.cjs`? ----------------------------------------- * Damit die Datei unabhaengig davon laeuft, ob Ihr Projekt in der * package.json `"type": "module"` stehen hat. Beide Welten funktionieren: * * CommonJS : const { HegaApiClient } = require('./hega-api-client.cjs'); * ESM : import { HegaApiClient } from './hega-api-client.cjs'; * * Sollte Ihr Bundler mit benannten Importen aus CommonJS Probleme haben, * hilft die Standard-Form: * * import hega from './hega-api-client.cjs'; * const client = new hega.HegaApiClient(...); * * TypeScript-Typen stehen in `hega-api-client.d.cts` - einfach neben * diese Datei legen, sie werden automatisch gefunden. * * --- Alle Methoden sind asynchron ------------------------------------- * Jeder API-Aufruf liefert ein Promise. Immer `await` benutzen (oder * `.then()`), sonst arbeiten Sie mit einem Promise statt mit dem * Ergebnis - der haeufigste Anfaengerfehler in dieser Sprache. * * Der Client deckt die Punkte ab, an denen Erstanbindungen * erfahrungsgemaess haengen bleiben: * * 1. Token-Lebenszyklus - der Access-Token gilt 30 Minuten, es gibt * KEINE Kulanzzeit. Das Feld `expiresAt` der Login-Antwort bezieht * sich auf das Refresh-Token und ist NICHT der Ablauf des * Access-Tokens. Dieser Client liest den tatsaechlichen Ablauf aus * dem JWT-Claim `exp` und erneuert rechtzeitig davor. * 2. `POST /api/Account/refresh-token` ist selbst abgesichert - der * noch gueltige Access-Token muss mitgeschickt werden. Nach Ablauf * hilft nur ein neuer Login (macht der Client automatisch). * 3. Es gibt zwei verschiedene Fehlerschemas - siehe `HegaApiError`. * 4. Gemischte Pfade: nur `DeliveryNote` und `Invoice` laufen mit * `/api/v1/`, alle uebrigen Endpunkte OHNE `v1` - sonst HTTP 404. * 5. Rate-Limit (HTTP 429) -> Wiederholung mit wachsender Wartezeit. * 6. Das Anlegen einer Bestellung ist NICHT idempotent - nach einem * Timeout niemals blind erneut senden (siehe `orderAddSafe()`). * * Das Demo am Dateiende laeuft nur beim direkten Aufruf des Skripts. * * Stand: August 2026 */ 'use strict'; const BASIS_TEST = 'https://test.api.hega.net'; const BASIS_LIVE = 'https://api.hega.net'; /* ===================================================================== * Fehlerobjekt * ===================================================================*/ /** * Fehler der HEGA-API. * * WICHTIG - je nach Pruefebene antwortet die API mit einem von ZWEI * Fehlerschemas. Diese Klasse vereinheitlicht beide: * * a) Fachliche Pruefung (Bestell-Endpunkte): * { "errorCode": "INVALID_QUANTITY", "message": "...", * "details": { ... }, * "errors": [ { ..., "details": { "Position": "2" } } ] } * -> `errorCode` ist gesetzt, `errors` enthaelt ALLE fehlerhaften * Positionen auf einmal. * * b) Formale Feldpruefung (greift VORHER, Standardformat von ASP.NET Core): * { "title": "One or more validation errors occurred.", * "status": 400, * "errors": { "Vorname": ["The field ... is required."] } } * -> `errorCode` ist null, die Meldungen stehen je Feld in * `fieldErrors`. * * Wer ausschliesslich auf `errorCode` prueft, uebersieht Fall (b) * vollstaendig und sieht nur einen nackten HTTP 400. */ class HegaApiError extends Error { /** * @param {string} meldung * @param {number} status HTTP-Statuscode (0 = keine Verbindung zustande gekommen) * @param {object} [zusatz] */ constructor(meldung, status, zusatz = {}) { super(meldung); this.name = 'HegaApiError'; this.status = status; /** @type {string|null} Maschinenlesbarer Fehlercode, z. B. ARTICLE_NOT_FOUND */ this.errorCode = zusatz.errorCode !== undefined ? zusatz.errorCode : null; /** @type {Array} Einzelfehler je Bestellposition (Schema a) */ this.errors = zusatz.errors || []; /** @type {Record} Feldfehler der formalen Pruefung (Schema b) */ this.fieldErrors = zusatz.fieldErrors || {}; /** Unveraenderte Antwort - fuer die eigene Protokollierung */ this.raw = zusatz.raw !== undefined ? zusatz.raw : null; } /** * HTTP 403 heisst hier in aller Regel NICHT "Token ungueltig", * sondern "Funktion fuer Ihr Konto nicht freigeschaltet". */ get istNichtFreigeschaltet() { return this.status === 403 || this.errorCode === 'API_NOT_ENABLED'; } /** Dieselbe Bestellnummer ging innerhalb von 24 Stunden schon ein. */ get istDoublette() { return this.status === 409 || this.errorCode === 'DUPLICATE_ORDER'; } } /* ===================================================================== * Client * ===================================================================*/ /** Client fuer die HEGA Portal-API (Dropshipping & B2B). */ class HegaApiClient { /** * @param {string} baseUrl BASIS_TEST oder BASIS_LIVE * @param {string} apiMail Benutzerkennung (E-Mail) * @param {string} password Kennwort * @param {string|number} kundennummer * @param {{timeout?: number, logger?: (zeile: string) => void}} [optionen] * `timeout` in SEKUNDEN (nicht Millisekunden!) - Vorgabe 60. */ constructor(baseUrl, apiMail, password, kundennummer, optionen = {}) { this.baseUrl = String(baseUrl).replace(/\/+$/, ''); this._apiMail = apiMail; this._password = password; this._kundennummer = String(kundennummer); // Nach aussen in Sekunden - passend zu den Beispielen in Handbuch, // PHP- und Python-Client. Intern rechnet Node in Millisekunden. this._timeoutMs = (optionen.timeout !== undefined ? optionen.timeout : 60) * 1000; this._logger = optionen.logger || null; this._accessToken = null; this._refreshToken = null; // Unix-Zeit (Sekunden) des tatsaechlichen Access-Token-Ablaufs (JWT-Claim `exp`) this._accessTokenAblauf = 0; } // ----------------------------------------------------------------- // 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. */ async orderValidate(bestellung) { const { rumpf } = await this.request('POST', '/api/DSOrder/OrderValidate/', { body: bestellung }); if (rumpf && typeof rumpf === 'object' && !Array.isArray(rumpf)) { return rumpf; } return { valid: false, errorCount: 1, errors: [] }; } /** * Legt eine Dropshipping-Bestellung an. * @throws {HegaApiError} bei fachlichen Fehlern (400/403/409) */ async orderAdd(bestellung) { const { rumpf } = await this.request('POST', '/api/DSOrder/OrderAdd/', { body: bestellung }); return typeof rumpf === 'string' ? rumpf : JSON.stringify(rumpf); } /** * `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 wird erst der Status der Bestellnummer abgefragt - so * entstehen keine Doubletten. */ async orderAddSafe(bestellung) { const bestellnummer = String((bestellung && bestellung.bestellnummer) || ''); try { return await this.orderAdd(bestellung); } catch (fehler) { if (!(fehler instanceof HegaApiError)) throw fehler; // Doublette: die Bestellung liegt bereits vor - kein Fehlerfall. if (fehler.istDoublette) { return 'Bereits vorhanden: ' + bestellnummer; } // Verbindungsabbruch oder Serverfehler: erst nachsehen, dann entscheiden. if (fehler.status === 0 || fehler.status >= 500) { this._log('OrderAdd unklar (Status ' + fehler.status + ') - pruefe Bestellnummer ...'); const vorhanden = await this.orderStatus(bestellnummer); if (vorhanden.length > 0) { return 'Bereits angekommen: ' + bestellnummer; } return this.orderAdd(bestellung); // sicher nicht angekommen } throw fehler; } } /** * 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. */ async orderStatus(bestellnummer) { if (!bestellnummer) return []; try { const { rumpf } = await this.request( 'GET', '/api/DSOrder/Customer/' + encodeURIComponent(bestellnummer), { idempotent: true }, ); return Array.isArray(rumpf) ? rumpf : []; } catch (fehler) { if (fehler instanceof HegaApiError && fehler.status === 404) return []; throw fehler; } } // ----------------------------------------------------------------- // Artikel & Bestand // ----------------------------------------------------------------- /** * Einzelnen Artikel abrufen - vor allem wegen `anzahlartve` * (Stueck je Verkaufseinheit) fuer die VE-Umrechnung. * * @returns {Promise} null, wenn der Artikel fuer Ihr Konto * nicht bestellbar ist (HTTP 404) */ async artikel(artikelnummer, kompakt = true) { const pfad = '/api/DSArtikelDaten/' + (kompakt ? 'light/' : '') + encodeURIComponent(artikelnummer); let rumpf; try { ({ rumpf } = await this.request('GET', pfad, { idempotent: true })); } catch (fehler) { if (fehler instanceof HegaApiError && fehler.status === 404) return null; throw fehler; } // Manche Endpunkte antworten mit einer Liste aus einem Element. if (Array.isArray(rumpf)) { return rumpf.length > 0 && typeof rumpf[0] === 'object' && rumpf[0] !== null ? rumpf[0] : null; } return rumpf && typeof rumpf === 'object' ? rumpf : null; } /** * 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". */ async bestand(artikelnummer) { try { const { rumpf } = await this.request( 'GET', '/api/DSArtikelBestand/' + encodeURIComponent(artikelnummer), { idempotent: true }, ); return rumpf && typeof rumpf === 'object' && !Array.isArray(rumpf) ? rumpf : null; } catch (fehler) { if (fehler instanceof HegaApiError && fehler.status === 404) return null; throw fehler; } } /** * 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. */ async artikelDatenKompaktAlle(pageSize = 500) { const alle = []; let seite = 1; let gesamt = null; // aus dem Header (nur auf Seite 1 gesetzt) const maxSeiten = 10000; // Notbremse gegen Endlosschleifen while (seite <= maxSeiten) { const { rumpf, header } = await this.request('GET', '/api/DSArtikelDaten/light', { query: { page: seite, pageSize: pageSize }, idempotent: true, }); if (gesamt === null && header['x-total-count']) { gesamt = parseInt(header['x-total-count'], 10); this._log('Sortiment gesamt: ' + gesamt + ' Artikel'); } const seitenDaten = Array.isArray(rumpf) ? rumpf : []; alle.push(...seitenDaten); this._log('Seite ' + seite + ': ' + seitenDaten.length + ' Artikel (Summe ' + alle.length + ')'); if (seitenDaten.length === 0) break; // leere Seite = wirklich zu Ende if (gesamt !== null && alle.length >= gesamt) break; seite += 1; } return alle; } // ----------------------------------------------------------------- // Belege // ----------------------------------------------------------------- /** * Beleg-PDF als Binaerdaten (`Buffer`). * * ACHTUNG: Diese beiden Endpunkte laufen als einzige MIT `/api/v1/` im * Pfad. Es werden nur Belege der letzten 12 Monate geliefert. * * @param {'DeliveryNote'|'Invoice'} art Lieferschein oder Rechnung */ async belegPdf(art, nummer) { const { rumpf } = await this.request( 'GET', '/api/v1/' + art + '/' + encodeURIComponent(nummer) + '/pdf', { idempotent: true, binaer: true }, ); return Buffer.isBuffer(rumpf) ? rumpf : Buffer.alloc(0); } // ----------------------------------------------------------------- // Authentifizierung // ----------------------------------------------------------------- /** Meldet an und legt Access- und Refresh-Token ab. */ async login() { const { status, rumpf } = await this._http('POST', '/api/Account/login', { body: { apimail: this._apiMail, password: this._password, kdnnr: this._kundennummer, }, query: { 'api-version': '1.0' }, mitToken: false, }); if (status !== 200 || !rumpf || typeof rumpf !== 'object' || !rumpf.accessToken) { throw this._baueFehler(status, rumpf, 'Login fehlgeschlagen'); } this._uebernehmeToken(rumpf); this._log('Login erfolgreich (Kundennummer ' + (rumpf.kundenNummer || '?') + ')'); } /** Meldet ab und gibt das Refresh-Token zurueck. */ async logout() { if (this._accessToken) { await this._http('POST', '/api/Account/logout', { query: { 'api-version': '1.0' }, mitToken: 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. */ async _stelleTokenSicher() { const jetzt = Date.now() / 1000; if (this._accessToken && jetzt < this._accessTokenAblauf - HegaApiClient.TOKEN_PUFFER_SEK) { return; } if (this._accessToken && this._refreshToken && jetzt < this._accessTokenAblauf) { // Die Erneuerung setzt einen noch gueltigen Access-Token voraus. if (await this._tokenErneuern()) return; } await 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. */ async _tokenErneuern() { const { status, rumpf } = await this._http('POST', '/api/Account/refresh-token', { body: { refreshToken: this._refreshToken }, query: { 'api-version': '1.0' }, mitToken: true, }); if (status === 200 && rumpf && typeof rumpf === 'object' && rumpf.accessToken) { this._uebernehmeToken(rumpf); this._log('Access-Token erneuert'); return true; } this._log('Erneuerung nicht moeglich (Status ' + 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. */ _uebernehmeToken(rumpf) { this._accessToken = String(rumpf.accessToken); this._refreshToken = rumpf.refreshToken || null; const exp = HegaApiClient._jwtAblauf(this._accessToken); // Konservativer Ersatzwert, falls sich das Tokenformat einmal aendert. this._accessTokenAblauf = exp !== null ? exp : Date.now() / 1000 + 25 * 60; } /** * Liest den Claim `exp` aus einem JWT. * Ohne Signaturpruefung - die Gueltigkeit prueft ausschliesslich der Server. */ static _jwtAblauf(jwt) { const teile = String(jwt).split('.'); if (teile.length !== 3) return null; try { const daten = JSON.parse(Buffer.from(teile[1], 'base64url').toString('utf8')); const exp = daten && typeof daten === 'object' ? daten.exp : null; if (typeof exp === 'number' && Number.isFinite(exp)) return Math.trunc(exp); if (typeof exp === 'string' && /^\d+$/.test(exp)) return parseInt(exp, 10); return null; } catch (fehler) { return null; } } // ----------------------------------------------------------------- // HTTP-Schicht // ----------------------------------------------------------------- /** * Aufruf inkl. Token-Verwaltung, Wiederholungen und Fehlerauswertung. * * @param {string} methode * @param {string} pfad * @param {{body?: any, query?: object, idempotent?: boolean, binaer?: boolean}} [optionen] * `idempotent`: Nur bei true wird nach Timeout/5xx wiederholt. * Fuer das Anlegen von Bestellungen IMMER false! * `binaer`: true = Antwort nicht als JSON auswerten (PDF/CSV) * @returns {Promise<{status: number, rumpf: any, header: Record}>} * @throws {HegaApiError} */ async request(methode, pfad, optionen = {}) { const { body = undefined, query = undefined, idempotent = false, binaer = false } = optionen; await this._stelleTokenSicher(); const alleParameter = Object.assign({ 'api-version': '1.0' }, query || {}); let wartezeit = 1; for (let versuch = 1; versuch <= HegaApiClient.MAX_VERSUCHE; versuch++) { const { status, rumpf, header } = await this._http(methode, pfad, { body: body, query: alleParameter, mitToken: true, binaer: binaer, }); // 401 - Sitzung abgelaufen: einmal neu anmelden und erneut versuchen. if (status === 401 && versuch < HegaApiClient.MAX_VERSUCHE) { this._log('HTTP 401 - melde neu an'); this._accessToken = null; await this.login(); continue; } // 429 - Rate-Limit: warten und erneut versuchen. if (status === 429 && versuch < HegaApiClient.MAX_VERSUCHE) { let pause = wartezeit; const retryAfter = (header['retry-after'] || '').trim(); if (/^\d+$/.test(retryAfter)) { pause = Math.max(1, parseInt(retryAfter, 10)); } this._log('HTTP 429 (Rate-Limit) - warte ' + pause + ' s'); await HegaApiClient._warte(pause); wartezeit *= 2; continue; } // Verbindungsabbruch (0) oder Serverfehler (5xx): // nur bei nachweislich wiederholbaren Aufrufen erneut senden. if ((status === 0 || status >= 500) && idempotent && versuch < HegaApiClient.MAX_VERSUCHE) { this._log('Status ' + status + ' - Wiederholung in ' + wartezeit + ' s'); await HegaApiClient._warte(wartezeit); wartezeit *= 2; continue; } if (status < 200 || status >= 300) { throw this._baueFehler(status, rumpf); } return { status: status, rumpf: rumpf, header: header }; } throw new HegaApiError( 'Aufruf nach ' + HegaApiClient.MAX_VERSUCHE + ' Versuchen nicht erfolgreich: ' + methode + ' ' + pfad, 0, ); } /** * Ein einzelner HTTP-Aufruf ohne Wiederholungslogik. * * Benutzt das eingebaute `fetch`. Wer stattdessen axios, undici oder * einen Proxy-Agent einsetzen will, ersetzt nur diese eine Methode - * die uebrige Logik bleibt unveraendert. */ async _http(methode, pfad, optionen = {}) { const { body = undefined, query = undefined, mitToken = true, binaer = false } = optionen; let url = this.baseUrl + pfad; if (query && Object.keys(query).length > 0) { const parameter = new URLSearchParams(); for (const [schluessel, wert] of Object.entries(query)) { parameter.append(schluessel, String(wert)); } url += '?' + parameter.toString(); } const kopf = { Accept: 'application/json' }; if (mitToken && this._accessToken) { kopf.Authorization = 'Bearer ' + this._accessToken; } const anfrage = { method: methode, headers: kopf, // Zeitueberschreitung: bricht die Anfrage sauber ab, statt endlos zu haengen. signal: AbortSignal.timeout(this._timeoutMs), }; if (body !== undefined && body !== null) { kopf['Content-Type'] = 'application/json'; // JSON.stringify liefert echtes UTF-8 - Umlaute gehen unveraendert // durch. Das Feld "straße" funktioniert damit ohne Zusatzaufwand. anfrage.body = JSON.stringify(body); } let antwort; try { antwort = await fetch(url, anfrage); } catch (fehler) { // Kein HTTP-Kontakt: Timeout, DNS, Verbindung abgelehnt, TLS. // Anders als in PHP/Python wirft `fetch` hier - Status 0 haelt das // Verhalten der drei Referenzclients identisch. return { status: 0, rumpf: HegaApiClient._fehlerText(fehler), header: {} }; } // Ab hier gab es eine echte Antwort - auch 4xx/5xx landen hier, // `fetch` wirft dabei NICHT (im Gegensatz zu manchen Bibliotheken). const header = {}; antwort.headers.forEach((wert, name) => { header[String(name).toLowerCase()] = String(wert); }); let rumpf; if (binaer) { rumpf = Buffer.from(await antwort.arrayBuffer()); } else { rumpf = HegaApiClient._rumpfLesen(await antwort.text()); } return { status: antwort.status, rumpf: rumpf, header: header }; } /** Dekodiert JSON; ist die Antwort kein JSON, kommt der Rohtext zurueck. */ static _rumpfLesen(text) { if (!text) return null; try { return JSON.parse(text); } catch (fehler) { return text; } } /** Lesbarer Text zu einem Verbindungsfehler (inkl. Ursache von fetch). */ static _fehlerText(fehler) { const grund = fehler && fehler.cause ? ' (' + (fehler.cause.message || fehler.cause) + ')' : ''; return String((fehler && fehler.message) || fehler) + grund; } /** @param {number} sekunden */ static _warte(sekunden) { return new Promise((fertig) => setTimeout(fertig, sekunden * 1000)); } /** * Baut aus einer Fehlerantwort einen `HegaApiError` und wertet dabei * BEIDE Fehlerschemas aus (siehe Kommentar an der Klasse). */ _baueFehler(status, rumpf, praefix = 'API-Fehler') { if (status === 0) { return new HegaApiError(praefix + ' - keine Verbindung: ' + rumpf, 0, { raw: rumpf }); } if (rumpf && typeof rumpf === 'object' && !Array.isArray(rumpf)) { // Schema (a): fachlicher Fehler mit Fehlercode if (rumpf.errorCode !== undefined) { const einzelfehler = Array.isArray(rumpf.errors) ? rumpf.errors : []; let text = String(rumpf.message !== undefined ? rumpf.message : ''); if (einzelfehler.length > 0) { text += ' (' + einzelfehler.length + ' fehlerhafte Position(en))'; } return new HegaApiError( praefix + ' (HTTP ' + status + ') [' + rumpf.errorCode + '] ' + text, status, { errorCode: String(rumpf.errorCode), errors: einzelfehler, raw: rumpf }, ); } // Schema (b): formale Feldpruefung if (rumpf.title !== undefined && rumpf.errors && typeof rumpf.errors === 'object') { const felder = []; for (const [feld, meldungen] of Object.entries(rumpf.errors)) { felder.push(feld + ': ' + (Array.isArray(meldungen) ? meldungen.join(' / ') : String(meldungen))); } return new HegaApiError( praefix + ' (HTTP ' + status + ') Feldpruefung: ' + felder.join(' | '), status, { fieldErrors: rumpf.errors, raw: rumpf }, ); } if (rumpf.message !== undefined) { return new HegaApiError(praefix + ' (HTTP ' + status + ') ' + rumpf.message, status, { raw: rumpf }); } } if (typeof rumpf === 'string' && rumpf !== '') { return new HegaApiError(praefix + ' (HTTP ' + status + ') ' + rumpf, status, { raw: rumpf }); } return new HegaApiError(praefix + ' (HTTP ' + status + ')', status, { raw: rumpf }); } _log(zeile) { if (this._logger !== null) { this._logger('[HEGA] ' + zeile); } } } /** Sekunden vor dem tatsaechlichen Ablauf, ab denen erneuert wird. */ HegaApiClient.TOKEN_PUFFER_SEK = 120; /** Maximale Anzahl Versuche je Aufruf (inkl. Erstversuch). */ HegaApiClient.MAX_VERSUCHE = 3; HegaApiClient.BASIS_TEST = BASIS_TEST; HegaApiClient.BASIS_LIVE = BASIS_LIVE; module.exports = { HegaApiClient, HegaApiError, BASIS_TEST, BASIS_LIVE }; /* ===================================================================== * Demo - laeuft nur beim direkten Aufruf: * * node hega-api-client.cjs * * Zugangsdaten nicht im Quelltext hinterlegen, sondern z. B. aus * Umgebungsvariablen lesen (siehe unten). * ===================================================================*/ async function _demo() { const client = new HegaApiClient( BASIS_TEST, // erst Test, spaeter BASIS_LIVE process.env.HEGA_API_MAIL || 'filiale@example.com', process.env.HEGA_API_PASSWORT || '***', process.env.HEGA_KUNDENNUMMER || '1234567', { timeout: 60, logger: (zeile) => process.stderr.write(zeile + '\n') }, ); // Beispielbestellung. Die Feldnamen sind exakt so zu verwenden - // das Strassenfeld heisst in der API tatsaechlich "straße". const 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. const pruefung = await client.orderValidate(bestellung); if (!pruefung.valid) { console.log('Bestellung fehlerhaft (' + (pruefung.errorCount || 0) + ' Problem(e)):'); for (const fehler of pruefung.errors || []) { const position = fehler.details && fehler.details.Position; console.log(' - [' + (fehler.errorCode || '?') + '] ' + (fehler.message || '') + (position ? ' (Position ' + position + ')' : '')); } return 1; } // 2) Erst danach wirklich anlegen. console.log(await client.orderAddSafe(bestellung)); // 3) Status abfragen - Identifikation ueber Ihre eigene Bestellnummer. for (const zeile of await client.orderStatus(String(bestellung.bestellnummer))) { console.log(' Status: ' + (zeile.status || '?') + ' Lieferschein: ' + (zeile.lsnr || '-') + ' Tracking: ' + (zeile.trackingid || '-')); } await client.logout(); return 0; } catch (fehler) { if (!(fehler instanceof HegaApiError)) throw fehler; process.stderr.write(fehler.message + '\n'); if (fehler.istNichtFreigeschaltet) { process.stderr.write( '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'); } for (const einzel of fehler.errors) { process.stderr.write(' - ' + (einzel.errorCode || '?') + ': ' + (einzel.message || '') + '\n'); } for (const [feld, meldungen] of Object.entries(fehler.fieldErrors)) { const text = Array.isArray(meldungen) ? meldungen.join(' / ') : String(meldungen); process.stderr.write(' - Feld ' + feld + ': ' + text + '\n'); } return 1; } } if (require.main === module) { _demo().then((code) => { process.exitCode = code; }); }