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:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-27 20:14:35 +02:00
1 parent 45ca0ea745
commit d38f4303ff
9 files changed
+272 -42

No files matched your search

+69
View File
@@ -8,6 +8,8 @@ import {
deriveJarWeightGrams,
slugifyProductName,
productSlugCandidates,
harvestFactChanges,
batchFactChanges,
} from "./apicore-mapping";
function makeProduct(overrides: Partial<ApiCoreProduct> = {}): 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);
});
});
+66
View File
@@ -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<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 };
}