diff --git a/README.md b/README.md
index 07eaad3..9cb4a57 100644
--- a/README.md
+++ b/README.md
@@ -198,17 +198,25 @@ ApiCore ist das separate Imkerei-Verwaltungssystem (NestJS-Backend mit eigener D
|---------|-----------|-------|
| `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" |
+| `BottlingBatch` → je Abfüllung **ein** `Product` | `Product` (ein Jahrgang) | `/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.
+- **Produkte: ein Jahrgang je Abfüllung.** In ApiCore ist „Waldhonig 500 g" *ein* Katalogartikel, an dem jedes Jahr eine weitere Abfüllung hängt. In Wabenhain gehört ein Produkt dagegen zu genau einer Charge — und das ist richtig so, denn jeder Jahrgang hat eigenen Preis, eigenen Verkaufstext, eigenes Geschmacksprofil und eigene Bewertungen. Honig ist ein Naturprodukt; die Bewertung des Waldhonigs von 2026 sagt über den von 2027 nichts aus. Aus einem ApiCore-Artikel werden deshalb **mehrere** Wabenhain-Produkte, eines je Abfüllung. Sie tragen denselben Namen, unterscheiden sich aber in der Adresse: `waldhonig-2026`, `waldhonig-2027`. Wurde ein Artikel im selben Jahr mehrfach abgefüllt, hängt die Losnummer hinten an.
+
+ Wiedererkannt wird ein Jahrgang über `Product.apicoreBottlingId` (die ApiCore-ID der Abfüllung), nicht über den Slug — der ist die öffentliche Adresse und soll lesbar bleiben.
+
+ Jeder Jahrgang entsteht als deaktivierter Entwurf **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. Gewicht kommt aus der Glasgröße der Abfüllung, nicht aus dem Artikel: sie sagt, was tatsächlich in dieses Glas kam. Der Lagerbestand startet bei 0, denn wie viele Gläser in den Webshop gehen und wie viele in Hofladen oder Wiederverkauf, entscheidet Wabenhain.
+
+ Ein bereits vorhandener Jahrgang wird übersprungen, nie aktualisiert: genau die 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 Jahrgang wirklich frisch aus ApiCore kommen, löscht man ihn in Wabenhain und synchronisiert erneut.
+
+ **Reihenfolge:** die Charge muss schon da sein, sonst gäbe es nichts, woran der Jahrgang hängt. Erst „Ernten" synchronisieren, dann „Produkte" — fehlende Chargen meldet der Lauf als „wartet auf den Ernten-Sync", nicht als Fehler.
- **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.
+- Charge und Ernte hängen von Anfang an am Jahrgang — er entsteht ja aus genau einer Abfüllung. Das ist die Verknüpfung, die die Herkunftsseite hinter dem QR-Code auf dem Glas füttert. Eine Abfüllung, die in ApiCore keinem Artikel zugeordnet ist (etwa Rohware für den Direktverkauf ab Hof), wird bewusst übergangen: daraus ließe sich kein Shop-Produkt bilden, es gäbe weder Namen noch Artikelnummer.
- Ein in ApiCore ausgelaufenes Produkt (`DISCONTINUED`) wird **nicht** automatisch deaktiviert: solange Gläser im Regal stehen, ist „nicht mehr produziert" kein Grund, den Abverkauf zu stoppen. Der Fall wird stattdessen im Ergebnis gemeldet.
-Wiederholte Läufe sind gefahrlos: Ernten und Produkte werden über einen stabilen Schlüssel `apicore-` wiedererkannt (`Harvest.slug` bzw. `Product.slug`), Chargen über die ApiCore-Losnummer als `Batch.code`.
+Wiederholte Läufe sind gefahrlos: Ernten werden über den stabilen Schlüssel `apicore-` als `Harvest.slug` wiedererkannt, Chargen über die ApiCore-Losnummer als `Batch.code`, Produkte über `Product.apicoreBottlingId`.
**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`.
diff --git a/prisma/migrations/20260917030000_add_product_apicore_bottling_id/migration.sql b/prisma/migrations/20260917030000_add_product_apicore_bottling_id/migration.sql
new file mode 100644
index 0000000..ee09e9b
--- /dev/null
+++ b/prisma/migrations/20260917030000_add_product_apicore_bottling_id/migration.sql
@@ -0,0 +1,12 @@
+-- Herkunftsschluessel fuer den ApiCore-Produkt-Sync: ID der Abfuellung
+-- (BottlingBatch), aus der dieses Produkt entstanden ist. Nullable, weil
+-- manuell angelegte Produkte keine Herkunft in ApiCore haben — alle
+-- bestehenden Zeilen bleiben damit unveraendert gueltig.
+--
+-- Eindeutig, damit ein wiederholter Sync-Lauf dieselbe Abfuellung nicht ein
+-- zweites Mal als Produkt anlegt.
+-- AlterTable
+ALTER TABLE "products" ADD COLUMN "apicoreBottlingId" TEXT;
+
+-- CreateIndex
+CREATE UNIQUE INDEX "products_apicoreBottlingId_key" ON "products"("apicoreBottlingId");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index c4b8def..81bb5e2 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -209,6 +209,22 @@ model Product {
slug String @unique
name String
category ProductCategory @default(HONIG)
+ /// ApiCore-ID der ABFUELLUNG (BottlingBatch), aus der dieses Produkt
+ /// synchronisiert wurde; null bei manuell angelegten Produkten.
+ ///
+ /// Warum die Abfuellung und nicht das ApiCore-Produkt: dort ist "Waldhonig
+ /// 500 g" EIN Katalogartikel, an dem jedes Jahr eine neue Abfuellung haengt.
+ /// In Wabenhain gehoert ein Produkt dagegen zu genau einer Charge, weil hier
+ /// jeder Jahrgang eigenen Preis, eigenen Verkaufstext, eigenes
+ /// Geschmacksprofil und eigene Bewertungen hat. Ein ApiCore-Artikel wird
+ /// deshalb zu MEHREREN Wabenhain-Produkten, eines je Jahrgang.
+ ///
+ /// Dient dem Sync als Idempotenz-Schluessel: daran erkennt ein erneuter
+ /// Lauf, dass diese Abfuellung schon importiert wurde. Bewusst NICHT ueber
+ /// den Slug geloest — der ist die oeffentliche Adresse und soll lesbar
+ /// bleiben ("waldhonig-2026"), darf sich also nicht nach einer fremden ID
+ /// richten.
+ apicoreBottlingId String? @unique
/// Nur fuer Honig-Produkte gesetzt (Kerzen/Propolis sind keiner Ernte/Charge zugeordnet)
harvestId String?
batchId String?
diff --git a/src/app/admin/actions/apicore-sync.ts b/src/app/admin/actions/apicore-sync.ts
index 40280ba..16e339b 100644
--- a/src/app/admin/actions/apicore-sync.ts
+++ b/src/app/admin/actions/apicore-sync.ts
@@ -21,7 +21,8 @@ import {
slugForApiCoreSource,
buildHarvestDescription,
buildProductDescription,
- deriveWeightGrams,
+ deriveJarWeightGrams,
+ productSlugCandidates,
} from "@/lib/apicore-mapping";
import { requireAdmin } from "./_shared";
@@ -75,7 +76,12 @@ interface ProductSyncCounts {
created: number;
skipped: number;
failed: number;
- /** In ApiCore nicht mehr verkaufsfaehig, in Wabenhain aber noch aktiv. */
+ /**
+ * Abfuellungen, deren Charge in Wabenhain noch fehlt. Kein Fehler, sondern
+ * eine Reihenfolge: zuerst der Ernten-Sync, danach noch einmal hier.
+ */
+ pending: number;
+ /** Artikel, die ApiCore nicht mehr als verkaufsfaehig fuehrt. */
discontinued: number;
}
@@ -164,55 +170,136 @@ async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise {
- const counts: ProductSyncCounts = { created: 0, skipped: 0, failed: 0, discontinued: 0 };
+async function syncProducts(
+ articles: ApiCoreProduct[],
+ bottlings: ApiCoreBottlingBatchWithHarvestId[]
+): Promise {
+ const counts: ProductSyncCounts = { created: 0, skipped: 0, failed: 0, pending: 0, discontinued: 0 };
+ const articleById = new Map(articles.map((article) => [article.id, article]));
- for (const source of sources) {
- const slug = slugForApiCoreSource(source.id);
+ for (const bottling of bottlings) {
+ // Eine Abfuellung ohne Artikel ist in ApiCore erlaubt (z.B. Rohware fuer
+ // den Direktverkauf ab Hof). Daraus laesst sich kein Shop-Produkt bilden:
+ // es gaebe weder Namen noch Artikelnummer.
+ if (!bottling.productId) continue;
+
+ const article = articleById.get(bottling.productId);
+ if (!article) {
+ counts.failed++;
+ console.error(
+ `[apicore-sync] Abfüllung ${bottling.lotNumber} verweist auf den Artikel ${bottling.productId}, den ApiCore nicht ausliefert — übersprungen.`
+ );
+ continue;
+ }
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++;
+ const existing = await prisma.product.findUnique({
+ where: { apicoreBottlingId: bottling.id },
+ select: { id: true },
+ });
+ if (existing) {
+ counts.skipped++;
continue;
}
- if (existing) {
- counts.skipped++;
+ // Ein in ApiCore ausgelaufener Artikel bekommt keinen NEUEN Jahrgang im
+ // Shop. Bereits importierte Jahrgaenge bleiben unberuehrt und weiter
+ // verkaeuflich: solange Glaeser im Regal stehen, ist "nicht mehr
+ // produziert" kein Grund, den Abverkauf abzubrechen. Diese Entscheidung
+ // trifft der Admin, nicht der Sync.
+ if (!isSellableStatus(article.status)) {
+ counts.discontinued++;
+ continue;
+ }
+
+ const batch = await prisma.batch.findUnique({
+ where: { code: bottling.lotNumber },
+ include: { harvest: { select: { id: true, year: true } } },
+ });
+ if (!batch) {
+ counts.pending++;
+ continue;
+ }
+
+ const slug = await firstFreeSlug(
+ productSlugCandidates(article.name, batch.harvest.year, bottling.lotNumber)
+ );
+ if (!slug) {
+ counts.failed++;
+ console.error(
+ `[apicore-sync] Für "${article.name}" (${batch.harvest.year}, Los ${bottling.lotNumber}) ist keine freie Adresse frei — Produkt bitte von Hand anlegen.`
+ );
continue;
}
await prisma.product.create({
data: {
slug,
- name: source.name,
- description: buildProductDescription(source),
- weightGrams: deriveWeightGrams(source),
+ apicoreBottlingId: bottling.id,
+ name: article.name,
+ description: buildProductDescription(article, batch.harvest.year),
+ // Die Glasgroesse der Abfuellung schlaegt die Angabe am Artikel:
+ // sie sagt, was tatsaechlich in dieses Glas kam.
+ weightGrams: deriveJarWeightGrams(bottling.jarSize, article),
category: "HONIG",
+ harvestId: batch.harvest.id,
+ batchId: batch.id,
priceCents: 0,
pricePerKgCents: 0,
isActive: false,
+ // Bewusst 0 statt bottling.jarCount: wie viele Glaeser in den
+ // Webshop gehen und wie viele in Hofladen, Wiederverkauf oder
+ // Rueckstellmuster, entscheidet Wabenhain — nicht die Abfuellmenge.
inventory: { create: { quantity: 0 } },
},
});
counts.created++;
} catch (error: unknown) {
counts.failed++;
- console.error(`[apicore-sync] Produkt ${source.id} konnte nicht importiert werden: ${describeError(error)}`);
+ console.error(
+ `[apicore-sync] Abfüllung ${bottling.lotNumber} konnte nicht als Produkt importiert werden: ${describeError(error)}`
+ );
}
}
return counts;
}
+/**
+ * Erste noch freie Adresse aus den Vorschlaegen, oder null, wenn alle belegt
+ * sind. Bewusst ohne Hochzaehlen: eine Adresse wie "waldhonig-2026-2" sagt
+ * niemandem etwas, und der Fall bedeutet ohnehin, dass hier jemand
+ * hinschauen muss.
+ */
+async function firstFreeSlug(candidates: string[]): Promise {
+ for (const slug of candidates) {
+ const taken = await prisma.product.findUnique({ where: { slug }, select: { id: true } });
+ if (!taken) return slug;
+ }
+ return null;
+}
+
// ---------------------------------------------------------------------------
// Chargen
// ---------------------------------------------------------------------------
@@ -273,78 +360,29 @@ async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promis
}
/**
- * Traegt die Charge (und ueber sie die Ernte) an synchronisierten Produkten
- * nach, wo ApiCore die Abfuellung einem Produkt zugeordnet hat. Genau diese
- * Verknuepfung macht auf der Produktseite und ueber den QR-Code auf dem Glas
- * die Herkunft nachvollziehbar.
- *
- * Bewusst nur dort, wo Product.batchId noch leer ist: hat jemand im Admin
- * schon eine Charge zugeordnet, ist das eine Entscheidung darueber, was
- * tatsaechlich im Karton liegt — die darf ein Sync nicht umbiegen. Der Lauf
- * ist dadurch gefahrlos wiederholbar.
- *
- * Fachliche Grenze: in ApiCore hat ein Produkt MEHRERE Abfuellungen, in
- * Wabenhain zeigt ein Produkt auf GENAU EINE Charge. Verknuepft wird deshalb
- * die zuerst gefundene, noch nicht zugeordnete Kombination; welche Charge
- * gerade verkauft wird, entscheidet weiterhin der Admin.
- */
-async function linkProductsToBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promise {
- let linked = 0;
-
- for (const source of sources) {
- if (!source.productId) continue;
-
- try {
- const product = await prisma.product.findUnique({
- where: { slug: slugForApiCoreSource(source.productId) },
- });
- if (!product || product.batchId) continue;
-
- const batch = await prisma.batch.findUnique({ where: { code: source.lotNumber } });
- if (!batch) continue;
-
- await prisma.product.update({
- where: { id: product.id },
- data: { batchId: batch.id, harvestId: batch.harvestId },
- });
- linked++;
- } catch (error: unknown) {
- console.error(
- `[apicore-sync] Charge ${source.lotNumber} konnte nicht mit ihrem Produkt verknüpft werden: ${describeError(error)}`
- );
- }
- }
-
- return linked;
-}
-
-/**
- * Chargen laden und, soweit gewuenscht, importieren und mit ihren Produkten
- * verknuepfen. Ein Fehlschlag beim Laden ist bewusst NICHT fatal fuer den
- * umgebenden Sync-Lauf: die bereits erfolgreich uebernommenen Ernten bzw.
- * Produkte sollen sichtbar bleiben, statt hinter einer Fehlerseite zu
+ * Chargen laden und importieren. Ein Fehlschlag beim LADEN ist bewusst nicht
+ * fatal fuer den umgebenden Sync-Lauf: die bereits erfolgreich uebernommenen
+ * Ernten sollen sichtbar bleiben, statt hinter einer Fehlerseite zu
* verschwinden.
+ *
+ * Eine nachtraegliche Verknuepfung Produkt<->Charge gibt es nicht mehr: seit
+ * je Abfuellung ein eigenes Produkt entsteht, wird es bereits mit Charge und
+ * Ernte angelegt (siehe syncProducts).
*/
-async function syncBatchesAndLinks(
- session: ApiCoreSession,
- options: { importBatches: boolean }
-): Promise<{ counts: SyncCounts; linked: number; loadError: string | null }> {
+async function loadAndSyncBatches(
+ session: ApiCoreSession
+): Promise<{ counts: SyncCounts; 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 };
+ return { counts: await syncBatches(sources), loadError: null };
}
// ---------------------------------------------------------------------------
@@ -375,13 +413,13 @@ export async function syncHarvestsFromApiCoreAction(): Promise {
detail: `${harvests.created} neu, ${harvests.updated} aktualisiert, ${harvests.skipped} übersprungen, ${harvests.failed} fehlgeschlagen`,
});
- const batches = await syncBatchesAndLinks(session, { importBatches: true });
+ const batches = await loadAndSyncBatches(session);
await logAdminAction(admin, {
action: "batch.sync_apicore",
entityType: "Batch",
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`,
+ : `${batches.counts.created} neu, ${batches.counts.skipped} übersprungen, ${batches.counts.failed} fehlgeschlagen`,
});
revalidatePath("/admin/ernten");
@@ -395,38 +433,44 @@ export async function syncHarvestsFromApiCoreAction(): Promise {
}
/**
- * 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.
+ * Sync-Knopf auf /admin/produkte: legt je ApiCore-ABFUELLUNG ein Produkt an,
+ * also einen eigenen Jahrgang (siehe syncProducts).
+ *
+ * Braucht dafuer beides: den Artikelkatalog (Name, Artikelnummer) und die
+ * Abfuellungen (Jahrgang, Glasgroesse, Losnummer). Chargen selbst werden hier
+ * NICHT importiert — das gehoert zum Ernten-Sync, weil eine Charge ohne ihre
+ * Ernte gar nicht anlegbar ist. Fehlt die Charge noch, meldet der Lauf das
+ * als "wartet auf den Ernten-Sync" statt als Fehler.
*/
export async function syncProductsFromApiCoreAction(): Promise {
const admin = await requireAdmin();
let session: ApiCoreSession;
- let sourceProducts: ApiCoreProduct[];
+ let articles: ApiCoreProduct[];
+ let bottlings: ApiCoreBottlingBatchWithHarvestId[];
try {
session = await openApiCoreSession();
- sourceProducts = await fetchApiCoreProducts(session);
+ [articles, bottlings] = await Promise.all([
+ fetchApiCoreProducts(session),
+ fetchApiCoreBottlingBatches(session),
+ ]);
} catch (error: unknown) {
redirect(`/admin/produkte?syncError=${encodeURIComponent(describeError(error))}`);
}
- const products = await syncProducts(sourceProducts);
- const batches = await syncBatchesAndLinks(session, { importBatches: false });
+ const products = await syncProducts(articles, bottlings);
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`,
+ `${products.pending} warten auf den Ernten-Sync, ${products.discontinued} in ApiCore ausgelaufen`,
});
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)}` : "")
+ `&failed=${products.failed}&pending=${products.pending}&discontinued=${products.discontinued}`
);
}
diff --git a/src/app/admin/produkte/page.tsx b/src/app/admin/produkte/page.tsx
index c09bcf3..2350b3f 100644
--- a/src/app/admin/produkte/page.tsx
+++ b/src/app/admin/produkte/page.tsx
@@ -8,8 +8,7 @@ import { getDictionary } from "@/i18n/get-dictionary";
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 { synced, skipped, failed, discontinued, pending, syncError } = await props.searchParams;
const products = await prisma.product.findMany({
include: { harvest: true, batch: true, inventory: true },
orderBy: { createdAt: "desc" },
@@ -28,7 +27,13 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
{synced !== undefined ? (
{t.adminProductsPage.syncResult(Number(synced), Number(skipped ?? 0), Number(failed ?? 0))}
- {Number(linked ?? 0) > 0 ? ` ${t.adminProductsPage.syncLinked(Number(linked))}` : ""}
+
+ ) : null}
+ {/* "pending" ist kein Fehler, sondern eine Reihenfolge: die Charge des
+ Jahrgangs kommt ueber den Ernten-Sync. Deshalb als Hinweis, nicht rot. */}
+ {Number(pending ?? 0) > 0 ? (
+
+ {t.adminProductsPage.syncPending(Number(pending))}
) : null}
{Number(discontinued ?? 0) > 0 ? (
@@ -41,11 +46,6 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
{t.adminProductsPage.syncError} ({syncError})
) : null}
- {linkError !== undefined ? (
-
- {t.adminProductsPage.linkError} ({linkError})
-
- ) : null}
diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts
index 80461bc..121d12b 100644
--- a/src/i18n/dictionaries/de.ts
+++ b/src/i18n/dictionaries/de.ts
@@ -947,15 +947,15 @@ export const de: Dictionary = {
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).",
+ "Legt für jede Abfüllung aus ApiCore ein eigenes Produkt an — also einen Jahrgang. „Waldhonig 2026“ und „Waldhonig 2027“ sind damit getrennte Produkte mit eigenem Preis, eigenem Text und eigenen Bewertungen; die Adresse trägt die Jahreszahl. Importierte Produkte sind zunächst deaktiviert und ohne Preis, denn ApiCore führt keine Verkaufspreise. Erst wenn Preis, Gewicht und Beschreibung gepflegt sind, lässt sich ein Produkt aktiv schalten. Vorhandene Produkte werden nur übersprungen, nie überschrieben. Die zugehörige Charge muss schon vorhanden sein — also zuerst den Ernten-Sync ausführen.",
syncButton: "Jetzt synchronisieren",
syncResult: (created, skipped, failed) =>
- `Produkte: ${created} neu angelegt, ${skipped} übersprungen (bereits vorhanden)${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
+ `Produkte: ${created} neue${created === 1 ? "r Jahrgang" : " Jahrgänge"} 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.`,
+ `${count} Abfüllung${count === 1 ? "" : "en"} gehör${count === 1 ? "t" : "en"} zu einem Artikel, den ApiCore nicht mehr führt — dafür wurde kein neuer Jahrgang angelegt. Bereits vorhandene Jahrgänge bleiben unverändert im Verkauf, damit der Restbestand abverkauft werden kann.`,
+ syncPending: (count) =>
+ `${count} Abfüllung${count === 1 ? "" : "en"} wartet${count === 1 ? "" : "en"} noch auf die zugehörige Charge. Zuerst unter „Ernten“ synchronisieren, dann hier erneut.`,
syncError: "Synchronisierung mit ApiCore fehlgeschlagen.",
- linkError: "Chargen konnten nicht geladen werden, die Produkte wurden trotzdem übernommen",
draftHint: "Entwurf: kein Preis gepflegt",
},
adminProductEditPage: {
diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts
index f257397..e23e52f 100644
--- a/src/i18n/dictionaries/en.ts
+++ b/src/i18n/dictionaries/en.ts
@@ -947,15 +947,15 @@ export const en: Dictionary = {
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).",
+ "Creates one product per bottling run from ApiCore — that is, per vintage. “Waldhonig 2026” and “Waldhonig 2027” are separate products with their own price, copy and reviews; the address carries the year. Imported products start out inactive and without a price, because ApiCore does not hold sales prices. A product can only be activated once price, weight and description have been filled in. Existing products are only ever skipped, never overwritten. The matching batch has to exist already — so run the harvest sync first.",
syncButton: "Sync now",
syncResult: (created, skipped, failed) =>
- `Products: ${created} created, ${skipped} skipped (already exist)${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
+ `Products: ${created} new vintage${created === 1 ? "" : "s"} 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.`,
+ `${count} bottling run${count === 1 ? "" : "s"} belong${count === 1 ? "s" : ""} to an article ApiCore no longer carries, so no new vintage was created for ${count === 1 ? "it" : "them"}. Vintages already in the shop stay on sale so remaining stock can be sold through.`,
+ syncPending: (count) =>
+ `${count} bottling run${count === 1 ? " is" : "s are"} still waiting for the matching batch. Sync under “Harvests” first, then run this again.`,
syncError: "Sync with ApiCore failed.",
- linkError: "Batches could not be loaded, the products were imported anyway",
draftHint: "Draft: no price set",
},
adminProductEditPage: {
diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts
index 2fd89bb..25ba73f 100644
--- a/src/i18n/dictionary-type.ts
+++ b/src/i18n/dictionary-type.ts
@@ -652,9 +652,8 @@ export interface Dictionary {
syncButton: string;
syncResult: (created: number, skipped: number, failed: number) => string;
syncDiscontinued: (count: number) => string;
- syncLinked: (count: number) => string;
+ syncPending: (count: number) => string;
syncError: string;
- linkError: string;
draftHint: string;
};
adminProductEditPage: {
diff --git a/src/lib/apicore-client.ts b/src/lib/apicore-client.ts
index 78d7450..0bada77 100644
--- a/src/lib/apicore-client.ts
+++ b/src/lib/apicore-client.ts
@@ -247,6 +247,12 @@ const bottlingBatchSchema = z.object({
date: z.string(),
bestBeforeDate: z.string(),
jarCount: z.number(),
+ // Glasgroesse dieser Abfuellung, in Gramm (siehe Einheiten-Hinweis an
+ // productSchema oben). Bewusst optional gelesen, obwohl ApiCore das Feld
+ // heute immer fuellt: faellt es einmal weg, soll der Sync auf die Angabe am
+ // Artikel zurueckfallen (deriveJarWeightGrams) statt die gesamte Antwort zu
+ // verwerfen.
+ jarSize: z.number().nullable().optional(),
// 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).
diff --git a/src/lib/apicore-mapping.test.ts b/src/lib/apicore-mapping.test.ts
index 054ee26..7f58562 100644
--- a/src/lib/apicore-mapping.test.ts
+++ b/src/lib/apicore-mapping.test.ts
@@ -5,6 +5,9 @@ import {
buildHarvestDescription,
buildProductDescription,
deriveWeightGrams,
+ deriveJarWeightGrams,
+ slugifyProductName,
+ productSlugCandidates,
} from "./apicore-mapping";
function makeProduct(overrides: Partial = {}): ApiCoreProduct {
@@ -157,6 +160,93 @@ describe("deriveWeightGrams", () => {
});
});
+describe("slugifyProductName", () => {
+ test("macht aus einem Namen eine adresstaugliche Form", () => {
+ expect(slugifyProductName("Waldhonig")).toBe("waldhonig");
+ expect(slugifyProductName("Waldhonig, cremig")).toBe("waldhonig-cremig");
+ });
+
+ test("schreibt Umlaute aus, statt sie zu verschlucken", () => {
+ // Arrange & Act & Assert: eine blosse Filterung auf a-z machte aus
+ // "Bluetenhonig" ein "bl-tenhonig".
+ expect(slugifyProductName("Blütenhonig")).toBe("bluetenhonig");
+ expect(slugifyProductName("Süßer Honig")).toBe("suesser-honig");
+ });
+});
+
+describe("productSlugCandidates", () => {
+ test("stellt den Jahrgang voran, weil er die Jahrgaenge unterscheidet", () => {
+ // Arrange & Act
+ const candidates = productSlugCandidates("Waldhonig", 2026, "FH-26-01");
+
+ // Assert: erste Wahl ist die lesbare Adresse mit Jahreszahl.
+ expect(candidates[0]).toBe("waldhonig-2026");
+ });
+
+ test("haelt die Losnummer als Ausweichadresse bereit", () => {
+ // Arrange & Act & Assert: deckt den Fall ab, dass derselbe Artikel im
+ // selben Jahr mehrfach abgefuellt wurde.
+ expect(productSlugCandidates("Waldhonig", 2026, "FH-26-02")).toEqual([
+ "waldhonig-2026",
+ "waldhonig-2026-fh-26-02",
+ ]);
+ });
+
+ test("zwei Jahrgaenge desselben Namens kollidieren nicht", () => {
+ // Arrange & Act
+ const first = productSlugCandidates("Waldhonig", 2026, "FH-26-01")[0];
+ const second = productSlugCandidates("Waldhonig", 2027, "FH-27-01")[0];
+
+ // Assert: genau das ist der Grund fuer die Jahreszahl — Product.slug ist
+ // eindeutig, und ohne sie waere der zweite Jahrgang nicht anlegbar.
+ expect(first).not.toBe(second);
+ });
+
+ test("faellt bei einem Namen ohne verwertbare Zeichen nicht auf eine leere Adresse zurueck", () => {
+ // Arrange & Act & Assert: ein leerer Slug waere eine ungueltige URL.
+ expect(productSlugCandidates("***", 2026, "")).toEqual(["produkt-2026"]);
+ });
+});
+
+describe("deriveJarWeightGrams", () => {
+ test("nimmt die Glasgroesse der Abfuellung", () => {
+ // Arrange
+ const source = makeProduct({ weight: 250 });
+
+ // Act & Assert: die Abfuellung sagt, was tatsaechlich in dieses Glas kam
+ // — der Artikel nur, was ueblicherweise vorgesehen ist.
+ expect(deriveJarWeightGrams(500, source)).toBe(500);
+ });
+
+ test("faellt auf die Angaben am Artikel zurueck, wenn die Abfuellung keine Groesse fuehrt", () => {
+ // Arrange
+ const source = makeProduct({ weight: 250 });
+
+ // Act & Assert
+ expect(deriveJarWeightGrams(null, source)).toBe(250);
+ expect(deriveJarWeightGrams(0, source)).toBe(250);
+ });
+
+ test("gibt 0 zurueck, wenn weder Abfuellung noch Artikel eine Menge kennen", () => {
+ expect(deriveJarWeightGrams(null, makeProduct())).toBe(0);
+ });
+});
+
+describe("buildProductDescription mit Jahrgang", () => {
+ test("nennt den Jahrgang zuerst", () => {
+ // Arrange
+ const source = makeProduct({ description: "Kräftig.", sku: "WH-500" });
+
+ // Act
+ const description = buildProductDescription(source, 2026);
+
+ // Assert
+ expect(description).toBe(
+ "Automatisch aus ApiCore importiert (Jahrgang 2026 · Artikelnummer WH-500). Kräftig."
+ );
+ });
+});
+
describe("isSellableStatus", () => {
test("erkennt ACTIVE als verkaufsfaehig", () => {
expect(isSellableStatus("ACTIVE")).toBe(true);
diff --git a/src/lib/apicore-mapping.ts b/src/lib/apicore-mapping.ts
index a4e6ff3..4a69022 100644
--- a/src/lib/apicore-mapping.ts
+++ b/src/lib/apicore-mapping.ts
@@ -47,9 +47,10 @@ export function buildHarvestDescription(source: ApiCoreHarvestWithApiaryName): s
* Aufforderung statt eines leeren Feldes — ein leerer Verkaufstext waere im
* Shop nicht von einem vergessenen zu unterscheiden.
*/
-export function buildProductDescription(source: ApiCoreProduct): string {
+export function buildProductDescription(source: ApiCoreProduct, vintageYear?: number): string {
const own = source.description?.trim();
const facts: string[] = [];
+ if (vintageYear != null) facts.push(`Jahrgang ${vintageYear}`);
if (source.sku?.trim()) facts.push(`Artikelnummer ${source.sku.trim()}`);
if (source.salesUnit?.trim()) facts.push(`Verkaufseinheit ${source.salesUnit.trim()}`);
@@ -75,3 +76,58 @@ export function deriveWeightGrams(source: ApiCoreProduct): number {
if (raw == null || !Number.isFinite(raw) || raw <= 0) return 0;
return Math.round(raw);
}
+
+/**
+ * Gewicht eines Glases aus dieser Abfuellung.
+ *
+ * Bevorzugt `jarSize` der Abfuellung gegenueber den Angaben am
+ * ApiCore-Artikel: die Abfuellung sagt, was TATSAECHLICH in dieses Glas
+ * gefuellt wurde, der Artikel nur, was ueblicherweise vorgesehen ist. Wird
+ * ein Jahrgang einmal in 250-g- statt 500-g-Glaeser gefuellt, steht das an
+ * der Abfuellung und nirgends sonst.
+ *
+ * Zur Einheit "Gramm" siehe den Kommentar an productSchema in
+ * apicore-client.ts: ApiCore fuehrt jarSize mit Werten wie 500 und gibt sie
+ * in der eigenen Oberflaeche als "g" aus.
+ */
+export function deriveJarWeightGrams(
+ jarSize: number | null | undefined,
+ source: ApiCoreProduct
+): number {
+ if (jarSize != null && Number.isFinite(jarSize) && jarSize > 0) return Math.round(jarSize);
+ return deriveWeightGrams(source);
+}
+
+/** Aus "Waldhonig, cremig" wird "waldhonig-cremig". */
+export function slugifyProductName(name: string): string {
+ return name
+ .toLowerCase()
+ .replace(/ä/g, "ae")
+ .replace(/ö/g, "oe")
+ .replace(/ü/g, "ue")
+ .replace(/ß/g, "ss")
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+}
+
+/**
+ * Moegliche oeffentliche Adressen fuer ein synchronisiertes Produkt, in
+ * absteigender Wuenschbarkeit — der Sync nimmt die erste noch freie.
+ *
+ * Der Jahrgang gehoert in die Adresse, weil genau er die Produkte
+ * unterscheidet: "Waldhonig" heisst jedes Jahr gleich, ist aber jedes Jahr
+ * ein anderes Erzeugnis mit eigenem Preis und eigenen Bewertungen. Ohne
+ * Jahreszahl kollidierte der zweite Jahrgang mit dem ersten (Product.slug ist
+ * eindeutig).
+ *
+ * Die Losnummer als zweite Stufe deckt den selteneren Fall ab, dass ein
+ * Artikel im selben Jahr mehrfach abgefuellt wurde. Faellt auch die weg,
+ * entscheidet der Aufrufer — hier wird bewusst nicht hochgezaehlt: eine
+ * Adresse wie "waldhonig-2026-2" sagt niemandem etwas.
+ */
+export function productSlugCandidates(name: string, year: number, lotNumber: string): string[] {
+ const base = slugifyProductName(name) || "produkt";
+ const withYear = `${base}-${year}`;
+ const lot = slugifyProductName(lotNumber);
+ return lot ? [withYear, `${withYear}-${lot}`] : [withYear];
+}