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:
1 parent
5caa85ce3e
commit
d52753cfef
7 files changed
+231
-17
No files matched your search
@@ -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)}` : "")
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user