From d38f4303ff29359958ef65100a595ecd3e02c42d Mon Sep 17 00:00:00 2001
From: Nick Szaunig
Date: Sun, 27 Sep 2026 20:14:35 +0200
Subject: [PATCH] feat(apicore-sync): field-level ownership between ApiCore and
the shop
ApiCore stays the source of truth for beekeeping facts, Wabenhain for
everything sales-related; both remain separate packages talking only over
ApiCore's HTTP API.
- Batches: lot number, fill date, best-before date and filled jar count
now follow ApiCore on every run (before: created once, corrections never
arrived). remainingGlasses - the shop's own stock - is never written; if
it exceeds a corrected fill quantity, the run reports it instead of
silently adjusting.
- Batches are matched by the new Batch.apicoreBottlingId instead of the lot
number, so correcting a lot number in ApiCore no longer creates a second
batch. Existing synced batches are adopted once via their lot number.
- Harvests: date and year always follow ApiCore, also when published or
edited; type, location and text stay as maintained in the admin.
- Products stay entirely shop-owned after creation (weight drives the
legally required unit price, so it is never changed automatically).
- Admin panel: batch update count and stock-conflict notice; intro text
states which system owns which fields.
Migration is additive (nullable unique column).
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../migration.sql | 13 ++
prisma/schema.prisma | 4 +
src/app/admin/actions/apicore-sync.ts | 124 +++++++++++++-----
src/components/apicore-sync-panel.tsx | 15 ++-
src/i18n/dictionaries/de.ts | 10 +-
src/i18n/dictionaries/en.ts | 10 +-
src/i18n/dictionary-type.ts | 3 +-
src/lib/apicore-mapping.test.ts | 69 ++++++++++
src/lib/apicore-mapping.ts | 66 ++++++++++
9 files changed, 272 insertions(+), 42 deletions(-)
create mode 100644 prisma/migrations/20260927010000_batch_apicore_bottling_id/migration.sql
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-" als Harvest.slug bzw. Product.slug, und die
- * ApiCore-Losnummer als Batch.code.
+ * "apicore-" als Harvest.slug, und die ApiCore-ID der Abfuellung
+ * als Batch.apicoreBottlingId bzw. Product.apicoreBottlingId.
*
* Wabenhains "type" bei Ernten (Erntesaison, z.B. "Sommertracht") und
* ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig") sind KEINE
@@ -72,10 +79,18 @@ interface SyncCounts {
}
interface HarvestSyncCounts extends SyncCounts {
- /** Entwuerfe, die ein Mensch bearbeitet hat und die deshalb unveraendert blieben. */
+ /**
+ * Entwuerfe, die ein Mensch bearbeitet hat: ihre Shop-Felder blieben
+ * unveraendert (Fakten wie das Datum werden trotzdem nachgezogen).
+ */
protectedDrafts: number;
}
+interface BatchSyncCounts extends SyncCounts {
+ /** Chargen, deren Shop-Bestand ueber der (korrigierten) Abfuellmenge liegt. */
+ stockConflicts: number;
+}
+
/**
* Hat jemand diese Ernte im Admin angefasst (bearbeitet, veroeffentlicht,
* zurueckgezogen)? Der Sync selbst protokolliert ohne entityId und zaehlt
@@ -130,12 +145,17 @@ async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise {
// Chargen
// ---------------------------------------------------------------------------
-async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promise {
- const counts: SyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0 };
+/**
+ * Die Wabenhain-Charge zu einer ApiCore-Abfuellung: zuerst ueber die
+ * gespeicherte ApiCore-ID, sonst — fuer Chargen aus der Zeit vor dieser
+ * Spalte — ueber die Losnummer, sofern die Charge noch keiner anderen
+ * Abfuellung gehoert.
+ */
+async function findBatchForBottling(bottlingId: string, lotNumber: string) {
+ const byId = await prisma.batch.findUnique({ where: { apicoreBottlingId: bottlingId } });
+ if (byId) return byId;
+ const byCode = await prisma.batch.findUnique({ where: { code: lotNumber } });
+ return byCode && byCode.apicoreBottlingId === null ? byCode : null;
+}
+
+async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promise {
+ const counts: BatchSyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0, stockConflicts: 0 };
for (const source of sources) {
if (!source.harvestSourceId) {
@@ -346,9 +382,30 @@ async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promis
}
try {
- const existing = await prisma.batch.findUnique({ where: { code: source.lotNumber } });
+ const existing = await findBatchForBottling(source.id, source.lotNumber);
if (existing) {
- counts.skipped++;
+ const { changes, stockExceedsFilled } = batchFactChanges(existing, {
+ code: source.lotNumber,
+ fillDate,
+ bestBeforeDate,
+ glassCount: Math.max(Math.round(source.jarCount), 0),
+ });
+ const adopt = existing.apicoreBottlingId === null;
+ if (stockExceedsFilled) {
+ counts.stockConflicts++;
+ console.warn(
+ `[apicore-sync] Charge ${source.lotNumber}: Shop-Bestand (${existing.remainingGlasses}) liegt über der Abfüllmenge — bitte prüfen.`
+ );
+ }
+ if (Object.keys(changes).length === 0 && !adopt) {
+ counts.skipped++;
+ continue;
+ }
+ await prisma.batch.update({
+ where: { id: existing.id },
+ data: { ...changes, apicoreBottlingId: source.id },
+ });
+ counts.updated++;
continue;
}
const harvest = await prisma.harvest.findUnique({
@@ -366,6 +423,7 @@ async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promis
await prisma.batch.create({
data: {
code: source.lotNumber,
+ apicoreBottlingId: source.id,
harvestId: harvest.id,
fillDate,
bestBeforeDate,
@@ -457,15 +515,16 @@ export async function syncApiCoreAction(formData: FormData): Promise {
batchLoadError = describeError(error);
}
- const batches = batchLoadError
- ? { created: 0, updated: 0, skipped: 0, failed: 0 }
+ const batches: BatchSyncCounts = batchLoadError
+ ? { created: 0, updated: 0, skipped: 0, failed: 0, stockConflicts: 0 }
: await syncBatches(bottlings);
await logAdminAction(admin, {
action: "batch.sync_apicore",
entityType: "Batch",
detail: batchLoadError
? `Chargen konnten nicht geladen werden: ${batchLoadError}`
- : `${batches.created} neu, ${batches.skipped} übersprungen, ${batches.failed} fehlgeschlagen`,
+ : `${batches.created} neu, ${batches.updated} aktualisiert, ${batches.skipped} unverändert, ` +
+ `${batches.stockConflicts} Bestandskonflikte, ${batches.failed} fehlgeschlagen`,
});
// 3. Produkte — je Abfuellung ein Jahrgang, jetzt mit vorhandenen Chargen.
@@ -485,7 +544,8 @@ export async function syncApiCoreAction(formData: FormData): Promise {
`${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}` +
+ `&bNew=${batches.created}&bUpdated=${batches.updated}&bSkipped=${batches.skipped}&bFailed=${batches.failed}` +
+ `&bConflicts=${batches.stockConflicts}` +
`&pNew=${products.created}&pSkipped=${products.skipped}&pFailed=${products.failed}` +
`&pPending=${products.pending}&pDiscontinued=${products.discontinued}` +
(batchLoadError ? `&batchLoadError=${encodeURIComponent(batchLoadError)}` : "")
diff --git a/src/components/apicore-sync-panel.tsx b/src/components/apicore-sync-panel.tsx
index 82b620e..2c2a036 100644
--- a/src/components/apicore-sync-panel.tsx
+++ b/src/components/apicore-sync-panel.tsx
@@ -40,6 +40,7 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe
const pending = count(searchParams.pPending);
const protectedDrafts = count(searchParams.hProtected);
+ const stockConflicts = count(searchParams.bConflicts);
const discontinued = count(searchParams.pDiscontinued);
return (
@@ -59,12 +60,24 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe
count(searchParams.hFailed)
)}
- {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 = {}): ApiCoreProduct {
@@ -272,3 +274,70 @@ describe("isSellableStatus", () => {
expect(isSellableStatus("")).toBe(false);
});
});
+
+describe("harvestFactChanges", () => {
+ test("liefert nichts, wenn Datum und Jahr unveraendert sind", () => {
+ // Arrange
+ const date = new Date("2026-07-14T00:00:00.000Z");
+
+ // Act
+ const changes = harvestFactChanges({ harvestDate: date, year: 2026 }, new Date(date));
+
+ // Assert
+ expect(changes).toEqual({});
+ });
+
+ test("uebernimmt ein in ApiCore korrigiertes Erntedatum samt Jahr", () => {
+ // Arrange
+ const existing = { harvestDate: new Date("2025-12-31T12:00:00.000Z"), year: 2025 };
+ const corrected = new Date("2026-01-02T12:00:00.000Z");
+
+ // Act
+ const changes = harvestFactChanges(existing, corrected);
+
+ // Assert
+ expect(changes).toEqual({ harvestDate: corrected, year: 2026 });
+ });
+});
+
+describe("batchFactChanges", () => {
+ const base = {
+ code: "WH-2026-0001",
+ fillDate: new Date("2026-08-01T00:00:00.000Z"),
+ bestBeforeDate: new Date("2028-08-01T00:00:00.000Z"),
+ glassCount: 100,
+ };
+
+ test("aktualisiert nur die ApiCore-eigenen Felder, die sich geaendert haben", () => {
+ // Arrange: MHD in ApiCore korrigiert, Shop hat 40 Glaeser verkauft.
+ const source = { ...base, bestBeforeDate: new Date("2028-07-31T00:00:00.000Z") };
+
+ // Act
+ const result = batchFactChanges({ ...base, remainingGlasses: 60 }, source);
+
+ // Assert
+ expect(result.changes).toEqual({ bestBeforeDate: source.bestBeforeDate });
+ expect(result.changes).not.toHaveProperty("remainingGlasses");
+ expect(result.stockExceedsFilled).toBe(false);
+ });
+
+ test("uebernimmt eine korrigierte Losnummer statt eine zweite Charge anzulegen", () => {
+ // Act
+ const result = batchFactChanges({ ...base, remainingGlasses: 100 }, { ...base, code: "WH-2026-0002" });
+
+ // Assert
+ expect(result.changes).toEqual({ code: "WH-2026-0002" });
+ });
+
+ test("meldet einen Shop-Bestand ueber der korrigierten Abfuellmenge, statt ihn anzugleichen", () => {
+ // Arrange: ApiCore korrigiert 100 auf 80 Glaeser, im Shop stehen noch 90.
+ const source = { ...base, glassCount: 80 };
+
+ // Act
+ const result = batchFactChanges({ ...base, remainingGlasses: 90 }, source);
+
+ // Assert
+ expect(result.changes).toEqual({ glassCount: 80 });
+ expect(result.stockExceedsFilled).toBe(true);
+ });
+});
diff --git a/src/lib/apicore-mapping.ts b/src/lib/apicore-mapping.ts
index b00927d..c97093e 100644
--- a/src/lib/apicore-mapping.ts
+++ b/src/lib/apicore-mapping.ts
@@ -135,3 +135,69 @@ export function productSlugCandidates(name: string, year: number, lotNumber: str
const lot = slugifyProductName(lotNumber);
return lot ? [withYear, `${withYear}-${lot}`] : [withYear];
}
+
+// ---------------------------------------------------------------------------
+// Zustaendigkeit pro Feld
+// ---------------------------------------------------------------------------
+//
+// ApiCore ist die Quelle der Wahrheit fuer Imkerei-Fakten, Wabenhain fuer
+// alles, was den Verkauf betrifft. Die Funktionen unten liefern fuer einen
+// bereits vorhandenen Datensatz NUR die ApiCore-eigenen Felder, und nur
+// dann, wenn sie sich tatsaechlich geaendert haben. Alles andere (Texte,
+// Bilder, Preis, Sichtbarkeit, Shop-Bestand, Zuordnungen) bleibt unberuehrt —
+// auch dann, wenn ApiCore dort etwas anderes stehen haette.
+
+/** Ernte: Datum und Jahr sind Fakten aus ApiCore. */
+export interface HarvestFacts {
+ harvestDate: Date;
+ year: number;
+}
+
+export function harvestFactChanges(
+ existing: HarvestFacts,
+ sourceDate: Date
+): Partial {
+ const changes: Partial = {};
+ if (existing.harvestDate.getTime() !== sourceDate.getTime()) changes.harvestDate = sourceDate;
+ const year = sourceDate.getFullYear();
+ if (existing.year !== year) changes.year = year;
+ return changes;
+}
+
+/**
+ * Charge: Losnummer, Abfuelldatum, Mindesthaltbarkeit und abgefuellte
+ * Glasanzahl sind Fakten aus ApiCore. `remainingGlasses` ist dagegen
+ * Wabenhains Shop-Bestand, veraendert durch echte Verkaeufe, und wird nie
+ * gesetzt.
+ */
+export interface BatchFacts {
+ code: string;
+ fillDate: Date;
+ bestBeforeDate: Date;
+ glassCount: number;
+}
+
+export interface BatchFactUpdate {
+ changes: Partial;
+ /**
+ * Der Shop-Bestand liegt nach der Korrektur ueber der abgefuellten Menge —
+ * das kann nur ein Mensch aufloesen (Zaehlfehler hier oder dort?), also
+ * wird es gemeldet statt still angeglichen.
+ */
+ stockExceedsFilled: boolean;
+}
+
+export function batchFactChanges(
+ existing: BatchFacts & { remainingGlasses: number },
+ source: BatchFacts
+): BatchFactUpdate {
+ const changes: Partial = {};
+ if (existing.code !== source.code) changes.code = source.code;
+ if (existing.fillDate.getTime() !== source.fillDate.getTime()) changes.fillDate = source.fillDate;
+ if (existing.bestBeforeDate.getTime() !== source.bestBeforeDate.getTime()) {
+ changes.bestBeforeDate = source.bestBeforeDate;
+ }
+ if (existing.glassCount !== source.glassCount) changes.glassCount = source.glassCount;
+ const glassCount = changes.glassCount ?? existing.glassCount;
+ return { changes, stockExceedsFilled: existing.remainingGlasses > glassCount };
+}