feat: Produkte aus ApiCore synchronisieren

Der Shop konnte Ernten und Chargen bereits aus ApiCore uebernehmen, den
Produktkatalog aber nicht - jedes Glas musste im Admin ein zweites Mal von
Hand angelegt werden. Neuer Knopf unter /admin/produkte zieht die Produkte
und traegt anschliessend die passende Charge am Produkt nach, wo ApiCore
die Abfuellung einem Produkt zugeordnet hat. Genau diese Verknuepfung
fuettert die Herkunftsseite hinter dem QR-Code auf dem Glas.

ApiCore fuehrt ueberhaupt keine Verkaufspreise. Importierte Produkte
entstehen deshalb als deaktivierte Entwuerfe mit Preis 0, den nur ein
Mensch setzen kann. Damit daraus nie eine Gratis-Bestellung wird, lehnen
die Aktivierung im Admin und der Checkout ein Produkt ohne Preis jetzt
ausdruecklich ab, und die Produktliste kennzeichnet solche Entwuerfe.

Ein bereits vorhandenes Produkt wird uebersprungen, nie ueberschrieben:
genau die drei Felder, die ApiCore liefert (Name, Beschreibung, Gewicht),
zieht der Admin vor der Freischaltung von Hand nach - ein Update wuerde
diese Arbeit bei jedem weiteren Lauf wegwerfen. Ein in ApiCore
ausgelaufenes Produkt wird nicht automatisch deaktiviert, sondern nur
gemeldet, damit der Restbestand abverkauft werden kann.

Nebenbei behoben: die beiden Produktformulare und die Bestelluebersicht
haben den ?error=-Parameter aus fail() nie ausgegeben - abgelehnte
Eingaben landeten wortlos wieder auf dem Formular.

Der Login gegen ApiCore ist jetzt von den Abfragen getrennt
(openApiCoreSession), damit ein Sync-Lauf mit einer einzigen Anmeldung
auskommt statt mit dreien. Die Feld-Abbildung liegt neu in
lib/apicore-mapping.ts und ist damit testbar, statt hinter "use server"
zu verschwinden.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-16 18:03:05 +02:00
1 parent c61ae92390
commit 78c19f1b73
15 files changed
+908 -226

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
import type { ApiCoreHarvestWithApiaryName, ApiCoreProduct } from "./apicore-client";
/**
* Reine Feld-Abbildung ApiCore -> Wabenhain. Bewusst getrennt von
* admin/actions/apicore-sync.ts: jene Datei traegt "use server" und darf
* deshalb ausschliesslich async Server Actions exportieren — dort waeren
* genau die Entscheidungen, die man pruefen moechte (Was wird aus einem
* Produkt ohne Beschreibung? Was aus einem fehlenden Gewicht?), nicht
* einzeln testbar.
*/
/**
* Stabiler, eindeutiger Slug aus der ApiCore-ID — zugleich der
* Idempotenz-Schluessel des Syncs: ein wiederholter Lauf erkennt daran, ob
* ein ApiCore-Datensatz schon einmal importiert wurde, statt bei jedem Lauf
* Duplikate anzulegen. Bewusst NICHT aus Name/Jahr abgeleitet (wie bei den
* manuellen Anlege-Formularen): ein umbenannter Artikel bekaeme sonst einen
* neuen Slug und wuerde ein zweites Mal importiert.
*/
export function slugForApiCoreSource(sourceId: string): string {
return `apicore-${sourceId}`;
}
/**
* Wabenhains Harvest.description ist ein Pflichtfeld, ApiCore liefert dafuer
* kein Aequivalent — baut stattdessen eine sinnvolle Vorbefuellung aus den
* vorhandenen Mengen-/Notizfeldern, die der Admin beim Review verfeinert.
*/
export function buildHarvestDescription(source: ApiCoreHarvestWithApiaryName): string {
const parts: string[] = [];
if (source.honeySuperCount != null) parts.push(`${source.honeySuperCount} Honigräume`);
if (source.grossWeight != null) parts.push(`Bruttogewicht ${source.grossWeight} kg`);
if (source.netWeight != null) parts.push(`Nettogewicht ${source.netWeight} kg`);
if (source.notes?.trim()) parts.push(source.notes.trim());
const detail = parts.length > 0 ? parts.join(" · ") : "Bitte Beschreibung ergänzen.";
return `Automatisch aus ApiCore importiert. ${detail}`;
}
/**
* Wabenhains Product.description ist ein Pflichtfeld und steht im Shop als
* Verkaufstext. ApiCores Beschreibung ist dagegen eine interne Notiz und oft
* leer.
*
* Sie wird deshalb zwar uebernommen, aber immer mit einem Herkunftshinweis
* versehen: der Text bleibt als Entwurf erkennbar, solange ihn niemand
* ersetzt hat. Ist gar nichts da, tritt an seine Stelle eine ausdrueckliche
* Aufforderung statt eines leeren Feldes — ein leerer Verkaufstext waere im
* Shop nicht von einem vergessenen zu unterscheiden.
*/
export function buildProductDescription(source: ApiCoreProduct): string {
const own = source.description?.trim();
const facts: string[] = [];
if (source.sku?.trim()) facts.push(`Artikelnummer ${source.sku.trim()}`);
if (source.salesUnit?.trim()) facts.push(`Verkaufseinheit ${source.salesUnit.trim()}`);
const detail = own || "Bitte Produktbeschreibung für den Shop ergänzen.";
const suffix = facts.length > 0 ? ` (${facts.join(" · ")})` : "";
return `Automatisch aus ApiCore importiert${suffix}. ${detail}`;
}
/**
* Gewicht in Gramm. Bevorzugt ApiCores `weight` (Gewicht des Artikels),
* ersatzweise `packagingSize` (Gebindegroesse) — zur Einheiten-Annahme
* "Gramm" siehe den Kommentar an productSchema in apicore-client.ts.
*
* Fehlt beides, wird bewusst 0 zurueckgegeben statt geraten. 0 ist in
* Wabenhain kein gueltiges Gewicht (adminProductNumbersSchema verlangt
* mindestens 1 g), das importierte Produkt laesst sich also gar nicht
* abspeichern, ohne dass jemand ein echtes Gewicht eintraegt — genau die
* gewuenschte Bremse, denn aus dem Gewicht ergibt sich der Grundpreis je
* Kilogramm, der in Deutschland verpflichtend anzugeben ist.
*/
export function deriveWeightGrams(source: ApiCoreProduct): number {
const raw = source.weight ?? source.packagingSize;
if (raw == null || !Number.isFinite(raw) || raw <= 0) return 0;
return Math.round(raw);
}