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
+906 -224

No files matched your search

+14 -10
View File
@@ -81,16 +81,20 @@ SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM="Wabenhain <[email protected]>"
# ApiCore-Ernten-Synchronisierung (optional, admin/ernten -> "ApiCore-
# Synchronisierung"). Ohne diese drei Werte zeigt der Sync-Button einen
# Konfigurationsfehler statt eines Ergebnisses. ApiCore ist ein separates
# Projekt (NestJS-Backend) mit reinem E-Mail/Passwort-Login (kein API-Key-
# Mechanismus) — hier IDEALERWEISE die Zugangsdaten eines dedizierten,
# MFA-freien Service-Kontos mit ausschliesslich den Berechtigungen
# "harvests:read", "apiaries:read" (optional, fuer Standortnamen),
# "extractions:read" und "bottling:read" (beide fuer den Chargen-Sync)
# eintragen, nicht die eines echten Admin-Zugangs. Details siehe
# src/lib/apicore-client.ts.
# ApiCore-Synchronisierung (optional). Zwei Knoepfe nutzen sie: admin/ernten
# -> "ApiCore-Synchronisierung" (Ernten + Chargen) und admin/produkte ->
# "ApiCore-Synchronisierung" (Produktkatalog). Ohne diese drei Werte zeigen
# beide einen Konfigurationsfehler statt eines Ergebnisses. ApiCore ist ein
# separates Projekt (NestJS-Backend) mit reinem E-Mail/Passwort-Login (kein
# API-Key-Mechanismus) — hier IDEALERWEISE die Zugangsdaten eines
# dedizierten, MFA-freien Service-Kontos eintragen, nicht die eines echten
# Admin-Zugangs. Benoetigte Berechtigungen, alle nur lesend:
# harvests:read Ernten
# products:read Produktkatalog
# extractions:read Schleuderungen (verbinden Charge und Ernte)
# bottling:read Abfuellungen = Chargen
# apiaries:read optional, nur fuer die Standortnamen an den Ernten
# Details siehe src/lib/apicore-client.ts.
APICORE_API_URL=
APICORE_SYNC_EMAIL=
APICORE_SYNC_PASSWORD=
+23
View File
@@ -39,6 +39,7 @@ Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe.
- Rechtliches: Impressum, Datenschutz, AGB, Widerrufsbelehrung, Versand und Zahlung (**Platzhaltertexte, rechtlich prüfen lassen**)
- **Kundenkonto** unter `/konto`: Registrierung mit **Pflicht-E-Mail-Verifizierung** (siehe „Sicherheit" unten) und Login (Passwort-Hash + signiertes Session-Cookie), optionale **Zwei-Faktor-Authentifizierung** (TOTP-Authenticator-App + Backup-Codes, `/konto/zwei-faktor`), Konto-Sperre nach Fehlversuchen, "Alle Geräte abmelden", "Passwort vergessen"-Link mit E-Mail-Reset, Bestellhistorie mit Versand-/Abholstatus, "Bestellung erneut auslösen", Adressverwaltung (mehrere Adressen, Standardadresse), **Wunschliste**, Rechnungsansicht als HTML und PDF-Download (`/rechnung/[nummer]`), Newsletter-Verwaltung mit Double-Opt-In
- **Admin-Backend** unter `/admin` (Zugriff über das normale Kundenkonto-Login + Rolle `ADMIN`, siehe „Admin-Zugang" unten): Dashboard mit Kennzahlen, Top-Produkten, Umsatz nach Kategorie, **Umsatzverlauf der letzten 14 Tage**, **Stammkund:innen-Zähler** und **meistgenutzten Gutscheinen**, Bestellliste mit Detailseite (`/admin/bestellungen/[id]`: vollständige Adresse, Artikel, Zahlung, Rabatt, Rechnungs-PDF) inkl. Status-Änderung (setzt automatisch Versand-/Abholdatum **und verschickt eine Versand-/Abholbereit-E-Mail**), Produkte/Ernten/Chargen jeweils mit Anlegen- und Bearbeiten-Formularen, Firmenanfragen-Inbox mit Statuspflege, **Benutzerverwaltung** (`/admin/benutzer`, siehe „Admin-Zugang" unten), **Versandkosten-Verwaltung** (`/admin/versand`, siehe „Versandkosten" unten), **Gutschein-Verwaltung** (`/admin/gutscheine`), **Bewertungsmoderation** (`/admin/bewertungen`: freischalten oder löschen), **Audit-Log** (`/admin/audit-log`, siehe „Sicherheit" unten)
- **ApiCore-Synchronisierung**: Ernten, Chargen und der Produktkatalog lassen sich per Knopfdruck aus dem Imkerei-Verwaltungssystem ApiCore übernehmen, statt dieselben Daten zweimal einzutippen — jeweils in einen Entwurfszustand, den erst ein Mensch freigibt, und ohne je etwas zu überschreiben, was im Admin bewusst gesetzt wurde (siehe „ApiCore-Synchronisierung" unten)
- **Transaktions-E-Mails** (Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in, Passwort-Reset) über SMTP, siehe Abschnitt „E-Mail-Versand" unten
- `sitemap.xml`, `robots.txt`, OpenGraph-Metadaten, Favicon (`app/icon.svg`) mit dem echten Wabenhain-Logo
- **Barrierefreiheit (WCAG AA)**: die Honigfarbe gibt es jetzt als zwei kontraststarke Varianten (`text-honey-dark` für helle, `text-honey-light` für dunkle Abschnitte wie `bg-forest`) statt eines einzelnen Tons, der auf keinem der beiden Hintergründe zuverlässig 4.5:1 erreichte; dieselbe Kontrastprüfung wurde site-weit für alle `text-ink/NN`-Texttöne nachgezogen (Admin-Bereich, Kundenkonto, Checkout, Rechnungen, Produktkarten, Geschmacksskala — 190+ Stellen in 50 Dateien) sowie für die Bildergalerie auf der Produktseite korrigiert (Vorschaubild-Buttons hatten einen `aria-label`, der den sichtbaren Bildtext überschrieben statt eingeschlossen hat). Lighthouse-Accessibility-Audit steht jetzt bei 100/100 ohne offene Verstöße auf Start-, Shop- und Produktseite.
@@ -189,6 +190,28 @@ Registrierung, Newsletter-Anmeldung und Firmenanfrage sind optional durch [Cloud
Die serverseitige Prüfung läuft über `src/lib/turnstile.ts` gegen die Cloudflare-Siteverify-API; das Widget selbst ist `src/components/turnstile-widget.tsx`.
## ApiCore-Synchronisierung
ApiCore ist das separate Imkerei-Verwaltungssystem (NestJS-Backend mit eigener Datenbank), in dem Völker, Ernten, Schleuderungen und Abfüllungen gepflegt werden. Wabenhain kann drei Datenarten daraus übernehmen, damit dieselbe Charge nicht zweimal von Hand eingetippt wird:
| ApiCore | Wabenhain | Knopf |
|---------|-----------|-------|
| `Harvest` (Ernte) | `Harvest` | `/admin/ernten` → „ApiCore-Synchronisierung" |
| `BottlingBatch` (Abfüllung) | `Batch` (Charge) | derselbe Knopf, läuft direkt nach den Ernten |
| `Product` (Produktkatalog) | `Product` | `/admin/produkte` → „ApiCore-Synchronisierung" |
**Grundregel: ApiCore liefert die Rohdaten, Wabenhain trifft die Verkaufsentscheidungen.** Ein Sync überschreibt deshalb nie etwas, das im Admin bewusst gesetzt wurde:
- **Ernten** entstehen als unveröffentlichte Entwürfe. Eine bereits veröffentlichte, früher synchronisierte Ernte bleibt unangetastet — sonst würde ein erneuter Lauf eine Freigabeentscheidung stillschweigend zurücknehmen.
- **Produkte** entstehen als deaktivierte Entwürfe **ohne Preis**: ApiCore führt überhaupt keine Verkaufspreise, das ist reine Betriebsdatenhaltung. Den Preis kann nur ein Mensch setzen. Solange er fehlt, lässt sich das Produkt nicht aktiv schalten (`admin/actions/products.ts`), und der Checkout lehnt es zusätzlich ab (`api/checkout/route.ts`) — ein importiertes Produkt kann also nie versehentlich für 0 € verkauft werden. Ein bereits vorhandenes Produkt wird übersprungen, nie aktualisiert: genau die drei Felder, die ApiCore liefert (Name, Beschreibung, Gewicht), zieht der Admin vor der Freischaltung von Hand nach, und ein Update würde diese Arbeit bei jedem weiteren Lauf wegwerfen. Soll ein Produkt wirklich frisch aus ApiCore kommen, löscht man es in Wabenhain und synchronisiert erneut.
- **Chargen** werden ausschließlich neu angelegt, nie aktualisiert. Ein Update würde `remainingGlasses` überschreiben — das ist Wabenhains eigener, durch echte Verkäufe veränderter Lagerbestand.
- Wo ApiCore eine Abfüllung einem Produkt zuordnet, trägt der Produkt-Sync die passende **Charge am Produkt** nach (und darüber die Ernte), aber nur solange dort noch keine Charge steht. Genau diese Verknüpfung füttert die Herkunftsseite hinter dem QR-Code auf dem Glas.
- Ein in ApiCore ausgelaufenes Produkt (`DISCONTINUED`) wird **nicht** automatisch deaktiviert: solange Gläser im Regal stehen, ist „nicht mehr produziert" kein Grund, den Abverkauf zu stoppen. Der Fall wird stattdessen im Ergebnis gemeldet.
Wiederholte Läufe sind gefahrlos: Ernten und Produkte werden über einen stabilen Schlüssel `apicore-<ApiCore-ID>` wiedererkannt (`Harvest.slug` bzw. `Product.slug`), Chargen über die ApiCore-Losnummer als `Batch.code`.
**Einrichtung:** `APICORE_API_URL`, `APICORE_SYNC_EMAIL` und `APICORE_SYNC_PASSWORD` in `.env` setzen (siehe `.env.example`). ApiCore kennt keinen API-Key-Mechanismus, nur E-Mail/Passwort-Login — deshalb dort ein **eigenes, MFA-freies Service-Konto** anlegen statt einen echten Admin-Zugang einzutragen. Es braucht ausschließlich Leserechte: `harvests:read`, `products:read`, `extractions:read`, `bottling:read` und optional `apiaries:read` (nur für die Standortnamen an den Ernten). Fehlt eine Berechtigung, meldet der Sync das konkret als HTTP 403 statt still nichts zu tun. Details und die Zuordnung Feld für Feld: `src/lib/apicore-client.ts`, `src/lib/apicore-mapping.ts` und `src/app/admin/actions/apicore-sync.ts`.
## Vollständiger Docker-Betrieb
```bash
+325 -118
View File
@@ -5,96 +5,98 @@ 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,
deriveWeightGrams,
} from "@/lib/apicore-mapping";
import { requireAdmin } from "./_shared";
/**
* Stabiler, eindeutiger Slug aus der ApiCore-Ernte-ID statt aus Typ/Jahr
* abgeleitet (wie beim manuellen createHarvestAction) — dient hier zugleich
* als Idempotenz-Schluessel: ein wiederholter Sync erkennt an diesem Slug,
* ob eine ApiCore-Ernte schon einmal importiert wurde, statt bei jedem Lauf
* Duplikate anzulegen.
*/
function slugFor(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.
*/
function buildDescription(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}`;
}
/**
* Zieht Ernten UND Chargen aus ApiCore.
* Uebernimmt Stammdaten aus ApiCore (separates Imkerei-Verwaltungssystem) in
* den Shop: Ernten, Produkte und Chargen.
*
* Ernten werden als unveroeffentlichte Entwuerfe angelegt (isPublished:
* false) bzw. bereits vorhandene, noch unveroeffentlichte Entwuerfe
* aktualisiert. Bereits vom Admin veroeffentlichte, frueher synchronisierte
* Ernten werden NICHT angefasst — sonst wuerde ein erneuter Sync eine
* bewusste Freigabe- und Bearbeitungsentscheidung des Admins stillschweigend
* zuruecksetzen.
* 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.
*
* Chargen (Wabenhain: Batch, ApiCore: BottlingBatch — siehe Kommentar in
* lib/apicore-client.ts fuer die Modul-Zuordnung) werden dagegen NUR neu
* angelegt, nie aktualisiert: anders als Harvest hat Batch kein
* isPublished-Feld, das eine bereits erfolgte Admin-Bearbeitung markieren
* koennte. Ein Update wuerde insbesondere remainingGlasses ueberschreiben —
* das ist Wabenhains EIGENER, durch echte Verkaeufe veraenderter
* Lagerbestand und darf niemals durch einen ApiCore-Sync zurueckgesetzt
* werden. Existiert eine Charge (per code) bereits, wird sie uebersprungen.
* Jede Charge braucht eine bereits in Wabenhain vorhandene (synchronisierte
* oder manuell angelegte) Ernte als harvestId — Chargen laufen deshalb nach
* den Ernten in diesem Lauf, damit gerade frisch angelegte Ernten schon
* verfuegbar sind.
* - 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.
*
* Admin-Berechtigungsfeld "type" bei Wabenhain (Erntesaison, z.B.
* "Sommertracht") und ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig")
* sind KEINE aequivalenten Taxonomien — es findet bewusst keine automatische
* 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.
*/
export async function syncHarvestsFromApiCoreAction(): Promise<void> {
const admin = await requireAdmin();
let sourceHarvests: ApiCoreHarvestWithApiaryName[];
try {
sourceHarvests = await fetchApiCoreHarvests();
} catch (error: unknown) {
const message =
error instanceof ApiCoreConfigError || error instanceof ApiCoreRequestError
? error.message
: error instanceof Error
? error.message
: "Unbekannter Fehler";
redirect(`/admin/ernten?syncError=${encodeURIComponent(message)}`);
}
interface SyncCounts {
created: number;
updated: number;
skipped: number;
failed: number;
}
let created = 0;
let updated = 0;
let skipped = 0;
let failed = 0;
/**
* Produkte kennen bewusst kein "updated" — sie werden nur neu angelegt,
* siehe syncProducts().
*/
interface ProductSyncCounts {
created: number;
skipped: number;
failed: number;
/** In ApiCore nicht mehr verkaufsfaehig, in Wabenhain aber noch aktiv. */
discontinued: number;
}
for (const source of sourceHarvests) {
const slug = slugFor(source.id);
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())) {
failed++;
counts.failed++;
console.error(`[apicore-sync] Ernte ${source.id} hat ein ungueltiges Datum ("${source.date}") — übersprungen.`);
continue;
}
@@ -102,9 +104,8 @@ export async function syncHarvestsFromApiCoreAction(): Promise<void> {
try {
const existing = await prisma.harvest.findUnique({ where: { slug } });
if (existing?.isPublished) {
skipped++;
counts.skipped++;
continue;
}
@@ -113,64 +114,122 @@ export async function syncHarvestsFromApiCoreAction(): Promise<void> {
type,
harvestDate: date,
location: source.apiaryName ?? "Altenmünster",
description: buildDescription(source),
description: buildHarvestDescription(source),
};
if (existing) {
await prisma.harvest.update({ where: { slug }, data });
updated++;
counts.updated++;
} else {
await prisma.harvest.create({ data: { ...data, slug, isPublished: false } });
created++;
counts.created++;
}
} catch (error: unknown) {
failed++;
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
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: ${message}`);
console.error(`[apicore-sync] Ernte ${source.id} konnte nicht importiert werden: ${describeError(error)}`);
}
}
await logAdminAction(admin, {
action: "harvest.sync_apicore",
entityType: "Harvest",
detail: `${created} neu, ${updated} aktualisiert, ${skipped} übersprungen, ${failed} fehlgeschlagen`,
});
return counts;
}
// Chargen-Sync laeuft NACH den Ernten (s.o., Doc-Kommentar) und ist
// bewusst nicht fatal fuer den gesamten Aufruf: schlaegt nur das Laden der
// Chargen fehl, sollen die bereits erfolgreich synchronisierten Ernten
// trotzdem sichtbar bleiben statt durch einen Redirect mit Fehlerseite
// verloren zu gehen.
let batchCreated = 0;
let batchSkipped = 0;
let batchFailed = 0;
let batchLoadError: string | null = null;
// ---------------------------------------------------------------------------
// 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.
*/
async function syncProducts(sources: ApiCoreProduct[]): Promise<ProductSyncCounts> {
const counts: ProductSyncCounts = { created: 0, skipped: 0, failed: 0, discontinued: 0 };
for (const source of sources) {
const slug = slugForApiCoreSource(source.id);
let sourceBatches: ApiCoreBottlingBatchWithHarvestId[] = [];
try {
sourceBatches = await fetchApiCoreBottlingBatches();
} catch (error: unknown) {
batchLoadError =
error instanceof ApiCoreConfigError || error instanceof ApiCoreRequestError
? error.message
: error instanceof Error
? error.message
: "Unbekannter Fehler";
const existing = await prisma.product.findUnique({ where: { slug } });
if (!isSellableStatus(source.status)) {
// Ein in ApiCore ausgelaufener Artikel wird NICHT automatisch
// deaktiviert: solange Glaeser im Regal stehen, ist "nicht mehr
// produziert" kein Grund, den Verkauf des Restbestands abzubrechen.
// Der Fall wird stattdessen gezaehlt und im Ergebnis gemeldet, damit
// der Admin selbst entscheidet.
if (existing?.isActive) counts.discontinued++;
else counts.skipped++;
continue;
}
for (const source of sourceBatches) {
if (existing) {
counts.skipped++;
continue;
}
await prisma.product.create({
data: {
slug,
name: source.name,
description: buildProductDescription(source),
weightGrams: deriveWeightGrams(source),
category: "HONIG",
priceCents: 0,
pricePerKgCents: 0,
isActive: false,
inventory: { create: { quantity: 0 } },
},
});
counts.created++;
} catch (error: unknown) {
counts.failed++;
console.error(`[apicore-sync] Produkt ${source.id} konnte nicht importiert werden: ${describeError(error)}`);
}
}
return counts;
}
// ---------------------------------------------------------------------------
// 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) {
batchFailed++;
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())) {
batchFailed++;
counts.failed++;
console.error(`[apicore-sync] Charge ${source.lotNumber} hat ein ungültiges Datum — übersprungen.`);
continue;
}
@@ -178,48 +237,196 @@ export async function syncHarvestsFromApiCoreAction(): Promise<void> {
try {
const existing = await prisma.batch.findUnique({ where: { code: source.lotNumber } });
if (existing) {
batchSkipped++;
counts.skipped++;
continue;
}
const harvest = await prisma.harvest.findUnique({ where: { slug: slugFor(source.harvestSourceId) } });
const harvest = await prisma.harvest.findUnique({
where: { slug: slugForApiCoreSource(source.harvestSourceId) },
});
if (!harvest) {
batchFailed++;
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: Math.max(Math.round(source.jarCount), 0),
remainingGlasses: Math.max(Math.round(source.jarCount), 0),
glassCount,
remainingGlasses: glassCount,
},
});
batchCreated++;
counts.created++;
} catch (error: unknown) {
batchFailed++;
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
console.error(`[apicore-sync] Charge ${source.lotNumber} konnte nicht importiert werden: ${message}`);
counts.failed++;
console.error(`[apicore-sync] Charge ${source.lotNumber} konnte nicht importiert werden: ${describeError(error)}`);
}
}
return counts;
}
/**
* Traegt die Charge (und ueber sie die Ernte) an synchronisierten Produkten
* nach, wo ApiCore die Abfuellung einem Produkt zugeordnet hat. Genau diese
* Verknuepfung macht auf der Produktseite und ueber den QR-Code auf dem Glas
* die Herkunft nachvollziehbar.
*
* Bewusst nur dort, wo Product.batchId noch leer ist: hat jemand im Admin
* schon eine Charge zugeordnet, ist das eine Entscheidung darueber, was
* tatsaechlich im Karton liegt — die darf ein Sync nicht umbiegen. Der Lauf
* ist dadurch gefahrlos wiederholbar.
*
* Fachliche Grenze: in ApiCore hat ein Produkt MEHRERE Abfuellungen, in
* Wabenhain zeigt ein Produkt auf GENAU EINE Charge. Verknuepft wird deshalb
* die zuerst gefundene, noch nicht zugeordnete Kombination; welche Charge
* gerade verkauft wird, entscheidet weiterhin der Admin.
*/
async function linkProductsToBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promise<number> {
let linked = 0;
for (const source of sources) {
if (!source.productId) continue;
try {
const product = await prisma.product.findUnique({
where: { slug: slugForApiCoreSource(source.productId) },
});
if (!product || product.batchId) continue;
const batch = await prisma.batch.findUnique({ where: { code: source.lotNumber } });
if (!batch) continue;
await prisma.product.update({
where: { id: product.id },
data: { batchId: batch.id, harvestId: batch.harvestId },
});
linked++;
} catch (error: unknown) {
console.error(
`[apicore-sync] Charge ${source.lotNumber} konnte nicht mit ihrem Produkt verknüpft werden: ${describeError(error)}`
);
}
}
return linked;
}
/**
* Chargen laden und, soweit gewuenscht, importieren und mit ihren Produkten
* verknuepfen. Ein Fehlschlag beim Laden ist bewusst NICHT fatal fuer den
* umgebenden Sync-Lauf: die bereits erfolgreich uebernommenen Ernten bzw.
* Produkte sollen sichtbar bleiben, statt hinter einer Fehlerseite zu
* verschwinden.
*/
async function syncBatchesAndLinks(
session: ApiCoreSession,
options: { importBatches: boolean }
): Promise<{ counts: SyncCounts; linked: number; loadError: string | null }> {
let sources: ApiCoreBottlingBatchWithHarvestId[];
try {
sources = await fetchApiCoreBottlingBatches(session);
} catch (error: unknown) {
return {
counts: { created: 0, updated: 0, skipped: 0, failed: 0 },
linked: 0,
loadError: describeError(error),
};
}
const counts = options.importBatches
? await syncBatches(sources)
: { created: 0, updated: 0, skipped: 0, failed: 0 };
const linked = await linkProductsToBatches(sources);
return { counts, linked, 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 syncBatchesAndLinks(session, { importBatches: true });
await logAdminAction(admin, {
action: "batch.sync_apicore",
entityType: "Batch",
detail: batchLoadError
? `Chargen konnten nicht geladen werden: ${batchLoadError}`
: `${batchCreated} neu, ${batchSkipped} übersprungen, ${batchFailed} fehlgeschlagen`,
detail: batches.loadError
? `Chargen konnten nicht geladen werden: ${batches.loadError}`
: `${batches.counts.created} neu, ${batches.counts.skipped} übersprungen, ${batches.counts.failed} fehlgeschlagen, ${batches.linked} mit Produkt verknüpft`,
});
revalidatePath("/admin/ernten");
revalidatePath("/admin/chargen");
revalidatePath("/admin/produkte");
redirect(
`/admin/ernten?synced=${created}&updated=${updated}&skipped=${skipped}&failed=${failed}` +
`&batchSynced=${batchCreated}&batchSkipped=${batchSkipped}&batchFailed=${batchFailed}` +
(batchLoadError ? `&batchLoadError=${encodeURIComponent(batchLoadError)}` : "")
`/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: zieht den Produktkatalog und traegt
* anschliessend die Charge an den synchronisierten Produkten nach. Chargen
* selbst werden hier NICHT importiert — das gehoert zum Ernten-Sync, weil
* eine Charge ohne ihre Ernte gar nicht anlegbar ist.
*/
export async function syncProductsFromApiCoreAction(): Promise<void> {
const admin = await requireAdmin();
let session: ApiCoreSession;
let sourceProducts: ApiCoreProduct[];
try {
session = await openApiCoreSession();
sourceProducts = await fetchApiCoreProducts(session);
} catch (error: unknown) {
redirect(`/admin/produkte?syncError=${encodeURIComponent(describeError(error))}`);
}
const products = await syncProducts(sourceProducts);
const batches = await syncBatchesAndLinks(session, { importBatches: false });
await logAdminAction(admin, {
action: "product.sync_apicore",
entityType: "Product",
detail:
`${products.created} neu, ${products.skipped} übersprungen, ${products.failed} fehlgeschlagen, ` +
`${products.discontinued} in ApiCore ausgelaufen, ${batches.linked} mit Charge verknüpft`,
});
revalidatePath("/admin/produkte");
redirect(
`/admin/produkte?synced=${products.created}&skipped=${products.skipped}` +
`&failed=${products.failed}&discontinued=${products.discontinued}&linked=${batches.linked}` +
(batches.loadError ? `&linkError=${encodeURIComponent(batches.loadError)}` : "")
);
}
+26 -2
View File
@@ -9,6 +9,24 @@ import { adminProductNumbersSchema } from "@/lib/validation";
import { isValidUploadPath } from "@/lib/uploads";
import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
/**
* Ein Produkt darf nicht aktiv geschaltet werden, solange kein Preis
* gepflegt ist. `adminProductNumbersSchema` erlaubt 0 bewusst weiterhin:
* ein Entwurf ohne Preis muss speicherbar bleiben — insbesondere die aus
* ApiCore importierten Produkte, die genau so entstehen (ApiCore fuehrt
* keine Verkaufspreise, siehe apicore-sync.ts). Verboten ist nur die
* Kombination "aktiv UND Preis 0", denn die waere im Shop als kostenloses
* Produkt bestellbar.
*/
function assertPricedIfActive(redirectPath: string, isActive: boolean, priceCents: number): void {
if (isActive && priceCents <= 0) {
fail(
redirectPath,
"Ein Produkt ohne Preis kann nicht aktiv geschaltet werden. Bitte zuerst einen Preis eintragen — oder das Produkt als Entwurf (nicht aktiv) speichern."
);
}
}
export async function createProductAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const slug = str(formData, "slug") || str(formData, "name").toLowerCase().replace(/[^a-z0-9]+/g, "-");
@@ -27,6 +45,9 @@ export async function createProductAction(formData: FormData): Promise<void> {
}
const numbers = parsedNumbers.data;
const isActive = formData.get("isActive") === "on";
assertPricedIfActive("/admin/produkte/neu", isActive, numbers.priceCents);
const category = (str(formData, "category") || "HONIG") as "HONIG" | "KERZEN" | "PROPOLIS";
// Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind
// davon unabhaengig. Eingaben aus dem Formular werden fuer diese Kategorien
@@ -50,7 +71,7 @@ export async function createProductAction(formData: FormData): Promise<void> {
priceCents: numbers.priceCents,
pricePerKgCents: numbers.pricePerKgCents,
images: strArray(formData, "images").filter(isValidUploadPath),
isActive: formData.get("isActive") === "on",
isActive,
inventory: { create: { quantity: numbers.quantity } },
},
});
@@ -84,6 +105,9 @@ export async function updateProductAction(formData: FormData): Promise<void> {
}
const numbers = parsedNumbers.data;
const isActive = formData.get("isActive") === "on";
assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents);
const previousInventory = await prisma.inventory.findUnique({
where: { productId: id },
select: { quantity: true },
@@ -103,7 +127,7 @@ export async function updateProductAction(formData: FormData): Promise<void> {
priceCents: numbers.priceCents,
pricePerKgCents: numbers.pricePerKgCents,
images: strArray(formData, "images").filter(isValidUploadPath),
isActive: formData.get("isActive") === "on",
isActive,
inventory: {
upsert: { create: { quantity: numbers.quantity }, update: { quantity: numbers.quantity } },
},
+7 -1
View File
@@ -12,7 +12,7 @@ import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
const t = getDictionary(await getLocale());
const statusLabels = t.orderStatusBadge;
const { q, status } = await props.searchParams;
const { q, status, error } = await props.searchParams;
const query = typeof q === "string" ? q.trim() : "";
const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined;
@@ -38,6 +38,12 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
<div>
<BackLink href="/admin" label={t.adminOrdersPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminOrdersPage.title}</h1>
{/* Die DHL-Aktionen leiten bei einer nicht mehr auffindbaren Bestellung
hierher zurueck (admin/actions/dhl.ts). Ohne diese Ausgabe sah das
aus wie ein folgenloser Klick. */}
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
<label className="text-sm">
+7
View File
@@ -10,6 +10,7 @@ import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminProductEditPage(props: PageProps<"/admin/produkte/[id]">) {
const t = getDictionary(await getLocale());
const { id } = await props.params;
const { error } = await props.searchParams;
const rawProduct = await prisma.product.findUnique({
where: { id },
include: { harvest: true, batch: true, inventory: true },
@@ -35,6 +36,12 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
)
: null}
</p>
{/* Abgelehnte Eingaben kommen ueber fail() als ?error= zurueck (siehe
admin/actions/_shared.ts). Ohne diese Ausgabe landete man wortlos
wieder auf dem Formular und wusste nicht, was abgelehnt wurde. */}
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form action={updateProductAction} className="space-y-4 bg-background border border-ink/10 p-6">
<input type="hidden" name="id" value={product.id} />
<Field name="name" label={t.adminProductEditPage.nameLabel} defaultValue={product.name} required />
+8 -1
View File
@@ -5,8 +5,9 @@ import { createProductAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminProductNewPage() {
export default async function AdminProductNewPage(props: PageProps<"/admin/produkte/neu">) {
const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
const [harvests, batches] = await Promise.all([
prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }),
prisma.batch.findMany({ orderBy: { fillDate: "desc" }, include: { harvest: true } }),
@@ -16,6 +17,12 @@ export default async function AdminProductNewPage() {
<div className="max-w-xl">
<BackLink href="/admin/produkte" label={t.adminProductNewPage.backToProducts} />
<h1 className="text-2xl text-forest mb-8">{t.adminProductNewPage.title}</h1>
{/* Abgelehnte Eingaben kommen ueber fail() als ?error= zurueck (siehe
admin/actions/_shared.ts). Ohne diese Ausgabe landete man wortlos
wieder auf dem leeren Formular und wusste nicht, was fehlte. */}
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form action={createProductAction} className="space-y-4 bg-background border border-ink/10 p-6">
<Field name="name" label={t.adminProductNewPage.nameLabel} required />
<Field name="slug" label={t.adminProductNewPage.slugLabel} />
+44 -1
View File
@@ -2,11 +2,14 @@ import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { formatPrice } from "@/lib/format";
import { syncProductsFromApiCoreAction } from "../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminProductsPage() {
export default async function AdminProductsPage(props: PageProps<"/admin/produkte">) {
const t = getDictionary(await getLocale());
const { synced, skipped, failed, discontinued, linked, syncError, linkError } =
await props.searchParams;
const products = await prisma.product.findMany({
include: { harvest: true, batch: true, inventory: true },
orderBy: { createdAt: "desc" },
@@ -21,6 +24,29 @@ export default async function AdminProductsPage() {
{t.adminProductsPage.newProduct}
</Link>
</div>
{synced !== undefined ? (
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
{t.adminProductsPage.syncResult(Number(synced), Number(skipped ?? 0), Number(failed ?? 0))}
{Number(linked ?? 0) > 0 ? ` ${t.adminProductsPage.syncLinked(Number(linked))}` : ""}
</p>
) : null}
{Number(discontinued ?? 0) > 0 ? (
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
{t.adminProductsPage.syncDiscontinued(Number(discontinued))}
</p>
) : null}
{syncError !== undefined ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
{t.adminProductsPage.syncError} ({syncError})
</p>
) : null}
{linkError !== undefined ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
{t.adminProductsPage.linkError} ({linkError})
</p>
) : null}
<div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10">
@@ -50,6 +76,13 @@ export default async function AdminProductsPage() {
<td className="p-4">{product.inventory?.quantity ?? 0}</td>
<td className="p-4">
{product.isActive ? t.adminProductsPage.statusActive : t.adminProductsPage.statusInactive}
{/* Ein Produkt ohne Preis laesst sich nicht aktiv schalten
(admin/actions/products.ts). Aus ApiCore importierte
Entwuerfe sehen sonst wie ein normales inaktives Produkt
aus, und es bliebe unklar, WARUM das Aktivieren scheitert. */}
{product.priceCents <= 0 ? (
<span className="block text-xs text-honey-dark">{t.adminProductsPage.draftHint}</span>
) : null}
</td>
<td className="p-4">
<Link href={`/admin/produkte/${product.id}`} className="text-forest underline">
@@ -64,6 +97,16 @@ export default async function AdminProductsPage() {
<p className="p-6 text-ink/65">{t.adminProductsPage.noProductsYet}</p>
) : null}
</div>
<div className="mt-10 bg-background border border-ink/10 p-6 max-w-3xl">
<h2 className="text-lg text-forest mb-4">{t.adminProductsPage.syncHeading}</h2>
<p className="text-sm text-ink/65 mb-4">{t.adminProductsPage.syncIntro}</p>
<form action={syncProductsFromApiCoreAction}>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminProductsPage.syncButton}
</button>
</form>
</div>
</div>
);
}
+16
View File
@@ -102,6 +102,22 @@ export async function POST(request: Request) {
);
}
// Preis 0 ist kein Geschenk, sondern ein noch nicht gepflegter Preis. Aus
// ApiCore importierte Produkte kommen genau so an (siehe
// admin/actions/apicore-sync.ts: ApiCore fuehrt keine Verkaufspreise), und
// auch ein manuell angelegtes Produkt kann versehentlich ohne Preis aktiv
// geschaltet werden. Die Aktivierung im Admin weist das bereits ab; diese
// Pruefung ist die zweite Haelfte davon und faengt Altbestaende sowie
// direkte Datenbank-Aenderungen ab, die nie durch das Admin-Formular
// gelaufen sind. Sonst waere die Bestellung tatsaechlich fuer 0 Euro
// abgeschlossen worden.
if (products.some((product) => product.priceCents <= 0)) {
return NextResponse.json(
{ error: "Ein Produkt im Warenkorb ist nicht mehr verfügbar." },
{ status: 400 }
);
}
const orderItemsData = input.items.map((cartItem) => {
const product = products.find((p) => p.id === cartItem.productId)!;
return {
+13 -1
View File
@@ -903,7 +903,7 @@ export const de: Dictionary = {
create: "Anlegen",
syncHeading: "ApiCore-Synchronisierung",
syncIntro:
"Importiert Ernten UND Chargen aus ApiCore. Ernten werden als Entwürfe angelegt (nicht öffentlich sichtbar); bereits veröffentlichte, zuvor synchronisierte Ernten werden dabei nicht verändert. Chargen werden nur neu angelegt (nie überschrieben, damit echte Lagerbestände erhalten bleiben) und brauchen eine bereits vorhandene Ernte.",
"Importiert Ernten UND Chargen aus ApiCore. Ernten werden als Entwürfe angelegt (nicht öffentlich sichtbar); bereits veröffentlichte, zuvor synchronisierte Ernten werden dabei nicht verändert. Chargen werden nur neu angelegt (nie überschrieben, damit echte Lagerbestände erhalten bleiben) und brauchen eine bereits vorhandene Ernte. Die Produkte selbst kommen über den eigenen Sync-Knopf unter „Produkte“.",
syncButton: "Jetzt synchronisieren",
syncResult: (created, updated, skipped, failed) =>
`Ernten: ${created} neu angelegt, ${updated} aktualisiert, ${skipped} übersprungen (bereits veröffentlicht)${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`,
@@ -945,6 +945,18 @@ export const de: Dictionary = {
statusInactive: "Inaktiv",
edit: "Bearbeiten",
noProductsYet: "Noch keine Produkte angelegt.",
syncHeading: "ApiCore-Synchronisierung",
syncIntro:
"Übernimmt den Produktkatalog aus ApiCore. Importierte Produkte sind zunächst deaktiviert und haben keinen Preis — ApiCore führt keine Verkaufspreise. Erst wenn Preis, Gewicht und Beschreibung gepflegt sind, lässt sich ein Produkt aktiv schalten und verkaufen. Vorhandene Produkte werden nur übersprungen, nie überschrieben — so geht keine eigene Pflege verloren. Wo ApiCore eine Abfüllung einem Produkt zuordnet, wird zusätzlich die passende Charge verknüpft (sofern sie schon über den Ernten-Sync importiert wurde).",
syncButton: "Jetzt synchronisieren",
syncResult: (created, skipped, failed) =>
`Produkte: ${created} neu angelegt, ${skipped} übersprungen (bereits vorhanden)${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
syncDiscontinued: (count) =>
`${count} aktive${count === 1 ? "s" : ""} Produkt${count === 1 ? "" : "e"} ${count === 1 ? "ist" : "sind"} in ApiCore ausgelaufen. Der Verkauf läuft bewusst weiter, damit der Restbestand abverkauft werden kann — bitte selbst entscheiden, ob deaktiviert werden soll.`,
syncLinked: (count) => `${count} Produkt${count === 1 ? "" : "e"} mit der zugehörigen Charge verknüpft.`,
syncError: "Synchronisierung mit ApiCore fehlgeschlagen.",
linkError: "Chargen konnten nicht geladen werden, die Produkte wurden trotzdem übernommen",
draftHint: "Entwurf: kein Preis gepflegt",
},
adminProductEditPage: {
backToProducts: "Zurück zu Produkten",
+13 -1
View File
@@ -903,7 +903,7 @@ export const en: Dictionary = {
create: "Create",
syncHeading: "ApiCore sync",
syncIntro:
"Imports harvests AND batches from ApiCore. Harvests are created as drafts (not publicly visible); previously synced harvests that are already published are left untouched. Batches are only ever created, never updated (so real stock levels are never overwritten), and need an already-existing harvest.",
"Imports harvests AND batches from ApiCore. Harvests are created as drafts (not publicly visible); previously synced harvests that are already published are left untouched. Batches are only ever created, never updated (so real stock levels are never overwritten), and need an already-existing harvest. The products themselves have their own sync button under “Products”.",
syncButton: "Sync now",
syncResult: (created, updated, skipped, failed) =>
`Harvests: ${created} created, ${updated} updated, ${skipped} skipped (already published)${failed > 0 ? `, ${failed} failed` : ""}.`,
@@ -945,6 +945,18 @@ export const en: Dictionary = {
statusInactive: "Inactive",
edit: "Edit",
noProductsYet: "No products created yet.",
syncHeading: "ApiCore sync",
syncIntro:
"Imports the product catalogue from ApiCore. Imported products start out inactive and without a price — ApiCore does not hold sales prices. A product can only be activated and sold once price, weight and description have been filled in. Existing products are only ever skipped, never overwritten, so no manual work is lost. Where ApiCore assigns a bottling run to a product, the matching batch is linked as well (provided it has already been imported by the harvest sync).",
syncButton: "Sync now",
syncResult: (created, skipped, failed) =>
`Products: ${created} created, ${skipped} skipped (already exist)${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
syncDiscontinued: (count) =>
`${count} active product${count === 1 ? " is" : "s are"} discontinued in ApiCore. They deliberately stay on sale so remaining stock can be sold through — decide yourself whether to deactivate them.`,
syncLinked: (count) => `${count} product${count === 1 ? "" : "s"} linked to the matching batch.`,
syncError: "Sync with ApiCore failed.",
linkError: "Batches could not be loaded, the products were imported anyway",
draftHint: "Draft: no price set",
},
adminProductEditPage: {
backToProducts: "Back to products",
+9
View File
@@ -647,6 +647,15 @@ export interface Dictionary {
statusInactive: string;
edit: string;
noProductsYet: string;
syncHeading: string;
syncIntro: string;
syncButton: string;
syncResult: (created: number, skipped: number, failed: number) => string;
syncDiscontinued: (count: number) => string;
syncLinked: (count: number) => string;
syncError: string;
linkError: string;
draftHint: string;
};
adminProductEditPage: {
backToProducts: string;
+152 -89
View File
@@ -3,8 +3,8 @@ import { z } from "zod";
/**
* HTTP-Client fuer die ApiCore-Backend-API (separates Projekt, lokal unter
* .../ApiCore/ApiCore-Backend — ein NestJS-Server mit eigener Postgres-DB),
* die die "Ernten"-Rohdaten fuer den Admin-Sync bereitstellt
* (admin/actions/apicore-sync.ts).
* die die Rohdaten fuer den Admin-Sync bereitstellt
* (admin/actions/apicore-sync.ts): Ernten, Produkte und Chargen.
*
* Auth-Modell von ApiCore: E-Mail/Passwort-Login -> kurzlebiges JWT
* (POST /auth/login), Endpunkte pruefen zusaetzlich granulare Berechtigungen
@@ -13,9 +13,9 @@ import { z } from "zod";
* interaktiven Login-Flow, optional mit einem zweiten MFA-Schritt
* (POST /auth/mfa/verify), siehe ApiCore-Backend/src/auth/. Fuer diesen
* automatisierten Sync wird dringend ein eigenes, MFA-freies Service-Konto
* mit ausschliesslich den Berechtigungen "harvests:read" (und optional
* "apiaries:read", fuer Standortnamen) empfohlen statt eines echten
* Admin-Zugangs.
* mit ausschliesslich Lese-Berechtigungen empfohlen statt eines echten
* Admin-Zugangs: "harvests:read", "products:read", "extractions:read",
* "bottling:read" und optional "apiaries:read" (nur fuer Standortnamen).
*
* Benoetigte Umgebungsvariablen (siehe .env.example):
* APICORE_API_URL z.B. "http://localhost:3001/api" (globaler Prefix
@@ -23,10 +23,15 @@ import { z } from "zod";
* APICORE_SYNC_EMAIL E-Mail des Service-Kontos
* APICORE_SYNC_PASSWORD Passwort des Service-Kontos
*
* Ohne diese drei Variablen wirft fetchApiCoreHarvests()/
* fetchApiCoreBottlingBatches() sofort einen ApiCoreConfigError — der
* Aufrufer (apicore-sync.ts) zeigt dessen Message dem Admin an, statt die
* Aktion stillschweigend nichts tun zu lassen.
* Ohne diese drei Variablen wirft openApiCoreSession() sofort einen
* ApiCoreConfigError — der Aufrufer (apicore-sync.ts) zeigt dessen Message
* dem Admin an, statt die Aktion stillschweigend nichts tun zu lassen.
*
* Die Anmeldung ist bewusst von den einzelnen Abfragen getrennt
* (openApiCoreSession -> fetchApiCore*(session)): ein Sync-Lauf zieht Ernten,
* Produkte UND Chargen und kaeme sonst auf drei getrennte Passwort-Logins
* gegen dasselbe Konto — bei ApiCores Anmelde-Bremse ein unnoetiges Risiko,
* den eigenen Service-Zugang auszusperren.
*
* Fuer Wabenhains "Charge" (prisma/schema.prisma: model Batch) ist NICHT
* ApiCores "HoneyBatch" (roher Rohhonig direkt nach der Schleuderung, vor
@@ -37,8 +42,6 @@ import { z } from "zod";
* ergibt sich erst ueber zwei Stufen (BottlingBatch.honeyBatch.extractionId
* -> Extraction.harvests[].harvestId), daher werden zusaetzlich alle
* Schleuderungen geladen (GET /extractions) und clientseitig verknuepft.
* Das erfordert zusaetzlich die Berechtigungen "extractions:read" und
* "bottling:read" fuer das Sync-Service-Konto.
*/
export class ApiCoreConfigError extends Error {}
@@ -49,23 +52,10 @@ const loginResponseSchema = z.union([
z.object({ mfaRequired: z.literal(true), mfaToken: z.string() }),
]);
const apiarySchema = z.object({ id: z.string(), name: z.string() });
const harvestSchema = z.object({
id: z.string(),
apiaryId: z.string(),
date: z.string(),
honeyType: z.string().nullable().optional(),
honeySuperCount: z.number().nullable().optional(),
grossWeight: z.number().nullable().optional(),
netWeight: z.number().nullable().optional(),
notes: z.string().nullable().optional(),
});
export type ApiCoreHarvest = z.infer<typeof harvestSchema>;
export interface ApiCoreHarvestWithApiaryName extends ApiCoreHarvest {
apiaryName: string | null;
/** Angemeldete Verbindung zu ApiCore, siehe Modul-Kommentar oben. */
export interface ApiCoreSession {
readonly baseUrl: string;
readonly token: string;
}
function getConfig(): { baseUrl: string; email: string; password: string } {
@@ -80,7 +70,9 @@ function getConfig(): { baseUrl: string; email: string; password: string } {
return { baseUrl: baseUrl.replace(/\/$/, ""), email, password };
}
async function login(baseUrl: string, email: string, password: string): Promise<string> {
export async function openApiCoreSession(): Promise<ApiCoreSession> {
const { baseUrl, email, password } = getConfig();
let res: Response;
try {
res = await fetch(`${baseUrl}/auth/login`, {
@@ -104,48 +96,139 @@ async function login(baseUrl: string, email: string, password: string): Promise<
"Das konfigurierte ApiCore-Sync-Konto hat MFA aktiviert. Fuer den automatisierten Sync wird ein MFA-freies Service-Konto benoetigt (siehe Kommentar in apicore-client.ts)."
);
}
return parsed.data.accessToken;
return { baseUrl, token: parsed.data.accessToken };
}
/**
* Eine GET-Abfrage inklusive Schema-Pruefung. Die drei Fehlerfaelle (nicht
* erreichbar / HTTP-Fehler / unerwartete Struktur) werden bewusst getrennt
* gemeldet: sie haben voellig verschiedene Ursachen — Netzwerk bzw. falsche
* URL, eine fehlende Berechtigung des Service-Kontos, und eine geaenderte
* ApiCore-Version.
*/
async function getJson<T>(
session: ApiCoreSession,
path: string,
schema: z.ZodType<T>,
label: string
): Promise<T> {
let res: Response;
try {
res = await fetch(`${session.baseUrl}${path}`, {
headers: { Authorization: `Bearer ${session.token}` },
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
throw new ApiCoreRequestError(`ApiCore unter ${session.baseUrl} nicht erreichbar: ${message}`);
}
if (!res.ok) {
throw new ApiCoreRequestError(
res.status === 403
? `ApiCore: ${label} konnten nicht geladen werden (HTTP 403) — dem Sync-Konto fehlt vermutlich die noetige Leseberechtigung, siehe .env.example.`
: `ApiCore: ${label} konnten nicht geladen werden (HTTP ${res.status}).`
);
}
const parsed = schema.safeParse(await res.json());
if (!parsed.success) {
throw new ApiCoreRequestError(`ApiCore: unerwartete Antwortstruktur fuer ${label}.`);
}
return parsed.data;
}
// ---------------------------------------------------------------------------
// Ernten
// ---------------------------------------------------------------------------
const apiarySchema = z.object({ id: z.string(), name: z.string() });
const harvestSchema = z.object({
id: z.string(),
apiaryId: z.string(),
date: z.string(),
honeyType: z.string().nullable().optional(),
honeySuperCount: z.number().nullable().optional(),
grossWeight: z.number().nullable().optional(),
netWeight: z.number().nullable().optional(),
notes: z.string().nullable().optional(),
});
export type ApiCoreHarvest = z.infer<typeof harvestSchema>;
export interface ApiCoreHarvestWithApiaryName extends ApiCoreHarvest {
apiaryName: string | null;
}
/** Best effort: Standortnamen sind eine Komfort-Anreicherung, kein kritischer Pfad. */
async function fetchApiaryNames(baseUrl: string, token: string): Promise<Map<string, string>> {
async function fetchApiaryNames(session: ApiCoreSession): Promise<Map<string, string>> {
try {
const res = await fetch(`${baseUrl}/apiaries`, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) return new Map();
const parsed = z.array(apiarySchema).safeParse(await res.json());
if (!parsed.success) return new Map();
return new Map(parsed.data.map((a) => [a.id, a.name]));
const apiaries = await getJson(session, "/apiaries", z.array(apiarySchema), "Standorte");
return new Map(apiaries.map((a) => [a.id, a.name]));
} catch {
return new Map();
}
}
export async function fetchApiCoreHarvests(): Promise<ApiCoreHarvestWithApiaryName[]> {
const { baseUrl, email, password } = getConfig();
const token = await login(baseUrl, email, password);
let res: Response;
try {
res = await fetch(`${baseUrl}/harvests`, { headers: { Authorization: `Bearer ${token}` } });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${message}`);
}
if (!res.ok) {
throw new ApiCoreRequestError(`ApiCore: Ernten konnten nicht geladen werden (HTTP ${res.status}).`);
}
const parsed = z.array(harvestSchema).safeParse(await res.json());
if (!parsed.success) {
throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Ernten.");
}
const apiaryNames = await fetchApiaryNames(baseUrl, token);
return parsed.data.map((harvest) => ({
export async function fetchApiCoreHarvests(
session: ApiCoreSession
): Promise<ApiCoreHarvestWithApiaryName[]> {
const harvests = await getJson(session, "/harvests", z.array(harvestSchema), "Ernten");
const apiaryNames = await fetchApiaryNames(session);
return harvests.map((harvest) => ({
...harvest,
apiaryName: apiaryNames.get(harvest.apiaryId) ?? null,
}));
}
// ---------------------------------------------------------------------------
// Produkte
// ---------------------------------------------------------------------------
/**
* ApiCores Product (ApiCore-Backend/prisma/schema.prisma). Zwei bewusste
* Abweichungen vom dortigen Prisma-Modell:
*
* - `status` wird als freier String gelesen, nicht als z.enum([...]). ApiCores
* ProductStatus kennt heute nur ACTIVE und DISCONTINUED; kaeme dort ein
* dritter Wert hinzu, wuerde ein Enum-Schema die GESAMTE Antwort verwerfen
* und der Sync mit "unerwartete Antwortstruktur" abbrechen, statt nur den
* einen unbekannten Artikel zu ignorieren. Siehe isSellableStatus().
* - Mengenangaben (`packagingSize`, `weight`) tragen in ApiCore KEINE Einheit
* — weder im Prisma-Modell noch an den Formularfeldern ("Gebindegroesse",
* "Gewicht", jeweils ohne Zusatz). Als Einheit wird GRAMM angenommen, weil
* ApiCore dieselbe Groesse an der Abfuellung (BottlingBatch.jarSize) mit
* Werten wie 500 fuehrt und sie in seiner eigenen Oberflaeche als "g"
* ausgibt (ApiCore-Frontend .../herkunft/[lot]/page.tsx). Die Annahme ist
* damit begruendet, aber von ApiCore nicht garantiert — deshalb ist das
* uebernommene Gewicht am importierten Entwurf im Admin sichtbar und
* aenderbar, bevor das Produkt ueberhaupt verkauft werden kann.
*/
const productSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable().optional(),
sku: z.string().nullable().optional(),
packagingSize: z.number().nullable().optional(),
weight: z.number().nullable().optional(),
salesUnit: z.string().nullable().optional(),
status: z.string(),
});
export type ApiCoreProduct = z.infer<typeof productSchema>;
/**
* Nur ACTIVE gilt als verkaufsfaehig. Alles andere (heute DISCONTINUED,
* morgen vielleicht ein weiterer Wert) wird bewusst als "nicht verkaufsfaehig"
* behandelt: ein unbekannter Status darf nicht dazu fuehren, dass ein Artikel
* ungeprueft als Shop-Entwurf auftaucht.
*/
export function isSellableStatus(status: string): boolean {
return status === "ACTIVE";
}
export async function fetchApiCoreProducts(session: ApiCoreSession): Promise<ApiCoreProduct[]> {
return getJson(session, "/products", z.array(productSchema), "Produkte");
}
// ---------------------------------------------------------------------------
// Chargen (BottlingBatch, siehe Modul-Kommentar oben)
// ---------------------------------------------------------------------------
@@ -164,6 +247,10 @@ const bottlingBatchSchema = z.object({
date: z.string(),
bestBeforeDate: z.string(),
jarCount: z.number(),
// Optional: eine Abfuellung muss in ApiCore keinem Produkt zugeordnet sein.
// Ist sie es, verknuepft der Sync die daraus entstandene Wabenhain-Charge
// mit dem passenden synchronisierten Produkt (siehe apicore-sync.ts).
productId: z.string().nullable().optional(),
honeyBatch: z.object({
id: z.string(),
extractionId: z.string(),
@@ -185,43 +272,19 @@ export interface ApiCoreBottlingBatchWithHarvestId extends ApiCoreBottlingBatch
harvestSourceId: string | null;
}
export async function fetchApiCoreBottlingBatches(): Promise<ApiCoreBottlingBatchWithHarvestId[]> {
const { baseUrl, email, password } = getConfig();
const token = await login(baseUrl, email, password);
const authHeader = { Authorization: `Bearer ${token}` };
let extractionsRes: Response;
let batchesRes: Response;
try {
[extractionsRes, batchesRes] = await Promise.all([
fetch(`${baseUrl}/extractions`, { headers: authHeader }),
fetch(`${baseUrl}/bottling-batches`, { headers: authHeader }),
export async function fetchApiCoreBottlingBatches(
session: ApiCoreSession
): Promise<ApiCoreBottlingBatchWithHarvestId[]> {
const [extractions, batches] = await Promise.all([
getJson(session, "/extractions", z.array(extractionSchema), "Schleuderungen"),
getJson(session, "/bottling-batches", z.array(bottlingBatchSchema), "Chargen"),
]);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${message}`);
}
if (!extractionsRes.ok) {
throw new ApiCoreRequestError(`ApiCore: Schleuderungen konnten nicht geladen werden (HTTP ${extractionsRes.status}).`);
}
if (!batchesRes.ok) {
throw new ApiCoreRequestError(`ApiCore: Chargen konnten nicht geladen werden (HTTP ${batchesRes.status}).`);
}
const parsedExtractions = z.array(extractionSchema).safeParse(await extractionsRes.json());
if (!parsedExtractions.success) {
throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Schleuderungen.");
}
const parsedBatches = z.array(bottlingBatchSchema).safeParse(await batchesRes.json());
if (!parsedBatches.success) {
throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Chargen.");
}
const firstHarvestByExtraction = new Map<string, string | null>(
parsedExtractions.data.map((extraction) => [extraction.id, extraction.harvests[0]?.harvestId ?? null])
extractions.map((extraction) => [extraction.id, extraction.harvests[0]?.harvestId ?? null])
);
return parsedBatches.data.map((batch) => ({
return batches.map((batch) => ({
...batch,
harvestSourceId: firstHarvestByExtraction.get(batch.honeyBatch.extractionId) ?? null,
}));
+172
View File
@@ -0,0 +1,172 @@
import { describe, test, expect } from "vitest";
import { isSellableStatus, type ApiCoreProduct, type ApiCoreHarvestWithApiaryName } from "./apicore-client";
import {
slugForApiCoreSource,
buildHarvestDescription,
buildProductDescription,
deriveWeightGrams,
} from "./apicore-mapping";
function makeProduct(overrides: Partial<ApiCoreProduct> = {}): ApiCoreProduct {
return {
id: "cm-product-1",
name: "Sommertracht 500 g",
description: null,
sku: null,
packagingSize: null,
weight: null,
salesUnit: null,
status: "ACTIVE",
...overrides,
};
}
function makeHarvest(
overrides: Partial<ApiCoreHarvestWithApiaryName> = {}
): ApiCoreHarvestWithApiaryName {
return {
id: "cm-harvest-1",
apiaryId: "cm-apiary-1",
date: "2026-07-14T00:00:00.000Z",
honeyType: "Lindenhonig",
honeySuperCount: null,
grossWeight: null,
netWeight: null,
notes: null,
apiaryName: "Streuobstwiese",
...overrides,
};
}
describe("slugForApiCoreSource", () => {
test("leitet den Slug ausschliesslich aus der ApiCore-ID ab", () => {
// Arrange
const sourceId = "cm-product-1";
// Act
const slug = slugForApiCoreSource(sourceId);
// Assert
expect(slug).toBe("apicore-cm-product-1");
});
test("bleibt stabil, wenn sich in ApiCore nur der Name aendert", () => {
// Arrange: derselbe Datensatz, in ApiCore umbenannt.
const before = makeProduct({ name: "Sommertracht 500 g" });
const after = makeProduct({ name: "Sommertracht cremig, 500 g" });
// Act & Assert: sonst wuerde der Sync ein zweites Produkt anlegen.
expect(slugForApiCoreSource(after.id)).toBe(slugForApiCoreSource(before.id));
});
});
describe("buildHarvestDescription", () => {
test("fasst die vorhandenen Mengenangaben und Notizen zusammen", () => {
// Arrange
const source = makeHarvest({ honeySuperCount: 12, grossWeight: 48.5, netWeight: 41, notes: " Sehr hell. " });
// Act
const description = buildHarvestDescription(source);
// Assert
expect(description).toBe(
"Automatisch aus ApiCore importiert. 12 Honigräume · Bruttogewicht 48.5 kg · Nettogewicht 41 kg · Sehr hell."
);
});
test("fordert eine Beschreibung an, wenn ApiCore gar nichts liefert", () => {
// Arrange
const source = makeHarvest();
// Act
const description = buildHarvestDescription(source);
// Assert
expect(description).toContain("Bitte Beschreibung ergänzen.");
});
});
describe("buildProductDescription", () => {
test("uebernimmt ApiCores Beschreibung und nennt Artikelnummer und Verkaufseinheit", () => {
// Arrange
const source = makeProduct({ description: "Cremig gerührt.", sku: "WH-500", salesUnit: "Glas" });
// Act
const description = buildProductDescription(source);
// Assert
expect(description).toBe(
"Automatisch aus ApiCore importiert (Artikelnummer WH-500 · Verkaufseinheit Glas). Cremig gerührt."
);
});
test("setzt eine Aufforderung ein, statt einen leeren Verkaufstext zu erzeugen", () => {
// Arrange: ApiCores description ist eine interne Notiz und oft leer.
const source = makeProduct({ description: " " });
// Act
const description = buildProductDescription(source);
// Assert
expect(description).toContain("Bitte Produktbeschreibung für den Shop ergänzen.");
});
});
describe("deriveWeightGrams", () => {
test("nimmt das Gewicht des Artikels", () => {
// Arrange
const source = makeProduct({ weight: 500, packagingSize: 250 });
// Act & Assert: weight schlaegt packagingSize.
expect(deriveWeightGrams(source)).toBe(500);
});
test("faellt auf die Gebindegroesse zurueck, wenn kein Gewicht gepflegt ist", () => {
// Arrange
const source = makeProduct({ weight: null, packagingSize: 250 });
// Act & Assert
expect(deriveWeightGrams(source)).toBe(250);
});
test("rundet auf volle Gramm", () => {
// Arrange
const source = makeProduct({ weight: 499.6 });
// Act & Assert
expect(deriveWeightGrams(source)).toBe(500);
});
test("gibt 0 zurueck statt zu raten, wenn ApiCore keine Menge kennt", () => {
// Arrange
const source = makeProduct();
// Act & Assert: 0 ist in Wabenhain kein speicherbares Gewicht und zwingt
// damit zur manuellen Pflege, bevor das Produkt verkauft werden kann.
expect(deriveWeightGrams(source)).toBe(0);
});
test("behandelt 0 und negative Werte wie eine fehlende Angabe", () => {
// Arrange
const zero = makeProduct({ weight: 0 });
const negative = makeProduct({ weight: -500 });
// Act & Assert
expect(deriveWeightGrams(zero)).toBe(0);
expect(deriveWeightGrams(negative)).toBe(0);
});
});
describe("isSellableStatus", () => {
test("erkennt ACTIVE als verkaufsfaehig", () => {
expect(isSellableStatus("ACTIVE")).toBe(true);
});
test("behandelt DISCONTINUED und unbekannte Status als nicht verkaufsfaehig", () => {
// Arrange & Act & Assert: ein kuenftiger, hier noch unbekannter
// ApiCore-Status darf nicht versehentlich als Shop-Entwurf durchrutschen.
expect(isSellableStatus("DISCONTINUED")).toBe(false);
expect(isSellableStatus("SEASONAL")).toBe(false);
expect(isSellableStatus("")).toBe(false);
});
});
+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);
}