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) <[email protected]>
This commit is contained in:
1 parent
45ca0ea745
commit
d38f4303ff
9 files changed
+272
-42
No files matched your search
@@ -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");
|
||||||
@@ -178,6 +178,10 @@ model Batch {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
/// z.B. FH-26-01
|
/// z.B. FH-26-01
|
||||||
code String @unique
|
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
|
harvestId String
|
||||||
fillDate DateTime
|
fillDate DateTime
|
||||||
bestBeforeDate DateTime
|
bestBeforeDate DateTime
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
buildProductDescription,
|
buildProductDescription,
|
||||||
deriveJarWeightGrams,
|
deriveJarWeightGrams,
|
||||||
productSlugCandidates,
|
productSlugCandidates,
|
||||||
|
harvestFactChanges,
|
||||||
|
batchFactChanges,
|
||||||
} from "@/lib/apicore-mapping";
|
} from "@/lib/apicore-mapping";
|
||||||
import { requireAdmin } from "./_shared";
|
import { requireAdmin } from "./_shared";
|
||||||
|
|
||||||
@@ -36,26 +38,31 @@ import { requireAdmin } from "./_shared";
|
|||||||
* bewusst gesetzt hat — importiert wird in einen Entwurfszustand, den erst
|
* bewusst gesetzt hat — importiert wird in einen Entwurfszustand, den erst
|
||||||
* ein Mensch freigibt.
|
* ein Mensch freigibt.
|
||||||
*
|
*
|
||||||
* - Ernten: werden als unveroeffentlichte Entwuerfe angelegt (isPublished
|
* Zustaendigkeit pro Feld (siehe apicore-mapping.ts, "Zustaendigkeit pro
|
||||||
* false) bzw. als solche aktualisiert — aber nur, solange niemand den
|
* Feld"): Imkerei-Fakten gehoeren ApiCore und werden bei jedem Lauf
|
||||||
* Entwurf im Admin bearbeitet hat (erkennbar am Audit-Log-Eintrag der
|
* nachgezogen, alles Verkaufsbezogene gehoert Wabenhain und wird nach dem
|
||||||
* Bearbeitung). Sonst wuerde jeder weitere Lauf die Korrekturen des Admins
|
* Anlegen nie mehr angefasst.
|
||||||
* (Erntesaison statt Honigsorte, Standort, Verkaufstext) wieder
|
*
|
||||||
* zuruecksetzen. Bereits veroeffentlichte Ernten bleiben ohnehin unberuehrt.
|
* - 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,
|
* - Produkte: werden als deaktivierte Entwuerfe angelegt (isActive false,
|
||||||
* Preis 0) und nur so lange aktualisiert, wie sie deaktiviert sind. Ein
|
* Preis 0) und nur so lange aktualisiert, wie sie deaktiviert sind. Ein
|
||||||
* bereits aktives Produkt wird nie angefasst — sonst wuerde ein Sync Preis,
|
* bereits aktives Produkt wird nie angefasst — sonst wuerde ein Sync Preis,
|
||||||
* Beschreibung und Geschmacksprofil ueberschreiben, die nur in Wabenhain
|
* Beschreibung und Geschmacksprofil ueberschreiben, die nur in Wabenhain
|
||||||
* gepflegt werden und die es in ApiCore gar nicht gibt.
|
* gepflegt werden und die es in ApiCore gar nicht gibt.
|
||||||
* - Chargen: werden NUR neu angelegt, nie aktualisiert. Anders als Harvest
|
* - Chargen: Losnummer, Abfuell- und MHD-Datum sowie die abgefuellte
|
||||||
* und Product hat Batch kein Feld, an dem eine erfolgte Admin-Bearbeitung
|
* Glasanzahl kommen immer aus ApiCore. remainingGlasses ist Wabenhains
|
||||||
* erkennbar waere. Ein Update wuerde insbesondere remainingGlasses
|
* eigener, durch echte Verkaeufe veraenderter Bestand und wird nie gesetzt;
|
||||||
* ueberschreiben — Wabenhains EIGENEN, durch echte Verkaeufe veraenderten
|
* liegt er nach einer Korrektur ueber der Abfuellmenge, meldet der Lauf das.
|
||||||
* Lagerbestand.
|
* 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:
|
* Idempotent ist das alles ueber einen stabilen Schluessel je Datensatz:
|
||||||
* "apicore-<ApiCore-ID>" als Harvest.slug bzw. Product.slug, und die
|
* "apicore-<ApiCore-ID>" als Harvest.slug, und die ApiCore-ID der Abfuellung
|
||||||
* ApiCore-Losnummer als Batch.code.
|
* als Batch.apicoreBottlingId bzw. Product.apicoreBottlingId.
|
||||||
*
|
*
|
||||||
* Wabenhains "type" bei Ernten (Erntesaison, z.B. "Sommertracht") und
|
* Wabenhains "type" bei Ernten (Erntesaison, z.B. "Sommertracht") und
|
||||||
* ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig") sind KEINE
|
* ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig") sind KEINE
|
||||||
@@ -72,10 +79,18 @@ interface SyncCounts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface HarvestSyncCounts extends 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;
|
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,
|
* Hat jemand diese Ernte im Admin angefasst (bearbeitet, veroeffentlicht,
|
||||||
* zurueckgezogen)? Der Sync selbst protokolliert ohne entityId und zaehlt
|
* zurueckgezogen)? Der Sync selbst protokolliert ohne entityId und zaehlt
|
||||||
@@ -130,12 +145,17 @@ async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise<Ha
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = await prisma.harvest.findUnique({ where: { slug } });
|
const existing = await prisma.harvest.findUnique({ where: { slug } });
|
||||||
if (existing?.isPublished) {
|
const shopOwned = existing && (existing.isPublished || (await wasEditedByAdmin(existing.id)));
|
||||||
counts.skipped++;
|
if (existing && shopOwned) {
|
||||||
continue;
|
// Nur die ApiCore-eigenen Fakten; Sorte, Ort und Text gehoeren jetzt dem Shop.
|
||||||
}
|
const facts = harvestFactChanges(existing, date);
|
||||||
if (existing && (await wasEditedByAdmin(existing.id))) {
|
if (!existing.isPublished) counts.protectedDrafts++;
|
||||||
counts.protectedDrafts++;
|
if (Object.keys(facts).length === 0) {
|
||||||
|
counts.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await prisma.harvest.update({ where: { slug }, data: facts });
|
||||||
|
counts.updated++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,10 +277,13 @@ async function syncProducts(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const batch = await prisma.batch.findUnique({
|
const found = await findBatchForBottling(bottling.id, bottling.lotNumber);
|
||||||
where: { code: bottling.lotNumber },
|
const batch = found
|
||||||
include: { harvest: { select: { id: true, year: true } } },
|
? await prisma.batch.findUnique({
|
||||||
});
|
where: { id: found.id },
|
||||||
|
include: { harvest: { select: { id: true, year: true } } },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
if (!batch) {
|
if (!batch) {
|
||||||
counts.pending++;
|
counts.pending++;
|
||||||
continue;
|
continue;
|
||||||
@@ -328,8 +351,21 @@ async function firstFreeSlug(candidates: string[]): Promise<string | null> {
|
|||||||
// Chargen
|
// Chargen
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promise<SyncCounts> {
|
/**
|
||||||
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<BatchSyncCounts> {
|
||||||
|
const counts: BatchSyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0, stockConflicts: 0 };
|
||||||
|
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
if (!source.harvestSourceId) {
|
if (!source.harvestSourceId) {
|
||||||
@@ -346,9 +382,30 @@ async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promis
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = await prisma.batch.findUnique({ where: { code: source.lotNumber } });
|
const existing = await findBatchForBottling(source.id, source.lotNumber);
|
||||||
if (existing) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
const harvest = await prisma.harvest.findUnique({
|
const harvest = await prisma.harvest.findUnique({
|
||||||
@@ -366,6 +423,7 @@ async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promis
|
|||||||
await prisma.batch.create({
|
await prisma.batch.create({
|
||||||
data: {
|
data: {
|
||||||
code: source.lotNumber,
|
code: source.lotNumber,
|
||||||
|
apicoreBottlingId: source.id,
|
||||||
harvestId: harvest.id,
|
harvestId: harvest.id,
|
||||||
fillDate,
|
fillDate,
|
||||||
bestBeforeDate,
|
bestBeforeDate,
|
||||||
@@ -457,15 +515,16 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
|||||||
batchLoadError = describeError(error);
|
batchLoadError = describeError(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const batches = batchLoadError
|
const batches: BatchSyncCounts = batchLoadError
|
||||||
? { created: 0, updated: 0, skipped: 0, failed: 0 }
|
? { created: 0, updated: 0, skipped: 0, failed: 0, stockConflicts: 0 }
|
||||||
: await syncBatches(bottlings);
|
: await syncBatches(bottlings);
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "batch.sync_apicore",
|
action: "batch.sync_apicore",
|
||||||
entityType: "Batch",
|
entityType: "Batch",
|
||||||
detail: batchLoadError
|
detail: batchLoadError
|
||||||
? `Chargen konnten nicht geladen werden: ${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.
|
// 3. Produkte — je Abfuellung ein Jahrgang, jetzt mit vorhandenen Chargen.
|
||||||
@@ -485,7 +544,8 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
|||||||
`${returnTo}?syncDone=1` +
|
`${returnTo}?syncDone=1` +
|
||||||
`&hNew=${harvests.created}&hUpdated=${harvests.updated}&hSkipped=${harvests.skipped}&hFailed=${harvests.failed}` +
|
`&hNew=${harvests.created}&hUpdated=${harvests.updated}&hSkipped=${harvests.skipped}&hFailed=${harvests.failed}` +
|
||||||
`&hProtected=${harvests.protectedDrafts}` +
|
`&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}` +
|
`&pNew=${products.created}&pSkipped=${products.skipped}&pFailed=${products.failed}` +
|
||||||
`&pPending=${products.pending}&pDiscontinued=${products.discontinued}` +
|
`&pPending=${products.pending}&pDiscontinued=${products.discontinued}` +
|
||||||
(batchLoadError ? `&batchLoadError=${encodeURIComponent(batchLoadError)}` : "")
|
(batchLoadError ? `&batchLoadError=${encodeURIComponent(batchLoadError)}` : "")
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe
|
|||||||
|
|
||||||
const pending = count(searchParams.pPending);
|
const pending = count(searchParams.pPending);
|
||||||
const protectedDrafts = count(searchParams.hProtected);
|
const protectedDrafts = count(searchParams.hProtected);
|
||||||
|
const stockConflicts = count(searchParams.bConflicts);
|
||||||
const discontinued = count(searchParams.pDiscontinued);
|
const discontinued = count(searchParams.pDiscontinued);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -59,12 +60,24 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe
|
|||||||
count(searchParams.hFailed)
|
count(searchParams.hFailed)
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p>{t.resultBatches(count(searchParams.bNew), count(searchParams.bSkipped), count(searchParams.bFailed))}</p>
|
<p>
|
||||||
|
{t.resultBatches(
|
||||||
|
count(searchParams.bNew),
|
||||||
|
count(searchParams.bUpdated),
|
||||||
|
count(searchParams.bSkipped),
|
||||||
|
count(searchParams.bFailed)
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
<p>{t.resultProducts(count(searchParams.pNew), count(searchParams.pSkipped), count(searchParams.pFailed))}</p>
|
<p>{t.resultProducts(count(searchParams.pNew), count(searchParams.pSkipped), count(searchParams.pFailed))}</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{/* Beides sind Hinweise, keine Fehler: die Abfuellung ist in Ordnung,
|
{/* Beides sind Hinweise, keine Fehler: die Abfuellung ist in Ordnung,
|
||||||
es fehlt nur etwas drumherum bzw. der Artikel laeuft aus. */}
|
es fehlt nur etwas drumherum bzw. der Artikel laeuft aus. */}
|
||||||
|
{stockConflicts > 0 ? (
|
||||||
|
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
|
||||||
|
{t.stockConflicts(stockConflicts)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{protectedDrafts > 0 ? (
|
{protectedDrafts > 0 ? (
|
||||||
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
|
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
|
||||||
{t.protectedDrafts(protectedDrafts)}
|
{t.protectedDrafts(protectedDrafts)}
|
||||||
|
|||||||
@@ -1156,12 +1156,12 @@ export const de: Dictionary = {
|
|||||||
adminApiCoreSync: {
|
adminApiCoreSync: {
|
||||||
heading: "ApiCore-Synchronisierung",
|
heading: "ApiCore-Synchronisierung",
|
||||||
intro:
|
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",
|
button: "Alles synchronisieren",
|
||||||
resultHarvests: (created, updated, skipped, failed) =>
|
resultHarvests: (created, updated, skipped, failed) =>
|
||||||
`Ernten: ${created} neu, ${updated} aktualisiert, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`,
|
`Ernten: ${created} neu, ${updated} aktualisiert, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`,
|
||||||
resultBatches: (created, skipped, failed) =>
|
resultBatches: (created, updated, skipped, failed) =>
|
||||||
`Chargen: ${created} neu, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
|
`Chargen: ${created} neu, ${updated} aktualisiert, ${skipped} unverändert${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
|
||||||
resultProducts: (created, skipped, failed) =>
|
resultProducts: (created, skipped, failed) =>
|
||||||
`Produkte: ${created} neue${created === 1 ? "r Jahrgang" : " Jahrgänge"}, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
|
`Produkte: ${created} neue${created === 1 ? "r Jahrgang" : " Jahrgänge"}, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
|
||||||
pending: (count) =>
|
pending: (count) =>
|
||||||
@@ -1169,7 +1169,9 @@ export const de: Dictionary = {
|
|||||||
discontinued: (count) =>
|
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.`,
|
`${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) =>
|
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}`,
|
lastRun: (when) => `Letzter vollständiger Lauf: ${when}`,
|
||||||
neverRun: "Noch kein vollständiger Lauf.",
|
neverRun: "Noch kein vollständiger Lauf.",
|
||||||
error: "Synchronisierung mit ApiCore fehlgeschlagen.",
|
error: "Synchronisierung mit ApiCore fehlgeschlagen.",
|
||||||
|
|||||||
@@ -1131,12 +1131,12 @@ export const en: Dictionary = {
|
|||||||
adminApiCoreSync: {
|
adminApiCoreSync: {
|
||||||
heading: "ApiCore sync",
|
heading: "ApiCore sync",
|
||||||
intro:
|
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",
|
button: "Sync everything",
|
||||||
resultHarvests: (created, updated, skipped, failed) =>
|
resultHarvests: (created, updated, skipped, failed) =>
|
||||||
`Harvests: ${created} created, ${updated} updated, ${skipped} skipped${failed > 0 ? `, ${failed} failed` : ""}.`,
|
`Harvests: ${created} created, ${updated} updated, ${skipped} skipped${failed > 0 ? `, ${failed} failed` : ""}.`,
|
||||||
resultBatches: (created, skipped, failed) =>
|
resultBatches: (created, updated, skipped, failed) =>
|
||||||
`Batches: ${created} created, ${skipped} skipped${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
|
`Batches: ${created} created, ${updated} updated, ${skipped} unchanged${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
|
||||||
resultProducts: (created, skipped, failed) =>
|
resultProducts: (created, skipped, failed) =>
|
||||||
`Products: ${created} new vintage${created === 1 ? "" : "s"}, ${skipped} skipped${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
|
`Products: ${created} new vintage${created === 1 ? "" : "s"}, ${skipped} skipped${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
|
||||||
pending: (count) =>
|
pending: (count) =>
|
||||||
@@ -1144,7 +1144,9 @@ export const en: Dictionary = {
|
|||||||
discontinued: (count) =>
|
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.`,
|
`${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) =>
|
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}`,
|
lastRun: (when) => `Last complete run: ${when}`,
|
||||||
neverRun: "No complete run yet.",
|
neverRun: "No complete run yet.",
|
||||||
error: "Sync with ApiCore failed.",
|
error: "Sync with ApiCore failed.",
|
||||||
|
|||||||
@@ -729,11 +729,12 @@ export interface Dictionary {
|
|||||||
intro: string;
|
intro: string;
|
||||||
button: string;
|
button: string;
|
||||||
resultHarvests: (created: number, updated: number, skipped: number, failed: number) => 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;
|
resultProducts: (created: number, skipped: number, failed: number) => string;
|
||||||
pending: (count: number) => string;
|
pending: (count: number) => string;
|
||||||
discontinued: (count: number) => string;
|
discontinued: (count: number) => string;
|
||||||
protectedDrafts: (count: number) => string;
|
protectedDrafts: (count: number) => string;
|
||||||
|
stockConflicts: (count: number) => string;
|
||||||
lastRun: (when: string) => string;
|
lastRun: (when: string) => string;
|
||||||
neverRun: string;
|
neverRun: string;
|
||||||
error: string;
|
error: string;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
deriveJarWeightGrams,
|
deriveJarWeightGrams,
|
||||||
slugifyProductName,
|
slugifyProductName,
|
||||||
productSlugCandidates,
|
productSlugCandidates,
|
||||||
|
harvestFactChanges,
|
||||||
|
batchFactChanges,
|
||||||
} from "./apicore-mapping";
|
} from "./apicore-mapping";
|
||||||
|
|
||||||
function makeProduct(overrides: Partial<ApiCoreProduct> = {}): ApiCoreProduct {
|
function makeProduct(overrides: Partial<ApiCoreProduct> = {}): ApiCoreProduct {
|
||||||
@@ -272,3 +274,70 @@ describe("isSellableStatus", () => {
|
|||||||
expect(isSellableStatus("")).toBe(false);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -135,3 +135,69 @@ export function productSlugCandidates(name: string, year: number, lotNumber: str
|
|||||||
const lot = slugifyProductName(lotNumber);
|
const lot = slugifyProductName(lotNumber);
|
||||||
return lot ? [withYear, `${withYear}-${lot}`] : [withYear];
|
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<HarvestFacts> {
|
||||||
|
const changes: Partial<HarvestFacts> = {};
|
||||||
|
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<BatchFacts>;
|
||||||
|
/**
|
||||||
|
* 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<BatchFacts> = {};
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Reference in new issue
Block a user