feat(apicore-sync): read from ApiCore's dedicated shop feed
The sync now fetches everything from ApiCore's GET /shop-sync in one call: an allowlisted feed (harvest facts, article master data, bottlings) that never contains internal notes and needs only the single permission "shop-sync:read" instead of read access to harvests, extractions etc. - Contract version is checked; an unknown version stops the run with a clear message and writes nothing. Extra fields are ignored. - One consistent snapshot, processed only after it arrived completely: a failed fetch writes nothing (no more half state of new harvests and old batches); the batch-load-error path is gone. - Falls back to the previous per-resource endpoints when ApiCore answers 404, so both packages can be deployed independently. - .env.example documents the reduced permission. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
d38f4303ff
commit
5642f0c4af
8 files changed
+224
-65
No files matched your search
+6
-6
@@ -98,12 +98,12 @@ SMTP_FROM="Wabenhain <[email protected]>"
|
|||||||
# separates Projekt (NestJS-Backend) mit reinem E-Mail/Passwort-Login (kein
|
# separates Projekt (NestJS-Backend) mit reinem E-Mail/Passwort-Login (kein
|
||||||
# API-Key-Mechanismus) — hier IDEALERWEISE die Zugangsdaten eines
|
# API-Key-Mechanismus) — hier IDEALERWEISE die Zugangsdaten eines
|
||||||
# dedizierten, MFA-freien Service-Kontos eintragen, nicht die eines echten
|
# dedizierten, MFA-freien Service-Kontos eintragen, nicht die eines echten
|
||||||
# Admin-Zugangs. Benoetigte Berechtigungen, alle nur lesend:
|
# Admin-Zugangs. Benoetigte Berechtigung (in ApiCore unter Benutzer & Rollen
|
||||||
# harvests:read Ernten
|
# eine eigene Rolle anlegen, die NUR diese eine Berechtigung hat):
|
||||||
# products:read Produktkatalog
|
# shop-sync:read freigegebene Shop-Daten (GET /shop-sync) — liefert nur
|
||||||
# extractions:read Schleuderungen (verbinden Charge und Ernte)
|
# Ernte-Fakten, Artikelstamm und Abfuellungen, keine Notizen
|
||||||
# bottling:read Abfuellungen = Chargen
|
# Nur fuer aeltere ApiCore-Staende ohne /shop-sync (Rueckfall) noetig:
|
||||||
# apiaries:read optional, nur fuer die Standortnamen an den Ernten
|
# harvests:read, products:read, extractions:read, bottling:read, apiaries:read
|
||||||
# Details siehe src/lib/apicore-client.ts.
|
# Details siehe src/lib/apicore-client.ts.
|
||||||
APICORE_API_URL=
|
APICORE_API_URL=
|
||||||
APICORE_SYNC_EMAIL=
|
APICORE_SYNC_EMAIL=
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import {
|
import {
|
||||||
openApiCoreSession,
|
openApiCoreSession,
|
||||||
fetchApiCoreHarvests,
|
fetchApiCoreShopData,
|
||||||
fetchApiCoreProducts,
|
|
||||||
fetchApiCoreBottlingBatches,
|
|
||||||
isSellableStatus,
|
isSellableStatus,
|
||||||
ApiCoreConfigError,
|
ApiCoreConfigError,
|
||||||
ApiCoreRequestError,
|
ApiCoreRequestError,
|
||||||
type ApiCoreSession,
|
type ApiCoreShopData,
|
||||||
type ApiCoreHarvestWithApiaryName,
|
type ApiCoreHarvestWithApiaryName,
|
||||||
type ApiCoreProduct,
|
type ApiCoreProduct,
|
||||||
type ApiCoreBottlingBatchWithHarvestId,
|
type ApiCoreBottlingBatchWithHarvestId,
|
||||||
@@ -472,32 +470,23 @@ function returnTargetFrom(formData: FormData): string {
|
|||||||
* beim ersten Durchlauf meldete der Produkt-Sync sonst reihenweise "wartet
|
* beim ersten Durchlauf meldete der Produkt-Sync sonst reihenweise "wartet
|
||||||
* auf den Ernten-Sync". Genau diese Stolperfalle faellt damit weg.
|
* auf den Ernten-Sync". Genau diese Stolperfalle faellt damit weg.
|
||||||
*
|
*
|
||||||
* Nebeneffekt: die Abfuellungen werden nur EINMAL von ApiCore geladen statt
|
* Alle Daten kommen aus EINEM Abruf (ApiCore GET /shop-sync) und werden erst
|
||||||
* zweimal — sie werden fuer Chargen und Produkte gleichermassen gebraucht.
|
* danach verarbeitet: scheitert der Abruf, wird gar nichts geschrieben — es
|
||||||
*
|
* entsteht nie ein halber Stand aus neuen Ernten und alten Chargen.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
const returnTo = returnTargetFrom(formData);
|
const returnTo = returnTargetFrom(formData);
|
||||||
|
|
||||||
let session: ApiCoreSession;
|
let source: ApiCoreShopData;
|
||||||
let sourceHarvests: ApiCoreHarvestWithApiaryName[];
|
|
||||||
let articles: ApiCoreProduct[];
|
|
||||||
try {
|
try {
|
||||||
session = await openApiCoreSession();
|
source = await fetchApiCoreShopData(await openApiCoreSession());
|
||||||
[sourceHarvests, articles] = await Promise.all([
|
|
||||||
fetchApiCoreHarvests(session),
|
|
||||||
fetchApiCoreProducts(session),
|
|
||||||
]);
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
redirect(`${returnTo}?syncError=${encodeURIComponent(describeError(error))}`);
|
redirect(`${returnTo}?syncError=${encodeURIComponent(describeError(error))}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Ernten
|
// 1. Ernten
|
||||||
const harvests = await syncHarvests(sourceHarvests);
|
const harvests = await syncHarvests(source.harvests);
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "harvest.sync_apicore",
|
action: "harvest.sync_apicore",
|
||||||
entityType: "Harvest",
|
entityType: "Harvest",
|
||||||
@@ -506,29 +495,18 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
|||||||
`${harvests.protectedDrafts} bearbeitete Entwürfe geschützt, ${harvests.failed} fehlgeschlagen`,
|
`${harvests.protectedDrafts} bearbeitete Entwürfe geschützt, ${harvests.failed} fehlgeschlagen`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Chargen — Abfuellungen einmal laden und fuer Schritt 3 weiterreichen.
|
// 2. Chargen
|
||||||
let bottlings: ApiCoreBottlingBatchWithHarvestId[] = [];
|
const batches = await syncBatches(source.bottlings);
|
||||||
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);
|
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "batch.sync_apicore",
|
action: "batch.sync_apicore",
|
||||||
entityType: "Batch",
|
entityType: "Batch",
|
||||||
detail: batchLoadError
|
detail:
|
||||||
? `Chargen konnten nicht geladen werden: ${batchLoadError}`
|
`${batches.created} neu, ${batches.updated} aktualisiert, ${batches.skipped} unverändert, ` +
|
||||||
: `${batches.created} neu, ${batches.updated} aktualisiert, ${batches.skipped} unverändert, ` +
|
`${batches.stockConflicts} Bestandskonflikte, ${batches.failed} fehlgeschlagen`,
|
||||||
`${batches.stockConflicts} Bestandskonflikte, ${batches.failed} fehlgeschlagen`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Produkte — je Abfuellung ein Jahrgang, jetzt mit vorhandenen Chargen.
|
// 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, {
|
await logAdminAction(admin, {
|
||||||
action: "product.sync_apicore",
|
action: "product.sync_apicore",
|
||||||
entityType: "Product",
|
entityType: "Product",
|
||||||
@@ -547,7 +525,6 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
|||||||
`&bNew=${batches.created}&bUpdated=${batches.updated}&bSkipped=${batches.skipped}&bFailed=${batches.failed}` +
|
`&bNew=${batches.created}&bUpdated=${batches.updated}&bSkipped=${batches.skipped}&bFailed=${batches.failed}` +
|
||||||
`&bConflicts=${batches.stockConflicts}` +
|
`&bConflicts=${batches.stockConflicts}` +
|
||||||
`&pNew=${products.created}&pSkipped=${products.skipped}&pFailed=${products.failed}` +
|
`&pNew=${products.created}&pSkipped=${products.skipped}&pFailed=${products.failed}` +
|
||||||
`&pPending=${products.pending}&pDiscontinued=${products.discontinued}` +
|
`&pPending=${products.pending}&pDiscontinued=${products.discontinued}`
|
||||||
(batchLoadError ? `&batchLoadError=${encodeURIComponent(batchLoadError)}` : "")
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,6 @@ function text(value: string | string[] | undefined): string | undefined {
|
|||||||
export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSearchParams }) {
|
export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSearchParams }) {
|
||||||
const t = getDictionary(await getLocale()).adminApiCoreSync;
|
const t = getDictionary(await getLocale()).adminApiCoreSync;
|
||||||
const syncError = text(searchParams.syncError);
|
const syncError = text(searchParams.syncError);
|
||||||
const batchLoadError = text(searchParams.batchLoadError);
|
|
||||||
const done = text(searchParams.syncDone) !== undefined;
|
const done = text(searchParams.syncDone) !== undefined;
|
||||||
|
|
||||||
if (!done && syncError === undefined) return null;
|
if (!done && syncError === undefined) return null;
|
||||||
@@ -93,11 +92,6 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe
|
|||||||
{t.discontinued(discontinued)}
|
{t.discontinued(discontinued)}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{batchLoadError !== undefined ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
|
||||||
{t.batchLoadError} ({batchLoadError})
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1175,7 +1175,6 @@ export const de: Dictionary = {
|
|||||||
lastRun: (when) => `Letzter vollständiger Lauf: ${when}`,
|
lastRun: (when) => `Letzter vollständiger Lauf: ${when}`,
|
||||||
neverRun: "Noch kein vollständiger Lauf.",
|
neverRun: "Noch kein vollständiger Lauf.",
|
||||||
error: "Synchronisierung mit ApiCore fehlgeschlagen.",
|
error: "Synchronisierung mit ApiCore fehlgeschlagen.",
|
||||||
batchLoadError: "Chargen konnten nicht geladen werden — ohne sie entstehen auch keine Produkte",
|
|
||||||
},
|
},
|
||||||
adminProductsPage: {
|
adminProductsPage: {
|
||||||
backToDashboard: "Zurück zum Dashboard",
|
backToDashboard: "Zurück zum Dashboard",
|
||||||
|
|||||||
@@ -1150,7 +1150,6 @@ export const en: Dictionary = {
|
|||||||
lastRun: (when) => `Last complete run: ${when}`,
|
lastRun: (when) => `Last complete run: ${when}`,
|
||||||
neverRun: "No complete run yet.",
|
neverRun: "No complete run yet.",
|
||||||
error: "Sync with ApiCore failed.",
|
error: "Sync with ApiCore failed.",
|
||||||
batchLoadError: "Batches could not be loaded — without them no products are created either",
|
|
||||||
},
|
},
|
||||||
adminProductsPage: {
|
adminProductsPage: {
|
||||||
backToDashboard: "Back to dashboard",
|
backToDashboard: "Back to dashboard",
|
||||||
|
|||||||
@@ -738,7 +738,6 @@ export interface Dictionary {
|
|||||||
lastRun: (when: string) => string;
|
lastRun: (when: string) => string;
|
||||||
neverRun: string;
|
neverRun: string;
|
||||||
error: string;
|
error: string;
|
||||||
batchLoadError: string;
|
|
||||||
};
|
};
|
||||||
adminProductsPage: {
|
adminProductsPage: {
|
||||||
backToDashboard: string;
|
backToDashboard: string;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
openApiCoreSession,
|
openApiCoreSession,
|
||||||
fetchApiCoreHarvests,
|
fetchApiCoreHarvests,
|
||||||
fetchApiCoreBottlingBatches,
|
fetchApiCoreBottlingBatches,
|
||||||
|
fetchApiCoreShopData,
|
||||||
ApiCoreRequestError,
|
ApiCoreRequestError,
|
||||||
type ApiCoreSession,
|
type ApiCoreSession,
|
||||||
} from "./apicore-client";
|
} from "./apicore-client";
|
||||||
@@ -77,4 +78,86 @@ describe("apicore-client", () => {
|
|||||||
// Act & Assert
|
// Act & Assert
|
||||||
await expect(fetchApiCoreBottlingBatches(session)).rejects.toBeInstanceOf(ApiCoreRequestError);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+120
-12
@@ -13,9 +13,11 @@ import { z } from "zod";
|
|||||||
* interaktiven Login-Flow, optional mit einem zweiten MFA-Schritt
|
* interaktiven Login-Flow, optional mit einem zweiten MFA-Schritt
|
||||||
* (POST /auth/mfa/verify), siehe ApiCore-Backend/src/auth/. Fuer diesen
|
* (POST /auth/mfa/verify), siehe ApiCore-Backend/src/auth/. Fuer diesen
|
||||||
* automatisierten Sync wird dringend ein eigenes, MFA-freies Service-Konto
|
* automatisierten Sync wird dringend ein eigenes, MFA-freies Service-Konto
|
||||||
* mit ausschliesslich Lese-Berechtigungen empfohlen statt eines echten
|
* mit genau EINER Berechtigung empfohlen statt eines echten Admin-Zugangs:
|
||||||
* Admin-Zugangs: "harvests:read", "products:read", "extractions:read",
|
* "shop-sync:read" fuer ApiCores Shop-Schnittstelle (fetchApiCoreShopData,
|
||||||
* "bottling:read" und optional "apiaries:read" (nur fuer Standortnamen).
|
* 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):
|
* Benoetigte Umgebungsvariablen (siehe .env.example):
|
||||||
* APICORE_API_URL z.B. "http://localhost:3001/api" (globaler Prefix
|
* APICORE_API_URL z.B. "http://localhost:3001/api" (globaler Prefix
|
||||||
@@ -123,21 +125,27 @@ export async function openApiCoreSession(): Promise<ApiCoreSession> {
|
|||||||
* URL, eine fehlende Berechtigung des Service-Kontos, und eine geaenderte
|
* URL, eine fehlende Berechtigung des Service-Kontos, und eine geaenderte
|
||||||
* ApiCore-Version.
|
* ApiCore-Version.
|
||||||
*/
|
*/
|
||||||
async function getJson<T>(
|
async function fetchFromApiCore(session: ApiCoreSession, path: string): Promise<Response> {
|
||||||
session: ApiCoreSession,
|
|
||||||
path: string,
|
|
||||||
schema: z.ZodType<T>,
|
|
||||||
label: string
|
|
||||||
): Promise<T> {
|
|
||||||
let res: Response;
|
|
||||||
try {
|
try {
|
||||||
res = await fetch(`${session.baseUrl}${path}`, {
|
return await fetch(`${session.baseUrl}${path}`, {
|
||||||
headers: { Authorization: `Bearer ${session.token}` },
|
headers: { Authorization: `Bearer ${session.token}` },
|
||||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
throw new ApiCoreRequestError(`ApiCore unter ${session.baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`);
|
throw new ApiCoreRequestError(`ApiCore unter ${session.baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJson<T>(
|
||||||
|
session: ApiCoreSession,
|
||||||
|
path: string,
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
label: string
|
||||||
|
): Promise<T> {
|
||||||
|
return parseResponse(await fetchFromApiCore(session, path), schema, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseResponse<T>(res: Response, schema: z.ZodType<T>, label: string): Promise<T> {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new ApiCoreRequestError(
|
throw new ApiCoreRequestError(
|
||||||
res.status === 403
|
res.status === 403
|
||||||
@@ -282,7 +290,19 @@ const bottlingBatchSchema = z.object({
|
|||||||
|
|
||||||
export type ApiCoreBottlingBatch = z.infer<typeof bottlingBatchSchema>;
|
export type ApiCoreBottlingBatch = z.infer<typeof bottlingBatchSchema>;
|
||||||
|
|
||||||
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
|
* ApiCore-ID der ERSTEN mit der Schleuderung verknuepften Ernte, oder null
|
||||||
* wenn die Schleuderung (noch) keiner Ernte zugeordnet ist. Eine
|
* wenn die Schleuderung (noch) keiner Ernte zugeordnet ist. Eine
|
||||||
@@ -312,3 +332,91 @@ export async function fetchApiCoreBottlingBatches(
|
|||||||
harvestSourceId: firstHarvestByExtraction.get(batch.honeyBatch.extractionId) ?? null,
|
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<ApiCoreShopData> {
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user