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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user