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