Files
Wabenhain/src/lib/apicore-client.ts
T
Nick SzaunigandClaude Opus 5 c525735285 feat: ein Shop-Produkt je Jahrgang statt je ApiCore-Artikel
Der Produkt-Sync bildete den Fall gleichnamiger Jahrgaenge nicht ab. In
ApiCore ist "Waldhonig 500 g" EIN Katalogartikel, an dem jedes Jahr eine
weitere Abfuellung haengt; der Sync legte daraus genau ein Wabenhain-Produkt
an. Ab dem zweiten Jahr passierte damit nichts mehr: das Produkt existierte
schon und wurde uebersprungen, und die Charge des neuen Jahrgangs blieb
unverknuepft, weil linkProductsToBatches nur eingesprungen ist, solange
Product.batchId leer war.

In Wabenhain gehoert ein Produkt zu genau einer Charge, und das ist richtig
so: jeder Jahrgang hat eigenen Preis, eigenen Verkaufstext, eigenes
Geschmacksprofil und eigene Bewertungen. Honig ist ein Naturprodukt, die
Bewertung des Waldhonigs von 2026 sagt ueber den von 2027 nichts aus.

Der Sync laeuft deshalb jetzt ueber die ABFUELLUNGEN statt ueber den
Artikelkatalog und legt je Abfuellung einen Jahrgang an, bereits mit Charge
und Ernte verknuepft. Die nachtraegliche Verknuepfung entfaellt ersatzlos.

Wiedererkannt wird ein Jahrgang ueber die neue Spalte
Product.apicoreBottlingId (nullable, unique; manuell angelegte Produkte
tragen null). Bewusst nicht ueber den Slug: der ist die oeffentliche Adresse
und soll lesbar bleiben. Er traegt stattdessen den Jahrgang
("waldhonig-2026") und faellt bei mehrfacher Abfuellung im selben Jahr auf
die Losnummer zurueck - hochgezaehlt wird nicht, "waldhonig-2026-2" sagt
niemandem etwas.

Weitere Entscheidungen, jeweils zugunsten dessen, was tatsaechlich im Glas
ist: das Gewicht kommt aus der Glasgroesse der Abfuellung statt aus dem
Artikel, und der Lagerbestand startet bei 0 statt bei der Abfuellmenge -
wie viele Glaeser in den Webshop gehen und wie viele in Hofladen oder
Wiederverkauf, entscheidet Wabenhain.

Ein in ApiCore ausgelaufener Artikel bekommt keinen neuen Jahrgang mehr,
bereits importierte bleiben unveraendert im Verkauf: solange Glaeser im
Regal stehen, ist "nicht mehr produziert" kein Grund, den Abverkauf
abzubrechen. Fehlt die Charge eines Jahrgangs noch, meldet der Lauf das als
"wartet auf den Ernten-Sync" statt als Fehler.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 03:24:02 +02:00

298 lines
12 KiB
TypeScript

