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

+4 -2
View File
@@ -87,8 +87,10 @@ SMTP_FROM="Wabenhain <[email protected]>"
# Projekt (NestJS-Backend) mit reinem E-Mail/Passwort-Login (kein API-Key-
# Mechanismus) — hier IDEALERWEISE die Zugangsdaten eines dedizierten,
# MFA-freien Service-Kontos mit ausschliesslich den Berechtigungen
# "harvests:read" (und optional "apiaries:read") eintragen, nicht die eines
# echten Admin-Zugangs. Details siehe src/lib/apicore-client.ts.
# "harvests:read", "apiaries:read" (optional, fuer Standortnamen),
# "extractions:read" und "bottling:read" (beide fuer den Chargen-Sync)
# eintragen, nicht die eines echten Admin-Zugangs. Details siehe
# src/lib/apicore-client.ts.
APICORE_API_URL=
APICORE_SYNC_EMAIL=
APICORE_SYNC_PASSWORD=
+105 -7
View File
@@ -6,9 +6,11 @@ import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import {
fetchApiCoreHarvests,
fetchApiCoreBottlingBatches,
ApiCoreConfigError,
ApiCoreRequestError,
type ApiCoreHarvestWithApiaryName,
type ApiCoreBottlingBatchWithHarvestId,
} from "@/lib/apicore-client";
import { requireAdmin } from "./_shared";
@@ -39,12 +41,27 @@ function buildDescription(source: ApiCoreHarvestWithApiaryName): string {
}
/**
* Zieht Ernten aus ApiCore und legt sie als unveroeffentlichte Entwuerfe an
* (isPublished: false) bzw. aktualisiert bereits vorhandene, noch
* unveroeffentlichte Entwuerfe. Bereits vom Admin veroeffentlichte,
* frueher synchronisierte Ernten werden NICHT angefasst — sonst wuerde ein
* erneuter Sync eine bewusste Freigabe- und Bearbeitungsentscheidung des
* Admins stillschweigend zuruecksetzen.
* Zieht Ernten UND Chargen aus ApiCore.
*
* Ernten werden als unveroeffentlichte Entwuerfe angelegt (isPublished:
* false) bzw. bereits vorhandene, noch unveroeffentlichte Entwuerfe
* aktualisiert. Bereits vom Admin veroeffentlichte, frueher synchronisierte
* Ernten werden NICHT angefasst — sonst wuerde ein erneuter Sync eine
* bewusste Freigabe- und Bearbeitungsentscheidung des Admins stillschweigend
* zuruecksetzen.
*
* Chargen (Wabenhain: Batch, ApiCore: BottlingBatch — siehe Kommentar in
* lib/apicore-client.ts fuer die Modul-Zuordnung) werden dagegen NUR neu
* angelegt, nie aktualisiert: anders als Harvest hat Batch kein
* isPublished-Feld, das eine bereits erfolgte Admin-Bearbeitung markieren
* koennte. Ein Update wuerde insbesondere remainingGlasses ueberschreiben —
* das ist Wabenhains EIGENER, durch echte Verkaeufe veraenderter
* Lagerbestand und darf niemals durch einen ApiCore-Sync zurueckgesetzt
* werden. Existiert eine Charge (per code) bereits, wird sie uebersprungen.
* Jede Charge braucht eine bereits in Wabenhain vorhandene (synchronisierte
* oder manuell angelegte) Ernte als harvestId — Chargen laufen deshalb nach
* den Ernten in diesem Lauf, damit gerade frisch angelegte Ernten schon
* verfuegbar sind.
*
* Admin-Berechtigungsfeld "type" bei Wabenhain (Erntesaison, z.B.
* "Sommertracht") und ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig")
@@ -122,6 +139,87 @@ export async function syncHarvestsFromApiCoreAction(): Promise<void> {
detail: `${created} neu, ${updated} aktualisiert, ${skipped} übersprungen, ${failed} fehlgeschlagen`,
});
// Chargen-Sync laeuft NACH den Ernten (s.o., Doc-Kommentar) und ist
// bewusst nicht fatal fuer den gesamten Aufruf: schlaegt nur das Laden der
// Chargen fehl, sollen die bereits erfolgreich synchronisierten Ernten
// trotzdem sichtbar bleiben statt durch einen Redirect mit Fehlerseite
// verloren zu gehen.
let batchCreated = 0;
let batchSkipped = 0;
let batchFailed = 0;
let batchLoadError: string | null = null;
let sourceBatches: ApiCoreBottlingBatchWithHarvestId[] = [];
try {
sourceBatches = await fetchApiCoreBottlingBatches();
} catch (error: unknown) {
batchLoadError =
error instanceof ApiCoreConfigError || error instanceof ApiCoreRequestError
? error.message
: error instanceof Error
? error.message
: "Unbekannter Fehler";
}
for (const source of sourceBatches) {
if (!source.harvestSourceId) {
batchFailed++;
console.error(`[apicore-sync] Charge ${source.lotNumber} ist keiner Ernte zugeordnet — übersprungen.`);
continue;
}
const fillDate = new Date(source.date);
const bestBeforeDate = new Date(source.bestBeforeDate);
if (Number.isNaN(fillDate.getTime()) || Number.isNaN(bestBeforeDate.getTime())) {
batchFailed++;
console.error(`[apicore-sync] Charge ${source.lotNumber} hat ein ungültiges Datum — übersprungen.`);
continue;
}
try {
const existing = await prisma.batch.findUnique({ where: { code: source.lotNumber } });
if (existing) {
batchSkipped++;
continue;
}
const harvest = await prisma.harvest.findUnique({ where: { slug: slugFor(source.harvestSourceId) } });
if (!harvest) {
batchFailed++;
console.error(
`[apicore-sync] Charge ${source.lotNumber}: zugehörige Ernte (ApiCore-ID ${source.harvestSourceId}) ist in Wabenhain noch nicht synchronisiert — übersprungen.`
);
continue;
}
await prisma.batch.create({
data: {
code: source.lotNumber,
harvestId: harvest.id,
fillDate,
bestBeforeDate,
glassCount: Math.max(Math.round(source.jarCount), 0),
remainingGlasses: Math.max(Math.round(source.jarCount), 0),
},
});
batchCreated++;
} catch (error: unknown) {
batchFailed++;
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
console.error(`[apicore-sync] Charge ${source.lotNumber} konnte nicht importiert werden: ${message}`);
}
}
await logAdminAction(admin, {
action: "batch.sync_apicore",
entityType: "Batch",
detail: batchLoadError
? `Chargen konnten nicht geladen werden: ${batchLoadError}`
: `${batchCreated} neu, ${batchSkipped} übersprungen, ${batchFailed} fehlgeschlagen`,
});
revalidatePath("/admin/ernten");
redirect(`/admin/ernten?synced=${created}&updated=${updated}&skipped=${skipped}&failed=${failed}`);
redirect(
`/admin/ernten?synced=${created}&updated=${updated}&skipped=${skipped}&failed=${failed}` +
`&batchSynced=${batchCreated}&batchSkipped=${batchSkipped}&batchFailed=${batchFailed}` +
(batchLoadError ? `&batchLoadError=${encodeURIComponent(batchLoadError)}` : "")
);
}
+12 -1
View File
@@ -8,7 +8,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten">) {
const t = getDictionary(await getLocale());
const { synced, updated, skipped, failed, syncError } = await props.searchParams;
const { synced, updated, skipped, failed, syncError, batchSynced, batchSkipped, batchFailed, batchLoadError } =
await props.searchParams;
const harvests = await prisma.harvest.findMany({
orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
include: { _count: { select: { batches: true, products: true } } },
@@ -29,6 +30,16 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
{t.adminHarvestsPage.syncError} ({syncError})
</p>
) : null}
{batchSynced !== undefined ? (
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
{t.adminHarvestsPage.batchSyncResult(Number(batchSynced), Number(batchSkipped ?? 0), Number(batchFailed ?? 0))}
</p>
) : null}
{batchLoadError !== undefined ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
{t.adminHarvestsPage.batchSyncError} ({batchLoadError})
</p>
) : null}
<div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm">
+5 -2
View File
@@ -897,11 +897,14 @@ export const de: Dictionary = {
create: "Anlegen",
syncHeading: "ApiCore-Synchronisierung",
syncIntro:
"Importiert Ernten aus ApiCore als Entwürfe (nicht öffentlich sichtbar). Bereits veröffentlichte, zuvor synchronisierte Ernten werden dabei nicht verändert — nur neue oder noch unveröffentlichte Entwürfe werden aktualisiert.",
"Importiert Ernten UND Chargen aus ApiCore. Ernten werden als Entwürfe angelegt (nicht öffentlich sichtbar) — bereits veröffentlichte, zuvor synchronisierte Ernten werden dabei nicht verändert. Chargen werden nur neu angelegt (nie überschrieben, damit echte Lagerbestände erhalten bleiben) und brauchen eine bereits vorhandene Ernte.",
syncButton: "Jetzt synchronisieren",
syncResult: (created, updated, skipped, failed) =>
`Synchronisierung abgeschlossen: ${created} neu angelegt, ${updated} aktualisiert, ${skipped} übersprungen (bereits veröffentlicht)${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`,
`Ernten: ${created} neu angelegt, ${updated} aktualisiert, ${skipped} übersprungen (bereits veröffentlicht)${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`,
syncError: "Synchronisierung mit ApiCore fehlgeschlagen.",
batchSyncResult: (created, skipped, failed) =>
`Chargen: ${created} neu angelegt, ${skipped} übersprungen (bereits vorhanden)${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
batchSyncError: "Chargen konnten nicht von ApiCore geladen werden",
},
adminHarvestEditPage: {
backToHarvests: "Zurück zu Ernten",
+5 -2
View File
@@ -897,11 +897,14 @@ export const en: Dictionary = {
create: "Create",
syncHeading: "ApiCore sync",
syncIntro:
"Imports harvests from ApiCore as drafts (not publicly visible). Previously synced harvests that are already published are left untouched — only new or still-unpublished drafts are updated.",
"Imports harvests AND batches from ApiCore. Harvests are created as drafts (not publicly visible) — previously synced harvests that are already published are left untouched. Batches are only ever created, never updated (so real stock levels are never overwritten), and need an already-existing harvest.",
syncButton: "Sync now",
syncResult: (created, updated, skipped, failed) =>
`Sync complete: ${created} created, ${updated} updated, ${skipped} skipped (already published)${failed > 0 ? `, ${failed} failed` : ""}.`,
`Harvests: ${created} created, ${updated} updated, ${skipped} skipped (already published)${failed > 0 ? `, ${failed} failed` : ""}.`,
syncError: "Sync with ApiCore failed.",
batchSyncResult: (created, skipped, failed) =>
`Batches: ${created} created, ${skipped} skipped (already exist)${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
batchSyncError: "Batches could not be loaded from ApiCore",
},
adminHarvestEditPage: {
backToHarvests: "Back to harvests",
+3
View File
@@ -603,6 +603,9 @@ export interface Dictionary {
/** created, updated, skipped, failed */
syncResult: (created: number, updated: number, skipped: number, failed: number) => string;
syncError: string;
/** created, skipped, failed */
batchSyncResult: (created: number, skipped: number, failed: number) => string;
batchSyncError: string;
};
adminHarvestEditPage: {
backToHarvests: string;
+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,
}));
}