diff --git a/.env.example b/.env.example
index 8268a81..fe65236 100644
--- a/.env.example
+++ b/.env.example
@@ -98,12 +98,12 @@ SMTP_FROM="Wabenhain "
# separates Projekt (NestJS-Backend) mit reinem E-Mail/Passwort-Login (kein
# API-Key-Mechanismus) — hier IDEALERWEISE die Zugangsdaten eines
# dedizierten, MFA-freien Service-Kontos eintragen, nicht die eines echten
-# Admin-Zugangs. Benoetigte Berechtigungen, alle nur lesend:
-# harvests:read Ernten
-# products:read Produktkatalog
-# extractions:read Schleuderungen (verbinden Charge und Ernte)
-# bottling:read Abfuellungen = Chargen
-# apiaries:read optional, nur fuer die Standortnamen an den Ernten
+# Admin-Zugangs. Benoetigte Berechtigung (in ApiCore unter Benutzer & Rollen
+# eine eigene Rolle anlegen, die NUR diese eine Berechtigung hat):
+# shop-sync:read freigegebene Shop-Daten (GET /shop-sync) — liefert nur
+# Ernte-Fakten, Artikelstamm und Abfuellungen, keine Notizen
+# Nur fuer aeltere ApiCore-Staende ohne /shop-sync (Rueckfall) noetig:
+# harvests:read, products:read, extractions:read, bottling:read, apiaries:read
# Details siehe src/lib/apicore-client.ts.
APICORE_API_URL=
APICORE_SYNC_EMAIL=
diff --git a/src/app/admin/actions/apicore-sync.ts b/src/app/admin/actions/apicore-sync.ts
index 7b67455..b59014e 100644
--- a/src/app/admin/actions/apicore-sync.ts
+++ b/src/app/admin/actions/apicore-sync.ts
@@ -6,13 +6,11 @@ import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import {
openApiCoreSession,
- fetchApiCoreHarvests,
- fetchApiCoreProducts,
- fetchApiCoreBottlingBatches,
+ fetchApiCoreShopData,
isSellableStatus,
ApiCoreConfigError,
ApiCoreRequestError,
- type ApiCoreSession,
+ type ApiCoreShopData,
type ApiCoreHarvestWithApiaryName,
type ApiCoreProduct,
type ApiCoreBottlingBatchWithHarvestId,
@@ -472,32 +470,23 @@ function returnTargetFrom(formData: FormData): string {
* beim ersten Durchlauf meldete der Produkt-Sync sonst reihenweise "wartet
* auf den Ernten-Sync". Genau diese Stolperfalle faellt damit weg.
*
- * Nebeneffekt: die Abfuellungen werden nur EINMAL von ApiCore geladen statt
- * zweimal — sie werden fuer Chargen und Produkte gleichermassen gebraucht.
- *
- * Ein Fehlschlag beim Laden der Chargen ist bewusst nicht fatal: die bereits
- * uebernommenen Ernten sollen sichtbar bleiben. Ohne Abfuellungen entfallen
- * dann allerdings auch die Produkte, denn ohne sie gibt es keine Jahrgaenge.
+ * Alle Daten kommen aus EINEM Abruf (ApiCore GET /shop-sync) und werden erst
+ * danach verarbeitet: scheitert der Abruf, wird gar nichts geschrieben — es
+ * entsteht nie ein halber Stand aus neuen Ernten und alten Chargen.
*/
export async function syncApiCoreAction(formData: FormData): Promise {
const admin = await requireAdmin();
const returnTo = returnTargetFrom(formData);
- let session: ApiCoreSession;
- let sourceHarvests: ApiCoreHarvestWithApiaryName[];
- let articles: ApiCoreProduct[];
+ let source: ApiCoreShopData;
try {
- session = await openApiCoreSession();
- [sourceHarvests, articles] = await Promise.all([
- fetchApiCoreHarvests(session),
- fetchApiCoreProducts(session),
- ]);
+ source = await fetchApiCoreShopData(await openApiCoreSession());
} catch (error: unknown) {
redirect(`${returnTo}?syncError=${encodeURIComponent(describeError(error))}`);
}
// 1. Ernten
- const harvests = await syncHarvests(sourceHarvests);
+ const harvests = await syncHarvests(source.harvests);
await logAdminAction(admin, {
action: "harvest.sync_apicore",
entityType: "Harvest",
@@ -506,29 +495,18 @@ export async function syncApiCoreAction(formData: FormData): Promise {
`${harvests.protectedDrafts} bearbeitete Entwürfe geschützt, ${harvests.failed} fehlgeschlagen`,
});
- // 2. Chargen — Abfuellungen einmal laden und fuer Schritt 3 weiterreichen.
- let bottlings: ApiCoreBottlingBatchWithHarvestId[] = [];
- let batchLoadError: string | null = null;
- try {
- bottlings = await fetchApiCoreBottlingBatches(session);
- } catch (error: unknown) {
- batchLoadError = describeError(error);
- }
-
- const batches: BatchSyncCounts = batchLoadError
- ? { created: 0, updated: 0, skipped: 0, failed: 0, stockConflicts: 0 }
- : await syncBatches(bottlings);
+ // 2. Chargen
+ const batches = await syncBatches(source.bottlings);
await logAdminAction(admin, {
action: "batch.sync_apicore",
entityType: "Batch",
- detail: batchLoadError
- ? `Chargen konnten nicht geladen werden: ${batchLoadError}`
- : `${batches.created} neu, ${batches.updated} aktualisiert, ${batches.skipped} unverändert, ` +
- `${batches.stockConflicts} Bestandskonflikte, ${batches.failed} fehlgeschlagen`,
+ detail:
+ `${batches.created} neu, ${batches.updated} aktualisiert, ${batches.skipped} unverändert, ` +
+ `${batches.stockConflicts} Bestandskonflikte, ${batches.failed} fehlgeschlagen`,
});
// 3. Produkte — je Abfuellung ein Jahrgang, jetzt mit vorhandenen Chargen.
- const products = await syncProducts(articles, bottlings);
+ const products = await syncProducts(source.products, source.bottlings);
await logAdminAction(admin, {
action: "product.sync_apicore",
entityType: "Product",
@@ -547,7 +525,6 @@ export async function syncApiCoreAction(formData: FormData): Promise {
`&bNew=${batches.created}&bUpdated=${batches.updated}&bSkipped=${batches.skipped}&bFailed=${batches.failed}` +
`&bConflicts=${batches.stockConflicts}` +
`&pNew=${products.created}&pSkipped=${products.skipped}&pFailed=${products.failed}` +
- `&pPending=${products.pending}&pDiscontinued=${products.discontinued}` +
- (batchLoadError ? `&batchLoadError=${encodeURIComponent(batchLoadError)}` : "")
+ `&pPending=${products.pending}&pDiscontinued=${products.discontinued}`
);
}
diff --git a/src/components/apicore-sync-panel.tsx b/src/components/apicore-sync-panel.tsx
index 2c2a036..4eff404 100644
--- a/src/components/apicore-sync-panel.tsx
+++ b/src/components/apicore-sync-panel.tsx
@@ -33,7 +33,6 @@ function text(value: string | string[] | undefined): string | undefined {
export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSearchParams }) {
const t = getDictionary(await getLocale()).adminApiCoreSync;
const syncError = text(searchParams.syncError);
- const batchLoadError = text(searchParams.batchLoadError);
const done = text(searchParams.syncDone) !== undefined;
if (!done && syncError === undefined) return null;
@@ -93,11 +92,6 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe
{t.discontinued(discontinued)}
) : null}
- {batchLoadError !== undefined ? (
-
- {t.batchLoadError} ({batchLoadError})
-
- ) : null}
>
);
}
diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts
index 74a7e07..72a2a45 100644
--- a/src/i18n/dictionaries/de.ts
+++ b/src/i18n/dictionaries/de.ts
@@ -1175,7 +1175,6 @@ export const de: Dictionary = {
lastRun: (when) => `Letzter vollständiger Lauf: ${when}`,
neverRun: "Noch kein vollständiger Lauf.",
error: "Synchronisierung mit ApiCore fehlgeschlagen.",
- batchLoadError: "Chargen konnten nicht geladen werden — ohne sie entstehen auch keine Produkte",
},
adminProductsPage: {
backToDashboard: "Zurück zum Dashboard",
diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts
index e28846f..bc4c4c5 100644
--- a/src/i18n/dictionaries/en.ts
+++ b/src/i18n/dictionaries/en.ts
@@ -1150,7 +1150,6 @@ export const en: Dictionary = {
lastRun: (when) => `Last complete run: ${when}`,
neverRun: "No complete run yet.",
error: "Sync with ApiCore failed.",
- batchLoadError: "Batches could not be loaded — without them no products are created either",
},
adminProductsPage: {
backToDashboard: "Back to dashboard",
diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts
index 1e73078..f1cb020 100644
--- a/src/i18n/dictionary-type.ts
+++ b/src/i18n/dictionary-type.ts
@@ -738,7 +738,6 @@ export interface Dictionary {
lastRun: (when: string) => string;
neverRun: string;
error: string;
- batchLoadError: string;
};
adminProductsPage: {
backToDashboard: string;
diff --git a/src/lib/apicore-client.test.ts b/src/lib/apicore-client.test.ts
index b7d705f..174fa5e 100644
--- a/src/lib/apicore-client.test.ts
+++ b/src/lib/apicore-client.test.ts
@@ -3,6 +3,7 @@ import {
openApiCoreSession,
fetchApiCoreHarvests,
fetchApiCoreBottlingBatches,
+ fetchApiCoreShopData,
ApiCoreRequestError,
type ApiCoreSession,
} from "./apicore-client";
@@ -77,4 +78,86 @@ describe("apicore-client", () => {
// Act & Assert
await expect(fetchApiCoreBottlingBatches(session)).rejects.toBeInstanceOf(ApiCoreRequestError);
});
+
+ describe("fetchApiCoreShopData", () => {
+ const feed = {
+ contractVersion: 1,
+ generatedAt: "2026-09-27T10:00:00.000Z",
+ harvests: [
+ {
+ id: "h1",
+ apiaryId: "a1",
+ apiaryName: "Streuobstwiese",
+ date: "2026-07-14T00:00:00.000Z",
+ honeyType: "Sommertracht",
+ honeySuperCount: null,
+ grossWeight: null,
+ netWeight: 41,
+ // Ein spaeter in ApiCore ergaenztes Feld darf den Sync nicht brechen.
+ futureField: "wird ignoriert",
+ },
+ ],
+ products: [{ id: "p1", name: "Sommertracht 500 g", status: "ACTIVE" }],
+ bottlings: [
+ {
+ id: "b1",
+ lotNumber: "WH-2026-0001",
+ date: "2026-08-01T00:00:00.000Z",
+ bestBeforeDate: "2028-08-01T00:00:00.000Z",
+ jarSize: 500,
+ jarCount: 60,
+ productId: "p1",
+ harvestIds: ["h1", "h2"],
+ },
+ ],
+ };
+
+ test("holt alles aus der Shop-Schnittstelle in einem Abruf", async () => {
+ // Arrange
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(feed));
+ vi.stubGlobal("fetch", fetchMock);
+
+ // Act
+ const data = await fetchApiCoreShopData(session);
+
+ // Assert
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock.mock.calls[0][0]).toBe("http://apicore.test/api/v1/shop-sync");
+ expect(data.harvests[0]).toMatchObject({ id: "h1", apiaryName: "Streuobstwiese", notes: null });
+ expect(data.bottlings[0]).toMatchObject({ id: "b1", harvestSourceId: "h1", jarSize: 500 });
+ expect(data.products[0].id).toBe("p1");
+ });
+
+ test("faellt bei einem aelteren ApiCore ohne Shop-Schnittstelle auf die Einzel-Endpunkte zurueck", async () => {
+ // Arrange
+ const fetchMock = vi.fn(async (url: string) =>
+ url.endsWith("/shop-sync") ? jsonResponse({ message: "Not Found" }, 404) : jsonResponse([])
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ // Act
+ const data = await fetchApiCoreShopData(session);
+
+ // Assert
+ const paths = fetchMock.mock.calls.map((call) => String(call[0]).replace(session.baseUrl, ""));
+ expect(paths).toEqual(expect.arrayContaining(["/harvests", "/products", "/extractions", "/bottling-batches"]));
+ expect(data).toEqual({ harvests: [], products: [], bottlings: [] });
+ });
+
+ test("bricht bei einer unbekannten Vertragsversion ab, statt Daten falsch zu deuten", async () => {
+ // Arrange
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ ...feed, contractVersion: 2 })));
+
+ // Act & Assert
+ await expect(fetchApiCoreShopData(session)).rejects.toThrow(/Schnittstellenversion 2/);
+ });
+
+ test("meldet fehlende Berechtigung, statt eine leere Datenquelle anzunehmen", async () => {
+ // Arrange
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ message: "Forbidden" }, 403)));
+
+ // Act & Assert
+ await expect(fetchApiCoreShopData(session)).rejects.toBeInstanceOf(ApiCoreRequestError);
+ });
+ });
});
diff --git a/src/lib/apicore-client.ts b/src/lib/apicore-client.ts
index 121dec2..78fb385 100644
--- a/src/lib/apicore-client.ts
+++ b/src/lib/apicore-client.ts
@@ -13,9 +13,11 @@ import { z } from "zod";
* interaktiven Login-Flow, optional mit einem zweiten MFA-Schritt
* (POST /auth/mfa/verify), siehe ApiCore-Backend/src/auth/. Fuer diesen
* automatisierten Sync wird dringend ein eigenes, MFA-freies Service-Konto
- * mit ausschliesslich Lese-Berechtigungen empfohlen statt eines echten
- * Admin-Zugangs: "harvests:read", "products:read", "extractions:read",
- * "bottling:read" und optional "apiaries:read" (nur fuer Standortnamen).
+ * mit genau EINER Berechtigung empfohlen statt eines echten Admin-Zugangs:
+ * "shop-sync:read" fuer ApiCores Shop-Schnittstelle (fetchApiCoreShopData,
+ * ganz unten). Nur aeltere ApiCore-Staende ohne diese Schnittstelle brauchen
+ * noch "harvests:read", "products:read", "extractions:read", "bottling:read"
+ * und optional "apiaries:read".
*
* Benoetigte Umgebungsvariablen (siehe .env.example):
* APICORE_API_URL z.B. "http://localhost:3001/api" (globaler Prefix
@@ -123,21 +125,27 @@ export async function openApiCoreSession(): Promise {
* URL, eine fehlende Berechtigung des Service-Kontos, und eine geaenderte
* ApiCore-Version.
*/
-async function getJson(
- session: ApiCoreSession,
- path: string,
- schema: z.ZodType,
- label: string
-): Promise {
- let res: Response;
+async function fetchFromApiCore(session: ApiCoreSession, path: string): Promise {
try {
- res = await fetch(`${session.baseUrl}${path}`, {
+ return await fetch(`${session.baseUrl}${path}`, {
headers: { Authorization: `Bearer ${session.token}` },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (error: unknown) {
throw new ApiCoreRequestError(`ApiCore unter ${session.baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`);
}
+}
+
+async function getJson(
+ session: ApiCoreSession,
+ path: string,
+ schema: z.ZodType,
+ label: string
+): Promise {
+ return parseResponse(await fetchFromApiCore(session, path), schema, label);
+}
+
+async function parseResponse(res: Response, schema: z.ZodType, label: string): Promise {
if (!res.ok) {
throw new ApiCoreRequestError(
res.status === 403
@@ -282,7 +290,19 @@ const bottlingBatchSchema = z.object({
export type ApiCoreBottlingBatch = z.infer;
-export interface ApiCoreBottlingBatchWithHarvestId extends ApiCoreBottlingBatch {
+/**
+ * Eine Abfuellung, wie der Sync sie braucht — unabhaengig davon, ob sie aus
+ * der Shop-Schnittstelle (fetchApiCoreShopData) oder den aelteren
+ * Einzel-Endpunkten stammt.
+ */
+export interface ApiCoreBottlingBatchWithHarvestId {
+ id: string;
+ lotNumber: string;
+ date: string;
+ bestBeforeDate: string;
+ jarCount: number;
+ jarSize?: number | null;
+ productId?: string | null;
/**
* ApiCore-ID der ERSTEN mit der Schleuderung verknuepften Ernte, oder null
* wenn die Schleuderung (noch) keiner Ernte zugeordnet ist. Eine
@@ -312,3 +332,91 @@ export async function fetchApiCoreBottlingBatches(
harvestSourceId: firstHarvestByExtraction.get(batch.honeyBatch.extractionId) ?? null,
}));
}
+
+// ---------------------------------------------------------------------------
+// Shop-Schnittstelle (ApiCore GET /shop-sync)
+// ---------------------------------------------------------------------------
+
+/**
+ * Version des Datenvertrags, die dieser Wabenhain-Stand versteht. ApiCore
+ * erhoeht sie nur bei inkompatiblen Aenderungen; neue Felder kommen ohne
+ * Versionswechsel dazu und werden hier ignoriert.
+ */
+export const SUPPORTED_SHOP_SYNC_CONTRACT = 1;
+
+const shopFeedSchema = z.object({
+ contractVersion: z.literal(SUPPORTED_SHOP_SYNC_CONTRACT),
+ harvests: z.array(
+ z.object({
+ id: z.string(),
+ apiaryId: z.string(),
+ apiaryName: z.string(),
+ date: z.string(),
+ honeyType: z.string().nullable(),
+ honeySuperCount: z.number().nullable(),
+ grossWeight: z.number().nullable(),
+ netWeight: z.number().nullable(),
+ })
+ ),
+ products: z.array(productSchema),
+ bottlings: z.array(
+ z.object({
+ id: z.string(),
+ lotNumber: z.string(),
+ date: z.string(),
+ bestBeforeDate: z.string(),
+ jarSize: z.number().nullable(),
+ jarCount: z.number(),
+ productId: z.string().nullable(),
+ harvestIds: z.array(z.string()),
+ })
+ ),
+});
+
+export interface ApiCoreShopData {
+ harvests: ApiCoreHarvestWithApiaryName[];
+ products: ApiCoreProduct[];
+ bottlings: ApiCoreBottlingBatchWithHarvestId[];
+}
+
+/**
+ * Alles, was der Sync braucht, in EINEM konsistenten Abruf — aus ApiCores
+ * eigener Shop-Schnittstelle. Die liefert nur freigegebene Felder (keine
+ * Notizen) und braucht nur die Berechtigung "shop-sync:read".
+ *
+ * Kennt ApiCore die Schnittstelle noch nicht (HTTP 404, aelterer Stand),
+ * faellt der Sync auf die bisherigen Einzel-Endpunkte zurueck. So koennen
+ * beide Systeme unabhaengig voneinander aktualisiert werden.
+ */
+export async function fetchApiCoreShopData(session: ApiCoreSession): Promise {
+ const res = await fetchFromApiCore(session, "/shop-sync");
+ if (res.status === 404) {
+ const [harvests, products, bottlings] = await Promise.all([
+ fetchApiCoreHarvests(session),
+ fetchApiCoreProducts(session),
+ fetchApiCoreBottlingBatches(session),
+ ]);
+ return { harvests, products, bottlings };
+ }
+
+ const body = await parseResponse(res, z.unknown(), "Shop-Daten");
+ const version = z.object({ contractVersion: z.number() }).safeParse(body);
+ if (version.success && version.data.contractVersion !== SUPPORTED_SHOP_SYNC_CONTRACT) {
+ throw new ApiCoreRequestError(
+ `ApiCore liefert die Schnittstellenversion ${version.data.contractVersion}, dieser Wabenhain-Stand versteht Version ${SUPPORTED_SHOP_SYNC_CONTRACT}. Bitte Wabenhain aktualisieren — es wurde nichts geändert.`
+ );
+ }
+ const parsed = shopFeedSchema.safeParse(body);
+ if (!parsed.success) {
+ throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Shop-Daten.");
+ }
+
+ return {
+ harvests: parsed.data.harvests.map((harvest) => ({ ...harvest, notes: null })),
+ products: parsed.data.products,
+ bottlings: parsed.data.bottlings.map(({ harvestIds, ...bottling }) => ({
+ ...bottling,
+ harvestSourceId: harvestIds[0] ?? null,
+ })),
+ };
+}