feat: ApiCore-Sync auf Chargen erweitern

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]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-04 14:37:11 +02:00
1 parent 5caa85ce3e
commit d52753cfef
7 files changed
+231 -17

No files matched your search

+97 -3
View File
@@ -23,9 +23,22 @@ import { z } from "zod";
* APICORE_SYNC_EMAIL E-Mail des Service-Kontos
* APICORE_SYNC_PASSWORD Passwort des Service-Kontos
*
* Ohne diese drei Variablen wirft fetchApiCoreHarvests() sofort einen
* ApiCoreConfigError — der Aufrufer (apicore-sync.ts) zeigt dessen Message
* dem Admin an, statt die Aktion stillschweigend nichts tun zu lassen.
* 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 {}
@@ -132,3 +145,84 @@ export async function fetchApiCoreHarvests(): Promise<ApiCoreHarvestWithApiaryNa
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,
}));
}