feat: Produkte aus ApiCore synchronisieren

Der Shop konnte Ernten und Chargen bereits aus ApiCore uebernehmen, den
Produktkatalog aber nicht - jedes Glas musste im Admin ein zweites Mal von
Hand angelegt werden. Neuer Knopf unter /admin/produkte zieht die Produkte
und traegt anschliessend die passende Charge am Produkt nach, wo ApiCore
die Abfuellung einem Produkt zugeordnet hat. Genau diese Verknuepfung
fuettert die Herkunftsseite hinter dem QR-Code auf dem Glas.

ApiCore fuehrt ueberhaupt keine Verkaufspreise. Importierte Produkte
entstehen deshalb als deaktivierte Entwuerfe mit Preis 0, den nur ein
Mensch setzen kann. Damit daraus nie eine Gratis-Bestellung wird, lehnen
die Aktivierung im Admin und der Checkout ein Produkt ohne Preis jetzt
ausdruecklich ab, und die Produktliste kennzeichnet solche Entwuerfe.

Ein bereits vorhandenes Produkt wird uebersprungen, nie ueberschrieben:
genau die drei Felder, die ApiCore liefert (Name, Beschreibung, Gewicht),
zieht der Admin vor der Freischaltung von Hand nach - ein Update wuerde
diese Arbeit bei jedem weiteren Lauf wegwerfen. Ein in ApiCore
ausgelaufenes Produkt wird nicht automatisch deaktiviert, sondern nur
gemeldet, damit der Restbestand abverkauft werden kann.

Nebenbei behoben: die beiden Produktformulare und die Bestelluebersicht
haben den ?error=-Parameter aus fail() nie ausgegeben - abgelehnte
Eingaben landeten wortlos wieder auf dem Formular.

Der Login gegen ApiCore ist jetzt von den Abfragen getrennt
(openApiCoreSession), damit ein Sync-Lauf mit einer einzigen Anmeldung
auskommt statt mit dreien. Die Feld-Abbildung liegt neu in
lib/apicore-mapping.ts und ist damit testbar, statt hinter "use server"
zu verschwinden.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-16 18:03:05 +02:00
1 parent c61ae92390
commit 78c19f1b73
15 files changed
+908 -226

No files matched your search

