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