import { z } from "zod";
/**
* HTTP-Client fuer die ApiCore-Backend-API (separates Projekt, lokal unter
* .../ApiCore/ApiCore-Backend — ein NestJS-Server mit eigener Postgres-DB),
* die die Rohdaten fuer den Admin-Sync bereitstellt
* (admin/actions/apicore-sync.ts): Ernten, Produkte und Chargen.
*
* Auth-Modell von ApiCore: E-Mail/Passwort-Login -> kurzlebiges JWT
* (POST /auth/login), Endpunkte pruefen zusaetzlich granulare Berechtigungen
* ("harvests:read" fuer GET /harvests). Es gibt dort AKTUELL KEINEN
* API-Key-/Service-Account-Mechanismus fuer maschinelle Zugriffe — nur den
* 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).
*
* Benoetigte Umgebungsvariablen (siehe .env.example):
* APICORE_API_URL z.B. "http://localhost:3001/api" (globaler Prefix
* "api" laut ApiCore-Backend/src/app.config.ts)
* APICORE_SYNC_EMAIL E-Mail des Service-Kontos
* APICORE_SYNC_PASSWORD Passwort des Service-Kontos
*
* Ohne diese drei Variablen wirft openApiCoreSession() sofort einen
* ApiCoreConfigError — der Aufrufer (apicore-sync.ts) zeigt dessen Message
* dem Admin an, statt die Aktion stillschweigend nichts tun zu lassen.
*
* Die Anmeldung ist bewusst von den einzelnen Abfragen getrennt
* (openApiCoreSession -> fetchApiCore*(session)): ein Sync-Lauf zieht Ernten,
* Produkte UND Chargen und kaeme sonst auf drei getrennte Passwort-Logins
* gegen dasselbe Konto — bei ApiCores Anmelde-Bremse ein unnoetiges Risiko,
* den eigenen Service-Zugang auszusperren.
*
* 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.
*/
export class ApiCoreConfigError extends Error {}
export class ApiCoreRequestError extends Error {}
const loginResponseSchema = z.union([
z.object({ accessToken: z.string().min(1) }),
z.object({ mfaRequired: z.literal(true), mfaToken: z.string() }),
]);
/** Angemeldete Verbindung zu ApiCore, siehe Modul-Kommentar oben. */
export interface ApiCoreSession {
readonly baseUrl: string;
readonly token: string;
}
function getConfig(): { baseUrl: string; email: string; password: string } {
const baseUrl = process.env.APICORE_API_URL;
const email = process.env.APICORE_SYNC_EMAIL;
const password = process.env.APICORE_SYNC_PASSWORD;
if (!baseUrl || !email || !password) {
throw new ApiCoreConfigError(
"ApiCore-Synchronisierung ist nicht konfiguriert. Bitte APICORE_API_URL, APICORE_SYNC_EMAIL und APICORE_SYNC_PASSWORD setzen (siehe .env.example)."
);
}
return { baseUrl: baseUrl.replace(/\/$/, ""), email, password };
}
export async function openApiCoreSession(): Promise<ApiCoreSession> {
const { baseUrl, email, password } = getConfig();
let res: Response;
try {
res = await fetch(`${baseUrl}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${message}`);
}
if (!res.ok) {
throw new ApiCoreRequestError(`ApiCore-Login fehlgeschlagen (HTTP ${res.status}).`);
}
const parsed = loginResponseSchema.safeParse(await res.json());
if (!parsed.success) {
throw new ApiCoreRequestError("ApiCore-Login: unerwartete Antwortstruktur.");
}
if ("mfaRequired" in parsed.data) {
throw new ApiCoreConfigError(
"Das konfigurierte ApiCore-Sync-Konto hat MFA aktiviert. Fuer den automatisierten Sync wird ein MFA-freies Service-Konto benoetigt (siehe Kommentar in apicore-client.ts)."
);
}
return { baseUrl, token: parsed.data.accessToken };
}
/**
* Eine GET-Abfrage inklusive Schema-Pruefung. Die drei Fehlerfaelle (nicht
* erreichbar / HTTP-Fehler / unerwartete Struktur) werden bewusst getrennt
* gemeldet: sie haben voellig verschiedene Ursachen — Netzwerk bzw. falsche
* URL, eine fehlende Berechtigung des Service-Kontos, und eine geaenderte
* ApiCore-Version.
*/
async function getJson<T>(
session: ApiCoreSession,
path: string,
schema: z.ZodType<T>,
label: string
): Promise<T> {
let res: Response;
try {
res = await fetch(`${session.baseUrl}${path}`, {
headers: { Authorization: `Bearer ${session.token}` },
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
throw new ApiCoreRequestError(`ApiCore unter ${session.baseUrl} nicht erreichbar: ${message}`);
}
if (!res.ok) {
throw new ApiCoreRequestError(
res.status === 403
? `ApiCore: ${label} konnten nicht geladen werden (HTTP 403) — dem Sync-Konto fehlt vermutlich die noetige Leseberechtigung, siehe .env.example.`
: `ApiCore: ${label} konnten nicht geladen werden (HTTP ${res.status}).`
);
}
const parsed = schema.safeParse(await res.json());
if (!parsed.success) {
throw new ApiCoreRequestError(`ApiCore: unerwartete Antwortstruktur fuer ${label}.`);
}
return parsed.data;
}
// ---------------------------------------------------------------------------
// Ernten
// ---------------------------------------------------------------------------
const apiarySchema = z.object({ id: z.string(), name: z.string() });
const harvestSchema = z.object({
id: z.string(),
apiaryId: z.string(),
date: z.string(),
honeyType: z.string().nullable().optional(),
honeySuperCount: z.number().nullable().optional(),
grossWeight: z.number().nullable().optional(),
netWeight: z.number().nullable().optional(),
notes: z.string().nullable().optional(),
});
export type ApiCoreHarvest = z.infer<typeof harvestSchema>;
export interface ApiCoreHarvestWithApiaryName extends ApiCoreHarvest {
apiaryName: string | null;
}
/** Best effort: Standortnamen sind eine Komfort-Anreicherung, kein kritischer Pfad. */
async function fetchApiaryNames(session: ApiCoreSession): Promise<Map<string, string>> {
try {
const apiaries = await getJson(session, "/apiaries", z.array(apiarySchema), "Standorte");
return new Map(apiaries.map((a) => [a.id, a.name]));
} catch {
return new Map();
}
}
export async function fetchApiCoreHarvests(
session: ApiCoreSession
): Promise<ApiCoreHarvestWithApiaryName[]> {
const harvests = await getJson(session, "/harvests", z.array(harvestSchema), "Ernten");
const apiaryNames = await fetchApiaryNames(session);
return harvests.map((harvest) => ({
...harvest,
apiaryName: apiaryNames.get(harvest.apiaryId) ?? null,
}));
}
// ---------------------------------------------------------------------------
// Produkte
// ---------------------------------------------------------------------------
/**
* ApiCores Product (ApiCore-Backend/prisma/schema.prisma). Zwei bewusste
* Abweichungen vom dortigen Prisma-Modell:
*
* - `status` wird als freier String gelesen, nicht als z.enum([...]). ApiCores
* ProductStatus kennt heute nur ACTIVE und DISCONTINUED; kaeme dort ein
* dritter Wert hinzu, wuerde ein Enum-Schema die GESAMTE Antwort verwerfen
* und der Sync mit "unerwartete Antwortstruktur" abbrechen, statt nur den
* einen unbekannten Artikel zu ignorieren. Siehe isSellableStatus().
* - Mengenangaben (`packagingSize`, `weight`) tragen in ApiCore KEINE Einheit
* — weder im Prisma-Modell noch an den Formularfeldern ("Gebindegroesse",
* "Gewicht", jeweils ohne Zusatz). Als Einheit wird GRAMM angenommen, weil
* ApiCore dieselbe Groesse an der Abfuellung (BottlingBatch.jarSize) mit
* Werten wie 500 fuehrt und sie in seiner eigenen Oberflaeche als "g"
* ausgibt (ApiCore-Frontend .../herkunft/[lot]/page.tsx). Die Annahme ist
* damit begruendet, aber von ApiCore nicht garantiert — deshalb ist das
* uebernommene Gewicht am importierten Entwurf im Admin sichtbar und
* aenderbar, bevor das Produkt ueberhaupt verkauft werden kann.
*/
const productSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable().optional(),
sku: z.string().nullable().optional(),
packagingSize: z.number().nullable().optional(),
weight: z.number().nullable().optional(),
salesUnit: z.string().nullable().optional(),
status: z.string(),
});
export type ApiCoreProduct = z.infer<typeof productSchema>;
/**
* Nur ACTIVE gilt als verkaufsfaehig. Alles andere (heute DISCONTINUED,
* morgen vielleicht ein weiterer Wert) wird bewusst als "nicht verkaufsfaehig"
* behandelt: ein unbekannter Status darf nicht dazu fuehren, dass ein Artikel
* ungeprueft als Shop-Entwurf auftaucht.
*/
export function isSellableStatus(status: string): boolean {
return status === "ACTIVE";
}
export async function fetchApiCoreProducts(session: ApiCoreSession): Promise<ApiCoreProduct[]> {
return getJson(session, "/products", z.array(productSchema), "Produkte");
}
// ---------------------------------------------------------------------------
// Chargen (BottlingBatch, siehe Modul-Kommentar oben)
// ---------------------------------------------------------------------------
const extractionSchema = z.object({
id: z.string(),
// ApiCores GET /extractions liefert je Schleuderung die verknuepften
// Ernten als ExtractionHarvest-Zeilen — pro Zeile reicht harvestId, die
// uebrigen (verschachtelten Volk-/Standort-)Felder werden nicht gebraucht.
harvests: z.array(z.object({ harvestId: z.string() })),
});
const bottlingBatchSchema = z.object({
id: z.string(),
lotNumber: z.string(),
date: z.string(),
bestBeforeDate: z.string(),
jarCount: z.number(),
// Glasgroesse dieser Abfuellung, in Gramm (siehe Einheiten-Hinweis an
// productSchema oben). Bewusst optional gelesen, obwohl ApiCore das Feld
// heute immer fuellt: faellt es einmal weg, soll der Sync auf die Angabe am
// Artikel zurueckfallen (deriveJarWeightGrams) statt die gesamte Antwort zu
// verwerfen.
jarSize: z.number().nullable().optional(),
// Optional: eine Abfuellung muss in ApiCore keinem Produkt zugeordnet sein.
// Ist sie es, verknuepft der Sync die daraus entstandene Wabenhain-Charge
// mit dem passenden synchronisierten Produkt (siehe apicore-sync.ts).
productId: z.string().nullable().optional(),
honeyBatch: z.object({
id: z.string(),
extractionId: z.string(),
}),
});
export type ApiCoreBottlingBatch = z.infer<typeof bottlingBatchSchema>;
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(
session: ApiCoreSession
): Promise<ApiCoreBottlingBatchWithHarvestId[]> {
const [extractions, batches] = await Promise.all([
getJson(session, "/extractions", z.array(extractionSchema), "Schleuderungen"),
getJson(session, "/bottling-batches", z.array(bottlingBatchSchema), "Chargen"),
]);
const firstHarvestByExtraction = new Map<string, string | null>(
extractions.map((extraction) => [extraction.id, extraction.harvests[0]?.harvestId ?? null])
);
return batches.map((batch) => ({
...batch,
harvestSourceId: firstHarvestByExtraction.get(batch.honeyBatch.extractionId) ?? null,
}));
}