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]>
134 lines
5.9 KiB
TypeScript
134 lines
5.9 KiB
TypeScript
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, vintageYear?: number): string {
|
|
const own = source.description?.trim();
|
|
const facts: string[] = [];
|
|
if (vintageYear != null) facts.push(`Jahrgang ${vintageYear}`);
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Gewicht eines Glases aus dieser Abfuellung.
|
|
*
|
|
* Bevorzugt `jarSize` der Abfuellung gegenueber den Angaben am
|
|
* ApiCore-Artikel: die Abfuellung sagt, was TATSAECHLICH in dieses Glas
|
|
* gefuellt wurde, der Artikel nur, was ueblicherweise vorgesehen ist. Wird
|
|
* ein Jahrgang einmal in 250-g- statt 500-g-Glaeser gefuellt, steht das an
|
|
* der Abfuellung und nirgends sonst.
|
|
*
|
|
* Zur Einheit "Gramm" siehe den Kommentar an productSchema in
|
|
* apicore-client.ts: ApiCore fuehrt jarSize mit Werten wie 500 und gibt sie
|
|
* in der eigenen Oberflaeche als "g" aus.
|
|
*/
|
|
export function deriveJarWeightGrams(
|
|
jarSize: number | null | undefined,
|
|
source: ApiCoreProduct
|
|
): number {
|
|
if (jarSize != null && Number.isFinite(jarSize) && jarSize > 0) return Math.round(jarSize);
|
|
return deriveWeightGrams(source);
|
|
}
|
|
|
|
/** Aus "Waldhonig, cremig" wird "waldhonig-cremig". */
|
|
export function slugifyProductName(name: string): string {
|
|
return name
|
|
.toLowerCase()
|
|
.replace(/ä/g, "ae")
|
|
.replace(/ö/g, "oe")
|
|
.replace(/ü/g, "ue")
|
|
.replace(/ß/g, "ss")
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
}
|
|
|
|
/**
|
|
* Moegliche oeffentliche Adressen fuer ein synchronisiertes Produkt, in
|
|
* absteigender Wuenschbarkeit — der Sync nimmt die erste noch freie.
|
|
*
|
|
* Der Jahrgang gehoert in die Adresse, weil genau er die Produkte
|
|
* unterscheidet: "Waldhonig" heisst jedes Jahr gleich, ist aber jedes Jahr
|
|
* ein anderes Erzeugnis mit eigenem Preis und eigenen Bewertungen. Ohne
|
|
* Jahreszahl kollidierte der zweite Jahrgang mit dem ersten (Product.slug ist
|
|
* eindeutig).
|
|
*
|
|
* Die Losnummer als zweite Stufe deckt den selteneren Fall ab, dass ein
|
|
* Artikel im selben Jahr mehrfach abgefuellt wurde. Faellt auch die weg,
|
|
* entscheidet der Aufrufer — hier wird bewusst nicht hochgezaehlt: eine
|
|
* Adresse wie "waldhonig-2026-2" sagt niemandem etwas.
|
|
*/
|
|
export function productSlugCandidates(name: string, year: number, lotNumber: string): string[] {
|
|
const base = slugifyProductName(name) || "produkt";
|
|
const withYear = `${base}-${year}`;
|
|
const lot = slugifyProductName(lotNumber);
|
|
return lot ? [withYear, `${withYear}-${lot}`] : [withYear];
|
|
}
|