feat: ein Knopf synchronisiert Ernten, Chargen und Produkte zusammen

Bisher gab es zwei Knoepfe auf zwei Seiten, und ihre Reihenfolge war nicht
beliebig: eine Charge braucht ihre Ernte als Pflicht-Fremdschluessel, ein
Produkt entsteht je Abfuellung und braucht die zugehoerige Charge. Wer mit
den Produkten anfing, bekam reihenweise "wartet auf den Ernten-Sync" und
musste danach noch einmal klicken. Diese Reihenfolge gehoert in den Ablauf,
nicht in den Kopf der bedienenden Person.

syncApiCoreAction ersetzt beide Actions und laeuft Ernten -> Chargen ->
Produkte in einem Durchgang. "Alles synchronisieren" steht auf beiden Seiten
und springt danach dorthin zurueck, von wo er ausgeloest wurde. Das Ziel
kommt aus einem Formularfeld und wird gegen eine feste Liste geprueft — ein
ungeprueftes returnTo waere eine offene Weiterleitung.

Nebeneffekt: die Abfuellungen werden nur noch EINMAL von ApiCore geladen.
Vorher holten Chargen- und Produkt-Sync dieselbe Liste getrennt.

Knopf und Ergebnisanzeige liegen jetzt in einer gemeinsamen Komponente
(components/apicore-sync-panel.tsx) statt zweimal in zwei Seiten. Die
Ergebnisse stehen als ein Block beieinander, je eine Zeile fuer Ernten,
Chargen und Produkte, plus die beiden Hinweise (Abfuellung ohne Charge,
ausgelaufener Artikel) als Hinweis statt als Fehler.

Damit wurden zehn i18n-Schluessel ueberfluessig, die vorher die beiden
getrennten Anzeigen bedienten; sie sind aus dem Vertrag und beiden
Sprachdateien entfernt.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-17 18:26:09 +02:00
1 parent 8f0f06fd13
commit 92ac826baa
8 files changed
+246 -213

No files matched your search

