diff --git a/.env.example b/.env.example index 2d5569e..2daf508 100644 --- a/.env.example +++ b/.env.example @@ -87,8 +87,10 @@ SMTP_FROM="Wabenhain " # Projekt (NestJS-Backend) mit reinem E-Mail/Passwort-Login (kein API-Key- # Mechanismus) — hier IDEALERWEISE die Zugangsdaten eines dedizierten, # MFA-freien Service-Kontos mit ausschliesslich den Berechtigungen -# "harvests:read" (und optional "apiaries:read") eintragen, nicht die eines -# echten Admin-Zugangs. Details siehe src/lib/apicore-client.ts. +# "harvests:read", "apiaries:read" (optional, fuer Standortnamen), +# "extractions:read" und "bottling:read" (beide fuer den Chargen-Sync) +# eintragen, nicht die eines echten Admin-Zugangs. Details siehe +# src/lib/apicore-client.ts. APICORE_API_URL= APICORE_SYNC_EMAIL= APICORE_SYNC_PASSWORD= diff --git a/src/app/admin/actions/apicore-sync.ts b/src/app/admin/actions/apicore-sync.ts index 5813f2f..df75813 100644 --- a/src/app/admin/actions/apicore-sync.ts +++ b/src/app/admin/actions/apicore-sync.ts @@ -6,9 +6,11 @@ import { prisma } from "@/lib/prisma"; import { logAdminAction } from "@/lib/audit-log"; import { fetchApiCoreHarvests, + fetchApiCoreBottlingBatches, ApiCoreConfigError, ApiCoreRequestError, type ApiCoreHarvestWithApiaryName, + type ApiCoreBottlingBatchWithHarvestId, } from "@/lib/apicore-client"; import { requireAdmin } from "./_shared"; @@ -39,12 +41,27 @@ function buildDescription(source: ApiCoreHarvestWithApiaryName): string { } /** - * Zieht Ernten aus ApiCore und legt sie als unveroeffentlichte Entwuerfe an - * (isPublished: false) bzw. aktualisiert bereits vorhandene, noch - * unveroeffentlichte Entwuerfe. Bereits vom Admin veroeffentlichte, - * frueher synchronisierte Ernten werden NICHT angefasst — sonst wuerde ein - * erneuter Sync eine bewusste Freigabe- und Bearbeitungsentscheidung des - * Admins stillschweigend zuruecksetzen. + * Zieht Ernten UND Chargen aus ApiCore. + * + * Ernten werden als unveroeffentlichte Entwuerfe angelegt (isPublished: + * false) bzw. bereits vorhandene, noch unveroeffentlichte Entwuerfe + * aktualisiert. Bereits vom Admin veroeffentlichte, frueher synchronisierte + * Ernten werden NICHT angefasst — sonst wuerde ein erneuter Sync eine + * bewusste Freigabe- und Bearbeitungsentscheidung des Admins stillschweigend + * zuruecksetzen. + * + * Chargen (Wabenhain: Batch, ApiCore: BottlingBatch — siehe Kommentar in + * lib/apicore-client.ts fuer die Modul-Zuordnung) werden dagegen NUR neu + * angelegt, nie aktualisiert: anders als Harvest hat Batch kein + * isPublished-Feld, das eine bereits erfolgte Admin-Bearbeitung markieren + * koennte. Ein Update wuerde insbesondere remainingGlasses ueberschreiben — + * das ist Wabenhains EIGENER, durch echte Verkaeufe veraenderter + * Lagerbestand und darf niemals durch einen ApiCore-Sync zurueckgesetzt + * werden. Existiert eine Charge (per code) bereits, wird sie uebersprungen. + * Jede Charge braucht eine bereits in Wabenhain vorhandene (synchronisierte + * oder manuell angelegte) Ernte als harvestId — Chargen laufen deshalb nach + * den Ernten in diesem Lauf, damit gerade frisch angelegte Ernten schon + * verfuegbar sind. * * Admin-Berechtigungsfeld "type" bei Wabenhain (Erntesaison, z.B. * "Sommertracht") und ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig") @@ -122,6 +139,87 @@ export async function syncHarvestsFromApiCoreAction(): Promise { detail: `${created} neu, ${updated} aktualisiert, ${skipped} übersprungen, ${failed} fehlgeschlagen`, }); + // Chargen-Sync laeuft NACH den Ernten (s.o., Doc-Kommentar) und ist + // bewusst nicht fatal fuer den gesamten Aufruf: schlaegt nur das Laden der + // Chargen fehl, sollen die bereits erfolgreich synchronisierten Ernten + // trotzdem sichtbar bleiben statt durch einen Redirect mit Fehlerseite + // verloren zu gehen. + let batchCreated = 0; + let batchSkipped = 0; + let batchFailed = 0; + let batchLoadError: string | null = null; + + let sourceBatches: ApiCoreBottlingBatchWithHarvestId[] = []; + try { + sourceBatches = await fetchApiCoreBottlingBatches(); + } catch (error: unknown) { + batchLoadError = + error instanceof ApiCoreConfigError || error instanceof ApiCoreRequestError + ? error.message + : error instanceof Error + ? error.message + : "Unbekannter Fehler"; + } + + for (const source of sourceBatches) { + if (!source.harvestSourceId) { + batchFailed++; + 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())) { + batchFailed++; + 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) { + batchSkipped++; + continue; + } + const harvest = await prisma.harvest.findUnique({ where: { slug: slugFor(source.harvestSourceId) } }); + if (!harvest) { + batchFailed++; + console.error( + `[apicore-sync] Charge ${source.lotNumber}: zugehörige Ernte (ApiCore-ID ${source.harvestSourceId}) ist in Wabenhain noch nicht synchronisiert — übersprungen.` + ); + continue; + } + + await prisma.batch.create({ + data: { + code: source.lotNumber, + harvestId: harvest.id, + fillDate, + bestBeforeDate, + glassCount: Math.max(Math.round(source.jarCount), 0), + remainingGlasses: Math.max(Math.round(source.jarCount), 0), + }, + }); + batchCreated++; + } catch (error: unknown) { + batchFailed++; + const message = error instanceof Error ? error.message : "Unbekannter Fehler"; + console.error(`[apicore-sync] Charge ${source.lotNumber} konnte nicht importiert werden: ${message}`); + } + } + + await logAdminAction(admin, { + action: "batch.sync_apicore", + entityType: "Batch", + detail: batchLoadError + ? `Chargen konnten nicht geladen werden: ${batchLoadError}` + : `${batchCreated} neu, ${batchSkipped} übersprungen, ${batchFailed} fehlgeschlagen`, + }); + revalidatePath("/admin/ernten"); - redirect(`/admin/ernten?synced=${created}&updated=${updated}&skipped=${skipped}&failed=${failed}`); + redirect( + `/admin/ernten?synced=${created}&updated=${updated}&skipped=${skipped}&failed=${failed}` + + `&batchSynced=${batchCreated}&batchSkipped=${batchSkipped}&batchFailed=${batchFailed}` + + (batchLoadError ? `&batchLoadError=${encodeURIComponent(batchLoadError)}` : "") + ); } diff --git a/src/app/admin/ernten/page.tsx b/src/app/admin/ernten/page.tsx index 1a6b2dc..de439d3 100644 --- a/src/app/admin/ernten/page.tsx +++ b/src/app/admin/ernten/page.tsx @@ -8,7 +8,8 @@ import { getDictionary } from "@/i18n/get-dictionary"; export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten">) { const t = getDictionary(await getLocale()); - const { synced, updated, skipped, failed, syncError } = await props.searchParams; + const { synced, updated, skipped, failed, syncError, batchSynced, batchSkipped, batchFailed, batchLoadError } = + await props.searchParams; const harvests = await prisma.harvest.findMany({ orderBy: [{ year: "desc" }, { harvestDate: "desc" }], include: { _count: { select: { batches: true, products: true } } }, @@ -29,6 +30,16 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten" {t.adminHarvestsPage.syncError} ({syncError})

) : null} + {batchSynced !== undefined ? ( +

+ {t.adminHarvestsPage.batchSyncResult(Number(batchSynced), Number(batchSkipped ?? 0), Number(batchFailed ?? 0))} +

+ ) : null} + {batchLoadError !== undefined ? ( +

+ {t.adminHarvestsPage.batchSyncError} ({batchLoadError}) +

+ ) : null}
diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts index d9cf0f6..0b835a8 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -897,11 +897,14 @@ export const de: Dictionary = { create: "Anlegen", syncHeading: "ApiCore-Synchronisierung", syncIntro: - "Importiert Ernten aus ApiCore als Entwürfe (nicht öffentlich sichtbar). Bereits veröffentlichte, zuvor synchronisierte Ernten werden dabei nicht verändert — nur neue oder noch unveröffentlichte Entwürfe werden aktualisiert.", + "Importiert Ernten UND Chargen aus ApiCore. Ernten werden als Entwürfe angelegt (nicht öffentlich sichtbar) — bereits veröffentlichte, zuvor synchronisierte Ernten werden dabei nicht verändert. Chargen werden nur neu angelegt (nie überschrieben, damit echte Lagerbestände erhalten bleiben) und brauchen eine bereits vorhandene Ernte.", syncButton: "Jetzt synchronisieren", syncResult: (created, updated, skipped, failed) => - `Synchronisierung abgeschlossen: ${created} neu angelegt, ${updated} aktualisiert, ${skipped} übersprungen (bereits veröffentlicht)${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`, + `Ernten: ${created} neu angelegt, ${updated} aktualisiert, ${skipped} übersprungen (bereits veröffentlicht)${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`, syncError: "Synchronisierung mit ApiCore fehlgeschlagen.", + batchSyncResult: (created, skipped, failed) => + `Chargen: ${created} neu angelegt, ${skipped} übersprungen (bereits vorhanden)${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`, + batchSyncError: "Chargen konnten nicht von ApiCore geladen werden", }, adminHarvestEditPage: { backToHarvests: "Zurück zu Ernten", diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index 1634882..96bbe4b 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -897,11 +897,14 @@ export const en: Dictionary = { create: "Create", syncHeading: "ApiCore sync", syncIntro: - "Imports harvests from ApiCore as drafts (not publicly visible). Previously synced harvests that are already published are left untouched — only new or still-unpublished drafts are updated.", + "Imports harvests AND batches from ApiCore. Harvests are created as drafts (not publicly visible) — previously synced harvests that are already published are left untouched. Batches are only ever created, never updated (so real stock levels are never overwritten), and need an already-existing harvest.", syncButton: "Sync now", syncResult: (created, updated, skipped, failed) => - `Sync complete: ${created} created, ${updated} updated, ${skipped} skipped (already published)${failed > 0 ? `, ${failed} failed` : ""}.`, + `Harvests: ${created} created, ${updated} updated, ${skipped} skipped (already published)${failed > 0 ? `, ${failed} failed` : ""}.`, syncError: "Sync with ApiCore failed.", + batchSyncResult: (created, skipped, failed) => + `Batches: ${created} created, ${skipped} skipped (already exist)${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`, + batchSyncError: "Batches could not be loaded from ApiCore", }, adminHarvestEditPage: { backToHarvests: "Back to harvests", diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts index 82a46e4..d02f7cb 100644 --- a/src/i18n/dictionary-type.ts +++ b/src/i18n/dictionary-type.ts @@ -603,6 +603,9 @@ export interface Dictionary { /** created, updated, skipped, failed */ syncResult: (created: number, updated: number, skipped: number, failed: number) => string; syncError: string; + /** created, skipped, failed */ + batchSyncResult: (created: number, skipped: number, failed: number) => string; + batchSyncError: string; }; adminHarvestEditPage: { backToHarvests: string; diff --git a/src/lib/apicore-client.ts b/src/lib/apicore-client.ts index ba636ab..99d08b2 100644 --- a/src/lib/apicore-client.ts +++ b/src/lib/apicore-client.ts @@ -23,9 +23,22 @@ import { z } from "zod"; * APICORE_SYNC_EMAIL E-Mail des Service-Kontos * APICORE_SYNC_PASSWORD Passwort des Service-Kontos * - * Ohne diese drei Variablen wirft fetchApiCoreHarvests() sofort einen - * ApiCoreConfigError — der Aufrufer (apicore-sync.ts) zeigt dessen Message - * dem Admin an, statt die Aktion stillschweigend nichts tun zu lassen. + * Ohne diese drei Variablen wirft fetchApiCoreHarvests()/ + * fetchApiCoreBottlingBatches() sofort einen ApiCoreConfigError — der + * Aufrufer (apicore-sync.ts) zeigt dessen Message dem Admin an, statt die + * Aktion stillschweigend nichts tun zu lassen. + * + * Fuer Wabenhains "Charge" (prisma/schema.prisma: model Batch) ist NICHT + * ApiCores "HoneyBatch" (roher Rohhonig direkt nach der Schleuderung, vor + * dem Abfuellen) das fachlich richtige Gegenstueck, sondern "BottlingBatch" + * (Schleuderung -> HoneyBatch -> Abfuellung -> BottlingBatch) — dessen + * Felder passen fast 1:1: lotNumber/code, date/fillDate, + * bestBeforeDate/bestBeforeDate, jarCount/glassCount. Die zugehoerige Ernte + * ergibt sich erst ueber zwei Stufen (BottlingBatch.honeyBatch.extractionId + * -> Extraction.harvests[].harvestId), daher werden zusaetzlich alle + * Schleuderungen geladen (GET /extractions) und clientseitig verknuepft. + * Das erfordert zusaetzlich die Berechtigungen "extractions:read" und + * "bottling:read" fuer das Sync-Service-Konto. */ export class ApiCoreConfigError extends Error {} @@ -132,3 +145,84 @@ export async function fetchApiCoreHarvests(): Promise; + +export interface ApiCoreBottlingBatchWithHarvestId extends ApiCoreBottlingBatch { + /** + * ApiCore-ID der ERSTEN mit der Schleuderung verknuepften Ernte, oder null + * wenn die Schleuderung (noch) keiner Ernte zugeordnet ist. Eine + * Schleuderung kann fachlich mehrere Ernten buendeln (ExtractionHarvest + * ist many-to-many) — Wabenhains Batch.harvestId ist dagegen ein + * einzelner Pflicht-Fremdschluessel, daher bewusst nur die erste + * verknuepfte Ernte. Admin kann das beim Review in Wabenhain jederzeit + * auf die tatsaechlich passende Ernte korrigieren. + */ + harvestSourceId: string | null; +} + +export async function fetchApiCoreBottlingBatches(): Promise { + const { baseUrl, email, password } = getConfig(); + const token = await login(baseUrl, email, password); + const authHeader = { Authorization: `Bearer ${token}` }; + + let extractionsRes: Response; + let batchesRes: Response; + try { + [extractionsRes, batchesRes] = await Promise.all([ + fetch(`${baseUrl}/extractions`, { headers: authHeader }), + fetch(`${baseUrl}/bottling-batches`, { headers: authHeader }), + ]); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unbekannter Fehler"; + throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${message}`); + } + if (!extractionsRes.ok) { + throw new ApiCoreRequestError(`ApiCore: Schleuderungen konnten nicht geladen werden (HTTP ${extractionsRes.status}).`); + } + if (!batchesRes.ok) { + throw new ApiCoreRequestError(`ApiCore: Chargen konnten nicht geladen werden (HTTP ${batchesRes.status}).`); + } + + const parsedExtractions = z.array(extractionSchema).safeParse(await extractionsRes.json()); + if (!parsedExtractions.success) { + throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Schleuderungen."); + } + const parsedBatches = z.array(bottlingBatchSchema).safeParse(await batchesRes.json()); + if (!parsedBatches.success) { + throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Chargen."); + } + + const firstHarvestByExtraction = new Map( + parsedExtractions.data.map((extraction) => [extraction.id, extraction.harvests[0]?.harvestId ?? null]) + ); + + return parsedBatches.data.map((batch) => ({ + ...batch, + harvestSourceId: firstHarvestByExtraction.get(batch.honeyBatch.extractionId) ?? null, + })); +}