feat: ein Shop-Produkt je Jahrgang statt je ApiCore-Artikel

Der Produkt-Sync bildete den Fall gleichnamiger Jahrgaenge nicht ab. In
ApiCore ist "Waldhonig 500 g" EIN Katalogartikel, an dem jedes Jahr eine
weitere Abfuellung haengt; der Sync legte daraus genau ein Wabenhain-Produkt
an. Ab dem zweiten Jahr passierte damit nichts mehr: das Produkt existierte
schon und wurde uebersprungen, und die Charge des neuen Jahrgangs blieb
unverknuepft, weil linkProductsToBatches nur eingesprungen ist, solange
Product.batchId leer war.

In Wabenhain gehoert ein Produkt zu genau einer Charge, und das ist richtig
so: jeder Jahrgang hat eigenen Preis, eigenen Verkaufstext, eigenes
Geschmacksprofil und eigene Bewertungen. Honig ist ein Naturprodukt, die
Bewertung des Waldhonigs von 2026 sagt ueber den von 2027 nichts aus.

Der Sync laeuft deshalb jetzt ueber die ABFUELLUNGEN statt ueber den
Artikelkatalog und legt je Abfuellung einen Jahrgang an, bereits mit Charge
und Ernte verknuepft. Die nachtraegliche Verknuepfung entfaellt ersatzlos.

Wiedererkannt wird ein Jahrgang ueber die neue Spalte
Product.apicoreBottlingId (nullable, unique; manuell angelegte Produkte
tragen null). Bewusst nicht ueber den Slug: der ist die oeffentliche Adresse
und soll lesbar bleiben. Er traegt stattdessen den Jahrgang
("waldhonig-2026") und faellt bei mehrfacher Abfuellung im selben Jahr auf
die Losnummer zurueck - hochgezaehlt wird nicht, "waldhonig-2026-2" sagt
niemandem etwas.

Weitere Entscheidungen, jeweils zugunsten dessen, was tatsaechlich im Glas
ist: das Gewicht kommt aus der Glasgroesse der Abfuellung statt aus dem
Artikel, und der Lagerbestand startet bei 0 statt bei der Abfuellmenge -
wie viele Glaeser in den Webshop gehen und wie viele in Hofladen oder
Wiederverkauf, entscheidet Wabenhain.

Ein in ApiCore ausgelaufener Artikel bekommt keinen neuen Jahrgang mehr,
bereits importierte bleiben unveraendert im Verkauf: solange Glaeser im
Regal stehen, ist "nicht mehr produziert" kein Grund, den Abverkauf
abzubrechen. Fehlt die Charge eines Jahrgangs noch, meldet der Lauf das als
"wartet auf den Ernten-Sync" statt als Fehler.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-17 03:24:02 +02:00
1 parent 95f5252366
commit c525735285
11 files changed
+351 -120

No files matched your search

+139 -95
View File
@@ -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<Sy
* Produkt wirklich frisch aus ApiCore kommen, loescht man es in Wabenhain
* und synchronisiert erneut. Dieselbe Regel gilt aus demselben Grund fuer
* Chargen.
*
* EIN PRODUKT JE JAHRGANG, nicht je ApiCore-Artikel. Der Sync laeuft deshalb
* ueber die ABFUELLUNGEN, nicht ueber den Artikelkatalog:
*
* In ApiCore ist "Waldhonig 500 g" ein einziger Katalogartikel, an dem jedes
* Jahr eine weitere Abfuellung haengt. In Wabenhain gehoert 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 ueber den von 2027 nichts aus.
*
* Aus einem ApiCore-Artikel werden hier also mehrere Wabenhain-Produkte,
* eines je Abfuellung. Wiedererkannt werden sie ueber Product.apicoreBottlingId
* — nicht ueber den Slug: der ist die oeffentliche Adresse und soll lesbar
* bleiben ("waldhonig-2026"), darf sich also nicht nach einer fremden ID
* richten.
*
* Reihenfolge: die Charge muss bereits in Wabenhain sein (Ernten-Sync), sonst
* gaebe es nichts, woran der Jahrgang haengt. Fehlt sie, wird die Abfuellung
* als "pending" gezaehlt statt als Fehler — der naechste Lauf holt sie nach.
*/
async function syncProducts(sources: ApiCoreProduct[]): Promise<ProductSyncCounts> {
const counts: ProductSyncCounts = { created: 0, skipped: 0, failed: 0, discontinued: 0 };
async function syncProducts(
articles: ApiCoreProduct[],
bottlings: ApiCoreBottlingBatchWithHarvestId[]
): Promise<ProductSyncCounts> {
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<string | null> {
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<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
* 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<void> {
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<void> {
}
/**
* 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<void> {
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}`
);
}
+8 -8
View File
@@ -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 ? (
<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}
{/* "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 ? (
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
{t.adminProductsPage.syncPending(Number(pending))}
</p>
) : null}
{Number(discontinued ?? 0) > 0 ? (
@@ -41,11 +46,6 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
{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">