feat: Produkte aus ApiCore synchronisieren
Der Shop konnte Ernten und Chargen bereits aus ApiCore uebernehmen, den Produktkatalog aber nicht - jedes Glas musste im Admin ein zweites Mal von Hand angelegt werden. Neuer Knopf unter /admin/produkte zieht die Produkte und traegt anschliessend die passende Charge am Produkt nach, wo ApiCore die Abfuellung einem Produkt zugeordnet hat. Genau diese Verknuepfung fuettert die Herkunftsseite hinter dem QR-Code auf dem Glas. ApiCore fuehrt ueberhaupt keine Verkaufspreise. Importierte Produkte entstehen deshalb als deaktivierte Entwuerfe mit Preis 0, den nur ein Mensch setzen kann. Damit daraus nie eine Gratis-Bestellung wird, lehnen die Aktivierung im Admin und der Checkout ein Produkt ohne Preis jetzt ausdruecklich ab, und die Produktliste kennzeichnet solche Entwuerfe. Ein bereits vorhandenes Produkt wird uebersprungen, nie ueberschrieben: genau die drei Felder, die ApiCore liefert (Name, Beschreibung, Gewicht), zieht der Admin vor der Freischaltung von Hand nach - ein Update wuerde diese Arbeit bei jedem weiteren Lauf wegwerfen. Ein in ApiCore ausgelaufenes Produkt wird nicht automatisch deaktiviert, sondern nur gemeldet, damit der Restbestand abverkauft werden kann. Nebenbei behoben: die beiden Produktformulare und die Bestelluebersicht haben den ?error=-Parameter aus fail() nie ausgegeben - abgelehnte Eingaben landeten wortlos wieder auf dem Formular. Der Login gegen ApiCore ist jetzt von den Abfragen getrennt (openApiCoreSession), damit ein Sync-Lauf mit einer einzigen Anmeldung auskommt statt mit dreien. Die Feld-Abbildung liegt neu in lib/apicore-mapping.ts und ist damit testbar, statt hinter "use server" zu verschwinden. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c61ae92390
commit
78c19f1b73
15 files changed
+908
-226
No files matched your search
+14
-10
@@ -81,16 +81,20 @@ SMTP_USER=
|
|||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_FROM="Wabenhain <[email protected]>"
|
SMTP_FROM="Wabenhain <[email protected]>"
|
||||||
|
|
||||||
# ApiCore-Ernten-Synchronisierung (optional, admin/ernten -> "ApiCore-
|
# ApiCore-Synchronisierung (optional). Zwei Knoepfe nutzen sie: admin/ernten
|
||||||
# Synchronisierung"). Ohne diese drei Werte zeigt der Sync-Button einen
|
# -> "ApiCore-Synchronisierung" (Ernten + Chargen) und admin/produkte ->
|
||||||
# Konfigurationsfehler statt eines Ergebnisses. ApiCore ist ein separates
|
# "ApiCore-Synchronisierung" (Produktkatalog). Ohne diese drei Werte zeigen
|
||||||
# Projekt (NestJS-Backend) mit reinem E-Mail/Passwort-Login (kein API-Key-
|
# beide einen Konfigurationsfehler statt eines Ergebnisses. ApiCore ist ein
|
||||||
# Mechanismus) — hier IDEALERWEISE die Zugangsdaten eines dedizierten,
|
# separates Projekt (NestJS-Backend) mit reinem E-Mail/Passwort-Login (kein
|
||||||
# MFA-freien Service-Kontos mit ausschliesslich den Berechtigungen
|
# API-Key-Mechanismus) — hier IDEALERWEISE die Zugangsdaten eines
|
||||||
# "harvests:read", "apiaries:read" (optional, fuer Standortnamen),
|
# dedizierten, MFA-freien Service-Kontos eintragen, nicht die eines echten
|
||||||
# "extractions:read" und "bottling:read" (beide fuer den Chargen-Sync)
|
# Admin-Zugangs. Benoetigte Berechtigungen, alle nur lesend:
|
||||||
# eintragen, nicht die eines echten Admin-Zugangs. Details siehe
|
# harvests:read Ernten
|
||||||
# src/lib/apicore-client.ts.
|
# 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_API_URL=
|
||||||
APICORE_SYNC_EMAIL=
|
APICORE_SYNC_EMAIL=
|
||||||
APICORE_SYNC_PASSWORD=
|
APICORE_SYNC_PASSWORD=
|
||||||
|
|||||||
@@ -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**)
|
- 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
|
- **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)
|
- **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
|
- **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
|
- `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.
|
- **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`.
|
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
|
## Vollständiger Docker-Betrieb
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -5,96 +5,98 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import {
|
import {
|
||||||
|
openApiCoreSession,
|
||||||
fetchApiCoreHarvests,
|
fetchApiCoreHarvests,
|
||||||
|
fetchApiCoreProducts,
|
||||||
fetchApiCoreBottlingBatches,
|
fetchApiCoreBottlingBatches,
|
||||||
|
isSellableStatus,
|
||||||
ApiCoreConfigError,
|
ApiCoreConfigError,
|
||||||
ApiCoreRequestError,
|
ApiCoreRequestError,
|
||||||
|
type ApiCoreSession,
|
||||||
type ApiCoreHarvestWithApiaryName,
|
type ApiCoreHarvestWithApiaryName,
|
||||||
|
type ApiCoreProduct,
|
||||||
type ApiCoreBottlingBatchWithHarvestId,
|
type ApiCoreBottlingBatchWithHarvestId,
|
||||||
} from "@/lib/apicore-client";
|
} from "@/lib/apicore-client";
|
||||||
|
import {
|
||||||
|
slugForApiCoreSource,
|
||||||
|
buildHarvestDescription,
|
||||||
|
buildProductDescription,
|
||||||
|
deriveWeightGrams,
|
||||||
|
} from "@/lib/apicore-mapping";
|
||||||
import { requireAdmin } from "./_shared";
|
import { requireAdmin } from "./_shared";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stabiler, eindeutiger Slug aus der ApiCore-Ernte-ID statt aus Typ/Jahr
|
* Uebernimmt Stammdaten aus ApiCore (separates Imkerei-Verwaltungssystem) in
|
||||||
* abgeleitet (wie beim manuellen createHarvestAction) — dient hier zugleich
|
* den Shop: Ernten, Produkte und Chargen.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Ernten werden als unveroeffentlichte Entwuerfe angelegt (isPublished:
|
* Durchgaengiges Prinzip fuer alle drei: ApiCore ist die Quelle der ROHDATEN,
|
||||||
* false) bzw. bereits vorhandene, noch unveroeffentlichte Entwuerfe
|
* Wabenhain bleibt die Quelle aller VERKAUFS-Entscheidungen. Ein Sync-Lauf
|
||||||
* aktualisiert. Bereits vom Admin veroeffentlichte, frueher synchronisierte
|
* darf deshalb nie etwas ueberschreiben, das eine Mitarbeiterin im Admin
|
||||||
* Ernten werden NICHT angefasst — sonst wuerde ein erneuter Sync eine
|
* bewusst gesetzt hat — importiert wird in einen Entwurfszustand, den erst
|
||||||
* bewusste Freigabe- und Bearbeitungsentscheidung des Admins stillschweigend
|
* ein Mensch freigibt.
|
||||||
* zuruecksetzen.
|
|
||||||
*
|
*
|
||||||
* Chargen (Wabenhain: Batch, ApiCore: BottlingBatch — siehe Kommentar in
|
* - Ernten: werden als unveroeffentlichte Entwuerfe angelegt (isPublished
|
||||||
* lib/apicore-client.ts fuer die Modul-Zuordnung) werden dagegen NUR neu
|
* false) bzw. als solche aktualisiert. Bereits veroeffentlichte, frueher
|
||||||
* angelegt, nie aktualisiert: anders als Harvest hat Batch kein
|
* synchronisierte Ernten bleiben unberuehrt.
|
||||||
* isPublished-Feld, das eine bereits erfolgte Admin-Bearbeitung markieren
|
* - Produkte: werden als deaktivierte Entwuerfe angelegt (isActive false,
|
||||||
* koennte. Ein Update wuerde insbesondere remainingGlasses ueberschreiben —
|
* Preis 0) und nur so lange aktualisiert, wie sie deaktiviert sind. Ein
|
||||||
* das ist Wabenhains EIGENER, durch echte Verkaeufe veraenderter
|
* bereits aktives Produkt wird nie angefasst — sonst wuerde ein Sync Preis,
|
||||||
* Lagerbestand und darf niemals durch einen ApiCore-Sync zurueckgesetzt
|
* Beschreibung und Geschmacksprofil ueberschreiben, die nur in Wabenhain
|
||||||
* werden. Existiert eine Charge (per code) bereits, wird sie uebersprungen.
|
* gepflegt werden und die es in ApiCore gar nicht gibt.
|
||||||
* Jede Charge braucht eine bereits in Wabenhain vorhandene (synchronisierte
|
* - Chargen: werden NUR neu angelegt, nie aktualisiert. Anders als Harvest
|
||||||
* oder manuell angelegte) Ernte als harvestId — Chargen laufen deshalb nach
|
* und Product hat Batch kein Feld, an dem eine erfolgte Admin-Bearbeitung
|
||||||
* den Ernten in diesem Lauf, damit gerade frisch angelegte Ernten schon
|
* erkennbar waere. Ein Update wuerde insbesondere remainingGlasses
|
||||||
* verfuegbar sind.
|
* ueberschreiben — Wabenhains EIGENEN, durch echte Verkaeufe veraenderten
|
||||||
|
* Lagerbestand.
|
||||||
*
|
*
|
||||||
* Admin-Berechtigungsfeld "type" bei Wabenhain (Erntesaison, z.B.
|
* Idempotent ist das alles ueber einen stabilen Schluessel je Datensatz:
|
||||||
* "Sommertracht") und ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig")
|
* "apicore-<ApiCore-ID>" als Harvest.slug bzw. Product.slug, und die
|
||||||
* sind KEINE aequivalenten Taxonomien — es findet bewusst keine automatische
|
* 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
|
* Uebersetzung statt, der Rohwert wird uebernommen und muss vom Admin beim
|
||||||
* Review korrigiert werden.
|
* Review korrigiert werden.
|
||||||
*/
|
*/
|
||||||
export async function syncHarvestsFromApiCoreAction(): Promise<void> {
|
|
||||||
const admin = await requireAdmin();
|
|
||||||
|
|
||||||
let sourceHarvests: ApiCoreHarvestWithApiaryName[];
|
interface SyncCounts {
|
||||||
try {
|
created: number;
|
||||||
sourceHarvests = await fetchApiCoreHarvests();
|
updated: number;
|
||||||
} catch (error: unknown) {
|
skipped: number;
|
||||||
const message =
|
failed: number;
|
||||||
error instanceof ApiCoreConfigError || error instanceof ApiCoreRequestError
|
}
|
||||||
? error.message
|
|
||||||
: error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Unbekannter Fehler";
|
|
||||||
redirect(`/admin/ernten?syncError=${encodeURIComponent(message)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let created = 0;
|
/**
|
||||||
let updated = 0;
|
* Produkte kennen bewusst kein "updated" — sie werden nur neu angelegt,
|
||||||
let skipped = 0;
|
* siehe syncProducts().
|
||||||
let failed = 0;
|
*/
|
||||||
|
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) {
|
function describeError(error: unknown): string {
|
||||||
const slug = slugFor(source.id);
|
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);
|
const date = new Date(source.date);
|
||||||
if (Number.isNaN(date.getTime())) {
|
if (Number.isNaN(date.getTime())) {
|
||||||
failed++;
|
counts.failed++;
|
||||||
console.error(`[apicore-sync] Ernte ${source.id} hat ein ungueltiges Datum ("${source.date}") — übersprungen.`);
|
console.error(`[apicore-sync] Ernte ${source.id} hat ein ungueltiges Datum ("${source.date}") — übersprungen.`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -102,9 +104,8 @@ export async function syncHarvestsFromApiCoreAction(): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = await prisma.harvest.findUnique({ where: { slug } });
|
const existing = await prisma.harvest.findUnique({ where: { slug } });
|
||||||
|
|
||||||
if (existing?.isPublished) {
|
if (existing?.isPublished) {
|
||||||
skipped++;
|
counts.skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,64 +114,122 @@ export async function syncHarvestsFromApiCoreAction(): Promise<void> {
|
|||||||
type,
|
type,
|
||||||
harvestDate: date,
|
harvestDate: date,
|
||||||
location: source.apiaryName ?? "Altenmünster",
|
location: source.apiaryName ?? "Altenmünster",
|
||||||
description: buildDescription(source),
|
description: buildHarvestDescription(source),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await prisma.harvest.update({ where: { slug }, data });
|
await prisma.harvest.update({ where: { slug }, data });
|
||||||
updated++;
|
counts.updated++;
|
||||||
} else {
|
} else {
|
||||||
await prisma.harvest.create({ data: { ...data, slug, isPublished: false } });
|
await prisma.harvest.create({ data: { ...data, slug, isPublished: false } });
|
||||||
created++;
|
counts.created++;
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
failed++;
|
counts.failed++;
|
||||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
|
||||||
// z.B. P2002 (unique constraint year+type), falls zwei ApiCore-Ernten
|
// z.B. P2002 (unique constraint year+type), falls zwei ApiCore-Ernten
|
||||||
// im selben Jahr denselben honeyType tragen — bewusst nicht fatal fuer
|
// im selben Jahr denselben honeyType tragen — bewusst nicht fatal fuer
|
||||||
// den gesamten Sync-Lauf, siehe Kommentar oben zur Taxonomie-Kollision.
|
// 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, {
|
return counts;
|
||||||
action: "harvest.sync_apicore",
|
}
|
||||||
entityType: "Harvest",
|
|
||||||
detail: `${created} neu, ${updated} aktualisiert, ${skipped} übersprungen, ${failed} fehlgeschlagen`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Chargen-Sync laeuft NACH den Ernten (s.o., Doc-Kommentar) und ist
|
// ---------------------------------------------------------------------------
|
||||||
// bewusst nicht fatal fuer den gesamten Aufruf: schlaegt nur das Laden der
|
// Produkte
|
||||||
// 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;
|
|
||||||
|
|
||||||
let sourceBatches: ApiCoreBottlingBatchWithHarvestId[] = [];
|
/**
|
||||||
try {
|
* Ein neu importiertes Produkt ist absichtlich NICHT verkaufsfaehig:
|
||||||
sourceBatches = await fetchApiCoreBottlingBatches();
|
*
|
||||||
} catch (error: unknown) {
|
* - `isActive: false` haelt es aus Shop, Suche und Checkout heraus.
|
||||||
batchLoadError =
|
* - `priceCents: 0` ist kein geschaetzter, sondern ein FEHLENDER Preis:
|
||||||
error instanceof ApiCoreConfigError || error instanceof ApiCoreRequestError
|
* ApiCore fuehrt ueberhaupt keine Verkaufspreise, das ist reine
|
||||||
? error.message
|
* Betriebsdatenhaltung. Den Preis kann nur ein Mensch setzen. Damit aus
|
||||||
: error instanceof Error
|
* diesem Platzhalter nie eine Gratis-Bestellung wird, weigern sich
|
||||||
? error.message
|
* zusaetzlich die Aktivierung im Admin und der Checkout, ein Produkt mit
|
||||||
: "Unbekannter Fehler";
|
* 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);
|
||||||
|
|
||||||
|
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<SyncCounts> {
|
||||||
|
const counts: SyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0 };
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
if (!source.harvestSourceId) {
|
if (!source.harvestSourceId) {
|
||||||
batchFailed++;
|
counts.failed++;
|
||||||
console.error(`[apicore-sync] Charge ${source.lotNumber} ist keiner Ernte zugeordnet — übersprungen.`);
|
console.error(`[apicore-sync] Charge ${source.lotNumber} ist keiner Ernte zugeordnet — übersprungen.`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const fillDate = new Date(source.date);
|
const fillDate = new Date(source.date);
|
||||||
const bestBeforeDate = new Date(source.bestBeforeDate);
|
const bestBeforeDate = new Date(source.bestBeforeDate);
|
||||||
if (Number.isNaN(fillDate.getTime()) || Number.isNaN(bestBeforeDate.getTime())) {
|
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.`);
|
console.error(`[apicore-sync] Charge ${source.lotNumber} hat ein ungültiges Datum — übersprungen.`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -178,48 +237,196 @@ export async function syncHarvestsFromApiCoreAction(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const existing = await prisma.batch.findUnique({ where: { code: source.lotNumber } });
|
const existing = await prisma.batch.findUnique({ where: { code: source.lotNumber } });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
batchSkipped++;
|
counts.skipped++;
|
||||||
continue;
|
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) {
|
if (!harvest) {
|
||||||
batchFailed++;
|
counts.failed++;
|
||||||
console.error(
|
console.error(
|
||||||
`[apicore-sync] Charge ${source.lotNumber}: zugehörige Ernte (ApiCore-ID ${source.harvestSourceId}) ist in Wabenhain noch nicht synchronisiert — übersprungen.`
|
`[apicore-sync] Charge ${source.lotNumber}: zugehörige Ernte (ApiCore-ID ${source.harvestSourceId}) ist in Wabenhain noch nicht synchronisiert — übersprungen.`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const glassCount = Math.max(Math.round(source.jarCount), 0);
|
||||||
await prisma.batch.create({
|
await prisma.batch.create({
|
||||||
data: {
|
data: {
|
||||||
code: source.lotNumber,
|
code: source.lotNumber,
|
||||||
harvestId: harvest.id,
|
harvestId: harvest.id,
|
||||||
fillDate,
|
fillDate,
|
||||||
bestBeforeDate,
|
bestBeforeDate,
|
||||||
glassCount: Math.max(Math.round(source.jarCount), 0),
|
glassCount,
|
||||||
remainingGlasses: Math.max(Math.round(source.jarCount), 0),
|
remainingGlasses: glassCount,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
batchCreated++;
|
counts.created++;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
batchFailed++;
|
counts.failed++;
|
||||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
console.error(`[apicore-sync] Charge ${source.lotNumber} konnte nicht importiert werden: ${describeError(error)}`);
|
||||||
console.error(`[apicore-sync] Charge ${source.lotNumber} konnte nicht importiert werden: ${message}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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, {
|
await logAdminAction(admin, {
|
||||||
action: "batch.sync_apicore",
|
action: "batch.sync_apicore",
|
||||||
entityType: "Batch",
|
entityType: "Batch",
|
||||||
detail: batchLoadError
|
detail: batches.loadError
|
||||||
? `Chargen konnten nicht geladen werden: ${batchLoadError}`
|
? `Chargen konnten nicht geladen werden: ${batches.loadError}`
|
||||||
: `${batchCreated} neu, ${batchSkipped} übersprungen, ${batchFailed} fehlgeschlagen`,
|
: `${batches.counts.created} neu, ${batches.counts.skipped} übersprungen, ${batches.counts.failed} fehlgeschlagen, ${batches.linked} mit Produkt verknüpft`,
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/ernten");
|
revalidatePath("/admin/ernten");
|
||||||
|
revalidatePath("/admin/chargen");
|
||||||
|
revalidatePath("/admin/produkte");
|
||||||
redirect(
|
redirect(
|
||||||
`/admin/ernten?synced=${created}&updated=${updated}&skipped=${skipped}&failed=${failed}` +
|
`/admin/ernten?synced=${harvests.created}&updated=${harvests.updated}&skipped=${harvests.skipped}&failed=${harvests.failed}` +
|
||||||
`&batchSynced=${batchCreated}&batchSkipped=${batchSkipped}&batchFailed=${batchFailed}` +
|
`&batchSynced=${batches.counts.created}&batchSkipped=${batches.counts.skipped}&batchFailed=${batches.counts.failed}` +
|
||||||
(batchLoadError ? `&batchLoadError=${encodeURIComponent(batchLoadError)}` : "")
|
(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)}` : "")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,24 @@ import { adminProductNumbersSchema } from "@/lib/validation";
|
|||||||
import { isValidUploadPath } from "@/lib/uploads";
|
import { isValidUploadPath } from "@/lib/uploads";
|
||||||
import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
|
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> {
|
export async function createProductAction(formData: FormData): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
const slug = str(formData, "slug") || str(formData, "name").toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
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 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";
|
const category = (str(formData, "category") || "HONIG") as "HONIG" | "KERZEN" | "PROPOLIS";
|
||||||
// Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind
|
// Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind
|
||||||
// davon unabhaengig. Eingaben aus dem Formular werden fuer diese Kategorien
|
// 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,
|
priceCents: numbers.priceCents,
|
||||||
pricePerKgCents: numbers.pricePerKgCents,
|
pricePerKgCents: numbers.pricePerKgCents,
|
||||||
images: strArray(formData, "images").filter(isValidUploadPath),
|
images: strArray(formData, "images").filter(isValidUploadPath),
|
||||||
isActive: formData.get("isActive") === "on",
|
isActive,
|
||||||
inventory: { create: { quantity: numbers.quantity } },
|
inventory: { create: { quantity: numbers.quantity } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -84,6 +105,9 @@ export async function updateProductAction(formData: FormData): Promise<void> {
|
|||||||
}
|
}
|
||||||
const numbers = parsedNumbers.data;
|
const numbers = parsedNumbers.data;
|
||||||
|
|
||||||
|
const isActive = formData.get("isActive") === "on";
|
||||||
|
assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents);
|
||||||
|
|
||||||
const previousInventory = await prisma.inventory.findUnique({
|
const previousInventory = await prisma.inventory.findUnique({
|
||||||
where: { productId: id },
|
where: { productId: id },
|
||||||
select: { quantity: true },
|
select: { quantity: true },
|
||||||
@@ -103,7 +127,7 @@ export async function updateProductAction(formData: FormData): Promise<void> {
|
|||||||
priceCents: numbers.priceCents,
|
priceCents: numbers.priceCents,
|
||||||
pricePerKgCents: numbers.pricePerKgCents,
|
pricePerKgCents: numbers.pricePerKgCents,
|
||||||
images: strArray(formData, "images").filter(isValidUploadPath),
|
images: strArray(formData, "images").filter(isValidUploadPath),
|
||||||
isActive: formData.get("isActive") === "on",
|
isActive,
|
||||||
inventory: {
|
inventory: {
|
||||||
upsert: { create: { quantity: numbers.quantity }, update: { quantity: numbers.quantity } },
|
upsert: { create: { quantity: numbers.quantity }, update: { quantity: numbers.quantity } },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
|||||||
export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
|
export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const statusLabels = t.orderStatusBadge;
|
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 query = typeof q === "string" ? q.trim() : "";
|
||||||
const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined;
|
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>
|
<div>
|
||||||
<BackLink href="/admin" label={t.adminOrdersPage.backToDashboard} />
|
<BackLink href="/admin" label={t.adminOrdersPage.backToDashboard} />
|
||||||
<h1 className="text-2xl text-forest mb-8">{t.adminOrdersPage.title}</h1>
|
<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">
|
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
|
||||||
<label className="text-sm">
|
<label className="text-sm">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
|||||||
export default async function AdminProductEditPage(props: PageProps<"/admin/produkte/[id]">) {
|
export default async function AdminProductEditPage(props: PageProps<"/admin/produkte/[id]">) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { id } = await props.params;
|
const { id } = await props.params;
|
||||||
|
const { error } = await props.searchParams;
|
||||||
const rawProduct = await prisma.product.findUnique({
|
const rawProduct = await prisma.product.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { harvest: true, batch: true, inventory: true },
|
include: { harvest: true, batch: true, inventory: true },
|
||||||
@@ -35,6 +36,12 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
|
|||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
</p>
|
</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">
|
<form action={updateProductAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||||
<input type="hidden" name="id" value={product.id} />
|
<input type="hidden" name="id" value={product.id} />
|
||||||
<Field name="name" label={t.adminProductEditPage.nameLabel} defaultValue={product.name} required />
|
<Field name="name" label={t.adminProductEditPage.nameLabel} defaultValue={product.name} required />
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { createProductAction } from "../../actions";
|
|||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
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 t = getDictionary(await getLocale());
|
||||||
|
const { error } = await props.searchParams;
|
||||||
const [harvests, batches] = await Promise.all([
|
const [harvests, batches] = await Promise.all([
|
||||||
prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }),
|
prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }),
|
||||||
prisma.batch.findMany({ orderBy: { fillDate: "desc" }, include: { harvest: true } }),
|
prisma.batch.findMany({ orderBy: { fillDate: "desc" }, include: { harvest: true } }),
|
||||||
@@ -16,6 +17,12 @@ export default async function AdminProductNewPage() {
|
|||||||
<div className="max-w-xl">
|
<div className="max-w-xl">
|
||||||
<BackLink href="/admin/produkte" label={t.adminProductNewPage.backToProducts} />
|
<BackLink href="/admin/produkte" label={t.adminProductNewPage.backToProducts} />
|
||||||
<h1 className="text-2xl text-forest mb-8">{t.adminProductNewPage.title}</h1>
|
<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">
|
<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="name" label={t.adminProductNewPage.nameLabel} required />
|
||||||
<Field name="slug" label={t.adminProductNewPage.slugLabel} />
|
<Field name="slug" label={t.adminProductNewPage.slugLabel} />
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import Link from "next/link";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
import { formatPrice } from "@/lib/format";
|
import { formatPrice } from "@/lib/format";
|
||||||
|
import { syncProductsFromApiCoreAction } from "../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
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 t = getDictionary(await getLocale());
|
||||||
|
const { synced, skipped, failed, discontinued, linked, syncError, linkError } =
|
||||||
|
await props.searchParams;
|
||||||
const products = await prisma.product.findMany({
|
const products = await prisma.product.findMany({
|
||||||
include: { harvest: true, batch: true, inventory: true },
|
include: { harvest: true, batch: true, inventory: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
@@ -21,6 +24,29 @@ export default async function AdminProductsPage() {
|
|||||||
{t.adminProductsPage.newProduct}
|
{t.adminProductsPage.newProduct}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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">
|
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-left text-ink/65 border-b border-ink/10">
|
<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.inventory?.quantity ?? 0}</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
{product.isActive ? t.adminProductsPage.statusActive : t.adminProductsPage.statusInactive}
|
{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>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
<Link href={`/admin/produkte/${product.id}`} className="text-forest underline">
|
<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>
|
<p className="p-6 text-ink/65">{t.adminProductsPage.noProductsYet}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 orderItemsData = input.items.map((cartItem) => {
|
||||||
const product = products.find((p) => p.id === cartItem.productId)!;
|
const product = products.find((p) => p.id === cartItem.productId)!;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -903,7 +903,7 @@ export const de: Dictionary = {
|
|||||||
create: "Anlegen",
|
create: "Anlegen",
|
||||||
syncHeading: "ApiCore-Synchronisierung",
|
syncHeading: "ApiCore-Synchronisierung",
|
||||||
syncIntro:
|
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",
|
syncButton: "Jetzt synchronisieren",
|
||||||
syncResult: (created, updated, skipped, failed) =>
|
syncResult: (created, updated, skipped, failed) =>
|
||||||
`Ernten: ${created} neu angelegt, ${updated} aktualisiert, ${skipped} übersprungen (bereits veröffentlicht)${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`,
|
`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",
|
statusInactive: "Inaktiv",
|
||||||
edit: "Bearbeiten",
|
edit: "Bearbeiten",
|
||||||
noProductsYet: "Noch keine Produkte angelegt.",
|
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: {
|
adminProductEditPage: {
|
||||||
backToProducts: "Zurück zu Produkten",
|
backToProducts: "Zurück zu Produkten",
|
||||||
|
|||||||
@@ -903,7 +903,7 @@ export const en: Dictionary = {
|
|||||||
create: "Create",
|
create: "Create",
|
||||||
syncHeading: "ApiCore sync",
|
syncHeading: "ApiCore sync",
|
||||||
syncIntro:
|
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",
|
syncButton: "Sync now",
|
||||||
syncResult: (created, updated, skipped, failed) =>
|
syncResult: (created, updated, skipped, failed) =>
|
||||||
`Harvests: ${created} created, ${updated} updated, ${skipped} skipped (already published)${failed > 0 ? `, ${failed} failed` : ""}.`,
|
`Harvests: ${created} created, ${updated} updated, ${skipped} skipped (already published)${failed > 0 ? `, ${failed} failed` : ""}.`,
|
||||||
@@ -945,6 +945,18 @@ export const en: Dictionary = {
|
|||||||
statusInactive: "Inactive",
|
statusInactive: "Inactive",
|
||||||
edit: "Edit",
|
edit: "Edit",
|
||||||
noProductsYet: "No products created yet.",
|
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: {
|
adminProductEditPage: {
|
||||||
backToProducts: "Back to products",
|
backToProducts: "Back to products",
|
||||||
|
|||||||
@@ -647,6 +647,15 @@ export interface Dictionary {
|
|||||||
statusInactive: string;
|
statusInactive: string;
|
||||||
edit: string;
|
edit: string;
|
||||||
noProductsYet: 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: {
|
adminProductEditPage: {
|
||||||
backToProducts: string;
|
backToProducts: string;
|
||||||
|
|||||||
+153
-90
@@ -3,8 +3,8 @@ import { z } from "zod";
|
|||||||
/**
|
/**
|
||||||
* HTTP-Client fuer die ApiCore-Backend-API (separates Projekt, lokal unter
|
* HTTP-Client fuer die ApiCore-Backend-API (separates Projekt, lokal unter
|
||||||
* .../ApiCore/ApiCore-Backend — ein NestJS-Server mit eigener Postgres-DB),
|
* .../ApiCore/ApiCore-Backend — ein NestJS-Server mit eigener Postgres-DB),
|
||||||
* die die "Ernten"-Rohdaten fuer den Admin-Sync bereitstellt
|
* die die Rohdaten fuer den Admin-Sync bereitstellt
|
||||||
* (admin/actions/apicore-sync.ts).
|
* (admin/actions/apicore-sync.ts): Ernten, Produkte und Chargen.
|
||||||
*
|
*
|
||||||
* Auth-Modell von ApiCore: E-Mail/Passwort-Login -> kurzlebiges JWT
|
* Auth-Modell von ApiCore: E-Mail/Passwort-Login -> kurzlebiges JWT
|
||||||
* (POST /auth/login), Endpunkte pruefen zusaetzlich granulare Berechtigungen
|
* (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
|
* interaktiven Login-Flow, optional mit einem zweiten MFA-Schritt
|
||||||
* (POST /auth/mfa/verify), siehe ApiCore-Backend/src/auth/. Fuer diesen
|
* (POST /auth/mfa/verify), siehe ApiCore-Backend/src/auth/. Fuer diesen
|
||||||
* automatisierten Sync wird dringend ein eigenes, MFA-freies Service-Konto
|
* automatisierten Sync wird dringend ein eigenes, MFA-freies Service-Konto
|
||||||
* mit ausschliesslich den Berechtigungen "harvests:read" (und optional
|
* mit ausschliesslich Lese-Berechtigungen empfohlen statt eines echten
|
||||||
* "apiaries:read", fuer Standortnamen) empfohlen statt eines echten
|
* Admin-Zugangs: "harvests:read", "products:read", "extractions:read",
|
||||||
* Admin-Zugangs.
|
* "bottling:read" und optional "apiaries:read" (nur fuer Standortnamen).
|
||||||
*
|
*
|
||||||
* Benoetigte Umgebungsvariablen (siehe .env.example):
|
* Benoetigte Umgebungsvariablen (siehe .env.example):
|
||||||
* APICORE_API_URL z.B. "http://localhost:3001/api" (globaler Prefix
|
* 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_EMAIL E-Mail des Service-Kontos
|
||||||
* APICORE_SYNC_PASSWORD Passwort des Service-Kontos
|
* APICORE_SYNC_PASSWORD Passwort des Service-Kontos
|
||||||
*
|
*
|
||||||
* Ohne diese drei Variablen wirft fetchApiCoreHarvests()/
|
* Ohne diese drei Variablen wirft openApiCoreSession() sofort einen
|
||||||
* fetchApiCoreBottlingBatches() sofort einen ApiCoreConfigError — der
|
* ApiCoreConfigError — der Aufrufer (apicore-sync.ts) zeigt dessen Message
|
||||||
* Aufrufer (apicore-sync.ts) zeigt dessen Message dem Admin an, statt die
|
* dem Admin an, statt die Aktion stillschweigend nichts tun zu lassen.
|
||||||
* 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
|
* Fuer Wabenhains "Charge" (prisma/schema.prisma: model Batch) ist NICHT
|
||||||
* ApiCores "HoneyBatch" (roher Rohhonig direkt nach der Schleuderung, vor
|
* 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
|
* ergibt sich erst ueber zwei Stufen (BottlingBatch.honeyBatch.extractionId
|
||||||
* -> Extraction.harvests[].harvestId), daher werden zusaetzlich alle
|
* -> Extraction.harvests[].harvestId), daher werden zusaetzlich alle
|
||||||
* Schleuderungen geladen (GET /extractions) und clientseitig verknuepft.
|
* 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 {}
|
export class ApiCoreConfigError extends Error {}
|
||||||
@@ -49,23 +52,10 @@ const loginResponseSchema = z.union([
|
|||||||
z.object({ mfaRequired: z.literal(true), mfaToken: z.string() }),
|
z.object({ mfaRequired: z.literal(true), mfaToken: z.string() }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const apiarySchema = z.object({ id: z.string(), name: z.string() });
|
/** Angemeldete Verbindung zu ApiCore, siehe Modul-Kommentar oben. */
|
||||||
|
export interface ApiCoreSession {
|
||||||
const harvestSchema = z.object({
|
readonly baseUrl: string;
|
||||||
id: z.string(),
|
readonly token: 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConfig(): { baseUrl: string; email: string; password: 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 };
|
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;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await fetch(`${baseUrl}/auth/login`, {
|
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)."
|
"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. */
|
/** 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 {
|
try {
|
||||||
const res = await fetch(`${baseUrl}/apiaries`, { headers: { Authorization: `Bearer ${token}` } });
|
const apiaries = await getJson(session, "/apiaries", z.array(apiarySchema), "Standorte");
|
||||||
if (!res.ok) return new Map();
|
return new Map(apiaries.map((a) => [a.id, a.name]));
|
||||||
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]));
|
|
||||||
} catch {
|
} catch {
|
||||||
return new Map();
|
return new Map();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchApiCoreHarvests(): Promise<ApiCoreHarvestWithApiaryName[]> {
|
export async function fetchApiCoreHarvests(
|
||||||
const { baseUrl, email, password } = getConfig();
|
session: ApiCoreSession
|
||||||
const token = await login(baseUrl, email, password);
|
): Promise<ApiCoreHarvestWithApiaryName[]> {
|
||||||
|
const harvests = await getJson(session, "/harvests", z.array(harvestSchema), "Ernten");
|
||||||
let res: Response;
|
const apiaryNames = await fetchApiaryNames(session);
|
||||||
try {
|
return harvests.map((harvest) => ({
|
||||||
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) => ({
|
|
||||||
...harvest,
|
...harvest,
|
||||||
apiaryName: apiaryNames.get(harvest.apiaryId) ?? null,
|
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)
|
// Chargen (BottlingBatch, siehe Modul-Kommentar oben)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -164,6 +247,10 @@ const bottlingBatchSchema = z.object({
|
|||||||
date: z.string(),
|
date: z.string(),
|
||||||
bestBeforeDate: z.string(),
|
bestBeforeDate: z.string(),
|
||||||
jarCount: z.number(),
|
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({
|
honeyBatch: z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
extractionId: z.string(),
|
extractionId: z.string(),
|
||||||
@@ -185,43 +272,19 @@ export interface ApiCoreBottlingBatchWithHarvestId extends ApiCoreBottlingBatch
|
|||||||
harvestSourceId: string | null;
|
harvestSourceId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchApiCoreBottlingBatches(): Promise<ApiCoreBottlingBatchWithHarvestId[]> {
|
export async function fetchApiCoreBottlingBatches(
|
||||||
const { baseUrl, email, password } = getConfig();
|
session: ApiCoreSession
|
||||||
const token = await login(baseUrl, email, password);
|
): Promise<ApiCoreBottlingBatchWithHarvestId[]> {
|
||||||
const authHeader = { Authorization: `Bearer ${token}` };
|
const [extractions, batches] = await Promise.all([
|
||||||
|
getJson(session, "/extractions", z.array(extractionSchema), "Schleuderungen"),
|
||||||
let extractionsRes: Response;
|
getJson(session, "/bottling-batches", z.array(bottlingBatchSchema), "Chargen"),
|
||||||
let batchesRes: Response;
|
]);
|
||||||
try {
|
|
||||||
[extractionsRes, batchesRes] = await Promise.all([
|
|
||||||
fetch(`${baseUrl}/extractions`, { headers: authHeader }),
|
|
||||||
fetch(`${baseUrl}/bottling-batches`, { headers: authHeader }),
|
|
||||||
]);
|
|
||||||
} 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>(
|
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,
|
...batch,
|
||||||
harvestSourceId: firstHarvestByExtraction.get(batch.honeyBatch.extractionId) ?? null,
|
harvestSourceId: firstHarvestByExtraction.get(batch.honeyBatch.extractionId) ?? null,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { ApiCoreHarvestWithApiaryName, ApiCoreProduct } from "./apicore-client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reine Feld-Abbildung ApiCore -> Wabenhain. Bewusst getrennt von
|
||||||
|
* admin/actions/apicore-sync.ts: jene Datei traegt "use server" und darf
|
||||||
|
* deshalb ausschliesslich async Server Actions exportieren — dort waeren
|
||||||
|
* genau die Entscheidungen, die man pruefen moechte (Was wird aus einem
|
||||||
|
* Produkt ohne Beschreibung? Was aus einem fehlenden Gewicht?), nicht
|
||||||
|
* einzeln testbar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stabiler, eindeutiger Slug aus der ApiCore-ID — zugleich der
|
||||||
|
* Idempotenz-Schluessel des Syncs: ein wiederholter Lauf erkennt daran, ob
|
||||||
|
* ein ApiCore-Datensatz schon einmal importiert wurde, statt bei jedem Lauf
|
||||||
|
* Duplikate anzulegen. Bewusst NICHT aus Name/Jahr abgeleitet (wie bei den
|
||||||
|
* manuellen Anlege-Formularen): ein umbenannter Artikel bekaeme sonst einen
|
||||||
|
* neuen Slug und wuerde ein zweites Mal importiert.
|
||||||
|
*/
|
||||||
|
export function slugForApiCoreSource(sourceId: string): string {
|
||||||
|
return `apicore-${sourceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wabenhains Harvest.description ist ein Pflichtfeld, ApiCore liefert dafuer
|
||||||
|
* kein Aequivalent — baut stattdessen eine sinnvolle Vorbefuellung aus den
|
||||||
|
* vorhandenen Mengen-/Notizfeldern, die der Admin beim Review verfeinert.
|
||||||
|
*/
|
||||||
|
export function buildHarvestDescription(source: ApiCoreHarvestWithApiaryName): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (source.honeySuperCount != null) parts.push(`${source.honeySuperCount} Honigräume`);
|
||||||
|
if (source.grossWeight != null) parts.push(`Bruttogewicht ${source.grossWeight} kg`);
|
||||||
|
if (source.netWeight != null) parts.push(`Nettogewicht ${source.netWeight} kg`);
|
||||||
|
if (source.notes?.trim()) parts.push(source.notes.trim());
|
||||||
|
const detail = parts.length > 0 ? parts.join(" · ") : "Bitte Beschreibung ergänzen.";
|
||||||
|
return `Automatisch aus ApiCore importiert. ${detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wabenhains Product.description ist ein Pflichtfeld und steht im Shop als
|
||||||
|
* Verkaufstext. ApiCores Beschreibung ist dagegen eine interne Notiz und oft
|
||||||
|
* leer.
|
||||||
|
*
|
||||||
|
* Sie wird deshalb zwar uebernommen, aber immer mit einem Herkunftshinweis
|
||||||
|
* versehen: der Text bleibt als Entwurf erkennbar, solange ihn niemand
|
||||||
|
* ersetzt hat. Ist gar nichts da, tritt an seine Stelle eine ausdrueckliche
|
||||||
|
* Aufforderung statt eines leeren Feldes — ein leerer Verkaufstext waere im
|
||||||
|
* Shop nicht von einem vergessenen zu unterscheiden.
|
||||||
|
*/
|
||||||
|
export function buildProductDescription(source: ApiCoreProduct): string {
|
||||||
|
const own = source.description?.trim();
|
||||||
|
const facts: string[] = [];
|
||||||
|
if (source.sku?.trim()) facts.push(`Artikelnummer ${source.sku.trim()}`);
|
||||||
|
if (source.salesUnit?.trim()) facts.push(`Verkaufseinheit ${source.salesUnit.trim()}`);
|
||||||
|
|
||||||
|
const detail = own || "Bitte Produktbeschreibung für den Shop ergänzen.";
|
||||||
|
const suffix = facts.length > 0 ? ` (${facts.join(" · ")})` : "";
|
||||||
|
return `Automatisch aus ApiCore importiert${suffix}. ${detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gewicht in Gramm. Bevorzugt ApiCores `weight` (Gewicht des Artikels),
|
||||||
|
* ersatzweise `packagingSize` (Gebindegroesse) — zur Einheiten-Annahme
|
||||||
|
* "Gramm" siehe den Kommentar an productSchema in apicore-client.ts.
|
||||||
|
*
|
||||||
|
* Fehlt beides, wird bewusst 0 zurueckgegeben statt geraten. 0 ist in
|
||||||
|
* Wabenhain kein gueltiges Gewicht (adminProductNumbersSchema verlangt
|
||||||
|
* mindestens 1 g), das importierte Produkt laesst sich also gar nicht
|
||||||
|
* abspeichern, ohne dass jemand ein echtes Gewicht eintraegt — genau die
|
||||||
|
* gewuenschte Bremse, denn aus dem Gewicht ergibt sich der Grundpreis je
|
||||||
|
* Kilogramm, der in Deutschland verpflichtend anzugeben ist.
|
||||||
|
*/
|
||||||
|
export function deriveWeightGrams(source: ApiCoreProduct): number {
|
||||||
|
const raw = source.weight ?? source.packagingSize;
|
||||||
|
if (raw == null || !Number.isFinite(raw) || raw <= 0) return 0;
|
||||||
|
return Math.round(raw);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user