"use server"; import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/prisma"; import { logAdminAction } from "@/lib/audit-log"; import { getCompanyInfo } from "@/lib/company-info"; import { createDhlShipmentLabel, DhlApiError, DhlNotConfiguredError, type DhlCreateShipmentResult } from "@/lib/dhl"; import { requireAdmin, fail, str } from "./_shared"; import { setFlash } from "@/lib/flash"; // Platzhalterwert, waehrend eine Versandmarken-Anfrage bei DHL laeuft — siehe // Kommentar bei der Serializable-Transaktion unten. Kein echtes DHL-Format, // daher eindeutig von jeder realen Sendungsnummer unterscheidbar. const DHL_CLAIM_SENTINEL = "__dhl_label_pending__"; /** * Erstellt per DHL Parcel DE Shipping API eine Versandmarke fuer eine * bezahlte Bestellung und speichert die zurueckgegebene Sendungsnummer im * Shipment-Datensatz (carrier="DHL"). Idempotent: ist bereits eine * Sendungsnummer erfasst, wird KEINE zweite Marke erzeugt (das wuerde bei * DHL echte Kosten verursachen) — die Aktion bricht in dem Fall mit einer * klaren Meldung ab, statt stillschweigend zu ueberschreiben. */ export async function createDhlLabelAction(formData: FormData): Promise { const admin = await requireAdmin(); const orderId = str(formData, "orderId"); const detailPath = `/admin/bestellungen/${orderId}`; const order = await prisma.order.findUnique({ where: { id: orderId }, include: { items: { select: { weightGrams: true, quantity: true } }, payment: { select: { status: true } }, shipment: { select: { trackingNumber: true } }, }, }); if (!order) { return fail("/admin/bestellungen", "Bestellung nicht gefunden."); } if (order.fulfillmentMethod !== "VERSAND") { return fail(detailPath, "Diese Bestellung ist zur Abholung markiert, kein Versand per DHL nötig."); } if (order.payment?.status !== "SUCCEEDED") { return fail(detailPath, "Die Bestellung ist noch nicht bezahlt — es kann keine Versandmarke erstellt werden."); } if (order.shipment?.trackingNumber) { return fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst."); } // Atomarer Claim VOR dem (kostenpflichtigen) DHL-Aufruf: der obige // findUnique-Check allein ist nicht ausreichend — zwei schnell // aufeinanderfolgende Klicks koennten sonst beide passieren und zwei // echte, kostenpflichtige DHL-Sendungen anlegen (gleiche Race-Condition // wie beim Retouren-Erstattungs-Fix). Ein Shipment-Datensatz kann bereits // OHNE DHL existieren (z.B. automatisch beim Statuswechsel auf // "VERSENDET", siehe admin/actions/orders.ts, oder manuell erfasst) — // ein reines "create()" mit Unique-Constraint waere daher in diesen // Faellen faelschlich blockiert. Serializable-Transaktion + Sentinel-Wert // statt der echten Sendungsnummer loest beide Faelle korrekt atomar. let claimed: boolean; try { claimed = await prisma.$transaction( async (tx) => { const existing = await tx.shipment.findUnique({ where: { orderId }, select: { trackingNumber: true } }); if (existing?.trackingNumber) return false; await tx.shipment.upsert({ where: { orderId }, create: { orderId, carrier: "DHL", trackingNumber: DHL_CLAIM_SENTINEL }, update: { carrier: "DHL", trackingNumber: DHL_CLAIM_SENTINEL }, }); return true; }, { isolationLevel: "Serializable" } ); } catch { claimed = false; } if (!claimed) { return fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst oder eine Anfrage läuft gerade."); } const totalWeightGrams = order.items.reduce((sum, item) => sum + item.weightGrams * item.quantity, 0); const company = await getCompanyInfo(); let result: DhlCreateShipmentResult; try { result = await createDhlShipmentLabel({ reference: order.orderNumber, weightGrams: totalWeightGrams, shipper: { companyName: company.name, firstName: "", lastName: "", street: company.address.street, zip: company.address.zip, city: company.address.city, // Firmensitz ist Deutschland (siehe lib/site-config.ts) — bewusst // fest verdrahtet statt aus dem lokalisierten Anzeigetext // ("Deutschland") geparst. country: "DE", phone: company.phone, email: company.email, }, receiver: { firstName: order.firstName, lastName: order.lastName, street: order.street, zip: order.zip, city: order.city, // Bei gesetzter Packstation-Nummer baut lib/dhl.ts daraus einen // Locker-Empfaenger statt einer Hausanschrift. packstationNumber: order.packstationNumber, postNumber: order.postNumber, country: order.country, phone: order.phone, email: order.email, }, }); } catch (error: unknown) { // Claim zuruecksetzen, damit ein erneuter Versuch moeglich ist — nur // wenn der Sentinel-Wert seitdem nicht durch etwas anderes (z.B. eine // manuelle Tracking-Eingabe waehrend der laufenden Anfrage) ersetzt // wurde, um dessen Aenderung nicht versehentlich zu ueberschreiben. await prisma.shipment .updateMany({ where: { orderId, trackingNumber: DHL_CLAIM_SENTINEL }, data: { trackingNumber: null } }) .catch(() => {}); if (error instanceof DhlNotConfiguredError) { return fail(detailPath, error.message); } if (error instanceof DhlApiError) { return fail(detailPath, error.message); } const message = error instanceof Error ? error.message : "Unbekannter Fehler"; return fail(detailPath, `DHL-Versandmarke konnte nicht erstellt werden: ${message}`); } try { await prisma.shipment.upsert({ where: { orderId }, create: { orderId, carrier: "DHL", trackingNumber: result.trackingNumber }, update: { carrier: "DHL", trackingNumber: result.trackingNumber }, }); } catch (error: unknown) { // Die DHL-Marke wurde bereits kostenpflichtig erstellt — geht die // Sendungsnummer hier verloren, ist sie ueber die DHL-API nicht erneut // abrufbar. Deshalb laut loggen (fuer manuelle Nacherfassung ueber das // Bestellungs-Detail, siehe admin/actions/orders.ts) statt sie // stillschweigend verschwinden zu lassen. Der Sentinel-Claim bleibt // bewusst unangetastet, damit kein zweiter — erneut kostenpflichtiger — // Versuch ueber die UI ausgeloest werden kann. console.error( `createDhlLabelAction: DHL-Sendungsnummer ${result.trackingNumber} fuer Bestellung ${order.orderNumber} (${orderId}) konnte nicht gespeichert werden — manuelle Nacherfassung noetig.`, error ); return fail( detailPath, "Die DHL-Versandmarke wurde erstellt, aber die Sendungsnummer konnte nicht gespeichert werden. Bitte Support kontaktieren, um sie manuell nachzutragen." ); } await logAdminAction(admin, { action: "order.dhl_label_created", entityType: "Order", entityId: orderId, detail: `${order.orderNumber}: DHL-Sendungsnummer ${result.trackingNumber}`, }); revalidatePath("/admin/bestellungen"); revalidatePath(detailPath); await setFlash("success", `DHL-Versandmarke erstellt. Sendungsnummer: ${result.trackingNumber}`); }