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:
1 parent
c61ae92390
commit
78c19f1b73
15 files changed
+908
-226
No files matched your search
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user