+70 -82
View File
@@ -359,53 +359,60 @@ async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promis
return counts;
}
/**
* 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 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 },
loadError: describeError(error),
};
}
return { counts: await syncBatches(sources), 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.
* Ziele, zu denen nach dem Sync zurueckgesprungen werden darf.
*
* Feste Liste statt des uebergebenen Werts: "returnTo" kommt aus einem
* Formularfeld und waere sonst eine offene Weiterleitung — ein praeparierter
* Wert koennte Admins auf eine fremde Seite schicken.
*/
export async function syncHarvestsFromApiCoreAction(): Promise<void> {
const RETURN_TARGETS = ["/admin/ernten", "/admin/produkte", "/admin/chargen"] as const;
function returnTargetFrom(formData: FormData): string {
const requested = formData.get("returnTo");
return RETURN_TARGETS.find((target) => target === requested) ?? "/admin/ernten";
}
/**
* EIN Knopf fuer alles: zieht Ernten, daraus die Chargen und daraus die
* Produkte — in genau dieser Reihenfolge, weil jede Stufe auf der
* vorherigen aufbaut.
*
* Eine Charge braucht ihre Ernte als Pflicht-Fremdschluessel, und ein
* Produkt entsteht je Abfuellung und braucht die zugehoerige Charge. Wer
* das getrennt ausloest, muss die Reihenfolge kennen und zweimal klicken;
* beim ersten Durchlauf meldete der Produkt-Sync sonst reihenweise "wartet
* auf den Ernten-Sync". Genau diese Stolperfalle faellt damit weg.
*
* Nebeneffekt: die Abfuellungen werden nur EINMAL von ApiCore geladen statt
* zweimal — sie werden fuer Chargen und Produkte gleichermassen gebraucht.
*
* Ein Fehlschlag beim Laden der Chargen ist bewusst nicht fatal: die bereits
* uebernommenen Ernten sollen sichtbar bleiben. Ohne Abfuellungen entfallen
* dann allerdings auch die Produkte, denn ohne sie gibt es keine Jahrgaenge.
*/
export async function syncApiCoreAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const returnTo = returnTargetFrom(formData);
let session: ApiCoreSession;
let sourceHarvests: ApiCoreHarvestWithApiaryName[];
let articles: ApiCoreProduct[];
try {
session = await openApiCoreSession();
sourceHarvests = await fetchApiCoreHarvests(session);
[sourceHarvests, articles] = await Promise.all([
fetchApiCoreHarvests(session),
fetchApiCoreProducts(session),
]);
} catch (error: unknown) {
redirect(`/admin/ernten?syncError=${encodeURIComponent(describeError(error))}`);
redirect(`${returnTo}?syncError=${encodeURIComponent(describeError(error))}`);
}
// 1. Ernten
const harvests = await syncHarvests(sourceHarvests);
await logAdminAction(admin, {
action: "harvest.sync_apicore",
@@ -413,64 +420,45 @@ export async function syncHarvestsFromApiCoreAction(): Promise<void> {
detail: `${harvests.created} neu, ${harvests.updated} aktualisiert, ${harvests.skipped} übersprungen, ${harvests.failed} fehlgeschlagen`,
});
const batches = await loadAndSyncBatches(session);
// 2. Chargen — Abfuellungen einmal laden und fuer Schritt 3 weiterreichen.
let bottlings: ApiCoreBottlingBatchWithHarvestId[] = [];
let batchLoadError: string | null = null;
try {
bottlings = await fetchApiCoreBottlingBatches(session);
} catch (error: unknown) {
batchLoadError = describeError(error);
}
const batches = batchLoadError
? { created: 0, updated: 0, skipped: 0, failed: 0 }
: await syncBatches(bottlings);
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`,
detail: batchLoadError
? `Chargen konnten nicht geladen werden: ${batchLoadError}`
: `${batches.created} neu, ${batches.skipped} übersprungen, ${batches.failed} fehlgeschlagen`,
});
// 3. Produkte — je Abfuellung ein Jahrgang, jetzt mit vorhandenen Chargen.
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.pending} warten auf die Charge, ${products.discontinued} in ApiCore ausgelaufen`,
});
revalidatePath("/admin/ernten");
revalidatePath("/admin/chargen");
revalidatePath("/admin/produkte");
redirect(
`/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: 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 articles: ApiCoreProduct[];
let bottlings: ApiCoreBottlingBatchWithHarvestId[];
try {
session = await openApiCoreSession();
[articles, bottlings] = await Promise.all([
fetchApiCoreProducts(session),
fetchApiCoreBottlingBatches(session),
]);
} catch (error: unknown) {
redirect(`/admin/produkte?syncError=${encodeURIComponent(describeError(error))}`);
}
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.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}&pending=${products.pending}&discontinued=${products.discontinued}`
`${returnTo}?syncDone=1` +
`&hNew=${harvests.created}&hUpdated=${harvests.updated}&hSkipped=${harvests.skipped}&hFailed=${harvests.failed}` +
`&bNew=${batches.created}&bSkipped=${batches.skipped}&bFailed=${batches.failed}` +
`&pNew=${products.created}&pSkipped=${products.skipped}&pFailed=${products.failed}` +
`&pPending=${products.pending}&pDiscontinued=${products.discontinued}` +
(batchLoadError ? `&batchLoadError=${encodeURIComponent(batchLoadError)}` : "")
);
}
+6 -37
View File
@@ -3,19 +3,15 @@ import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form";
import { formatDate } from "@/lib/format";
import {
createHarvestAction,
toggleHarvestPublishedAction,
syncHarvestsFromApiCoreAction,
deleteHarvestAction,
} from "../actions";
import { createHarvestAction, toggleHarvestPublishedAction, deleteHarvestAction } from "../actions";
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten">) {
const t = getDictionary(await getLocale());
const { synced, updated, skipped, failed, syncError, batchSynced, batchSkipped, batchFailed, batchLoadError, error } =
await props.searchParams;
const searchParams = await props.searchParams;
const error = searchParams.error;
const harvests = await prisma.harvest.findMany({
orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
include: { _count: { select: { batches: true, products: true } } },
@@ -29,26 +25,7 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
{synced !== undefined ? (
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
{t.adminHarvestsPage.syncResult(Number(synced), Number(updated ?? 0), Number(skipped ?? 0), Number(failed ?? 0))}
</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.adminHarvestsPage.syncError} ({syncError})
</p>
) : null}
{batchSynced !== undefined ? (
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
{t.adminHarvestsPage.batchSyncResult(Number(batchSynced), Number(batchSkipped ?? 0), Number(batchFailed ?? 0))}
</p>
) : null}
{batchLoadError !== undefined ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
{t.adminHarvestsPage.batchSyncError} ({batchLoadError})
</p>
) : null}
<ApiCoreSyncResult searchParams={searchParams} />
<div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm">
@@ -139,15 +116,7 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
</form>
</div>
<div className="bg-background border border-ink/10 p-6">
<h2 className="text-lg text-forest mb-4">{t.adminHarvestsPage.syncHeading}</h2>
<p className="text-sm text-ink/65 mb-4">{t.adminHarvestsPage.syncIntro}</p>
<form action={syncHarvestsFromApiCoreAction}>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminHarvestsPage.syncButton}
</button>
</form>
</div>
<ApiCoreSyncPanel returnTo="/admin/ernten" />
</div>
</div>
);
+6 -32
View File
@@ -3,13 +3,14 @@ import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { formatPrice } from "@/lib/format";
import { ConfirmForm } from "@/components/confirm-form";
import { deleteProductAction, syncProductsFromApiCoreAction } from "../actions";
import { deleteProductAction } from "../actions";
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
import { getLocale } from "@/i18n/locale";
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, pending, syncError } = await props.searchParams;
const searchParams = await props.searchParams;
const products = await prisma.product.findMany({
include: {
harvest: true,
@@ -31,28 +32,7 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
</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))}
</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 ? (
<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}
<ApiCoreSyncResult searchParams={searchParams} />
<div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm">
@@ -124,14 +104,8 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
) : 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 className="mt-10 max-w-3xl">
<ApiCoreSyncPanel returnTo="/admin/produkte" />
</div>
</div>
);