"use server"; import { redirect } from "next/navigation"; import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/prisma"; import { logAdminAction } from "@/lib/audit-log"; import { openApiCoreSession, fetchApiCoreHarvests, fetchApiCoreProducts, fetchApiCoreBottlingBatches, isSellableStatus, ApiCoreConfigError, ApiCoreRequestError, type ApiCoreSession, type ApiCoreHarvestWithApiaryName, type ApiCoreProduct, type ApiCoreBottlingBatchWithHarvestId, } from "@/lib/apicore-client"; import { slugForApiCoreSource, buildHarvestDescription, buildProductDescription, deriveJarWeightGrams, productSlugCandidates, } from "@/lib/apicore-mapping"; import { requireAdmin } from "./_shared"; /** * Uebernimmt Stammdaten aus ApiCore (separates Imkerei-Verwaltungssystem) in * den Shop: Ernten, Produkte und Chargen. * * Durchgaengiges Prinzip fuer alle drei: ApiCore ist die Quelle der ROHDATEN, * Wabenhain bleibt die Quelle aller VERKAUFS-Entscheidungen. Ein Sync-Lauf * darf deshalb nie etwas ueberschreiben, das eine Mitarbeiterin im Admin * bewusst gesetzt hat — importiert wird in einen Entwurfszustand, den erst * ein Mensch freigibt. * * - Ernten: werden als unveroeffentlichte Entwuerfe angelegt (isPublished * false) bzw. als solche aktualisiert. Bereits veroeffentlichte, frueher * synchronisierte Ernten bleiben unberuehrt. * - Produkte: werden als deaktivierte Entwuerfe angelegt (isActive false, * Preis 0) und nur so lange aktualisiert, wie sie deaktiviert sind. Ein * bereits aktives Produkt wird nie angefasst — sonst wuerde ein Sync Preis, * Beschreibung und Geschmacksprofil ueberschreiben, die nur in Wabenhain * gepflegt werden und die es in ApiCore gar nicht gibt. * - Chargen: werden NUR neu angelegt, nie aktualisiert. Anders als Harvest * und Product hat Batch kein Feld, an dem eine erfolgte Admin-Bearbeitung * erkennbar waere. Ein Update wuerde insbesondere remainingGlasses * ueberschreiben — Wabenhains EIGENEN, durch echte Verkaeufe veraenderten * Lagerbestand. * * Idempotent ist das alles ueber einen stabilen Schluessel je Datensatz: * "apicore-" als Harvest.slug bzw. Product.slug, und die * ApiCore-Losnummer als Batch.code. * * Wabenhains "type" bei Ernten (Erntesaison, z.B. "Sommertracht") und * ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig") sind KEINE * aequivalenten Taxonomien — es findet bewusst keine automatische * Uebersetzung statt, der Rohwert wird uebernommen und muss vom Admin beim * Review korrigiert werden. */ interface SyncCounts { created: number; updated: number; skipped: number; failed: number; } /** * Produkte kennen bewusst kein "updated" — sie werden nur neu angelegt, * siehe syncProducts(). */ interface ProductSyncCounts { created: number; skipped: number; failed: number; /** * Abfuellungen, deren Charge in Wabenhain noch fehlt. Kein Fehler, sondern * eine Reihenfolge: zuerst der Ernten-Sync, danach noch einmal hier. */ pending: number; /** Artikel, die ApiCore nicht mehr als verkaufsfaehig fuehrt. */ discontinued: number; } function describeError(error: unknown): string { if (error instanceof ApiCoreConfigError || error instanceof ApiCoreRequestError) return error.message; if (error instanceof Error) return error.message; return "Unbekannter Fehler"; } // --------------------------------------------------------------------------- // Ernten // --------------------------------------------------------------------------- async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise { const counts: SyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0 }; for (const source of sources) { const slug = slugForApiCoreSource(source.id); const date = new Date(source.date); if (Number.isNaN(date.getTime())) { counts.failed++; console.error(`[apicore-sync] Ernte ${source.id} hat ein ungueltiges Datum ("${source.date}") — übersprungen.`); continue; } const type = source.honeyType?.trim() || `Ernte ${source.id.slice(0, 8)}`; try { const existing = await prisma.harvest.findUnique({ where: { slug } }); if (existing?.isPublished) { counts.skipped++; continue; } const data = { year: date.getFullYear(), type, harvestDate: date, location: source.apiaryName ?? "Altenmünster", description: buildHarvestDescription(source), }; if (existing) { await prisma.harvest.update({ where: { slug }, data }); counts.updated++; } else { await prisma.harvest.create({ data: { ...data, slug, isPublished: false } }); counts.created++; } } catch (error: unknown) { counts.failed++; // z.B. P2002 (unique constraint year+type), falls zwei ApiCore-Ernten // im selben Jahr denselben honeyType tragen — bewusst nicht fatal fuer // den gesamten Sync-Lauf, siehe Kommentar oben zur Taxonomie-Kollision. console.error(`[apicore-sync] Ernte ${source.id} konnte nicht importiert werden: ${describeError(error)}`); } } return counts; } // --------------------------------------------------------------------------- // Produkte // --------------------------------------------------------------------------- /** * Ein neu importiertes Produkt ist absichtlich NICHT verkaufsfaehig: * * - `isActive: false` haelt es aus Shop, Suche und Checkout heraus. * - `priceCents: 0` ist kein geschaetzter, sondern ein FEHLENDER Preis: * ApiCore fuehrt ueberhaupt keine Verkaufspreise, das ist reine * Betriebsdatenhaltung. Den Preis kann nur ein Mensch setzen. Damit aus * diesem Platzhalter nie eine Gratis-Bestellung wird, weigern sich * zusaetzlich die Aktivierung im Admin und der Checkout, ein Produkt mit * Preis 0 zu verkaufen (admin/actions/products.ts, api/checkout/route.ts). * - Kategorie HONIG als Vorgabe: ApiCore kennt keine Shop-Kategorien, und * eine Imkerei-Warenwirtschaft fuehrt ueberwiegend Honig. Kerzen oder * Propolis stellt der Admin beim Review um. * - Geschmacksprofil, Konsistenz und Bilder bleiben auf den Schema-Vorgaben: * dafuer gibt es in ApiCore keine Entsprechung, das ist Shop-Redaktion. * * Ein schon vorhandenes Produkt wird NIE aktualisiert, auch kein Entwurf. * Genau die drei Felder, die ApiCore liefert (Name, Beschreibung, Gewicht), * sind die, die der Admin vor der Freischaltung von Hand nachzieht — ein * Update wuerde diese Arbeit bei jedem weiteren Lauf wieder wegwerfen, und * am Entwurf ist nicht erkennbar, ob schon jemand daran war. Soll ein * Produkt wirklich frisch aus ApiCore kommen, loescht man es in Wabenhain * und synchronisiert erneut. Dieselbe Regel gilt aus demselben Grund fuer * Chargen. * * EIN PRODUKT JE JAHRGANG, nicht je ApiCore-Artikel. Der Sync laeuft deshalb * ueber die ABFUELLUNGEN, nicht ueber den Artikelkatalog: * * In ApiCore ist "Waldhonig 500 g" ein einziger Katalogartikel, an dem jedes * Jahr eine weitere Abfuellung haengt. In Wabenhain gehoert ein Produkt * dagegen zu genau einer Charge — und das ist richtig so, denn jeder Jahrgang * hat eigenen Preis, eigenen Verkaufstext, eigenes Geschmacksprofil und * eigene Bewertungen. Honig ist ein Naturprodukt; die Bewertung des * Waldhonigs von 2026 sagt ueber den von 2027 nichts aus. * * Aus einem ApiCore-Artikel werden hier also mehrere Wabenhain-Produkte, * eines je Abfuellung. Wiedererkannt werden sie ueber Product.apicoreBottlingId * — nicht ueber den Slug: der ist die oeffentliche Adresse und soll lesbar * bleiben ("waldhonig-2026"), darf sich also nicht nach einer fremden ID * richten. * * Reihenfolge: die Charge muss bereits in Wabenhain sein (Ernten-Sync), sonst * gaebe es nichts, woran der Jahrgang haengt. Fehlt sie, wird die Abfuellung * als "pending" gezaehlt statt als Fehler — der naechste Lauf holt sie nach. */ async function syncProducts( articles: ApiCoreProduct[], bottlings: ApiCoreBottlingBatchWithHarvestId[] ): Promise { const counts: ProductSyncCounts = { created: 0, skipped: 0, failed: 0, pending: 0, discontinued: 0 }; const articleById = new Map(articles.map((article) => [article.id, article])); for (const bottling of bottlings) { // Eine Abfuellung ohne Artikel ist in ApiCore erlaubt (z.B. Rohware fuer // den Direktverkauf ab Hof). Daraus laesst sich kein Shop-Produkt bilden: // es gaebe weder Namen noch Artikelnummer. if (!bottling.productId) continue; const article = articleById.get(bottling.productId); if (!article) { counts.failed++; console.error( `[apicore-sync] Abfüllung ${bottling.lotNumber} verweist auf den Artikel ${bottling.productId}, den ApiCore nicht ausliefert — übersprungen.` ); continue; } try { const existing = await prisma.product.findUnique({ where: { apicoreBottlingId: bottling.id }, select: { id: true }, }); if (existing) { counts.skipped++; continue; } // Ein in ApiCore ausgelaufener Artikel bekommt keinen NEUEN Jahrgang im // Shop. Bereits importierte Jahrgaenge bleiben unberuehrt und weiter // verkaeuflich: solange Glaeser im Regal stehen, ist "nicht mehr // produziert" kein Grund, den Abverkauf abzubrechen. Diese Entscheidung // trifft der Admin, nicht der Sync. if (!isSellableStatus(article.status)) { counts.discontinued++; continue; } const batch = await prisma.batch.findUnique({ where: { code: bottling.lotNumber }, include: { harvest: { select: { id: true, year: true } } }, }); if (!batch) { counts.pending++; continue; } const slug = await firstFreeSlug( productSlugCandidates(article.name, batch.harvest.year, bottling.lotNumber) ); if (!slug) { counts.failed++; console.error( `[apicore-sync] Für "${article.name}" (${batch.harvest.year}, Los ${bottling.lotNumber}) ist keine freie Adresse frei — Produkt bitte von Hand anlegen.` ); continue; } await prisma.product.create({ data: { slug, apicoreBottlingId: bottling.id, name: article.name, description: buildProductDescription(article, batch.harvest.year), // Die Glasgroesse der Abfuellung schlaegt die Angabe am Artikel: // sie sagt, was tatsaechlich in dieses Glas kam. weightGrams: deriveJarWeightGrams(bottling.jarSize, article), category: "HONIG", harvestId: batch.harvest.id, batchId: batch.id, priceCents: 0, pricePerKgCents: 0, isActive: false, // Bewusst 0 statt bottling.jarCount: wie viele Glaeser in den // Webshop gehen und wie viele in Hofladen, Wiederverkauf oder // Rueckstellmuster, entscheidet Wabenhain — nicht die Abfuellmenge. inventory: { create: { quantity: 0 } }, }, }); counts.created++; } catch (error: unknown) { counts.failed++; console.error( `[apicore-sync] Abfüllung ${bottling.lotNumber} konnte nicht als Produkt importiert werden: ${describeError(error)}` ); } } return counts; } /** * Erste noch freie Adresse aus den Vorschlaegen, oder null, wenn alle belegt * sind. Bewusst ohne Hochzaehlen: eine Adresse wie "waldhonig-2026-2" sagt * niemandem etwas, und der Fall bedeutet ohnehin, dass hier jemand * hinschauen muss. */ async function firstFreeSlug(candidates: string[]): Promise { for (const slug of candidates) { const taken = await prisma.product.findUnique({ where: { slug }, select: { id: true } }); if (!taken) return slug; } return null; } // --------------------------------------------------------------------------- // Chargen // --------------------------------------------------------------------------- async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promise { const counts: SyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0 }; for (const source of sources) { if (!source.harvestSourceId) { counts.failed++; console.error(`[apicore-sync] Charge ${source.lotNumber} ist keiner Ernte zugeordnet — übersprungen.`); continue; } const fillDate = new Date(source.date); const bestBeforeDate = new Date(source.bestBeforeDate); if (Number.isNaN(fillDate.getTime()) || Number.isNaN(bestBeforeDate.getTime())) { counts.failed++; console.error(`[apicore-sync] Charge ${source.lotNumber} hat ein ungültiges Datum — übersprungen.`); continue; } try { const existing = await prisma.batch.findUnique({ where: { code: source.lotNumber } }); if (existing) { counts.skipped++; continue; } const harvest = await prisma.harvest.findUnique({ where: { slug: slugForApiCoreSource(source.harvestSourceId) }, }); if (!harvest) { counts.failed++; console.error( `[apicore-sync] Charge ${source.lotNumber}: zugehörige Ernte (ApiCore-ID ${source.harvestSourceId}) ist in Wabenhain noch nicht synchronisiert — übersprungen.` ); continue; } const glassCount = Math.max(Math.round(source.jarCount), 0); await prisma.batch.create({ data: { code: source.lotNumber, harvestId: harvest.id, fillDate, bestBeforeDate, glassCount, remainingGlasses: glassCount, }, }); counts.created++; } catch (error: unknown) { counts.failed++; console.error(`[apicore-sync] Charge ${source.lotNumber} konnte nicht importiert werden: ${describeError(error)}`); } } return counts; } /** * Chargen laden und importieren. Ein Fehlschlag beim LADEN ist bewusst nicht * fatal fuer den umgebenden Sync-Lauf: die bereits erfolgreich uebernommenen * Ernten sollen sichtbar bleiben, statt hinter einer Fehlerseite zu * verschwinden. * * Eine nachtraegliche Verknuepfung Produkt<->Charge gibt es nicht mehr: seit * je Abfuellung ein eigenes Produkt entsteht, wird es bereits mit Charge und * Ernte angelegt (siehe syncProducts). */ async function loadAndSyncBatches( session: ApiCoreSession ): Promise<{ counts: SyncCounts; loadError: string | null }> { let sources: ApiCoreBottlingBatchWithHarvestId[]; try { sources = await fetchApiCoreBottlingBatches(session); } catch (error: unknown) { return { counts: { created: 0, updated: 0, skipped: 0, failed: 0 }, loadError: describeError(error), }; } return { counts: await syncBatches(sources), loadError: null }; } // --------------------------------------------------------------------------- // Server Actions // --------------------------------------------------------------------------- /** * Sync-Knopf auf /admin/ernten: zieht Ernten und die daraus entstandenen * Chargen. Chargen laufen NACH den Ernten, damit eine gerade erst angelegte * Ernte als Pflicht-Fremdschluessel schon bereitsteht. */ export async function syncHarvestsFromApiCoreAction(): Promise { const admin = await requireAdmin(); let session: ApiCoreSession; let sourceHarvests: ApiCoreHarvestWithApiaryName[]; try { session = await openApiCoreSession(); sourceHarvests = await fetchApiCoreHarvests(session); } catch (error: unknown) { redirect(`/admin/ernten?syncError=${encodeURIComponent(describeError(error))}`); } const harvests = await syncHarvests(sourceHarvests); await logAdminAction(admin, { action: "harvest.sync_apicore", entityType: "Harvest", detail: `${harvests.created} neu, ${harvests.updated} aktualisiert, ${harvests.skipped} übersprungen, ${harvests.failed} fehlgeschlagen`, }); const batches = await loadAndSyncBatches(session); await logAdminAction(admin, { action: "batch.sync_apicore", entityType: "Batch", detail: batches.loadError ? `Chargen konnten nicht geladen werden: ${batches.loadError}` : `${batches.counts.created} neu, ${batches.counts.skipped} übersprungen, ${batches.counts.failed} fehlgeschlagen`, }); revalidatePath("/admin/ernten"); revalidatePath("/admin/chargen"); revalidatePath("/admin/produkte"); redirect( `/admin/ernten?synced=${harvests.created}&updated=${harvests.updated}&skipped=${harvests.skipped}&failed=${harvests.failed}` + `&batchSynced=${batches.counts.created}&batchSkipped=${batches.counts.skipped}&batchFailed=${batches.counts.failed}` + (batches.loadError ? `&batchLoadError=${encodeURIComponent(batches.loadError)}` : "") ); } /** * Sync-Knopf auf /admin/produkte: legt je ApiCore-ABFUELLUNG ein Produkt an, * also einen eigenen Jahrgang (siehe syncProducts). * * Braucht dafuer beides: den Artikelkatalog (Name, Artikelnummer) und die * Abfuellungen (Jahrgang, Glasgroesse, Losnummer). Chargen selbst werden hier * NICHT importiert — das gehoert zum Ernten-Sync, weil eine Charge ohne ihre * Ernte gar nicht anlegbar ist. Fehlt die Charge noch, meldet der Lauf das * als "wartet auf den Ernten-Sync" statt als Fehler. */ export async function syncProductsFromApiCoreAction(): Promise { const admin = await requireAdmin(); let session: ApiCoreSession; let articles: ApiCoreProduct[]; let bottlings: ApiCoreBottlingBatchWithHarvestId[]; try { session = await openApiCoreSession(); [articles, bottlings] = await Promise.all([ fetchApiCoreProducts(session), fetchApiCoreBottlingBatches(session), ]); } catch (error: unknown) { redirect(`/admin/produkte?syncError=${encodeURIComponent(describeError(error))}`); } const products = await syncProducts(articles, bottlings); await logAdminAction(admin, { action: "product.sync_apicore", entityType: "Product", detail: `${products.created} neu, ${products.skipped} übersprungen, ${products.failed} fehlgeschlagen, ` + `${products.pending} warten auf den Ernten-Sync, ${products.discontinued} in ApiCore ausgelaufen`, }); revalidatePath("/admin/produkte"); redirect( `/admin/produkte?synced=${products.created}&skipped=${products.skipped}` + `&failed=${products.failed}&pending=${products.pending}&discontinued=${products.discontinued}` ); }