Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m8s
Im Checkout laesst sich unter "Versand" jetzt zwischen der eigenen
Anschrift und einer DHL-Packstation waehlen. Postleitzahl eingeben,
suchen, Packstation aus der Trefferliste anklicken, DHL-Postnummer
eintragen — die Auswahl wird an der Bestellung gespeichert.
Aufbau:
- lib/dhl-locations.ts: Client fuer die DHL Location Finder Unified API
(GET /find-by-address). Bewusst getrennt von lib/dhl.ts — das ist ein
anderer DHL-Dienst mit anderer Authentifizierung: nur der API-Key im
Header, kein OAuth und kein Geschaeftskundenvertrag. Die
Packstation-Auswahl funktioniert damit auch, solange die
Versandmarken-Erstellung noch nicht eingerichtet ist.
- api/shop/packstationen: serverseitiger Zwischenschritt, damit der
DHL-API-Key nicht ins Browser-Bundle geraet. Streng rate-limitiert
(15/Minute statt 60 wie die Produktsuche) und mit 15-Minuten-Cache:
dahinter steht ein FREMDES Kontingent von 500 Aufrufen pro Tag, das ein
ungebremstes Suchfeld an einem Nachmittag aufbrauchen koennte.
- Order/Address: packstationNumber + postNumber (Migration
20260915222938_add_packstation_delivery, beide nullable).
- checkoutSchema: Postnummer als 6-10 Ziffern, plus zwei refine-Regeln —
ohne Postnummer keine Packstation (DHL verlangt sie zwingend), und eine
Packstation nur bei Versand, nicht bei Abholung.
Zwei Entscheidungen, die Folgearbeit ersparen:
- Bei Packstation-Auswahl tragen street/zip/city zusaetzlich die lesbare
Anschrift ("Packstation 145", PLZ, Ort). Dadurch zeigen Rechnung,
Bestaetigungsmail, Sendungsverfolgung und Admin-Ansicht ohne jede
Aenderung etwas Sinnvolles; packstationNumber/postNumber tragen daneben
das, was DHL technisch braucht.
- lib/dhl.ts baut daraus einen Locker-Empfaenger statt einer
Hausanschrift. DHL kennt fuer den Empfaenger zwei sich ausschliessende
Formen; eine Packstation als Strassentext zu schicken ergaebe eine Marke
auf eine Anschrift, die es nicht gibt.
Die Option erscheint nur, wenn DHL_API_KEY gesetzt ist — sonst wuerde
Kundschaft in eine Suche laufen, die immer fehlschlaegt.
Geprueft: sechs Tests fuer das Umsetzen der DHL-Antwort (die
Packstation-Nummer steht in location.keywordId, NICHT in
location.ids[0].locationId — letzteres ist ein Systemcode, mit dem weder
Kundschaft noch DHL etwas anfangen kann). Dazu am laufenden
Produktions-Build: ohne Key liefert der Endpunkt 503 und der Checkout
zeigt die Option gar nicht; mit Key 400 bei ungueltiger PLZ und ein echter
Aufruf an api.dhl.com, der mit 401 quittiert wird — URL, Header und
Anfrageformat stimmen also. Im Browser durchgeklickt: Umschalten blendet
das Strassenfeld aus und Postnummer samt Suche ein, die Suche ruft die
Route und zeigt die Fehlermeldung an.
Feldnamen beider DHL-APIs vorab gegen developer.dhl.com geprueft. Ein
Lauf gegen einen echten Schluessel steht noch aus.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
393 lines
15 KiB
TypeScript
393 lines
15 KiB
TypeScript
/**
|
|
* Client fuer die DHL Parcel DE Shipping API v2 ("Post & Parcel Germany",
|
|
* developer.dhl.com) — erstellt Versandmarken (Labels) fuer nationale
|
|
* DHL-Paketsendungen.
|
|
*
|
|
* Authentifizierung laut aktueller DHL-Dokumentation (Stand: Recherche
|
|
* September 2026, developer.dhl.com/api-reference/parcel-de-shipping-post-parcel-germany-v2):
|
|
* Zwei Faktoren sind noetig, nicht nur ein API-Key:
|
|
* 1. Ein OAuth2-Access-Token ueber den ROPC-Grant (Resource Owner Password
|
|
* Credentials) unter POST /parcel/de/account/auth/ropc/v1/token, mit
|
|
* App-Client-ID/-Secret UND den Zugangsdaten des DHL-Geschaeftskunden-
|
|
* portals (GKP-Benutzername/-Passwort) im Body.
|
|
* 2. Der App-API-Key zusaetzlich als "dhl-api-key"-Header auf jedem
|
|
* eigentlichen API-Aufruf (identifiziert die registrierte App/Quota).
|
|
* DHL kuendigt an, das aeltere reine Basic-Auth-Schema mittelfristig
|
|
* abzuschaffen — dieser Client verwendet daher bewusst den OAuth2/ROPC-Weg.
|
|
*
|
|
* Sandbox-Basis-URL: https://api-sandbox.dhl.com
|
|
* Produktions-Basis-URL: https://api-eu.dhl.com
|
|
*
|
|
* WICHTIG: Die genauen Feldnamen/Endpunkte wurden anhand der oeffentlichen
|
|
* API-Referenz und des zugehoerigen OpenAPI-Schemas recherchiert, konnten in
|
|
* dieser Umgebung aber nicht gegen einen echten Sandbox-Account getestet
|
|
* werden (keine DHL-Zugangsdaten vorhanden). Vor dem produktiven Einsatz:
|
|
* einmal mit echten Sandbox-Zugangsdaten (siehe .env.example) gegen
|
|
* https://developer.dhl.com testen und bei Abweichungen die Request-/
|
|
* Response-Shape unten anpassen — DHL liefert bei Validierungsfehlern
|
|
* sprechende Fehlermeldungen, die per DhlApiError durchgereicht werden.
|
|
*/
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fehlerklassen
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Wird geworfen, wenn eine/mehrere der benoetigten DHL_*-Umgebungsvariablen fehlen. */
|
|
export class DhlNotConfiguredError extends Error {
|
|
constructor() {
|
|
super(
|
|
"DHL ist noch nicht konfiguriert. Bitte DHL_API_KEY, DHL_API_SECRET, DHL_GKP_USERNAME, " +
|
|
"DHL_GKP_PASSWORD und DHL_ACCOUNT_NUMBER in der .env-Datei setzen (siehe .env.example)."
|
|
);
|
|
this.name = "DhlNotConfiguredError";
|
|
}
|
|
}
|
|
|
|
/** Wird bei einem fehlgeschlagenen Aufruf der DHL-API geworfen (Auth, Validierung, Netzwerk, ...). */
|
|
export class DhlApiError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "DhlApiError";
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Konfiguration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Ohne Timeout wuerde eine haengende (nicht fehlerhafte, sondern einfach nie
|
|
// antwortende) DHL-API die aufrufende Admin-Server-Action unbegrenzt
|
|
// blockieren und dabei den DHL_CLAIM_SENTINEL-Claim (siehe admin/actions/dhl.ts)
|
|
// auf unbestimmte Zeit offen halten.
|
|
const DHL_REQUEST_TIMEOUT_MS = 20_000;
|
|
|
|
interface DhlConfig {
|
|
apiKey: string;
|
|
apiSecret: string;
|
|
gkpUsername: string;
|
|
gkpPassword: string;
|
|
/**
|
|
* "billingNumber" — 14-stellig: 10-stellige EKP (Kundennummer) + 2-stelliges
|
|
* Verfahren + 2-stellige Teilnahme, z.B. "33333333330101" fuer V01PAK.
|
|
* Wird vom DHL-Vertrieb im Rahmen des Geschaeftskundenvertrags vergeben.
|
|
*/
|
|
accountNumber: string;
|
|
baseUrl: string;
|
|
}
|
|
|
|
function getDhlConfig(): DhlConfig {
|
|
const apiKey = process.env.DHL_API_KEY;
|
|
const apiSecret = process.env.DHL_API_SECRET;
|
|
const gkpUsername = process.env.DHL_GKP_USERNAME;
|
|
const gkpPassword = process.env.DHL_GKP_PASSWORD;
|
|
const accountNumber = process.env.DHL_ACCOUNT_NUMBER;
|
|
|
|
if (!apiKey || !apiSecret || !gkpUsername || !gkpPassword || !accountNumber) {
|
|
throw new DhlNotConfiguredError();
|
|
}
|
|
|
|
const environment = (process.env.DHL_ENVIRONMENT ?? "sandbox").trim().toLowerCase();
|
|
const baseUrl = environment === "production" ? "https://api-eu.dhl.com" : "https://api-sandbox.dhl.com";
|
|
|
|
return { apiKey, apiSecret, gkpUsername, gkpPassword, accountNumber, baseUrl };
|
|
}
|
|
|
|
/** Fuer die bedingte Anzeige/Deaktivierung des "Versandmarke erstellen"-Buttons im Admin-UI. */
|
|
export function isDhlConfigured(): boolean {
|
|
try {
|
|
getDhlConfig();
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// OAuth2-Access-Token (ROPC), pro Serverprozess zwischengespeichert
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface CachedToken {
|
|
accessToken: string;
|
|
expiresAt: number;
|
|
}
|
|
|
|
// Modul-weiter In-Memory-Cache: analog zu lib/rate-limit.ts ausreichend fuer
|
|
// den Single-Container-Betrieb dieser Seite. Bei horizontaler Skalierung
|
|
// wuerde jede Instanz ihr eigenes Token ziehen, was fuer DHL unproblematisch
|
|
// ist (kein geteilter Zustand noetig).
|
|
let cachedToken: CachedToken | null = null;
|
|
|
|
async function fetchAccessToken(config: DhlConfig): Promise<string> {
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(`${config.baseUrl}/parcel/de/account/auth/ropc/v1/token`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
body: new URLSearchParams({
|
|
grant_type: "password",
|
|
username: config.gkpUsername,
|
|
password: config.gkpPassword,
|
|
client_id: config.apiKey,
|
|
client_secret: config.apiSecret,
|
|
}),
|
|
signal: AbortSignal.timeout(DHL_REQUEST_TIMEOUT_MS),
|
|
});
|
|
} catch (error: unknown) {
|
|
const message = error instanceof Error ? error.message : "Unbekannter Netzwerkfehler";
|
|
throw new DhlApiError(`DHL-Authentifizierung nicht erreichbar: ${message}`);
|
|
}
|
|
|
|
const payload = await safeReadJson(response);
|
|
if (!response.ok) {
|
|
throw new DhlApiError(
|
|
`DHL-Authentifizierung fehlgeschlagen (HTTP ${response.status}): ${extractErrorDetail(payload) ?? "unbekannter Fehler"}`
|
|
);
|
|
}
|
|
|
|
const accessToken = typeof payload?.access_token === "string" ? payload.access_token : null;
|
|
if (!accessToken) {
|
|
throw new DhlApiError("DHL-Authentifizierung lieferte kein Access Token zurück.");
|
|
}
|
|
|
|
const expiresInSeconds = typeof payload?.expires_in === "number" ? payload.expires_in : 3600;
|
|
// 60 Sekunden Sicherheitsabstand vor dem tatsaechlichen Ablauf, damit kein
|
|
// knapp abgelaufenes Token noch fuer einen Request verwendet wird.
|
|
cachedToken = { accessToken, expiresAt: Date.now() + Math.max(expiresInSeconds - 60, 30) * 1000 };
|
|
return accessToken;
|
|
}
|
|
|
|
async function getAccessToken(config: DhlConfig): Promise<string> {
|
|
if (cachedToken && cachedToken.expiresAt > Date.now()) return cachedToken.accessToken;
|
|
return fetchAccessToken(config);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Adress-Mapping
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// DHL erwartet Laendercodes nach ISO 3166-1 ALPHA-3, waehrend Order.country
|
|
// in diesem Projekt ALPHA-2 speichert (siehe prisma/schema.prisma). Bewusst
|
|
// nur die in einem deutschen Honig-Versandhandel realistischen Zielmaerkte
|
|
// gelistet — ein nicht gelistetes Land liefert einen klaren Fehler statt
|
|
// eines falsch geratenen Codes.
|
|
const COUNTRY_ISO2_TO_ISO3: Record<string, string> = {
|
|
DE: "DEU",
|
|
AT: "AUT",
|
|
CH: "CHE",
|
|
FR: "FRA",
|
|
NL: "NLD",
|
|
BE: "BEL",
|
|
LU: "LUX",
|
|
IT: "ITA",
|
|
ES: "ESP",
|
|
PL: "POL",
|
|
DK: "DNK",
|
|
CZ: "CZE",
|
|
};
|
|
|
|
function toIso3Country(iso2: string): string {
|
|
const code = COUNTRY_ISO2_TO_ISO3[iso2.trim().toUpperCase()];
|
|
if (!code) {
|
|
throw new DhlApiError(
|
|
`Land "${iso2}" wird vom DHL-Client aktuell nicht unterstützt (siehe COUNTRY_ISO2_TO_ISO3 in lib/dhl.ts).`
|
|
);
|
|
}
|
|
return code;
|
|
}
|
|
|
|
/**
|
|
* Trennt eine deutsche Adresszeile ("Musterstraße 12a") in Straßenname und
|
|
* Hausnummer fuer die getrennten DHL-Adressfelder (addressStreet/addressHouse).
|
|
* Ohne erkennbare Hausnummer am Ende landet die komplette Zeile im
|
|
* Straßenfeld — das Hausnummernfeld ist bei DHL optional, ein falscher
|
|
* Trennversuch waere schlechter als gar keine Trennung.
|
|
*/
|
|
function splitStreet(street: string): { addressStreet: string; addressHouse?: string } {
|
|
const trimmed = street.trim();
|
|
const match = trimmed.match(/^(.*\S)\s+(\d+\s*[a-zA-Z]?)$/);
|
|
if (!match) return { addressStreet: trimmed };
|
|
return { addressStreet: match[1], addressHouse: match[2].replace(/\s+/g, "") };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Versandmarke erstellen
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface DhlAddressInput {
|
|
firstName: string;
|
|
lastName: string;
|
|
street: string;
|
|
zip: string;
|
|
city: string;
|
|
/** ISO 3166-1 ALPHA-2, z.B. "DE" — wie Order.country in prisma/schema.prisma. */
|
|
country: string;
|
|
phone?: string | null;
|
|
email?: string | null;
|
|
/**
|
|
* Nur bei Lieferung an eine DHL-Packstation gesetzt (Order.packstationNumber).
|
|
* Ist sie vorhanden, verlangt DHL eine voellig andere Empfaengerstruktur —
|
|
* siehe buildDhlConsignee() unten.
|
|
*/
|
|
packstationNumber?: string | null;
|
|
/** DHL-Postnummer der empfangenden Person, 6-10 Ziffern (Order.postNumber). */
|
|
postNumber?: string | null;
|
|
}
|
|
|
|
export interface DhlShipperInput extends DhlAddressInput {
|
|
companyName: string;
|
|
}
|
|
|
|
export interface DhlCreateShipmentInput {
|
|
/** Wird als DHL-Referenz (refNo) mitgeschickt, z.B. die Wabenhain-Bestellnummer. */
|
|
reference: string;
|
|
/** Gesamtgewicht der Sendung in Gramm (Summe aller Positionen inkl. Menge). */
|
|
weightGrams: number;
|
|
shipper: DhlShipperInput;
|
|
receiver: DhlAddressInput;
|
|
}
|
|
|
|
export interface DhlCreateShipmentResult {
|
|
trackingNumber: string;
|
|
/** Base64-kodiertes PDF-Label — wird aktuell nicht persistiert (siehe README/Bericht). */
|
|
labelPdfBase64: string;
|
|
}
|
|
|
|
// Deutsches Standard-Inlandspaket. Andere Produkte (z.B. Warenpost/Kleinpaket
|
|
// fuer kleine, leichte Sendungen) sind bewusst nicht abgebildet — YAGNI,
|
|
// bis der Bedarf konkret entsteht.
|
|
const DHL_PRODUCT_NATIONAL_PARCEL = "V01PAK";
|
|
|
|
function buildDhlContact(address: DhlAddressInput, name1: string) {
|
|
const { addressStreet, addressHouse } = splitStreet(address.street);
|
|
return {
|
|
name1,
|
|
phone: address.phone || undefined,
|
|
email: address.email || undefined,
|
|
address: {
|
|
addressStreet,
|
|
addressHouse,
|
|
postalCode: address.zip,
|
|
city: address.city,
|
|
country: toIso3Country(address.country),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Empfaengerblock fuer die Shipping-API v2.
|
|
*
|
|
* DHL kennt fuer den Empfaenger zwei sich gegenseitig ausschliessende
|
|
* Formen: eine normale Hausanschrift mit Strasse und Hausnummer, oder eine
|
|
* Packstation. Letztere wird NICHT als Strasse uebermittelt — DHL erwartet
|
|
* stattdessen "lockerID" (die Packstation-Nummer) zusammen mit der
|
|
* "postNumber" der empfangenden Person; Strassen- und Hausnummernfelder
|
|
* entfallen dabei ersatzlos. Eine Packstation als Strassentext zu schicken
|
|
* ergaebe eine Marke auf eine Anschrift, die es so nicht gibt.
|
|
*/
|
|
function buildDhlConsignee(address: DhlAddressInput, name: string) {
|
|
if (address.packstationNumber && address.postNumber) {
|
|
return {
|
|
name,
|
|
lockerID: address.packstationNumber,
|
|
postNumber: address.postNumber,
|
|
postalCode: address.zip,
|
|
city: address.city,
|
|
country: toIso3Country(address.country),
|
|
};
|
|
}
|
|
return buildDhlContact(address, name);
|
|
}
|
|
|
|
export async function createDhlShipmentLabel(
|
|
input: DhlCreateShipmentInput
|
|
): Promise<DhlCreateShipmentResult> {
|
|
const config = getDhlConfig();
|
|
const accessToken = await getAccessToken(config);
|
|
|
|
const requestBody = {
|
|
shipments: [
|
|
{
|
|
product: DHL_PRODUCT_NATIONAL_PARCEL,
|
|
billingNumber: config.accountNumber,
|
|
// DHL erlaubt max. 35 Zeichen fuer refNo.
|
|
refNo: input.reference.slice(0, 35),
|
|
shipper: buildDhlContact(input.shipper, input.shipper.companyName),
|
|
consignee: buildDhlConsignee(
|
|
input.receiver,
|
|
`${input.receiver.firstName} ${input.receiver.lastName}`.trim()
|
|
),
|
|
details: {
|
|
weight: { uom: "g", value: Math.max(Math.round(input.weightGrams), 1) },
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(`${config.baseUrl}/parcel/de/shipping/v2/orders`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json",
|
|
Authorization: `Bearer ${accessToken}`,
|
|
"dhl-api-key": config.apiKey,
|
|
},
|
|
body: JSON.stringify(requestBody),
|
|
signal: AbortSignal.timeout(DHL_REQUEST_TIMEOUT_MS),
|
|
});
|
|
} catch (error: unknown) {
|
|
const message = error instanceof Error ? error.message : "Unbekannter Netzwerkfehler";
|
|
throw new DhlApiError(`DHL-API nicht erreichbar: ${message}`);
|
|
}
|
|
|
|
const payload = await safeReadJson(response);
|
|
|
|
if (!response.ok) {
|
|
throw new DhlApiError(
|
|
`DHL-Versandmarke konnte nicht erstellt werden (HTTP ${response.status}): ${extractErrorDetail(payload) ?? "unbekannter Fehler"}`
|
|
);
|
|
}
|
|
|
|
const item = Array.isArray(payload?.items) ? payload.items[0] : undefined;
|
|
const validationError = Array.isArray(item?.validationMessages)
|
|
? item.validationMessages.find((entry: { validationState?: string }) => entry?.validationState === "Error")
|
|
: undefined;
|
|
if (validationError) {
|
|
throw new DhlApiError(`DHL meldet einen Fehler in den Sendungsdaten: ${validationError.validationMessage ?? "unbekannt"}`);
|
|
}
|
|
|
|
const trackingNumber = typeof item?.shipmentNo === "string" ? item.shipmentNo : null;
|
|
const labelPdfBase64 = typeof item?.label?.b64 === "string" ? item.label.b64 : null;
|
|
if (!trackingNumber || !labelPdfBase64) {
|
|
throw new DhlApiError("DHL-Antwort enthielt keine Sendungsnummer oder kein Label — bitte Sandbox-Response pruefen.");
|
|
}
|
|
|
|
return { trackingNumber, labelPdfBase64 };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Hilfsfunktionen
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function safeReadJson(response: Response): Promise<Record<string, unknown> | undefined> {
|
|
try {
|
|
return await response.json();
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/** Extrahiert eine lesbare Fehlermeldung aus gaengigen DHL-Fehlerformaten (RFC7807-aehnlich). */
|
|
function extractErrorDetail(payload: Record<string, unknown> | undefined): string | undefined {
|
|
if (!payload) return undefined;
|
|
if (typeof payload.detail === "string") return payload.detail;
|
|
if (typeof payload.title === "string") return payload.title;
|
|
if (typeof payload.error_description === "string") return payload.error_description;
|
|
if (typeof payload.message === "string") return payload.message;
|
|
const status = payload.status as { detail?: string; title?: string } | undefined;
|
|
if (status?.detail) return status.detail;
|
|
if (status?.title) return status.title;
|
|
return undefined;
|
|
}
|