diff --git a/src/app/admin/actions/apicore-sync.ts b/src/app/admin/actions/apicore-sync.ts index dc378dc..b1968f4 100644 --- a/src/app/admin/actions/apicore-sync.ts +++ b/src/app/admin/actions/apicore-sync.ts @@ -37,8 +37,11 @@ import { requireAdmin } from "./_shared"; * ein Mensch freigibt. * * - Ernten: werden als unveroeffentlichte Entwuerfe angelegt (isPublished - * false) bzw. als solche aktualisiert. Bereits veroeffentlichte, frueher - * synchronisierte Ernten bleiben unberuehrt. + * false) bzw. als solche aktualisiert — aber nur, solange niemand den + * Entwurf im Admin bearbeitet hat (erkennbar am Audit-Log-Eintrag der + * Bearbeitung). Sonst wuerde jeder weitere Lauf die Korrekturen des Admins + * (Erntesaison statt Honigsorte, Standort, Verkaufstext) wieder + * zuruecksetzen. Bereits veroeffentlichte Ernten bleiben ohnehin 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, @@ -68,6 +71,23 @@ interface SyncCounts { failed: number; } +interface HarvestSyncCounts extends SyncCounts { + /** Entwuerfe, die ein Mensch bearbeitet hat und die deshalb unveraendert blieben. */ + protectedDrafts: number; +} + +/** + * Hat jemand diese Ernte im Admin angefasst (bearbeitet, veroeffentlicht, + * zurueckgezogen)? Der Sync selbst protokolliert ohne entityId und zaehlt + * damit nicht mit. + */ +async function wasEditedByAdmin(harvestId: string): Promise { + const entries = await prisma.auditLog.count({ + where: { entityType: "Harvest", entityId: harvestId, action: { not: "harvest.create" } }, + }); + return entries > 0; +} + /** * Produkte kennen bewusst kein "updated" — sie werden nur neu angelegt, * siehe syncProducts(). @@ -95,8 +115,8 @@ function describeError(error: unknown): string { // Ernten // --------------------------------------------------------------------------- -async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise { - const counts: SyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0 }; +async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise { + const counts: HarvestSyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0, protectedDrafts: 0 }; for (const source of sources) { const slug = slugForApiCoreSource(source.id); @@ -114,6 +134,10 @@ async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise { await logAdminAction(admin, { action: "harvest.sync_apicore", entityType: "Harvest", - detail: `${harvests.created} neu, ${harvests.updated} aktualisiert, ${harvests.skipped} übersprungen, ${harvests.failed} fehlgeschlagen`, + detail: + `${harvests.created} neu, ${harvests.updated} aktualisiert, ${harvests.skipped} übersprungen, ` + + `${harvests.protectedDrafts} bearbeitete Entwürfe geschützt, ${harvests.failed} fehlgeschlagen`, }); // 2. Chargen — Abfuellungen einmal laden und fuer Schritt 3 weiterreichen. @@ -458,6 +484,7 @@ export async function syncApiCoreAction(formData: FormData): Promise { redirect( `${returnTo}?syncDone=1` + `&hNew=${harvests.created}&hUpdated=${harvests.updated}&hSkipped=${harvests.skipped}&hFailed=${harvests.failed}` + + `&hProtected=${harvests.protectedDrafts}` + `&bNew=${batches.created}&bSkipped=${batches.skipped}&bFailed=${batches.failed}` + `&pNew=${products.created}&pSkipped=${products.skipped}&pFailed=${products.failed}` + `&pPending=${products.pending}&pDiscontinued=${products.discontinued}` + diff --git a/src/components/apicore-sync-panel.tsx b/src/components/apicore-sync-panel.tsx index e0dc923..82b620e 100644 --- a/src/components/apicore-sync-panel.tsx +++ b/src/components/apicore-sync-panel.tsx @@ -1,4 +1,5 @@ import { syncApiCoreAction } from "@/app/admin/actions"; +import { prisma } from "@/lib/prisma"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; import { Button } from "@/components/ui"; @@ -38,6 +39,7 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe if (!done && syncError === undefined) return null; const pending = count(searchParams.pPending); + const protectedDrafts = count(searchParams.hProtected); const discontinued = count(searchParams.pDiscontinued); return ( @@ -63,6 +65,11 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe ) : null} {/* Beides sind Hinweise, keine Fehler: die Abfuellung ist in Ordnung, es fehlt nur etwas drumherum bzw. der Artikel laeuft aus. */} + {protectedDrafts > 0 ? ( +

+ {t.protectedDrafts(protectedDrafts)} +

+ ) : null} {pending > 0 ? (

{t.pending(pending)} @@ -88,12 +95,31 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe * daraus keine offene Weiterleitung wird. */ export async function ApiCoreSyncPanel({ returnTo }: { returnTo: string }) { - const t = getDictionary(await getLocale()).adminApiCoreSync; + const locale = await getLocale(); + const t = getDictionary(locale).adminApiCoreSync; + // Jeder vollstaendige Lauf schreibt zuletzt den Produkt-Eintrag — der ist + // damit der Zeitpunkt des letzten Laufs, der bis zum Ende kam. + const lastRun = await prisma.auditLog.findFirst({ + where: { action: "product.sync_apicore" }, + orderBy: { createdAt: "desc" }, + select: { createdAt: true }, + }); return (

{t.heading}

{t.intro}

+

+ {lastRun + ? t.lastRun( + lastRun.createdAt.toLocaleString(locale === "en" ? "en-GB" : "de-DE", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "Europe/Berlin", + }) + ) + : t.neverRun} +