Der bestehende Ernten-Sync (admin/ernten) laedt jetzt zusaetzlich Chargen aus ApiCore. Fachliche Zuordnung recherchiert direkt im ApiCore-Backend-Quellcode (../ApiCore/ApiCore-Backend): Wabenhains "Charge" (model Batch) entspricht ApiCores "BottlingBatch" (Schleuderung -> HoneyBatch -> Abfuellung -> BottlingBatch), NICHT "HoneyBatch" selbst — die Felder passen fast 1:1 (lotNumber/code, date/fillDate, bestBeforeDate/bestBeforeDate, jarCount/glassCount). - lib/apicore-client.ts: fetchApiCoreBottlingBatches() laedt GET /bottling-batches (inkl. verknuepfter HoneyBatch) und GET /extractions (inkl. verknuepfter Ernten) und loest daraus die zugehoerige ApiCore-Ernten-ID pro Charge auf. - admin/actions/apicore-sync.ts: Chargen-Sync laeuft nach dem Ernten-Sync (damit frisch angelegte Ernten schon als harvestId verfuegbar sind), legt Chargen NUR NEU an (nie Update) — anders als Harvest hat Batch kein isPublished-Feld, ein Update wuerde insbesondere remainingGlasses (echter, durch Verkaeufe veraenderter Lagerbestand) stillschweigend zuruecksetzen. Nicht-fatal: schlaegt nur der Chargen-Teil fehl, bleiben bereits synchronisierte Ernten erhalten. - admin/ernten/page.tsx + i18n: zeigt Chargen-Sync-Ergebnis separat an. - .env.example: zwei zusaetzliche ApiCore-Berechtigungen dokumentiert (extractions:read, bottling:read). Verifiziert: tsc --noEmit clean, 118/118 Tests gruen, pnpm build clean. Konnte nicht gegen einen echten ApiCore-Server getestet werden (siehe bereits bestehende Einschraenkung beim Ernten-Sync) — Feldnamen/Shape direkt aus ApiCore-Backends Prisma-Schema und Service-Code entnommen. Co-Authored-By: Claude Sonnet 5 <[email protected]>
229 lines
9.4 KiB
TypeScript
229 lines
9.4 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 "Ernten"-Rohdaten fuer den Admin-Sync bereitstellt
|
|
* (admin/actions/apicore-sync.ts).
|
|
*
|
|
* 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 den Berechtigungen "harvests:read" (und optional
|
|
* "apiaries:read", fuer Standortnamen) empfohlen statt eines echten
|
|
* Admin-Zugangs.
|
|
*
|
|
* 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 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 {}
|
|
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() }),
|
|
]);
|
|
|
|
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;
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
async function login(baseUrl: string, email: string, password: string): Promise<string> {
|
|
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 parsed.data.accessToken;
|
|
}
|
|
|
|
/** Best effort: Standortnamen sind eine Komfort-Anreicherung, kein kritischer Pfad. */
|
|
async function fetchApiaryNames(baseUrl: string, token: string): Promise<Map<string, string>> {
|
|
try {
|
|
const res = await fetch(`${baseUrl}/apiaries`, { headers: { Authorization: `Bearer ${token}` } });
|
|
if (!res.ok) return new Map();
|
|
const parsed = z.array(apiarySchema).safeParse(await res.json());
|
|
if (!parsed.success) return new Map();
|
|
return new Map(parsed.data.map((a) => [a.id, a.name]));
|
|
} catch {
|
|
return new Map();
|
|
}
|
|
}
|
|
|
|
export async function fetchApiCoreHarvests(): Promise<ApiCoreHarvestWithApiaryName[]> {
|
|
const { baseUrl, email, password } = getConfig();
|
|
const token = await login(baseUrl, email, password);
|
|
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(`${baseUrl}/harvests`, { headers: { Authorization: `Bearer ${token}` } });
|
|
} 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: Ernten konnten nicht geladen werden (HTTP ${res.status}).`);
|
|
}
|
|
const parsed = z.array(harvestSchema).safeParse(await res.json());
|
|
if (!parsed.success) {
|
|
throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Ernten.");
|
|
}
|
|
|
|
const apiaryNames = await fetchApiaryNames(baseUrl, token);
|
|
return parsed.data.map((harvest) => ({
|
|
...harvest,
|
|
apiaryName: apiaryNames.get(harvest.apiaryId) ?? null,
|
|
}));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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(),
|
|
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(): Promise<ApiCoreBottlingBatchWithHarvestId[]> {
|
|
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<string, string | null>(
|
|
parsedExtractions.data.map((extraction) => [extraction.id, extraction.harvests[0]?.harvestId ?? null])
|
|
);
|
|
|
|
return parsedBatches.data.map((batch) => ({
|
|
...batch,
|
|
harvestSourceId: firstHarvestByExtraction.get(batch.honeyBatch.extractionId) ?? null,
|
|
}));
|
|
}
|