feat: Packstation ueber DHL-Standortsuche als Lieferziel waehlbar
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:
Nick SzaunigandClaude Opus 5 committed 2026-09-16 00:37:00 +02:00
1 parent ff365c3cb1
commit 9dd71ffb9a
16 files changed
+757 -8

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
import { describe, it, expect } from "vitest";
import { parseLocations } from "./dhl-locations";
/**
* Ein Treffer im Format der DHL Location Finder API. Die Packstation-Nummer
* steht in location.keywordId — location.ids[0].locationId ist ein
* zusammengesetzter Systemcode und ausdruecklich NICHT die Nummer, die
* Kundschaft am Geraet eingibt.
*/
function dhlLocation(overrides: Record<string, unknown> = {}) {
return {
url: "https://api.dhl.com/location-finder/v1/locations/8003-453113145",
location: {
ids: [{ locationId: "8003-453113145", provider: "parcel-de" }],
keyword: "DHL Packstation",
keywordId: "145",
type: "locker",
},
name: "Packstation 145",
distance: 820,
place: {
address: {
countryCode: "DE",
postalCode: "86450",
addressLocality: "Altenmünster",
streetAddress: "Bahnhofstr. 1",
},
},
...overrides,
};
}
describe("parseLocations", () => {
it("nimmt die Packstation-Nummer aus keywordId, nicht aus locationId", () => {
const [packstation] = parseLocations([dhlLocation()]);
// locationId waere "8003-453113145" — damit koennte niemand etwas anfangen,
// und DHL wuerde die Versandmarke ablehnen.
expect(packstation.number).toBe("145");
expect(packstation.zip).toBe("86450");
expect(packstation.city).toBe("Altenmünster");
expect(packstation.street).toBe("Bahnhofstr. 1");
expect(packstation.distanceMeters).toBe(820);
});
it("sortiert nach Entfernung aufsteigend", () => {
const parsed = parseLocations([
dhlLocation({ distance: 3000, location: { keywordId: "300", keyword: "DHL Packstation" } }),
dhlLocation({ distance: 120, location: { keywordId: "120", keyword: "DHL Packstation" } }),
]);
expect(parsed.map((p) => p.number)).toEqual(["120", "300"]);
});
it("ueberspringt einen Eintrag ohne Packstation-Nummer, statt die ganze Suche scheitern zu lassen", () => {
const parsed = parseLocations([
{ location: { keyword: "DHL Packstation" }, place: { address: {} } },
dhlLocation(),
]);
// Ein Standort ohne Nummer ist unbrauchbar — die uebrigen bleiben gueltig.
expect(parsed).toHaveLength(1);
expect(parsed[0].number).toBe("145");
});
it("faellt auf einen zusammengesetzten Namen zurueck, wenn DHL keinen liefert", () => {
const [packstation] = parseLocations([dhlLocation({ name: undefined })]);
expect(packstation.name).toBe("Packstation 145");
});
it("kommt ohne Strasse und ohne Entfernung zurecht", () => {
const [packstation] = parseLocations([
dhlLocation({
distance: undefined,
place: { address: { postalCode: "10115", addressLocality: "Berlin" } },
}),
]);
expect(packstation.street).toBe("");
expect(packstation.distanceMeters).toBeNull();
// Ohne Entfernung darf der Eintrag nicht verschwinden, nur ans Ende rutschen.
expect(packstation.city).toBe("Berlin");
});
it("liefert fuer eine leere Trefferliste ein leeres Ergebnis", () => {
expect(parseLocations([])).toEqual([]);
});
});
+212
View File
@@ -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 });
}
+37 -1
View File
@@ -223,6 +223,14 @@ export interface DhlAddressInput {
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 {
@@ -265,6 +273,31 @@ function buildDhlContact(address: DhlAddressInput, name1: string) {
};
}
/**
* 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> {
@@ -279,7 +312,10 @@ export async function createDhlShipmentLabel(
// DHL erlaubt max. 35 Zeichen fuer refNo.
refNo: input.reference.slice(0, 35),
shipper: buildDhlContact(input.shipper, input.shipper.companyName),
consignee: buildDhlContact(input.receiver, `${input.receiver.firstName} ${input.receiver.lastName}`.trim()),
consignee: buildDhlConsignee(
input.receiver,
`${input.receiver.firstName} ${input.receiver.lastName}`.trim()
),
details: {
weight: { uom: "g", value: Math.max(Math.round(input.weightGrams), 1) },
},
+26 -1
View File
@@ -157,7 +157,32 @@ export const checkoutSchema = z.object({
.max(50, "Zu viele verschiedene Positionen im Warenkorb."),
discountCode: z.string().trim().max(50).optional(),
giftMessage: z.string().trim().max(500, "Die Geschenknachricht darf höchstens 500 Zeichen lang sein.").optional(),
});
/** Nummer der ueber die DHL-Standortsuche gewaehlten Packstation (z.B. "145"). */
packstationNumber: z
.string()
.trim()
.regex(/^d{1,10}$/, "Die Packstation-Nummer besteht nur aus Ziffern.")
.optional(),
/** DHL-Postnummer der empfangenden Person — bei Packstation-Lieferung Pflicht. */
postNumber: z
.string()
.trim()
.regex(/^d{6,10}$/, "Die DHL-Postnummer besteht aus 6 bis 10 Ziffern.")
.optional(),
})
// DHL verlangt bei Lieferung an eine Packstation zwingend die Postnummer
// der empfangenden Person — ohne sie laesst sich spaeter keine
// Versandmarke erstellen, die Bestellung waere also nicht zustellbar.
.refine((data) => !data.packstationNumber || Boolean(data.postNumber), {
message: "Für die Lieferung an eine Packstation wird deine DHL-Postnummer benötigt.",
path: ["postNumber"],
})
// Eine Packstation ist ein Versandziel — bei Abholung ergibt sie keinen Sinn
// und wuerde sonst still mitgespeichert.
.refine((data) => !data.packstationNumber || data.fulfillmentMethod === "VERSAND", {
message: "Eine Packstation ist nur bei Versand möglich.",
path: ["packstationNumber"],
});
export type CheckoutInput = z.infer<typeof checkoutSchema>;
export const reviewSchema = z.object({