- Harvest descriptions no longer include ApiCore's internal harvest notes. The description becomes public on "Veröffentlichen", so a draft published without a careful read could have carried operational notes into the shop. - A repeated sync overwrote unpublished harvest drafts, undoing the admin's corrections (season instead of honey type, location, sales text). Drafts with an admin audit entry are now left alone and reported as protected. - Every ApiCore request has a 20 s timeout; before, an ApiCore that accepted the connection but never answered left the admin action hanging. - The sync panel shows the time of the last complete run. - Client tests: timeout, failed fetch is never treated as an empty source, malformed responses are rejected as a whole. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
138 lines
6.1 KiB
TypeScript
138 lines
6.1 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 Mengenfeldern, die der Admin beim Review verfeinert.
|
|
*
|
|
* ApiCores `notes` werden bewusst NICHT uebernommen: das sind interne
|
|
* Betriebsnotizen des Imkers (Beobachtungen, Probleme, Behandlungshinweise),
|
|
* und die Beschreibung wird mit dem Veroeffentlichen oeffentlich. Ein Klick
|
|
* auf "Veroeffentlichen" ohne Durchlesen duerfte sie sonst in den Shop tragen.
|
|
*/
|
|
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`);
|
|
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];
|
|
}
|