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 }; +}