Files
Wabenhain/src/app/admin/actions/apicore-sync.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

477 lines
18 KiB
TypeScript

"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import {
openApiCoreSession,
fetchApiCoreHarvests,
fetchApiCoreProducts,
fetchApiCoreBottlingBatches,
isSellableStatus,
ApiCoreConfigError,
ApiCoreRequestError,
type ApiCoreSession,
type ApiCoreHarvestWithApiaryName,
type ApiCoreProduct,
type ApiCoreBottlingBatchWithHarvestId,
} from "@/lib/apicore-client";
import {
slugForApiCoreSource,
buildHarvestDescription,
buildProductDescription,
deriveJarWeightGrams,
productSlugCandidates,
} from "@/lib/apicore-mapping";
import { requireAdmin } from "./_shared";
/**
* Uebernimmt Stammdaten aus ApiCore (separates Imkerei-Verwaltungssystem) in
* den Shop: Ernten, Produkte und Chargen.
*
* Durchgaengiges Prinzip fuer alle drei: ApiCore ist die Quelle der ROHDATEN,
* Wabenhain bleibt die Quelle aller VERKAUFS-Entscheidungen. Ein Sync-Lauf
* darf deshalb nie etwas ueberschreiben, das eine Mitarbeiterin im Admin
* bewusst gesetzt hat — importiert wird in einen Entwurfszustand, den erst
* ein Mensch freigibt.
*
* - Ernten: werden als unveroeffentlichte Entwuerfe angelegt (isPublished
* false) bzw. als solche aktualisiert. Bereits veroeffentlichte, frueher
* synchronisierte Ernten bleiben unberuehrt.
* - Produkte: werden als deaktivierte Entwuerfe angelegt (isActive false,
* Preis 0) und nur so lange aktualisiert, wie sie deaktiviert sind. Ein
* bereits aktives Produkt wird nie angefasst — sonst wuerde ein Sync Preis,
* Beschreibung und Geschmacksprofil ueberschreiben, die nur in Wabenhain
* gepflegt werden und die es in ApiCore gar nicht gibt.
* - Chargen: werden NUR neu angelegt, nie aktualisiert. Anders als Harvest
* und Product hat Batch kein Feld, an dem eine erfolgte Admin-Bearbeitung
* erkennbar waere. Ein Update wuerde insbesondere remainingGlasses
* ueberschreiben — Wabenhains EIGENEN, durch echte Verkaeufe veraenderten
* Lagerbestand.
*
* Idempotent ist das alles ueber einen stabilen Schluessel je Datensatz:
* "apicore-<ApiCore-ID>" als Harvest.slug bzw. Product.slug, und die
* ApiCore-Losnummer als Batch.code.
*
* Wabenhains "type" bei Ernten (Erntesaison, z.B. "Sommertracht") und
* ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig") sind KEINE
* aequivalenten Taxonomien — es findet bewusst keine automatische
* Uebersetzung statt, der Rohwert wird uebernommen und muss vom Admin beim
* Review korrigiert werden.
*/
interface SyncCounts {
created: number;
updated: number;
skipped: number;
failed: number;
}
/**
* Produkte kennen bewusst kein "updated" — sie werden nur neu angelegt,
* siehe syncProducts().
*/
interface ProductSyncCounts {
created: number;
skipped: number;
failed: number;
/**
* Abfuellungen, deren Charge in Wabenhain noch fehlt. Kein Fehler, sondern
* eine Reihenfolge: zuerst der Ernten-Sync, danach noch einmal hier.
*/
pending: number;
/** Artikel, die ApiCore nicht mehr als verkaufsfaehig fuehrt. */
discontinued: number;
}
function describeError(error: unknown): string {
if (error instanceof ApiCoreConfigError || error instanceof ApiCoreRequestError) return error.message;
if (error instanceof Error) return error.message;
return "Unbekannter Fehler";
}
// ---------------------------------------------------------------------------
// Ernten
// ---------------------------------------------------------------------------
async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise<SyncCounts> {
const counts: SyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0 };
for (const source of sources) {
const slug = slugForApiCoreSource(source.id);
const date = new Date(source.date);
if (Number.isNaN(date.getTime())) {
counts.failed++;
console.error(`[apicore-sync] Ernte ${source.id} hat ein ungueltiges Datum ("${source.date}") — übersprungen.`);
continue;
}
const type = source.honeyType?.trim() || `Ernte ${source.id.slice(0, 8)}`;
try {
const existing = await prisma.harvest.findUnique({ where: { slug } });
if (existing?.isPublished) {
counts.skipped++;
continue;
}
const data = {
year: date.getFullYear(),
type,
harvestDate: date,
location: source.apiaryName ?? "Altenmünster",
description: buildHarvestDescription(source),
};
if (existing) {
await prisma.harvest.update({ where: { slug }, data });
counts.updated++;
} else {
await prisma.harvest.create({ data: { ...data, slug, isPublished: false } });
counts.created++;
}
} catch (error: unknown) {
counts.failed++;
// z.B. P2002 (unique constraint year+type), falls zwei ApiCore-Ernten
// im selben Jahr denselben honeyType tragen — bewusst nicht fatal fuer
// den gesamten Sync-Lauf, siehe Kommentar oben zur Taxonomie-Kollision.
console.error(`[apicore-sync] Ernte ${source.id} konnte nicht importiert werden: ${describeError(error)}`);
}
}
return counts;
}
// ---------------------------------------------------------------------------
// Produkte
// ---------------------------------------------------------------------------
/**
* Ein neu importiertes Produkt ist absichtlich NICHT verkaufsfaehig:
*
* - `isActive: false` haelt es aus Shop, Suche und Checkout heraus.
* - `priceCents: 0` ist kein geschaetzter, sondern ein FEHLENDER Preis:
* ApiCore fuehrt ueberhaupt keine Verkaufspreise, das ist reine
* Betriebsdatenhaltung. Den Preis kann nur ein Mensch setzen. Damit aus
* diesem Platzhalter nie eine Gratis-Bestellung wird, weigern sich
* zusaetzlich die Aktivierung im Admin und der Checkout, ein Produkt mit
* Preis 0 zu verkaufen (admin/actions/products.ts, api/checkout/route.ts).
* - Kategorie HONIG als Vorgabe: ApiCore kennt keine Shop-Kategorien, und
* eine Imkerei-Warenwirtschaft fuehrt ueberwiegend Honig. Kerzen oder
* Propolis stellt der Admin beim Review um.
* - Geschmacksprofil, Konsistenz und Bilder bleiben auf den Schema-Vorgaben:
* dafuer gibt es in ApiCore keine Entsprechung, das ist Shop-Redaktion.
*
* Ein schon vorhandenes Produkt wird NIE aktualisiert, auch kein Entwurf.
* Genau die drei Felder, die ApiCore liefert (Name, Beschreibung, Gewicht),
* sind die, die der Admin vor der Freischaltung von Hand nachzieht — ein
* Update wuerde diese Arbeit bei jedem weiteren Lauf wieder wegwerfen, und
* am Entwurf ist nicht erkennbar, ob schon jemand daran war. Soll ein
* Produkt wirklich frisch aus ApiCore kommen, loescht man es in Wabenhain
* und synchronisiert erneut. Dieselbe Regel gilt aus demselben Grund fuer
* Chargen.
*
* EIN PRODUKT JE JAHRGANG, nicht je ApiCore-Artikel. Der Sync laeuft deshalb
* ueber die ABFUELLUNGEN, nicht ueber den Artikelkatalog:
*
* In ApiCore ist "Waldhonig 500 g" ein einziger Katalogartikel, an dem jedes
* Jahr eine weitere Abfuellung haengt. In Wabenhain gehoert ein Produkt
* dagegen zu genau einer Charge — und das ist richtig so, denn 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.
*
* Aus einem ApiCore-Artikel werden hier also mehrere Wabenhain-Produkte,
* eines je Abfuellung. Wiedererkannt werden sie ueber Product.apicoreBottlingId
* — nicht ueber den Slug: der ist die oeffentliche Adresse und soll lesbar
* bleiben ("waldhonig-2026"), darf sich also nicht nach einer fremden ID
* richten.
*
* Reihenfolge: die Charge muss bereits in Wabenhain sein (Ernten-Sync), sonst
* gaebe es nichts, woran der Jahrgang haengt. Fehlt sie, wird die Abfuellung
* als "pending" gezaehlt statt als Fehler — der naechste Lauf holt sie nach.
*/
async function syncProducts(
articles: ApiCoreProduct[],
bottlings: ApiCoreBottlingBatchWithHarvestId[]
): Promise<ProductSyncCounts> {
const counts: ProductSyncCounts = { created: 0, skipped: 0, failed: 0, pending: 0, discontinued: 0 };
const articleById = new Map(articles.map((article) => [article.id, article]));
for (const bottling of bottlings) {
// Eine Abfuellung ohne Artikel ist in ApiCore erlaubt (z.B. Rohware fuer
// den Direktverkauf ab Hof). Daraus laesst sich kein Shop-Produkt bilden:
// es gaebe weder Namen noch Artikelnummer.
if (!bottling.productId) continue;
const article = articleById.get(bottling.productId);
if (!article) {
counts.failed++;
console.error(
`[apicore-sync] Abfüllung ${bottling.lotNumber} verweist auf den Artikel ${bottling.productId}, den ApiCore nicht ausliefert — übersprungen.`
);
continue;
}
try {
const existing = await prisma.product.findUnique({
where: { apicoreBottlingId: bottling.id },
select: { id: true },
});
if (existing) {
counts.skipped++;
continue;
}
// Ein in ApiCore ausgelaufener Artikel bekommt keinen NEUEN Jahrgang im
// Shop. Bereits importierte Jahrgaenge bleiben unberuehrt und weiter
// verkaeuflich: solange Glaeser im Regal stehen, ist "nicht mehr
// produziert" kein Grund, den Abverkauf abzubrechen. Diese Entscheidung
// trifft der Admin, nicht der Sync.
if (!isSellableStatus(article.status)) {
counts.discontinued++;
continue;
}
const batch = await prisma.batch.findUnique({
where: { code: bottling.lotNumber },
include: { harvest: { select: { id: true, year: true } } },
});
if (!batch) {
counts.pending++;
continue;
}
const slug = await firstFreeSlug(
productSlugCandidates(article.name, batch.harvest.year, bottling.lotNumber)
);
if (!slug) {
counts.failed++;
console.error(
`[apicore-sync] Für "${article.name}" (${batch.harvest.year}, Los ${bottling.lotNumber}) ist keine freie Adresse frei — Produkt bitte von Hand anlegen.`
);
continue;
}
await prisma.product.create({
data: {
slug,
apicoreBottlingId: bottling.id,
name: article.name,
description: buildProductDescription(article, batch.harvest.year),
// Die Glasgroesse der Abfuellung schlaegt die Angabe am Artikel:
// sie sagt, was tatsaechlich in dieses Glas kam.
weightGrams: deriveJarWeightGrams(bottling.jarSize, article),
category: "HONIG",
harvestId: batch.harvest.id,
batchId: batch.id,
priceCents: 0,
pricePerKgCents: 0,
isActive: false,
// Bewusst 0 statt bottling.jarCount: wie viele Glaeser in den
// Webshop gehen und wie viele in Hofladen, Wiederverkauf oder
// Rueckstellmuster, entscheidet Wabenhain — nicht die Abfuellmenge.
inventory: { create: { quantity: 0 } },
},
});
counts.created++;
} catch (error: unknown) {
counts.failed++;
console.error(
`[apicore-sync] Abfüllung ${bottling.lotNumber} konnte nicht als Produkt importiert werden: ${describeError(error)}`
);
}
}
return counts;
}
/**
* Erste noch freie Adresse aus den Vorschlaegen, oder null, wenn alle belegt
* sind. Bewusst ohne Hochzaehlen: eine Adresse wie "waldhonig-2026-2" sagt
* niemandem etwas, und der Fall bedeutet ohnehin, dass hier jemand
* hinschauen muss.
*/
async function firstFreeSlug(candidates: string[]): Promise<string | null> {
for (const slug of candidates) {
const taken = await prisma.product.findUnique({ where: { slug }, select: { id: true } });
if (!taken) return slug;
}
return null;
}
// ---------------------------------------------------------------------------
// Chargen
// ---------------------------------------------------------------------------
async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promise<SyncCounts> {
const counts: SyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0 };
for (const source of sources) {
if (!source.harvestSourceId) {
counts.failed++;
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())) {
counts.failed++;
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) {
counts.skipped++;
continue;
}
const harvest = await prisma.harvest.findUnique({
where: { slug: slugForApiCoreSource(source.harvestSourceId) },
});
if (!harvest) {
counts.failed++;
console.error(
`[apicore-sync] Charge ${source.lotNumber}: zugehörige Ernte (ApiCore-ID ${source.harvestSourceId}) ist in Wabenhain noch nicht synchronisiert — übersprungen.`
);
continue;
}
const glassCount = Math.max(Math.round(source.jarCount), 0);
await prisma.batch.create({
data: {
code: source.lotNumber,
harvestId: harvest.id,
fillDate,
bestBeforeDate,
glassCount,
remainingGlasses: glassCount,
},
});
counts.created++;
} catch (error: unknown) {
counts.failed++;
console.error(`[apicore-sync] Charge ${source.lotNumber} konnte nicht importiert werden: ${describeError(error)}`);
}
}
return counts;
}
/**
* Chargen laden und importieren. Ein Fehlschlag beim LADEN ist bewusst nicht
* fatal fuer den umgebenden Sync-Lauf: die bereits erfolgreich uebernommenen
* Ernten sollen sichtbar bleiben, statt hinter einer Fehlerseite zu
* verschwinden.
*
* Eine nachtraegliche Verknuepfung Produkt<->Charge gibt es nicht mehr: seit
* je Abfuellung ein eigenes Produkt entsteht, wird es bereits mit Charge und
* Ernte angelegt (siehe syncProducts).
*/
async function loadAndSyncBatches(
session: ApiCoreSession
): Promise<{ counts: SyncCounts; loadError: string | null }> {
let sources: ApiCoreBottlingBatchWithHarvestId[];
try {
sources = await fetchApiCoreBottlingBatches(session);
} catch (error: unknown) {
return {
counts: { created: 0, updated: 0, skipped: 0, failed: 0 },
loadError: describeError(error),
};
}
return { counts: await syncBatches(sources), loadError: null };
}
// ---------------------------------------------------------------------------
// Server Actions
// ---------------------------------------------------------------------------
/**
* Sync-Knopf auf /admin/ernten: zieht Ernten und die daraus entstandenen
* Chargen. Chargen laufen NACH den Ernten, damit eine gerade erst angelegte
* Ernte als Pflicht-Fremdschluessel schon bereitsteht.
*/
export async function syncHarvestsFromApiCoreAction(): Promise<void> {
const admin = await requireAdmin();
let session: ApiCoreSession;
let sourceHarvests: ApiCoreHarvestWithApiaryName[];
try {
session = await openApiCoreSession();
sourceHarvests = await fetchApiCoreHarvests(session);
} catch (error: unknown) {
redirect(`/admin/ernten?syncError=${encodeURIComponent(describeError(error))}`);
}
const harvests = await syncHarvests(sourceHarvests);
await logAdminAction(admin, {
action: "harvest.sync_apicore",
entityType: "Harvest",
detail: `${harvests.created} neu, ${harvests.updated} aktualisiert, ${harvests.skipped} übersprungen, ${harvests.failed} fehlgeschlagen`,
});
const batches = await loadAndSyncBatches(session);
await logAdminAction(admin, {
action: "batch.sync_apicore",
entityType: "Batch",
detail: batches.loadError
? `Chargen konnten nicht geladen werden: ${batches.loadError}`
: `${batches.counts.created} neu, ${batches.counts.skipped} übersprungen, ${batches.counts.failed} fehlgeschlagen`,
});
revalidatePath("/admin/ernten");
revalidatePath("/admin/chargen");
revalidatePath("/admin/produkte");
redirect(
`/admin/ernten?synced=${harvests.created}&updated=${harvests.updated}&skipped=${harvests.skipped}&failed=${harvests.failed}` +
`&batchSynced=${batches.counts.created}&batchSkipped=${batches.counts.skipped}&batchFailed=${batches.counts.failed}` +
(batches.loadError ? `&batchLoadError=${encodeURIComponent(batches.loadError)}` : "")
);
}
/**
* Sync-Knopf auf /admin/produkte: legt je ApiCore-ABFUELLUNG ein Produkt an,
* also einen eigenen Jahrgang (siehe syncProducts).
*
* Braucht dafuer beides: den Artikelkatalog (Name, Artikelnummer) und die
* Abfuellungen (Jahrgang, Glasgroesse, Losnummer). Chargen selbst werden hier
* NICHT importiert — das gehoert zum Ernten-Sync, weil eine Charge ohne ihre
* Ernte gar nicht anlegbar ist. Fehlt die Charge noch, meldet der Lauf das
* als "wartet auf den Ernten-Sync" statt als Fehler.
*/
export async function syncProductsFromApiCoreAction(): Promise<void> {
const admin = await requireAdmin();
let session: ApiCoreSession;
let articles: ApiCoreProduct[];
let bottlings: ApiCoreBottlingBatchWithHarvestId[];
try {
session = await openApiCoreSession();
[articles, bottlings] = await Promise.all([
fetchApiCoreProducts(session),
fetchApiCoreBottlingBatches(session),
]);
} catch (error: unknown) {
redirect(`/admin/produkte?syncError=${encodeURIComponent(describeError(error))}`);
}
const products = await syncProducts(articles, bottlings);
await logAdminAction(admin, {
action: "product.sync_apicore",
entityType: "Product",
detail:
`${products.created} neu, ${products.skipped} übersprungen, ${products.failed} fehlgeschlagen, ` +
`${products.pending} warten auf den Ernten-Sync, ${products.discontinued} in ApiCore ausgelaufen`,
});
revalidatePath("/admin/produkte");
redirect(
`/admin/produkte?synced=${products.created}&skipped=${products.skipped}` +
`&failed=${products.failed}&pending=${products.pending}&discontinued=${products.discontinued}`
);
}