feat: DHL-Sendungsverlauf direkt auf der Tracking-Seite anzeigen
/bestellung-verfolgen holt den Sendungsverlauf serverseitig ueber die DHL Shipment Tracking - Unified API und zeigt ihn im eigenen Layout an, statt Kundschaft auf dhl.de weiterzuleiten. - lib/dhl-tracking.ts: Abfrage mit API-Key, Zod-Validierung, Timeout, Cache pro Sendung (unterwegs 10 min, zugestellt 12 h, Fehler 2 min) - Abfrage erst nach erfolgreicher Pruefung von Bestellnummer + E-Mail, damit die Seite nicht als offener Proxy zu DHL dient - Ohne freigeschalteten Key bleibt die Seite unveraendert (Nummer + Link) - Optional DHL_TRACKING_API_KEY, falls das Tracking in eigener DHL-App liegt - Datenschutzerklaerung um die Statusabfrage bei DHL ergaenzt Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
2659db64bd
commit
01eaca39dc
10 files changed
+674
-5
No files matched your search
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
const ENV_KEYS = ["DHL_API_KEY", "DHL_TRACKING_API_KEY"] as const;
|
||||
const originalEnv: Record<string, string | undefined> = {};
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
/** Ein Ereignis im Format der DHL Shipment Tracking – Unified API. */
|
||||
function dhlEvent(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
timestamp: "2026-09-24T08:15:00Z",
|
||||
location: { address: { addressLocality: "Augsburg", postalCode: "86150", countryCode: "DE" } },
|
||||
statusCode: "transit",
|
||||
status: "In Zustellung",
|
||||
description: "Die Sendung wurde in das Zustellfahrzeug geladen.",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function dhlPayload(shipmentOverrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
shipments: [
|
||||
{
|
||||
id: "00340434161234567890",
|
||||
service: "parcel-de",
|
||||
status: dhlEvent(),
|
||||
estimatedTimeOfDelivery: "2026-09-25T18:00:00Z",
|
||||
events: [
|
||||
dhlEvent({
|
||||
timestamp: "2026-09-23T17:40:00Z",
|
||||
statusCode: "pre-transit",
|
||||
status: "Elektronisch angekündigt",
|
||||
description: "Die Sendung wurde elektronisch angekündigt.",
|
||||
location: undefined,
|
||||
}),
|
||||
dhlEvent(),
|
||||
],
|
||||
...shipmentOverrides,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const QUERY = { trackingNumber: "00340434161234567890", postalCode: "86450", locale: "de" as const };
|
||||
|
||||
// dhl-tracking.ts haelt Ergebnisse in einem modulweiten Cache — pro Test ein
|
||||
// frisches Modul, damit kein Treffer aus einem vorherigen Test den erwarteten
|
||||
// fetch-Aufruf stillschweigend ueberspringt (gleiches Muster wie dhl.test.ts).
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
for (const key of ENV_KEYS) {
|
||||
originalEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
if (originalEnv[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = originalEnv[key];
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("parseTrackingResponse", () => {
|
||||
it("liefert Status, voraussichtliche Zustellung und Ereignisse, neuestes zuerst", async () => {
|
||||
const { parseTrackingResponse } = await import("./dhl-tracking");
|
||||
|
||||
const info = parseTrackingResponse(dhlPayload());
|
||||
|
||||
expect(info?.status).toBe("transit");
|
||||
expect(info?.statusText).toBe("In Zustellung");
|
||||
expect(info?.estimatedDelivery?.toISOString()).toBe("2026-09-25T18:00:00.000Z");
|
||||
expect(info?.events.map((e) => e.status)).toEqual(["transit", "pre-transit"]);
|
||||
expect(info?.events[0].location).toBe("Augsburg");
|
||||
expect(info?.events[1].location).toBeNull();
|
||||
});
|
||||
|
||||
it("bildet unbekannte Statuscodes auf 'unknown' ab, statt die Antwort zu verwerfen", async () => {
|
||||
const { parseTrackingResponse } = await import("./dhl-tracking");
|
||||
|
||||
const info = parseTrackingResponse(dhlPayload({ status: dhlEvent({ statusCode: "neuer-code" }) }));
|
||||
|
||||
expect(info?.status).toBe("unknown");
|
||||
});
|
||||
|
||||
it("ueberspringt Ereignisse mit ungueltigem Zeitstempel", async () => {
|
||||
const { parseTrackingResponse } = await import("./dhl-tracking");
|
||||
|
||||
const info = parseTrackingResponse(dhlPayload({ events: [dhlEvent({ timestamp: "kein-datum" }), dhlEvent()] }));
|
||||
|
||||
expect(info?.events).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("kuerzt uebermaessig lange Texte, damit eine fremde Antwort das Layout nicht sprengt", async () => {
|
||||
const { parseTrackingResponse } = await import("./dhl-tracking");
|
||||
|
||||
const info = parseTrackingResponse(dhlPayload({ events: [dhlEvent({ description: "x".repeat(5000) })] }));
|
||||
|
||||
expect(info?.events[0].description.length).toBeLessThanOrEqual(300);
|
||||
});
|
||||
|
||||
it("liefert null bei einer Antwort ohne Sendung", async () => {
|
||||
const { parseTrackingResponse } = await import("./dhl-tracking");
|
||||
|
||||
expect(parseTrackingResponse({ shipments: [] })).toBeNull();
|
||||
expect(parseTrackingResponse("<html>")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDhlTracking", () => {
|
||||
it("ruft ohne API-Key das Netzwerk gar nicht erst auf", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { getDhlTracking } = await import("./dhl-tracking");
|
||||
|
||||
await expect(getDhlTracking(QUERY)).resolves.toEqual({ kind: "disabled" });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("schickt eine offensichtlich ungueltige Sendungsnummer nicht an DHL", async () => {
|
||||
process.env.DHL_API_KEY = "api-key";
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { getDhlTracking } = await import("./dhl-tracking");
|
||||
|
||||
await expect(getDhlTracking({ ...QUERY, trackingNumber: "../x?y=1" })).resolves.toEqual({ kind: "not-found" });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fragt mit API-Key-Header, Service, PLZ und Sprache an und liefert die Ereignisse", async () => {
|
||||
process.env.DHL_API_KEY = "api-key";
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(dhlPayload()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { getDhlTracking } = await import("./dhl-tracking");
|
||||
|
||||
const result = await getDhlTracking(QUERY);
|
||||
|
||||
expect(result.kind).toBe("ok");
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
const parsedUrl = new URL(url);
|
||||
expect(parsedUrl.origin + parsedUrl.pathname).toBe("https://api-eu.dhl.com/track/shipments");
|
||||
expect(parsedUrl.searchParams.get("trackingNumber")).toBe(QUERY.trackingNumber);
|
||||
expect(parsedUrl.searchParams.get("service")).toBe("parcel-de");
|
||||
expect(parsedUrl.searchParams.get("recipientPostalCode")).toBe("86450");
|
||||
expect(parsedUrl.searchParams.get("language")).toBe("de");
|
||||
expect((init.headers as Record<string, string>)["DHL-API-Key"]).toBe("api-key");
|
||||
});
|
||||
|
||||
it("bevorzugt DHL_TRACKING_API_KEY, falls das Tracking in einer eigenen DHL-App freigeschaltet ist", async () => {
|
||||
process.env.DHL_API_KEY = "shipping-key";
|
||||
process.env.DHL_TRACKING_API_KEY = "tracking-key";
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(dhlPayload()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { getDhlTracking } = await import("./dhl-tracking");
|
||||
|
||||
await getDhlTracking(QUERY);
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect((init.headers as Record<string, string>)["DHL-API-Key"]).toBe("tracking-key");
|
||||
});
|
||||
|
||||
it("beantwortet eine wiederholte Abfrage aus dem Cache, um das DHL-Kontingent zu schonen", async () => {
|
||||
process.env.DHL_API_KEY = "api-key";
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(dhlPayload()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { getDhlTracking } = await import("./dhl-tracking");
|
||||
|
||||
await getDhlTracking(QUERY);
|
||||
await getDhlTracking(QUERY);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("meldet 404 als 'not-found' — die Sendung ist DHL (noch) nicht bekannt", async () => {
|
||||
process.env.DHL_API_KEY = "api-key";
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ status: 404 }, 404)));
|
||||
const { getDhlTracking } = await import("./dhl-tracking");
|
||||
|
||||
await expect(getDhlTracking(QUERY)).resolves.toEqual({ kind: "not-found" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Kontingent erschoepft", () => Promise.resolve(jsonResponse({}, 429))],
|
||||
["falscher Key", () => Promise.resolve(jsonResponse({}, 401))],
|
||||
["Netzwerkfehler", () => Promise.reject(new TypeError("fetch failed"))],
|
||||
["kaputtes JSON", () => Promise.resolve(new Response("<html>", { status: 200 }))],
|
||||
])("liefert bei %s 'unavailable' statt die Seite scheitern zu lassen", async (_label, impl) => {
|
||||
process.env.DHL_API_KEY = "api-key";
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation(impl));
|
||||
const { getDhlTracking } = await import("./dhl-tracking");
|
||||
|
||||
await expect(getDhlTracking(QUERY)).resolves.toEqual({ kind: "unavailable" });
|
||||
});
|
||||
|
||||
it("wiederholt eine fehlgeschlagene Abfrage nicht sofort, sondern haelt kurz Abstand", async () => {
|
||||
process.env.DHL_API_KEY = "api-key";
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({}, 429));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { getDhlTracking } = await import("./dhl-tracking");
|
||||
|
||||
await getDhlTracking(QUERY);
|
||||
await getDhlTracking(QUERY);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Client fuer die DHL Shipment Tracking – Unified API — holt den Sendungs-
|
||||
* verlauf serverseitig, damit /bestellung-verfolgen ihn im eigenen Layout
|
||||
* anzeigen kann, statt Kundschaft auf dhl.de weiterzuleiten.
|
||||
*
|
||||
* Wie die Standortsuche (lib/dhl-locations.ts) braucht dieser Dienst nur den
|
||||
* App-API-Key (Header "DHL-API-Key"), kein OAuth2 und keinen
|
||||
* Geschaeftskundenvertrag. Voraussetzung ist, dass die DHL-App im Developer
|
||||
* Portal das Produkt "Shipment Tracking - Unified" abonniert hat. Ist das in
|
||||
* einer EIGENEN App freigeschaltet, wird DHL_TRACKING_API_KEY bevorzugt.
|
||||
*
|
||||
* Endpunkt laut oeffentlicher API-Referenz (developer.dhl.com/api-reference/
|
||||
* shipment-tracking):
|
||||
* GET https://api-eu.dhl.com/track/shipments
|
||||
* ?trackingNumber=...&service=parcel-de&recipientPostalCode=...&language=de
|
||||
* Mit recipientPostalCode liefert DHL fuer Pakete in Deutschland den
|
||||
* ausfuehrlicheren Verlauf; ohne sie nur einen groben Status.
|
||||
*
|
||||
* Diese Funktion wirft nie: Fehler werden als { kind: "unavailable" }
|
||||
* gemeldet, damit die Tracking-Seite immer noch Sendungsnummer und
|
||||
* DHL-Link zeigt, wenn DHL gestoert ist oder das Kontingent aufgebraucht ist.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import type { Locale } from "@/i18n/config";
|
||||
|
||||
const TRACKING_URL = "https://api-eu.dhl.com/track/shipments";
|
||||
|
||||
// Kuerzer als bei der Versandmarken-Erstellung: die Abfrage blockiert das
|
||||
// Rendern der Tracking-Seite, Kundschaft wartet also direkt darauf.
|
||||
const REQUEST_TIMEOUT_MS = 6_000;
|
||||
|
||||
/**
|
||||
* Das Einstiegskontingent der Unified API ist knapp (Stand der Recherche
|
||||
* einige hundert Aufrufe pro Tag). Deshalb ein Cache pro Sendung:
|
||||
* - unterwegs: 10 Minuten — DHL aktualisiert ohnehin nur alle paar Stunden
|
||||
* - zugestellt: 12 Stunden — dort aendert sich nichts mehr
|
||||
* - nicht gefunden / Fehler: kurze Sperre, damit ein Neuladen der Seite oder
|
||||
* ein 429 nicht sofort den naechsten Aufruf ausloest
|
||||
*/
|
||||
const CACHE_TTL_IN_TRANSIT_MS = 10 * 60 * 1000;
|
||||
const CACHE_TTL_DELIVERED_MS = 12 * 60 * 60 * 1000;
|
||||
const CACHE_TTL_NOT_FOUND_MS = 10 * 60 * 1000;
|
||||
const CACHE_TTL_UNAVAILABLE_MS = 2 * 60 * 1000;
|
||||
const MAX_CACHE_ENTRIES = 1000;
|
||||
|
||||
/** Obergrenze fuer Texte aus der DHL-Antwort — fremde Daten sollen das Layout nicht sprengen. */
|
||||
const MAX_TEXT_LENGTH = 300;
|
||||
const MAX_EVENTS = 50;
|
||||
|
||||
/**
|
||||
* DHL-Sendungsnummern bestehen aus Buchstaben und Ziffern (Paketnummern 12-20
|
||||
* Ziffern, internationale Nummern z.B. "JJD..." oder "LX...DE"). Alles andere
|
||||
* geht gar nicht erst raus — die Nummer stammt zwar aus dem Admin-Bereich,
|
||||
* landet aber in einer URL zu einem fremden Dienst.
|
||||
*/
|
||||
const TRACKING_NUMBER_PATTERN = /^[A-Za-z0-9]{8,39}$/;
|
||||
|
||||
export const DHL_TRACKING_STATUSES = ["pre-transit", "transit", "delivered", "failure", "unknown"] as const;
|
||||
export type DhlTrackingStatus = (typeof DHL_TRACKING_STATUSES)[number];
|
||||
|
||||
export interface DhlTrackingEvent {
|
||||
timestamp: Date;
|
||||
status: DhlTrackingStatus;
|
||||
/** Kurztext laut DHL, z.B. "In Zustellung". */
|
||||
statusText: string;
|
||||
/** Ausfuehrlicher Text laut DHL; faellt auf statusText zurueck. */
|
||||
description: string;
|
||||
/** Ort, sofern DHL einen mitliefert. */
|
||||
location: string | null;
|
||||
}
|
||||
|
||||
export interface DhlTrackingInfo {
|
||||
status: DhlTrackingStatus;
|
||||
statusText: string;
|
||||
estimatedDelivery: Date | null;
|
||||
/** Neuestes Ereignis zuerst. */
|
||||
events: DhlTrackingEvent[];
|
||||
}
|
||||
|
||||
export type DhlTrackingResult =
|
||||
| { kind: "ok"; info: DhlTrackingInfo }
|
||||
| { kind: "not-found" }
|
||||
| { kind: "unavailable" }
|
||||
| { kind: "disabled" };
|
||||
|
||||
// Nur die Felder, die tatsaechlich angezeigt werden — zusaetzliche Felder
|
||||
// aufseiten DHLs duerfen das Parsen nicht brechen.
|
||||
const eventSchema = z.object({
|
||||
timestamp: z.string(),
|
||||
statusCode: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
location: z
|
||||
.object({ address: z.object({ addressLocality: z.string().optional() }).optional() })
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const shipmentSchema = z.object({
|
||||
status: z.unknown().optional(),
|
||||
estimatedTimeOfDelivery: z.string().optional(),
|
||||
events: z.array(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const responseSchema = z.object({
|
||||
shipments: z.array(shipmentSchema).min(1),
|
||||
});
|
||||
|
||||
const cache = new Map<string, { expiresAt: number; value: DhlTrackingResult }>();
|
||||
|
||||
function getApiKey(): string | null {
|
||||
return process.env.DHL_TRACKING_API_KEY?.trim() || process.env.DHL_API_KEY?.trim() || null;
|
||||
}
|
||||
|
||||
export function isDhlTrackingConfigured(): boolean {
|
||||
return getApiKey() !== null;
|
||||
}
|
||||
|
||||
interface GetDhlTrackingOptions {
|
||||
trackingNumber: string;
|
||||
/** Postleitzahl der Lieferanschrift (bei Packstation: die der Packstation). */
|
||||
postalCode: string;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export async function getDhlTracking({
|
||||
trackingNumber,
|
||||
postalCode,
|
||||
locale,
|
||||
}: GetDhlTrackingOptions): Promise<DhlTrackingResult> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) return { kind: "disabled" };
|
||||
|
||||
const normalizedNumber = trackingNumber.replace(/\s+/g, "");
|
||||
if (!TRACKING_NUMBER_PATTERN.test(normalizedNumber)) return { kind: "not-found" };
|
||||
|
||||
const cacheKey = `${normalizedNumber}|${postalCode}|${locale}`;
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
||||
|
||||
const result = await fetchTracking(apiKey, normalizedNumber, postalCode, locale);
|
||||
rememberInCache(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function fetchTracking(
|
||||
apiKey: string,
|
||||
trackingNumber: string,
|
||||
postalCode: string,
|
||||
locale: Locale
|
||||
): Promise<DhlTrackingResult> {
|
||||
const query = new URLSearchParams({ trackingNumber, service: "parcel-de", language: locale });
|
||||
if (/^\d{5}$/.test(postalCode)) query.set("recipientPostalCode", postalCode);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${TRACKING_URL}?${query}`, {
|
||||
headers: { "DHL-API-Key": apiKey, Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const reason = error instanceof Error && error.name === "TimeoutError" ? "Zeitueberschreitung" : "nicht erreichbar";
|
||||
console.warn(`[dhl-tracking] Sendungsverfolgung ${reason}.`);
|
||||
return { kind: "unavailable" };
|
||||
}
|
||||
|
||||
// 404 = DHL kennt die Sendung (noch) nicht, z.B. direkt nach dem Erstellen
|
||||
// der Versandmarke. Kein Fehler, sondern ein gueltiger Zwischenstand.
|
||||
if (response.status === 404) return { kind: "not-found" };
|
||||
|
||||
if (!response.ok) {
|
||||
// Nur den Status loggen: die Sendungsnummer gehoert zu einer Bestellung
|
||||
// und hat im Server-Log nichts verloren.
|
||||
console.warn(`[dhl-tracking] DHL hat mit Status ${response.status} geantwortet.`);
|
||||
return { kind: "unavailable" };
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
console.warn("[dhl-tracking] Antwort war kein gueltiges JSON.");
|
||||
return { kind: "unavailable" };
|
||||
}
|
||||
|
||||
const info = parseTrackingResponse(payload);
|
||||
if (!info) {
|
||||
console.warn("[dhl-tracking] Antwort hatte ein unerwartetes Format.");
|
||||
return { kind: "unavailable" };
|
||||
}
|
||||
return { kind: "ok", info };
|
||||
}
|
||||
|
||||
/**
|
||||
* Als eigene Funktion exportiert, damit sich das Parsen der DHL-Antwort ohne
|
||||
* Netzwerkzugriff testen laesst (siehe dhl-tracking.test.ts).
|
||||
*/
|
||||
export function parseTrackingResponse(payload: unknown): DhlTrackingInfo | null {
|
||||
const parsed = responseSchema.safeParse(payload);
|
||||
if (!parsed.success) return null;
|
||||
|
||||
const shipment = parsed.data.shipments[0];
|
||||
const events = (shipment.events ?? [])
|
||||
.map(parseEvent)
|
||||
.filter((event): event is DhlTrackingEvent => event !== null)
|
||||
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
|
||||
.slice(0, MAX_EVENTS);
|
||||
|
||||
// Der Gesamtstatus steht separat in shipment.status; fehlt er, ist das
|
||||
// neueste Ereignis die beste Naeherung.
|
||||
const current = parseEvent(shipment.status) ?? events[0] ?? null;
|
||||
|
||||
return {
|
||||
status: current?.status ?? "unknown",
|
||||
statusText: current?.statusText ?? "",
|
||||
estimatedDelivery: parseDate(shipment.estimatedTimeOfDelivery),
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
function parseEvent(entry: unknown): DhlTrackingEvent | null {
|
||||
const parsed = eventSchema.safeParse(entry);
|
||||
if (!parsed.success) return null;
|
||||
|
||||
const timestamp = parseDate(parsed.data.timestamp);
|
||||
if (!timestamp) return null;
|
||||
|
||||
const statusText = clean(parsed.data.status);
|
||||
return {
|
||||
timestamp,
|
||||
status: toStatus(parsed.data.statusCode),
|
||||
statusText,
|
||||
description: clean(parsed.data.description) || statusText,
|
||||
location: clean(parsed.data.location?.address?.addressLocality) || null,
|
||||
};
|
||||
}
|
||||
|
||||
function toStatus(code: string | undefined): DhlTrackingStatus {
|
||||
return (DHL_TRACKING_STATUSES as readonly string[]).includes(code ?? "") ? (code as DhlTrackingStatus) : "unknown";
|
||||
}
|
||||
|
||||
function parseDate(value: string | undefined): Date | null {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function clean(value: string | undefined): string {
|
||||
return (value ?? "").replace(/\s+/g, " ").trim().slice(0, MAX_TEXT_LENGTH);
|
||||
}
|
||||
|
||||
function ttlFor(result: DhlTrackingResult): number {
|
||||
switch (result.kind) {
|
||||
case "ok":
|
||||
return result.info.status === "delivered" ? CACHE_TTL_DELIVERED_MS : CACHE_TTL_IN_TRANSIT_MS;
|
||||
case "not-found":
|
||||
return CACHE_TTL_NOT_FOUND_MS;
|
||||
default:
|
||||
return CACHE_TTL_UNAVAILABLE_MS;
|
||||
}
|
||||
}
|
||||
|
||||
function rememberInCache(key: string, value: DhlTrackingResult): void {
|
||||
// Speicher begrenzen: abgelaufene Eintraege gelegentlich aufraeumen
|
||||
// (gleiches Muster wie in lib/dhl-locations.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() + ttlFor(value), value });
|
||||
}
|
||||
@@ -7,9 +7,13 @@
|
||||
*/
|
||||
export function getCarrierTrackingUrl(carrier: string | null, trackingNumber: string | null): string | null {
|
||||
if (!carrier || !trackingNumber) return null;
|
||||
const normalized = carrier.trim().toLowerCase();
|
||||
if (normalized === "dhl") {
|
||||
if (isDhlCarrier(carrier)) {
|
||||
return `https://www.dhl.de/de/privatkunden/dhl-sendungsverfolgung.html?piececode=${encodeURIComponent(trackingNumber)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Der Versanddienstleister wird im Admin-Bereich als Freitext gepflegt. */
|
||||
export function isDhlCarrier(carrier: string | null): boolean {
|
||||
return carrier?.trim().toLowerCase() === "dhl";
|
||||
}
|
||||
Reference in new issue
Block a user