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,
+ }));
+}