feat: Packstation ueber DHL-Standortsuche als Lieferziel waehlbar
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m8s
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]>
This commit is contained in:
1 parent
ff365c3cb1
commit
9dd71ffb9a
16 files changed
+757
-8
No files matched your search
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Client fuer die DHL Location Finder Unified API — sucht Packstationen in
|
||||
* der Naehe einer Postleitzahl, damit sich beim Checkout eine Packstation
|
||||
* statt einer Hausanschrift als Lieferziel waehlen laesst.
|
||||
*
|
||||
* Bewusst getrennt von lib/dhl.ts: das ist ein ANDERER DHL-Dienst mit
|
||||
* anderer Authentifizierung. Die Standortsuche braucht nur den App-API-Key
|
||||
* (Header "DHL-API-Key"), nicht den OAuth2/ROPC-Weg und keinen
|
||||
* Geschaeftskundenvertrag. Die Packstation-Auswahl funktioniert damit auch,
|
||||
* solange die Versandmarken-Erstellung noch nicht eingerichtet ist.
|
||||
*
|
||||
* Endpunkt laut oeffentlicher API-Referenz (developer.dhl.com/api-reference/
|
||||
* location-finder, Stand September 2026):
|
||||
* GET https://api.dhl.com/location-finder/v1/find-by-address
|
||||
* ?countryCode=DE&postalCode=...&locationType=locker&providerType=parcel
|
||||
* Die Packstation-Nummer steht in location.keywordId; location.ids[0].locationId
|
||||
* ist ein zusammengesetzter Systemcode und NICHT die Nummer, die Kundschaft
|
||||
* auf dem Geraet sieht.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
/** Wird geworfen, wenn DHL_API_KEY fehlt — die Suche ist dann nicht nutzbar. */
|
||||
export class PackstationNotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"Die Packstation-Suche ist noch nicht konfiguriert. Bitte DHL_API_KEY in der .env-Datei setzen (siehe .env.example)."
|
||||
);
|
||||
this.name = "PackstationNotConfiguredError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Wird bei einem fehlgeschlagenen Aufruf der DHL-Standortsuche geworfen. */
|
||||
export class PackstationApiError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "PackstationApiError";
|
||||
}
|
||||
}
|
||||
|
||||
const LOCATION_FINDER_BASE_URL = "https://api.dhl.com/location-finder/v1";
|
||||
|
||||
// Ohne Timeout blockiert eine haengende (nicht fehlerhafte, sondern nie
|
||||
// antwortende) DHL-API den Request-Handler unbegrenzt — gleiche Begruendung
|
||||
// wie bei DHL_REQUEST_TIMEOUT_MS in lib/dhl.ts, hier kuerzer, weil die Suche
|
||||
// waehrend des Checkouts interaktiv passiert.
|
||||
const REQUEST_TIMEOUT_MS = 8_000;
|
||||
|
||||
/**
|
||||
* DHL nennt als Einstiegskontingent 500 Aufrufe pro TAG. Ein Suchfeld im
|
||||
* Checkout kann das schnell aufbrauchen, deshalb ein kurzer Cache pro
|
||||
* Suchbegriff: Packstation-Standorte aendern sich nicht im Minutentakt, und
|
||||
* mehrere Kund:innen aus derselben Stadt suchen dieselbe Postleitzahl.
|
||||
*/
|
||||
const CACHE_TTL_MS = 15 * 60 * 1000;
|
||||
const MAX_CACHE_ENTRIES = 500;
|
||||
const cache = new Map<string, { expiresAt: number; value: Packstation[] }>();
|
||||
|
||||
export interface Packstation {
|
||||
/** Packstation-Nummer, wie sie auf dem Geraet steht (z.B. "145"). */
|
||||
number: string;
|
||||
/** Anzeigename laut DHL, z.B. "Packstation 145". */
|
||||
name: string;
|
||||
street: string;
|
||||
zip: string;
|
||||
city: string;
|
||||
/** Luftlinie in Metern zum Suchmittelpunkt, sofern DHL sie mitliefert. */
|
||||
distanceMeters: number | null;
|
||||
}
|
||||
|
||||
// Nur die Felder, die tatsaechlich gebraucht werden — alles Weitere bleibt
|
||||
// bewusst ungeparst, damit ein zusaetzliches Feld aufseiten DHLs die Suche
|
||||
// nicht bricht. Umgekehrt faellt eine fehlende Packstation-Nummer hier
|
||||
// sichtbar durch, statt als leerer String in einer Bestellung zu landen.
|
||||
const locationSchema = z.object({
|
||||
location: z.object({
|
||||
keywordId: z.string().min(1),
|
||||
keyword: z.string().optional(),
|
||||
}),
|
||||
name: z.string().optional(),
|
||||
distance: z.number().optional(),
|
||||
place: z.object({
|
||||
address: z.object({
|
||||
postalCode: z.string(),
|
||||
addressLocality: z.string(),
|
||||
streetAddress: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const responseSchema = z.object({
|
||||
locations: z.array(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export function isPackstationSearchConfigured(): boolean {
|
||||
return Boolean(process.env.DHL_API_KEY?.trim());
|
||||
}
|
||||
|
||||
interface FindPackstationsOptions {
|
||||
/** Deutsche Postleitzahl, fuenfstellig. */
|
||||
postalCode: string;
|
||||
/** Optionaler Ort — schraenkt bei mehrdeutigen Postleitzahlen weiter ein. */
|
||||
city?: string;
|
||||
/** Obergrenze der Treffer (DHL-seitig zusaetzlich begrenzt). */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht Packstationen zu einer Postleitzahl. Liefert eine nach Entfernung
|
||||
* aufsteigend sortierte Liste; DHL sortiert bereits so, die Sortierung hier
|
||||
* ist nur die Absicherung dagegen, dass sich das stillschweigend aendert.
|
||||
*/
|
||||
export async function findPackstations({
|
||||
postalCode,
|
||||
city,
|
||||
limit = 10,
|
||||
}: FindPackstationsOptions): Promise<Packstation[]> {
|
||||
const apiKey = process.env.DHL_API_KEY?.trim();
|
||||
if (!apiKey) throw new PackstationNotConfiguredError();
|
||||
|
||||
const cacheKey = `${postalCode}|${city ?? ""}|${limit}`;
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
||||
|
||||
const query = new URLSearchParams({
|
||||
countryCode: "DE",
|
||||
postalCode,
|
||||
// "locker" = Packstation; "parcel" grenzt auf Post & Parcel Germany ein,
|
||||
// damit keine DHL-Express-Standorte in der Liste landen.
|
||||
locationType: "locker",
|
||||
providerType: "parcel",
|
||||
limit: String(Math.min(Math.max(limit, 1), 50)),
|
||||
});
|
||||
if (city) query.set("addressLocality", city);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${LOCATION_FINDER_BASE_URL}/find-by-address?${query}`, {
|
||||
headers: { "DHL-API-Key": apiKey, Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
throw new PackstationApiError(
|
||||
error instanceof Error && error.name === "TimeoutError"
|
||||
? "Die DHL-Standortsuche hat nicht rechtzeitig geantwortet."
|
||||
: "Die DHL-Standortsuche ist nicht erreichbar."
|
||||
);
|
||||
}
|
||||
|
||||
// 404 bedeutet bei diesem Endpunkt "keine Standorte gefunden", nicht
|
||||
// "Endpunkt existiert nicht" — das ist ein gueltiges, leeres Ergebnis und
|
||||
// darf nicht als Fehler in der Oberflaeche landen.
|
||||
if (response.status === 404) {
|
||||
rememberInCache(cacheKey, []);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new PackstationApiError(
|
||||
`Die DHL-Standortsuche hat mit Status ${response.status} geantwortet.`
|
||||
);
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
throw new PackstationApiError("Die DHL-Standortsuche hat keine gueltige Antwort geliefert.");
|
||||
}
|
||||
|
||||
const parsed = responseSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
throw new PackstationApiError("Die Antwort der DHL-Standortsuche hat ein unerwartetes Format.");
|
||||
}
|
||||
|
||||
const packstations = parseLocations(parsed.data.locations ?? []);
|
||||
rememberInCache(cacheKey, packstations);
|
||||
return packstations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Als eigene Funktion exportiert, damit sich das Parsen der DHL-Antwort ohne
|
||||
* Netzwerkzugriff testen laesst (siehe dhl-locations.test.ts).
|
||||
*/
|
||||
export function parseLocations(entries: unknown[]): Packstation[] {
|
||||
return entries
|
||||
.map((entry) => locationSchema.safeParse(entry))
|
||||
// Einzelne unvollstaendige Eintraege ueberspringen, statt die ganze Suche
|
||||
// scheitern zu lassen: ein Standort ohne Nummer ist fuer uns unbrauchbar,
|
||||
// die uebrigen bleiben aber gueltig.
|
||||
.filter((result) => result.success)
|
||||
.map(({ data }): Packstation => ({
|
||||
number: data.location.keywordId,
|
||||
name: data.name?.trim() || `Packstation ${data.location.keywordId}`,
|
||||
street: data.place.address.streetAddress?.trim() ?? "",
|
||||
zip: data.place.address.postalCode,
|
||||
city: data.place.address.addressLocality,
|
||||
distanceMeters: typeof data.distance === "number" ? data.distance : null,
|
||||
}))
|
||||
.sort((a, b) => (a.distanceMeters ?? Infinity) - (b.distanceMeters ?? Infinity));
|
||||
}
|
||||
|
||||
function rememberInCache(key: string, value: Packstation[]): void {
|
||||
// Speicher begrenzen: abgelaufene Eintraege gelegentlich aufraeumen
|
||||
// (gleiches Muster wie in lib/rate-limit.ts).
|
||||
if (cache.size > MAX_CACHE_ENTRIES) {
|
||||
const now = Date.now();
|
||||
for (const [existingKey, entry] of cache) {
|
||||
if (entry.expiresAt <= now) cache.delete(existingKey);
|
||||
}
|
||||
}
|
||||
cache.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, value });
|
||||
}
|
||||
Reference in new issue
Block a user