+172
View File
@@ -0,0 +1,172 @@
import { describe, test, expect } from "vitest";
import { isSellableStatus, type ApiCoreProduct, type ApiCoreHarvestWithApiaryName } from "./apicore-client";
import {
slugForApiCoreSource,
buildHarvestDescription,
buildProductDescription,
deriveWeightGrams,
} from "./apicore-mapping";
function makeProduct(overrides: Partial<ApiCoreProduct> = {}): ApiCoreProduct {
return {
id: "cm-product-1",
name: "Sommertracht 500 g",
description: null,
sku: null,
packagingSize: null,
weight: null,
salesUnit: null,
status: "ACTIVE",
...overrides,
};
}
function makeHarvest(
overrides: Partial<ApiCoreHarvestWithApiaryName> = {}
): ApiCoreHarvestWithApiaryName {
return {
id: "cm-harvest-1",
apiaryId: "cm-apiary-1",
date: "2026-07-14T00:00:00.000Z",
honeyType: "Lindenhonig",
honeySuperCount: null,
grossWeight: null,
netWeight: null,
notes: null,
apiaryName: "Streuobstwiese",
...overrides,
};
}
describe("slugForApiCoreSource", () => {
test("leitet den Slug ausschliesslich aus der ApiCore-ID ab", () => {
// Arrange
const sourceId = "cm-product-1";
// Act
const slug = slugForApiCoreSource(sourceId);
// Assert
expect(slug).toBe("apicore-cm-product-1");
});
test("bleibt stabil, wenn sich in ApiCore nur der Name aendert", () => {
// Arrange: derselbe Datensatz, in ApiCore umbenannt.
const before = makeProduct({ name: "Sommertracht 500 g" });
const after = makeProduct({ name: "Sommertracht cremig, 500 g" });
// Act & Assert: sonst wuerde der Sync ein zweites Produkt anlegen.
expect(slugForApiCoreSource(after.id)).toBe(slugForApiCoreSource(before.id));
});
});
describe("buildHarvestDescription", () => {
test("fasst die vorhandenen Mengenangaben und Notizen zusammen", () => {
// Arrange
const source = makeHarvest({ honeySuperCount: 12, grossWeight: 48.5, netWeight: 41, notes: " Sehr hell. " });
// Act
const description = buildHarvestDescription(source);
// Assert
expect(description).toBe(
"Automatisch aus ApiCore importiert. 12 Honigräume · Bruttogewicht 48.5 kg · Nettogewicht 41 kg · Sehr hell."
);
});
test("fordert eine Beschreibung an, wenn ApiCore gar nichts liefert", () => {
// Arrange
const source = makeHarvest();
// Act
const description = buildHarvestDescription(source);
// Assert
expect(description).toContain("Bitte Beschreibung ergänzen.");
});
});
describe("buildProductDescription", () => {
test("uebernimmt ApiCores Beschreibung und nennt Artikelnummer und Verkaufseinheit", () => {
// Arrange
const source = makeProduct({ description: "Cremig gerührt.", sku: "WH-500", salesUnit: "Glas" });
// Act
const description = buildProductDescription(source);
// Assert
expect(description).toBe(
"Automatisch aus ApiCore importiert (Artikelnummer WH-500 · Verkaufseinheit Glas). Cremig gerührt."
);
});
test("setzt eine Aufforderung ein, statt einen leeren Verkaufstext zu erzeugen", () => {
// Arrange: ApiCores description ist eine interne Notiz und oft leer.
const source = makeProduct({ description: " " });
// Act
const description = buildProductDescription(source);
// Assert
expect(description).toContain("Bitte Produktbeschreibung für den Shop ergänzen.");
});
});
describe("deriveWeightGrams", () => {
test("nimmt das Gewicht des Artikels", () => {
// Arrange
const source = makeProduct({ weight: 500, packagingSize: 250 });
// Act & Assert: weight schlaegt packagingSize.
expect(deriveWeightGrams(source)).toBe(500);
});
test("faellt auf die Gebindegroesse zurueck, wenn kein Gewicht gepflegt ist", () => {
// Arrange
const source = makeProduct({ weight: null, packagingSize: 250 });
// Act & Assert
expect(deriveWeightGrams(source)).toBe(250);
});
test("rundet auf volle Gramm", () => {
// Arrange
const source = makeProduct({ weight: 499.6 });
// Act & Assert
expect(deriveWeightGrams(source)).toBe(500);
});
test("gibt 0 zurueck statt zu raten, wenn ApiCore keine Menge kennt", () => {
// Arrange
const source = makeProduct();
// Act & Assert: 0 ist in Wabenhain kein speicherbares Gewicht und zwingt
// damit zur manuellen Pflege, bevor das Produkt verkauft werden kann.
expect(deriveWeightGrams(source)).toBe(0);
});
test("behandelt 0 und negative Werte wie eine fehlende Angabe", () => {
// Arrange
const zero = makeProduct({ weight: 0 });
const negative = makeProduct({ weight: -500 });
// Act & Assert
expect(deriveWeightGrams(zero)).toBe(0);
expect(deriveWeightGrams(negative)).toBe(0);
});
});
describe("isSellableStatus", () => {
test("erkennt ACTIVE als verkaufsfaehig", () => {
expect(isSellableStatus("ACTIVE")).toBe(true);
});
test("behandelt DISCONTINUED und unbekannte Status als nicht verkaufsfaehig", () => {
// Arrange & Act & Assert: ein kuenftiger, hier noch unbekannter
// ApiCore-Status darf nicht versehentlich als Shop-Entwurf durchrutschen.
expect(isSellableStatus("DISCONTINUED")).toBe(false);
expect(isSellableStatus("SEASONAL")).toBe(false);
expect(isSellableStatus("")).toBe(false);
});
});