diff --git a/.env.example b/.env.example index 79286be..5806689 100644 --- a/.env.example +++ b/.env.example @@ -81,16 +81,20 @@ SMTP_USER= SMTP_PASSWORD= SMTP_FROM="Wabenhain " -# 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= diff --git a/README.md b/README.md index cc0a9f4..6eb06b8 100644 --- a/README.md +++ b/README.md @@ -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-` 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 diff --git a/src/app/admin/actions/apicore-sync.ts b/src/app/admin/actions/apicore-sync.ts index df75813..40280ba 100644 --- a/src/app/admin/actions/apicore-sync.ts +++ b/src/app/admin/actions/apicore-sync.ts @@ -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-" 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 { - 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 { + 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 { 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 { 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 +// --------------------------------------------------------------------------- - 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"; +/** + * 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 { + const counts: ProductSyncCounts = { created: 0, skipped: 0, failed: 0, discontinued: 0 }; + + for (const source of sources) { + const slug = slugForApiCoreSource(source.id); + + try { + 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; + } + + 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)}`); + } } - for (const source of sourceBatches) { + return counts; +} + +// --------------------------------------------------------------------------- +// Chargen +// --------------------------------------------------------------------------- + +async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promise { + 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 { 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 { + 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 { + 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 { + 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)}` : "") ); } diff --git a/src/app/admin/actions/products.ts b/src/app/admin/actions/products.ts index ad22800..887d80c 100644 --- a/src/app/admin/actions/products.ts +++ b/src/app/admin/actions/products.ts @@ -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 { 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 { } 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 { 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 { } 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 { 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 } }, }, diff --git a/src/app/admin/bestellungen/page.tsx b/src/app/admin/bestellungen/page.tsx index 2662a4e..fab5abd 100644 --- a/src/app/admin/bestellungen/page.tsx +++ b/src/app/admin/bestellungen/page.tsx @@ -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

{t.adminOrdersPage.title}

+ {/* 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 ? ( +

{error}

+ ) : null}