feat: Produkte aus ApiCore synchronisieren

Der Shop konnte Ernten und Chargen bereits aus ApiCore uebernehmen, den
Produktkatalog aber nicht - jedes Glas musste im Admin ein zweites Mal von
Hand angelegt werden. Neuer Knopf unter /admin/produkte zieht die Produkte
und traegt anschliessend die passende Charge am Produkt nach, wo ApiCore
die Abfuellung einem Produkt zugeordnet hat. Genau diese Verknuepfung
fuettert die Herkunftsseite hinter dem QR-Code auf dem Glas.

ApiCore fuehrt ueberhaupt keine Verkaufspreise. Importierte Produkte
entstehen deshalb als deaktivierte Entwuerfe mit Preis 0, den nur ein
Mensch setzen kann. Damit daraus nie eine Gratis-Bestellung wird, lehnen
die Aktivierung im Admin und der Checkout ein Produkt ohne Preis jetzt
ausdruecklich ab, und die Produktliste kennzeichnet solche Entwuerfe.

Ein bereits vorhandenes Produkt wird uebersprungen, nie ueberschrieben:
genau die drei Felder, die ApiCore liefert (Name, Beschreibung, Gewicht),
zieht der Admin vor der Freischaltung von Hand nach - ein Update wuerde
diese Arbeit bei jedem weiteren Lauf wegwerfen. Ein in ApiCore
ausgelaufenes Produkt wird nicht automatisch deaktiviert, sondern nur
gemeldet, damit der Restbestand abverkauft werden kann.

Nebenbei behoben: die beiden Produktformulare und die Bestelluebersicht
haben den ?error=-Parameter aus fail() nie ausgegeben - abgelehnte
Eingaben landeten wortlos wieder auf dem Formular.

Der Login gegen ApiCore ist jetzt von den Abfragen getrennt
(openApiCoreSession), damit ein Sync-Lauf mit einer einzigen Anmeldung
auskommt statt mit dreien. Die Feld-Abbildung liegt neu in
lib/apicore-mapping.ts und ist damit testbar, statt hinter "use server"
zu verschwinden.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-16 18:03:05 +02:00
1 parent c61ae92390
commit 78c19f1b73
15 files changed
+908 -226

No files matched your search

