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
@@ -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-<ApiCore-ID>" als Harvest.slug bzw. Product.slug, und die
|
||||
* ApiCore-Losnummer als Batch.code.
|
||||
* "apicore-<ApiCore-ID>" 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<Ha
|
||||
|
||||
try {
|
||||
const existing = await prisma.harvest.findUnique({ where: { slug } });
|
||||
if (existing?.isPublished) {
|
||||
counts.skipped++;
|
||||
continue;
|
||||
}
|
||||
if (existing && (await wasEditedByAdmin(existing.id))) {
|
||||
counts.protectedDrafts++;
|
||||
const shopOwned = existing && (existing.isPublished || (await wasEditedByAdmin(existing.id)));
|
||||
if (existing && shopOwned) {
|
||||
// Nur die ApiCore-eigenen Fakten; Sorte, Ort und Text gehoeren jetzt dem Shop.
|
||||
const facts = harvestFactChanges(existing, date);
|
||||
if (!existing.isPublished) counts.protectedDrafts++;
|
||||
if (Object.keys(facts).length === 0) {
|
||||
counts.skipped++;
|
||||
continue;
|
||||
}
|
||||
await prisma.harvest.update({ where: { slug }, data: facts });
|
||||
counts.updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -257,10 +277,13 @@ async function syncProducts(
|
||||
continue;
|
||||
}
|
||||
|
||||
const batch = await prisma.batch.findUnique({
|
||||
where: { code: bottling.lotNumber },
|
||||
include: { harvest: { select: { id: true, year: true } } },
|
||||
});
|
||||
const found = await findBatchForBottling(bottling.id, bottling.lotNumber);
|
||||
const batch = found
|
||||
? await prisma.batch.findUnique({
|
||||
where: { id: found.id },
|
||||
include: { harvest: { select: { id: true, year: true } } },
|
||||
})
|
||||
: null;
|
||||
if (!batch) {
|
||||
counts.pending++;
|
||||
continue;
|
||||
@@ -328,8 +351,21 @@ async function firstFreeSlug(candidates: string[]): Promise<string | null> {
|
||||
// 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) {
|
||||
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<void> {
|
||||
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<void> {
|
||||
`${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)}` : "")
|
||||
|
||||
Reference in new issue
Block a user