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:
1 parent
c61ae92390
commit
78c19f1b73
15 files changed
+908
-226
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user