+326 -119
View File
@@ -5,96 +5,98 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import {
openApiCoreSession,
fetchApiCoreHarvests,
fetchApiCoreProducts,
fetchApiCoreBottlingBatches,
isSellableStatus,
ApiCoreConfigError,
ApiCoreRequestError,
type ApiCoreSession,
type ApiCoreHarvestWithApiaryName,
type ApiCoreProduct,
type ApiCoreBottlingBatchWithHarvestId,
} from "@/lib/apicore-client";
import {
slugForApiCoreSource,
buildHarvestDescription,
buildProductDescription,
deriveWeightGrams,
} from "@/lib/apicore-mapping";
import { requireAdmin } from "./_shared";
/**
* Stabiler, eindeutiger Slug aus der ApiCore-Ernte-ID statt aus Typ/Jahr
* abgeleitet (wie beim manuellen createHarvestAction) — dient hier zugleich
* als Idempotenz-Schluessel: ein wiederholter Sync erkennt an diesem Slug,
* ob eine ApiCore-Ernte schon einmal importiert wurde, statt bei jedem Lauf
* Duplikate anzulegen.
*/
function slugFor(sourceId: string): string {
return `apicore-${sourceId}`;
}
/**
* Wabenhains Harvest.description ist ein Pflichtfeld, ApiCore liefert dafuer
* kein Aequivalent — baut stattdessen eine sinnvolle Vorbefuellung aus den
* vorhandenen Mengen-/Notizfeldern, die der Admin beim Review verfeinert.
*/
function buildDescription(source: ApiCoreHarvestWithApiaryName): string {
const parts: string[] = [];
if (source.honeySuperCount != null) parts.push(`${source.honeySuperCount} Honigräume`);
if (source.grossWeight != null) parts.push(`Bruttogewicht ${source.grossWeight} kg`);
if (source.netWeight != null) parts.push(`Nettogewicht ${source.netWeight} kg`);
if (source.notes?.trim()) parts.push(source.notes.trim());
const detail = parts.length > 0 ? parts.join(" · ") : "Bitte Beschreibung ergänzen.";
return `Automatisch aus ApiCore importiert. ${detail}`;
}
/**
* Zieht Ernten UND Chargen aus ApiCore.
* Uebernimmt Stammdaten aus ApiCore (separates Imkerei-Verwaltungssystem) in
* den Shop: Ernten, Produkte und Chargen.
*
* Ernten werden als unveroeffentlichte Entwuerfe angelegt (isPublished:
* false) bzw. bereits vorhandene, noch unveroeffentlichte Entwuerfe
* aktualisiert. Bereits vom Admin veroeffentlichte, frueher synchronisierte
* Ernten werden NICHT angefasst — sonst wuerde ein erneuter Sync eine
* bewusste Freigabe- und Bearbeitungsentscheidung des Admins stillschweigend
* zuruecksetzen.
* Durchgaengiges Prinzip fuer alle drei: ApiCore ist die Quelle der ROHDATEN,
* Wabenhain bleibt die Quelle aller VERKAUFS-Entscheidungen. Ein Sync-Lauf
* darf deshalb nie etwas ueberschreiben, das eine Mitarbeiterin im Admin
* bewusst gesetzt hat — importiert wird in einen Entwurfszustand, den erst
* ein Mensch freigibt.
*
* Chargen (Wabenhain: Batch, ApiCore: BottlingBatch — siehe Kommentar in
* lib/apicore-client.ts fuer die Modul-Zuordnung) werden dagegen NUR neu
* angelegt, nie aktualisiert: anders als Harvest hat Batch kein
* isPublished-Feld, das eine bereits erfolgte Admin-Bearbeitung markieren
* koennte. Ein Update wuerde insbesondere remainingGlasses ueberschreiben —
* das ist Wabenhains EIGENER, durch echte Verkaeufe veraenderter
* Lagerbestand und darf niemals durch einen ApiCore-Sync zurueckgesetzt
* werden. Existiert eine Charge (per code) bereits, wird sie uebersprungen.
* Jede Charge braucht eine bereits in Wabenhain vorhandene (synchronisierte
* oder manuell angelegte) Ernte als harvestId — Chargen laufen deshalb nach
* den Ernten in diesem Lauf, damit gerade frisch angelegte Ernten schon
* verfuegbar sind.
* - Ernten: werden als unveroeffentlichte Entwuerfe angelegt (isPublished
* false) bzw. als solche aktualisiert. Bereits veroeffentlichte, frueher
* synchronisierte Ernten bleiben unberuehrt.
* - Produkte: werden als deaktivierte Entwuerfe angelegt (isActive false,
* Preis 0) und nur so lange aktualisiert, wie sie deaktiviert sind. Ein
* bereits aktives Produkt wird nie angefasst — sonst wuerde ein Sync Preis,
* Beschreibung und Geschmacksprofil ueberschreiben, die nur in Wabenhain
* gepflegt werden und die es in ApiCore gar nicht gibt.
* - Chargen: werden NUR neu angelegt, nie aktualisiert. Anders als Harvest
* und Product hat Batch kein Feld, an dem eine erfolgte Admin-Bearbeitung
* erkennbar waere. Ein Update wuerde insbesondere remainingGlasses
* ueberschreiben — Wabenhains EIGENEN, durch echte Verkaeufe veraenderten
* Lagerbestand.
*
* Admin-Berechtigungsfeld "type" bei Wabenhain (Erntesaison, z.B.
* "Sommertracht") und ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig")
* sind KEINE aequivalenten Taxonomien — es findet bewusst keine automatische
* Idempotent ist das alles ueber einen stabilen Schluessel je Datensatz:
* "apicore-<ApiCore-ID>" als Harvest.slug bzw. Product.slug, und die
* ApiCore-Losnummer als Batch.code.
*
* Wabenhains "type" bei Ernten (Erntesaison, z.B. "Sommertracht") und
* ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig") sind KEINE
* aequivalenten Taxonomien — es findet bewusst keine automatische
* Uebersetzung statt, der Rohwert wird uebernommen und muss vom Admin beim
* Review korrigiert werden.
*/
export async function syncHarvestsFromApiCoreAction(): Promise<void> {
const admin = await requireAdmin();
let sourceHarvests: ApiCoreHarvestWithApiaryName[];
try {
sourceHarvests = await fetchApiCoreHarvests();
} catch (error: unknown) {
const message =
error instanceof ApiCoreConfigError || error instanceof ApiCoreRequestError
? error.message
: error instanceof Error
? error.message
: "Unbekannter Fehler";
redirect(`/admin/ernten?syncError=${encodeURIComponent(message)}`);
}
interface SyncCounts {
created: number;
updated: number;
skipped: number;
failed: number;
}
let created = 0;
let updated = 0;
let skipped = 0;
let failed = 0;
/**
* Produkte kennen bewusst kein "updated" — sie werden nur neu angelegt,
* siehe syncProducts().
*/
interface ProductSyncCounts {
created: number;
skipped: number;
failed: number;
/** In ApiCore nicht mehr verkaufsfaehig, in Wabenhain aber noch aktiv. */
discontinued: number;
}
for (const source of sourceHarvests) {
const slug = slugFor(source.id);
function describeError(error: unknown): string {
if (error instanceof ApiCoreConfigError || error instanceof ApiCoreRequestError) return error.message;
if (error instanceof Error) return error.message;
return "Unbekannter Fehler";
}
// ---------------------------------------------------------------------------
// Ernten
// ---------------------------------------------------------------------------
async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise<SyncCounts> {
const counts: SyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0 };
for (const source of sources) {
const slug = slugForApiCoreSource(source.id);
const date = new Date(source.date);
if (Number.isNaN(date.getTime())) {
failed++;
counts.failed++;
console.error(`[apicore-sync] Ernte ${source.id} hat ein ungueltiges Datum ("${source.date}") — übersprungen.`);
continue;
}
@@ -102,9 +104,8 @@ export async function syncHarvestsFromApiCoreAction(): Promise<void> {
try {
const existing = await prisma.harvest.findUnique({ where: { slug } });
if (existing?.isPublished) {
skipped++;
counts.skipped++;
continue;
}
@@ -113,64 +114,122 @@ export async function syncHarvestsFromApiCoreAction(): Promise<void> {
type,
harvestDate: date,
location: source.apiaryName ?? "Altenmünster",
description: buildDescription(source),
description: buildHarvestDescription(source),
};
if (existing) {
await prisma.harvest.update({ where: { slug }, data });
updated++;
counts.updated++;
} else {
await prisma.harvest.create({ data: { ...data, slug, isPublished: false } });
created++;
counts.created++;
}
} catch (error: unknown) {
failed++;
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
counts.failed++;
// z.B. P2002 (unique constraint year+type), falls zwei ApiCore-Ernten
// im selben Jahr denselben honeyType tragen — bewusst nicht fatal fuer
// den gesamten Sync-Lauf, siehe Kommentar oben zur Taxonomie-Kollision.
console.error(`[apicore-sync] Ernte ${source.id} konnte nicht importiert werden: ${message}`);
console.error(`[apicore-sync] Ernte ${source.id} konnte nicht importiert werden: ${describeError(error)}`);
}
}
await logAdminAction(admin, {
action: "harvest.sync_apicore",
entityType: "Harvest",
detail: `${created} neu, ${updated} aktualisiert, ${skipped} übersprungen, ${failed} fehlgeschlagen`,
});
return counts;
}
// Chargen-Sync laeuft NACH den Ernten (s.o., Doc-Kommentar) und ist
// bewusst nicht fatal fuer den gesamten Aufruf: schlaegt nur das Laden der
// Chargen fehl, sollen die bereits erfolgreich synchronisierten Ernten
// trotzdem sichtbar bleiben statt durch einen Redirect mit Fehlerseite
// verloren zu gehen.
let batchCreated = 0;
let batchSkipped = 0;
let batchFailed = 0;
let batchLoadError: string | null = null;
// ---------------------------------------------------------------------------
// Produkte
// ---------------------------------------------------------------------------
let sourceBatches: ApiCoreBottlingBatchWithHarvestId[] = [];
try {
sourceBatches = await fetchApiCoreBottlingBatches();
} catch (error: unknown) {
batchLoadError =
error instanceof ApiCoreConfigError || error instanceof ApiCoreRequestError
? error.message
: error instanceof Error
? error.message
: "Unbekannter Fehler";
/**
* Ein neu importiertes Produkt ist absichtlich NICHT verkaufsfaehig:
*
* - `isActive: false` haelt es aus Shop, Suche und Checkout heraus.
* - `priceCents: 0` ist kein geschaetzter, sondern ein FEHLENDER Preis:
* ApiCore fuehrt ueberhaupt keine Verkaufspreise, das ist reine
* Betriebsdatenhaltung. Den Preis kann nur ein Mensch setzen. Damit aus
* diesem Platzhalter nie eine Gratis-Bestellung wird, weigern sich
* zusaetzlich die Aktivierung im Admin und der Checkout, ein Produkt mit
* Preis 0 zu verkaufen (admin/actions/products.ts, api/checkout/route.ts).
* - Kategorie HONIG als Vorgabe: ApiCore kennt keine Shop-Kategorien, und
* eine Imkerei-Warenwirtschaft fuehrt ueberwiegend Honig. Kerzen oder
* Propolis stellt der Admin beim Review um.
* - Geschmacksprofil, Konsistenz und Bilder bleiben auf den Schema-Vorgaben:
* dafuer gibt es in ApiCore keine Entsprechung, das ist Shop-Redaktion.
*
* Ein schon vorhandenes Produkt wird NIE aktualisiert, auch kein Entwurf.
* Genau die drei Felder, die ApiCore liefert (Name, Beschreibung, Gewicht),
* sind die, die der Admin vor der Freischaltung von Hand nachzieht — ein
* Update wuerde diese Arbeit bei jedem weiteren Lauf wieder wegwerfen, und
* am Entwurf ist nicht erkennbar, ob schon jemand daran war. Soll ein
* Produkt wirklich frisch aus ApiCore kommen, loescht man es in Wabenhain
* und synchronisiert erneut. Dieselbe Regel gilt aus demselben Grund fuer
* Chargen.
*/
async function syncProducts(sources: ApiCoreProduct[]): Promise<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) {
batchFailed++;
counts.failed++;
console.error(`[apicore-sync] Charge ${source.lotNumber} ist keiner Ernte zugeordnet — übersprungen.`);
continue;
}
const fillDate = new Date(source.date);
const bestBeforeDate = new Date(source.bestBeforeDate);
if (Number.isNaN(fillDate.getTime()) || Number.isNaN(bestBeforeDate.getTime())) {
batchFailed++;
counts.failed++;
console.error(`[apicore-sync] Charge ${source.lotNumber} hat ein ungültiges Datum — übersprungen.`);
continue;
}
@@ -178,48 +237,196 @@ export async function syncHarvestsFromApiCoreAction(): Promise<void> {
try {
const existing = await prisma.batch.findUnique({ where: { code: source.lotNumber } });
if (existing) {
batchSkipped++;
counts.skipped++;
continue;
}
const harvest = await prisma.harvest.findUnique({ where: { slug: slugFor(source.harvestSourceId) } });
const harvest = await prisma.harvest.findUnique({
where: { slug: slugForApiCoreSource(source.harvestSourceId) },
});
if (!harvest) {
batchFailed++;
counts.failed++;
console.error(
`[apicore-sync] Charge ${source.lotNumber}: zugehörige Ernte (ApiCore-ID ${source.harvestSourceId}) ist in Wabenhain noch nicht synchronisiert — übersprungen.`
);
continue;
}
const glassCount = Math.max(Math.round(source.jarCount), 0);
await prisma.batch.create({
data: {
code: source.lotNumber,
harvestId: harvest.id,
fillDate,
bestBeforeDate,
glassCount: Math.max(Math.round(source.jarCount), 0),
remainingGlasses: Math.max(Math.round(source.jarCount), 0),
glassCount,
remainingGlasses: glassCount,
},
});
batchCreated++;
counts.created++;
} catch (error: unknown) {
batchFailed++;
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
console.error(`[apicore-sync] Charge ${source.lotNumber} konnte nicht importiert werden: ${message}`);
counts.failed++;
console.error(`[apicore-sync] Charge ${source.lotNumber} konnte nicht importiert werden: ${describeError(error)}`);
}
}
return counts;
}
/**
* Traegt die Charge (und ueber sie die Ernte) an synchronisierten Produkten
* nach, wo ApiCore die Abfuellung einem Produkt zugeordnet hat. Genau diese
* Verknuepfung macht auf der Produktseite und ueber den QR-Code auf dem Glas
* die Herkunft nachvollziehbar.
*
* Bewusst nur dort, wo Product.batchId noch leer ist: hat jemand im Admin
* schon eine Charge zugeordnet, ist das eine Entscheidung darueber, was
* tatsaechlich im Karton liegt — die darf ein Sync nicht umbiegen. Der Lauf
* ist dadurch gefahrlos wiederholbar.
*
* Fachliche Grenze: in ApiCore hat ein Produkt MEHRERE Abfuellungen, in
* Wabenhain zeigt ein Produkt auf GENAU EINE Charge. Verknuepft wird deshalb
* die zuerst gefundene, noch nicht zugeordnete Kombination; welche Charge
* gerade verkauft wird, entscheidet weiterhin der Admin.
*/
async function linkProductsToBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promise<number> {
let linked = 0;
for (const source of sources) {
if (!source.productId) continue;
try {
const product = await prisma.product.findUnique({
where: { slug: slugForApiCoreSource(source.productId) },
});
if (!product || product.batchId) continue;
const batch = await prisma.batch.findUnique({ where: { code: source.lotNumber } });
if (!batch) continue;
await prisma.product.update({
where: { id: product.id },
data: { batchId: batch.id, harvestId: batch.harvestId },
});
linked++;
} catch (error: unknown) {
console.error(
`[apicore-sync] Charge ${source.lotNumber} konnte nicht mit ihrem Produkt verknüpft werden: ${describeError(error)}`
);
}
}
return linked;
}
/**
* Chargen laden und, soweit gewuenscht, importieren und mit ihren Produkten
* verknuepfen. Ein Fehlschlag beim Laden ist bewusst NICHT fatal fuer den
* umgebenden Sync-Lauf: die bereits erfolgreich uebernommenen Ernten bzw.
* Produkte sollen sichtbar bleiben, statt hinter einer Fehlerseite zu
* verschwinden.
*/
async function syncBatchesAndLinks(
session: ApiCoreSession,
options: { importBatches: boolean }
): Promise<{ counts: SyncCounts; linked: number; loadError: string | null }> {
let sources: ApiCoreBottlingBatchWithHarvestId[];
try {
sources = await fetchApiCoreBottlingBatches(session);
} catch (error: unknown) {
return {
counts: { created: 0, updated: 0, skipped: 0, failed: 0 },
linked: 0,
loadError: describeError(error),
};
}
const counts = options.importBatches
? await syncBatches(sources)
: { created: 0, updated: 0, skipped: 0, failed: 0 };
const linked = await linkProductsToBatches(sources);
return { counts, linked, loadError: null };
}
// ---------------------------------------------------------------------------
// Server Actions
// ---------------------------------------------------------------------------
/**
* Sync-Knopf auf /admin/ernten: zieht Ernten und die daraus entstandenen
* Chargen. Chargen laufen NACH den Ernten, damit eine gerade erst angelegte
* Ernte als Pflicht-Fremdschluessel schon bereitsteht.
*/
export async function syncHarvestsFromApiCoreAction(): Promise<void> {
const admin = await requireAdmin();
let session: ApiCoreSession;
let sourceHarvests: ApiCoreHarvestWithApiaryName[];
try {
session = await openApiCoreSession();
sourceHarvests = await fetchApiCoreHarvests(session);
} catch (error: unknown) {
redirect(`/admin/ernten?syncError=${encodeURIComponent(describeError(error))}`);
}
const harvests = await syncHarvests(sourceHarvests);
await logAdminAction(admin, {
action: "harvest.sync_apicore",
entityType: "Harvest",
detail: `${harvests.created} neu, ${harvests.updated} aktualisiert, ${harvests.skipped} übersprungen, ${harvests.failed} fehlgeschlagen`,
});
const batches = await syncBatchesAndLinks(session, { importBatches: true });
await logAdminAction(admin, {
action: "batch.sync_apicore",
entityType: "Batch",
detail: batchLoadError
? `Chargen konnten nicht geladen werden: ${batchLoadError}`
: `${batchCreated} neu, ${batchSkipped} übersprungen, ${batchFailed} fehlgeschlagen`,
detail: batches.loadError
? `Chargen konnten nicht geladen werden: ${batches.loadError}`
: `${batches.counts.created} neu, ${batches.counts.skipped} übersprungen, ${batches.counts.failed} fehlgeschlagen, ${batches.linked} mit Produkt verknüpft`,
});
revalidatePath("/admin/ernten");
revalidatePath("/admin/chargen");
revalidatePath("/admin/produkte");
redirect(
`/admin/ernten?synced=${created}&updated=${updated}&skipped=${skipped}&failed=${failed}` +
`&batchSynced=${batchCreated}&batchSkipped=${batchSkipped}&batchFailed=${batchFailed}` +
(batchLoadError ? `&batchLoadError=${encodeURIComponent(batchLoadError)}` : "")
`/admin/ernten?synced=${harvests.created}&updated=${harvests.updated}&skipped=${harvests.skipped}&failed=${harvests.failed}` +
`&batchSynced=${batches.counts.created}&batchSkipped=${batches.counts.skipped}&batchFailed=${batches.counts.failed}` +
(batches.loadError ? `&batchLoadError=${encodeURIComponent(batches.loadError)}` : "")
);
}
/**
* Sync-Knopf auf /admin/produkte: zieht den Produktkatalog und traegt
* anschliessend die Charge an den synchronisierten Produkten nach. Chargen
* selbst werden hier NICHT importiert — das gehoert zum Ernten-Sync, weil
* eine Charge ohne ihre Ernte gar nicht anlegbar ist.
*/
export async function syncProductsFromApiCoreAction(): Promise<void> {
const admin = await requireAdmin();
let session: ApiCoreSession;
let sourceProducts: ApiCoreProduct[];
try {
session = await openApiCoreSession();
sourceProducts = await fetchApiCoreProducts(session);
} catch (error: unknown) {
redirect(`/admin/produkte?syncError=${encodeURIComponent(describeError(error))}`);
}
const products = await syncProducts(sourceProducts);
const batches = await syncBatchesAndLinks(session, { importBatches: false });
await logAdminAction(admin, {
action: "product.sync_apicore",
entityType: "Product",
detail:
`${products.created} neu, ${products.skipped} übersprungen, ${products.failed} fehlgeschlagen, ` +
`${products.discontinued} in ApiCore ausgelaufen, ${batches.linked} mit Charge verknüpft`,
});
revalidatePath("/admin/produkte");
redirect(
`/admin/produkte?synced=${products.created}&skipped=${products.skipped}` +
`&failed=${products.failed}&discontinued=${products.discontinued}&linked=${batches.linked}` +
(batches.loadError ? `&linkError=${encodeURIComponent(batches.loadError)}` : "")
);
}
+26 -2
View File
@@ -9,6 +9,24 @@ import { adminProductNumbersSchema } from "@/lib/validation";
import { isValidUploadPath } from "@/lib/uploads";
import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
/**
* Ein Produkt darf nicht aktiv geschaltet werden, solange kein Preis
* gepflegt ist. `adminProductNumbersSchema` erlaubt 0 bewusst weiterhin:
* ein Entwurf ohne Preis muss speicherbar bleiben — insbesondere die aus
* ApiCore importierten Produkte, die genau so entstehen (ApiCore fuehrt
* keine Verkaufspreise, siehe apicore-sync.ts). Verboten ist nur die
* Kombination "aktiv UND Preis 0", denn die waere im Shop als kostenloses
* Produkt bestellbar.
*/
function assertPricedIfActive(redirectPath: string, isActive: boolean, priceCents: number): void {
if (isActive && priceCents <= 0) {
fail(
redirectPath,
"Ein Produkt ohne Preis kann nicht aktiv geschaltet werden. Bitte zuerst einen Preis eintragen — oder das Produkt als Entwurf (nicht aktiv) speichern."
);
}
}
export async function createProductAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const slug = str(formData, "slug") || str(formData, "name").toLowerCase().replace(/[^a-z0-9]+/g, "-");
@@ -27,6 +45,9 @@ export async function createProductAction(formData: FormData): Promise<void> {
}
const numbers = parsedNumbers.data;
const isActive = formData.get("isActive") === "on";
assertPricedIfActive("/admin/produkte/neu", isActive, numbers.priceCents);
const category = (str(formData, "category") || "HONIG") as "HONIG" | "KERZEN" | "PROPOLIS";
// Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind
// davon unabhaengig. Eingaben aus dem Formular werden fuer diese Kategorien
@@ -50,7 +71,7 @@ export async function createProductAction(formData: FormData): Promise<void> {
priceCents: numbers.priceCents,
pricePerKgCents: numbers.pricePerKgCents,
images: strArray(formData, "images").filter(isValidUploadPath),
isActive: formData.get("isActive") === "on",
isActive,
inventory: { create: { quantity: numbers.quantity } },
},
});
@@ -84,6 +105,9 @@ export async function updateProductAction(formData: FormData): Promise<void> {
}
const numbers = parsedNumbers.data;
const isActive = formData.get("isActive") === "on";
assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents);
const previousInventory = await prisma.inventory.findUnique({
where: { productId: id },
select: { quantity: true },
@@ -103,7 +127,7 @@ export async function updateProductAction(formData: FormData): Promise<void> {
priceCents: numbers.priceCents,
pricePerKgCents: numbers.pricePerKgCents,
images: strArray(formData, "images").filter(isValidUploadPath),
isActive: formData.get("isActive") === "on",
isActive,
inventory: {
upsert: { create: { quantity: numbers.quantity }, update: { quantity: numbers.quantity } },
},
+7 -1
View File
@@ -12,7 +12,7 @@ import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
const t = getDictionary(await getLocale());
const statusLabels = t.orderStatusBadge;
const { q, status } = await props.searchParams;
const { q, status, error } = await props.searchParams;
const query = typeof q === "string" ? q.trim() : "";
const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined;
@@ -38,6 +38,12 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
<div>
<BackLink href="/admin" label={t.adminOrdersPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminOrdersPage.title}</h1>
{/* Die DHL-Aktionen leiten bei einer nicht mehr auffindbaren Bestellung
hierher zurueck (admin/actions/dhl.ts). Ohne diese Ausgabe sah das
aus wie ein folgenloser Klick. */}
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
<label className="text-sm">
+7
View File
@@ -10,6 +10,7 @@ import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminProductEditPage(props: PageProps<"/admin/produkte/[id]">) {
const t = getDictionary(await getLocale());
const { id } = await props.params;
const { error } = await props.searchParams;
const rawProduct = await prisma.product.findUnique({
where: { id },
include: { harvest: true, batch: true, inventory: true },
@@ -35,6 +36,12 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
)
: null}
</p>
{/* Abgelehnte Eingaben kommen ueber fail() als ?error= zurueck (siehe
admin/actions/_shared.ts). Ohne diese Ausgabe landete man wortlos
wieder auf dem Formular und wusste nicht, was abgelehnt wurde. */}
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form action={updateProductAction} className="space-y-4 bg-background border border-ink/10 p-6">
<input type="hidden" name="id" value={product.id} />
<Field name="name" label={t.adminProductEditPage.nameLabel} defaultValue={product.name} required />
+8 -1
View File
@@ -5,8 +5,9 @@ import { createProductAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminProductNewPage() {
export default async function AdminProductNewPage(props: PageProps<"/admin/produkte/neu">) {
const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
const [harvests, batches] = await Promise.all([
prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }),
prisma.batch.findMany({ orderBy: { fillDate: "desc" }, include: { harvest: true } }),
@@ -16,6 +17,12 @@ export default async function AdminProductNewPage() {
<div className="max-w-xl">
<BackLink href="/admin/produkte" label={t.adminProductNewPage.backToProducts} />
<h1 className="text-2xl text-forest mb-8">{t.adminProductNewPage.title}</h1>
{/* Abgelehnte Eingaben kommen ueber fail() als ?error= zurueck (siehe
admin/actions/_shared.ts). Ohne diese Ausgabe landete man wortlos
wieder auf dem leeren Formular und wusste nicht, was fehlte. */}
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form action={createProductAction} className="space-y-4 bg-background border border-ink/10 p-6">
<Field name="name" label={t.adminProductNewPage.nameLabel} required />
<Field name="slug" label={t.adminProductNewPage.slugLabel} />
+44 -1
View File
@@ -2,11 +2,14 @@ import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { formatPrice } from "@/lib/format";
import { syncProductsFromApiCoreAction } from "../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminProductsPage() {
export default async function AdminProductsPage(props: PageProps<"/admin/produkte">) {
const t = getDictionary(await getLocale());
const { synced, skipped, failed, discontinued, linked, syncError, linkError } =
await props.searchParams;
const products = await prisma.product.findMany({
include: { harvest: true, batch: true, inventory: true },
orderBy: { createdAt: "desc" },
@@ -21,6 +24,29 @@ export default async function AdminProductsPage() {
{t.adminProductsPage.newProduct}
</Link>
</div>
{synced !== undefined ? (
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
{t.adminProductsPage.syncResult(Number(synced), Number(skipped ?? 0), Number(failed ?? 0))}
{Number(linked ?? 0) > 0 ? ` ${t.adminProductsPage.syncLinked(Number(linked))}` : ""}
</p>
) : null}
{Number(discontinued ?? 0) > 0 ? (
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
{t.adminProductsPage.syncDiscontinued(Number(discontinued))}
</p>
) : null}
{syncError !== undefined ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
{t.adminProductsPage.syncError} ({syncError})
</p>
) : null}
{linkError !== undefined ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
{t.adminProductsPage.linkError} ({linkError})
</p>
) : null}
<div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10">
@@ -50,6 +76,13 @@ export default async function AdminProductsPage() {
<td className="p-4">{product.inventory?.quantity ?? 0}</td>
<td className="p-4">
{product.isActive ? t.adminProductsPage.statusActive : t.adminProductsPage.statusInactive}
{/* Ein Produkt ohne Preis laesst sich nicht aktiv schalten
(admin/actions/products.ts). Aus ApiCore importierte
Entwuerfe sehen sonst wie ein normales inaktives Produkt
aus, und es bliebe unklar, WARUM das Aktivieren scheitert. */}
{product.priceCents <= 0 ? (
<span className="block text-xs text-honey-dark">{t.adminProductsPage.draftHint}</span>
) : null}
</td>
<td className="p-4">
<Link href={`/admin/produkte/${product.id}`} className="text-forest underline">
@@ -64,6 +97,16 @@ export default async function AdminProductsPage() {
<p className="p-6 text-ink/65">{t.adminProductsPage.noProductsYet}</p>
) : null}
</div>
<div className="mt-10 bg-background border border-ink/10 p-6 max-w-3xl">
<h2 className="text-lg text-forest mb-4">{t.adminProductsPage.syncHeading}</h2>
<p className="text-sm text-ink/65 mb-4">{t.adminProductsPage.syncIntro}</p>
<form action={syncProductsFromApiCoreAction}>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminProductsPage.syncButton}
</button>
</form>
</div>
</div>
);
}
+16
View File
@@ -102,6 +102,22 @@ export async function POST(request: Request) {
);
}
// Preis 0 ist kein Geschenk, sondern ein noch nicht gepflegter Preis. Aus
// ApiCore importierte Produkte kommen genau so an (siehe
// admin/actions/apicore-sync.ts: ApiCore fuehrt keine Verkaufspreise), und
// auch ein manuell angelegtes Produkt kann versehentlich ohne Preis aktiv
// geschaltet werden. Die Aktivierung im Admin weist das bereits ab; diese
// Pruefung ist die zweite Haelfte davon und faengt Altbestaende sowie
// direkte Datenbank-Aenderungen ab, die nie durch das Admin-Formular
// gelaufen sind. Sonst waere die Bestellung tatsaechlich fuer 0 Euro
// abgeschlossen worden.
if (products.some((product) => product.priceCents <= 0)) {
return NextResponse.json(
{ error: "Ein Produkt im Warenkorb ist nicht mehr verfügbar." },
{ status: 400 }
);
}
const orderItemsData = input.items.map((cartItem) => {
const product = products.find((p) => p.id === cartItem.productId)!;
return {