diff --git a/prisma/migrations/20260927010000_batch_apicore_bottling_id/migration.sql b/prisma/migrations/20260927010000_batch_apicore_bottling_id/migration.sql
new file mode 100644
index 0000000..56352b8
--- /dev/null
+++ b/prisma/migrations/20260927010000_batch_apicore_bottling_id/migration.sql
@@ -0,0 +1,13 @@
+-- Herkunftsschluessel fuer den ApiCore-Chargen-Sync: ID der Abfuellung
+-- (BottlingBatch). Bisher wurde eine Charge nur ueber die Losnummer (code)
+-- wiedererkannt — wird die Losnummer in ApiCore korrigiert, entstand beim
+-- naechsten Lauf eine zweite Charge. Nullable: manuell angelegte Chargen
+-- haben keine Herkunft in ApiCore, bestehende Zeilen bleiben gueltig. Schon
+-- synchronisierte Chargen erhalten die ID beim naechsten Lauf ueber ihre
+-- Losnummer.
+
+-- AlterTable
+ALTER TABLE "batches" ADD COLUMN "apicoreBottlingId" TEXT;
+
+-- CreateIndex
+CREATE UNIQUE INDEX "batches_apicoreBottlingId_key" ON "batches"("apicoreBottlingId");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 00265fa..658a45a 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -178,6 +178,10 @@ model Batch {
id String @id @default(cuid())
/// z.B. FH-26-01
code String @unique
+ /// ApiCore-ID der Abfuellung (BottlingBatch), aus der diese Charge stammt.
+ /// Stabiler Zuordnungsschluessel fuer den Sync: die Losnummer (code) kann in
+ /// ApiCore korrigiert werden, die ID nicht. Null bei manuell angelegten Chargen.
+ apicoreBottlingId String? @unique
harvestId String
fillDate DateTime
bestBeforeDate DateTime
diff --git a/src/app/admin/actions/apicore-sync.ts b/src/app/admin/actions/apicore-sync.ts
index b1968f4..7b67455 100644
--- a/src/app/admin/actions/apicore-sync.ts
+++ b/src/app/admin/actions/apicore-sync.ts
@@ -23,6 +23,8 @@ import {
buildProductDescription,
deriveJarWeightGrams,
productSlugCandidates,
+ harvestFactChanges,
+ batchFactChanges,
} from "@/lib/apicore-mapping";
import { requireAdmin } from "./_shared";
@@ -36,26 +38,31 @@ import { requireAdmin } from "./_shared";
* bewusst gesetzt hat — importiert wird in einen Entwurfszustand, den erst
* ein Mensch freigibt.
*
- * - Ernten: werden als unveroeffentlichte Entwuerfe angelegt (isPublished
- * 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.
+ * Zustaendigkeit pro Feld (siehe apicore-mapping.ts, "Zustaendigkeit pro
+ * Feld"): Imkerei-Fakten gehoeren ApiCore und werden bei jedem Lauf
+ * nachgezogen, alles Verkaufsbezogene gehoert Wabenhain und wird nach dem
+ * Anlegen nie mehr angefasst.
+ *
+ * - Ernten: Datum und Jahr kommen immer aus ApiCore, auch bei
+ * veroeffentlichten Ernten. Sorte, Standort und Text sind nur eine
+ * Vorbelegung: ein unveroeffentlichter, noch von niemandem bearbeiteter
+ * Entwurf wird damit aufgefrischt, sonst bleiben sie, wie der Admin sie
+ * gesetzt hat.
* - 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.
+ * - Chargen: Losnummer, Abfuell- und MHD-Datum sowie die abgefuellte
+ * Glasanzahl kommen immer aus ApiCore. remainingGlasses ist Wabenhains
+ * eigener, durch echte Verkaeufe veraenderter Bestand und wird nie gesetzt;
+ * liegt er nach einer Korrektur ueber der Abfuellmenge, meldet der Lauf das.
+ * Wiedererkannt wird eine Charge an der ApiCore-ID der Abfuellung, nicht an
+ * der Losnummer — die kann in ApiCore korrigiert werden.
*
* Idempotent ist das alles ueber einen stabilen Schluessel je Datensatz:
- * "apicore-
{t.resultBatches(count(searchParams.bNew), count(searchParams.bSkipped), count(searchParams.bFailed))}
++ {t.resultBatches( + count(searchParams.bNew), + count(searchParams.bUpdated), + count(searchParams.bSkipped), + count(searchParams.bFailed) + )} +
{t.resultProducts(count(searchParams.pNew), count(searchParams.pSkipped), count(searchParams.pFailed))}
) : null} {/* Beides sind Hinweise, keine Fehler: die Abfuellung ist in Ordnung, es fehlt nur etwas drumherum bzw. der Artikel laeuft aus. */} + {stockConflicts > 0 ? ( ++ {t.stockConflicts(stockConflicts)} +
+ ) : null} {protectedDrafts > 0 ? (
{t.protectedDrafts(protectedDrafts)}
diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts
index 3aa1ded..74a7e07 100644
--- a/src/i18n/dictionaries/de.ts
+++ b/src/i18n/dictionaries/de.ts
@@ -1156,12 +1156,12 @@ export const de: Dictionary = {
adminApiCoreSync: {
heading: "ApiCore-Synchronisierung",
intro:
- "Holt in einem Durchgang Ernten, Chargen und Produkte aus ApiCore — in dieser Reihenfolge, weil jede Stufe auf der vorherigen aufbaut. Ernten und Produkte entstehen als Entwürfe: Ernten unveröffentlicht, Produkte deaktiviert und ohne Preis, denn ApiCore führt keine Verkaufspreise. Je Abfüllung entsteht ein eigener Jahrgang. Bereits freigegebene und im Admin bearbeitete Einträge werden nie überschrieben; interne ApiCore-Notizen werden nicht übernommen.",
+ "Holt in einem Durchgang Ernten, Chargen und Produkte aus ApiCore — in dieser Reihenfolge, weil jede Stufe auf der vorherigen aufbaut. Ernten und Produkte entstehen als Entwürfe: Ernten unveröffentlicht, Produkte deaktiviert und ohne Preis, denn ApiCore führt keine Verkaufspreise. Je Abfüllung entsteht ein eigener Jahrgang. Imkerei-Fakten (Datum, Losnummer, MHD, Abfüllmenge) kommen immer aus ApiCore; Texte, Preise, Bilder, Sichtbarkeit und Shop-Bestand bleiben so, wie sie hier gepflegt werden. Interne ApiCore-Notizen werden nicht übernommen.",
button: "Alles synchronisieren",
resultHarvests: (created, updated, skipped, failed) =>
`Ernten: ${created} neu, ${updated} aktualisiert, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`,
- resultBatches: (created, skipped, failed) =>
- `Chargen: ${created} neu, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
+ resultBatches: (created, updated, skipped, failed) =>
+ `Chargen: ${created} neu, ${updated} aktualisiert, ${skipped} unverändert${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
resultProducts: (created, skipped, failed) =>
`Produkte: ${created} neue${created === 1 ? "r Jahrgang" : " Jahrgänge"}, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
pending: (count) =>
@@ -1169,7 +1169,9 @@ export const de: Dictionary = {
discontinued: (count) =>
`${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 bleiben im Verkauf.`,
protectedDrafts: (count) =>
- `${count} Ernte-Entw${count === 1 ? "urf wurde" : "ürfe wurden"} im Admin bereits bearbeitet und deshalb nicht überschrieben.`,
+ `${count} Ernte-Entw${count === 1 ? "urf wurde" : "ürfe wurden"} im Admin bereits bearbeitet: Sorte, Ort und Text bleiben, nur Datum und Jahr kommen aus ApiCore.`,
+ stockConflicts: (count) =>
+ `Bei ${count} Charge${count === 1 ? "" : "n"} liegt der Shop-Bestand über der in ApiCore erfassten Abfüllmenge. Bitte Bestand unter Chargen prüfen – er wurde nicht automatisch geändert.`,
lastRun: (when) => `Letzter vollständiger Lauf: ${when}`,
neverRun: "Noch kein vollständiger Lauf.",
error: "Synchronisierung mit ApiCore fehlgeschlagen.",
diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts
index a24f702..e28846f 100644
--- a/src/i18n/dictionaries/en.ts
+++ b/src/i18n/dictionaries/en.ts
@@ -1131,12 +1131,12 @@ export const en: Dictionary = {
adminApiCoreSync: {
heading: "ApiCore sync",
intro:
- "Fetches harvests, batches and products from ApiCore in one run — in that order, because each step builds on the previous one. Harvests and products are created as drafts: harvests unpublished, products inactive and without a price, because ApiCore does not hold sales prices. Each bottling run becomes its own vintage. Entries already released or edited in the admin are never overwritten; internal ApiCore notes are not copied.",
+ "Fetches harvests, batches and products from ApiCore in one run — in that order, because each step builds on the previous one. Harvests and products are created as drafts: harvests unpublished, products inactive and without a price, because ApiCore does not hold sales prices. Each bottling run becomes its own vintage. Beekeeping facts (dates, lot number, best-before date, filled quantity) always come from ApiCore; texts, prices, images, visibility and shop stock stay as maintained here. Internal ApiCore notes are not copied.",
button: "Sync everything",
resultHarvests: (created, updated, skipped, failed) =>
`Harvests: ${created} created, ${updated} updated, ${skipped} skipped${failed > 0 ? `, ${failed} failed` : ""}.`,
- resultBatches: (created, skipped, failed) =>
- `Batches: ${created} created, ${skipped} skipped${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
+ resultBatches: (created, updated, skipped, failed) =>
+ `Batches: ${created} created, ${updated} updated, ${skipped} unchanged${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
resultProducts: (created, skipped, failed) =>
`Products: ${created} new vintage${created === 1 ? "" : "s"}, ${skipped} skipped${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
pending: (count) =>
@@ -1144,7 +1144,9 @@ export const en: Dictionary = {
discontinued: (count) =>
`${count} bottling run${count === 1 ? "" : "s"} belong to an article ApiCore no longer carries, so no new vintage was created. Existing ones stay on sale.`,
protectedDrafts: (count) =>
- `${count} harvest draft${count === 1 ? " was" : "s were"} already edited in the admin and therefore not overwritten.`,
+ `${count} harvest draft${count === 1 ? " was" : "s were"} already edited in the admin: type, location and text stay, only date and year come from ApiCore.`,
+ stockConflicts: (count) =>
+ `For ${count} batch${count === 1 ? "" : "es"} the shop stock exceeds the quantity filled according to ApiCore. Please check the stock under batches — it was not changed automatically.`,
lastRun: (when) => `Last complete run: ${when}`,
neverRun: "No complete run yet.",
error: "Sync with ApiCore failed.",
diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts
index a1b6f69..1e73078 100644
--- a/src/i18n/dictionary-type.ts
+++ b/src/i18n/dictionary-type.ts
@@ -729,11 +729,12 @@ export interface Dictionary {
intro: string;
button: string;
resultHarvests: (created: number, updated: number, skipped: number, failed: number) => string;
- resultBatches: (created: number, skipped: number, failed: number) => string;
+ resultBatches: (created: number, updated: number, skipped: number, failed: number) => string;
resultProducts: (created: number, skipped: number, failed: number) => string;
pending: (count: number) => string;
discontinued: (count: number) => string;
protectedDrafts: (count: number) => string;
+ stockConflicts: (count: number) => string;
lastRun: (when: string) => string;
neverRun: string;
error: string;
diff --git a/src/lib/apicore-mapping.test.ts b/src/lib/apicore-mapping.test.ts
index c264061..14da1ee 100644
--- a/src/lib/apicore-mapping.test.ts
+++ b/src/lib/apicore-mapping.test.ts
@@ -8,6 +8,8 @@ import {
deriveJarWeightGrams,
slugifyProductName,
productSlugCandidates,
+ harvestFactChanges,
+ batchFactChanges,
} from "./apicore-mapping";
function makeProduct(overrides: Partial