feat: ein Shop-Produkt je Jahrgang statt je ApiCore-Artikel
Der Produkt-Sync bildete den Fall gleichnamiger Jahrgaenge nicht ab. In
ApiCore ist "Waldhonig 500 g" EIN Katalogartikel, an dem jedes Jahr eine
weitere Abfuellung haengt; der Sync legte daraus genau ein Wabenhain-Produkt
an. Ab dem zweiten Jahr passierte damit nichts mehr: das Produkt existierte
schon und wurde uebersprungen, und die Charge des neuen Jahrgangs blieb
unverknuepft, weil linkProductsToBatches nur eingesprungen ist, solange
Product.batchId leer war.
In Wabenhain gehoert ein Produkt zu genau einer Charge, und das ist richtig
so: jeder Jahrgang hat eigenen Preis, eigenen Verkaufstext, eigenes
Geschmacksprofil und eigene Bewertungen. Honig ist ein Naturprodukt, die
Bewertung des Waldhonigs von 2026 sagt ueber den von 2027 nichts aus.
Der Sync laeuft deshalb jetzt ueber die ABFUELLUNGEN statt ueber den
Artikelkatalog und legt je Abfuellung einen Jahrgang an, bereits mit Charge
und Ernte verknuepft. Die nachtraegliche Verknuepfung entfaellt ersatzlos.
Wiedererkannt wird ein Jahrgang ueber die neue Spalte
Product.apicoreBottlingId (nullable, unique; manuell angelegte Produkte
tragen null). Bewusst nicht ueber den Slug: der ist die oeffentliche Adresse
und soll lesbar bleiben. Er traegt stattdessen den Jahrgang
("waldhonig-2026") und faellt bei mehrfacher Abfuellung im selben Jahr auf
die Losnummer zurueck - hochgezaehlt wird nicht, "waldhonig-2026-2" sagt
niemandem etwas.
Weitere Entscheidungen, jeweils zugunsten dessen, was tatsaechlich im Glas
ist: das Gewicht kommt aus der Glasgroesse der Abfuellung statt aus dem
Artikel, und der Lagerbestand startet bei 0 statt bei der Abfuellmenge -
wie viele Glaeser in den Webshop gehen und wie viele in Hofladen oder
Wiederverkauf, entscheidet Wabenhain.
Ein in ApiCore ausgelaufener Artikel bekommt keinen neuen Jahrgang mehr,
bereits importierte bleiben unveraendert im Verkauf: solange Glaeser im
Regal stehen, ist "nicht mehr produziert" kein Grund, den Abverkauf
abzubrechen. Fehlt die Charge eines Jahrgangs noch, meldet der Lauf das als
"wartet auf den Ernten-Sync" statt als Fehler.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
95f5252366
commit
c525735285
11 files changed
+351
-120
No files matched your search
@@ -247,6 +247,12 @@ const bottlingBatchSchema = z.object({
|
||||
date: z.string(),
|
||||
bestBeforeDate: z.string(),
|
||||
jarCount: z.number(),
|
||||
// Glasgroesse dieser Abfuellung, in Gramm (siehe Einheiten-Hinweis an
|
||||
// productSchema oben). Bewusst optional gelesen, obwohl ApiCore das Feld
|
||||
// heute immer fuellt: faellt es einmal weg, soll der Sync auf die Angabe am
|
||||
// Artikel zurueckfallen (deriveJarWeightGrams) statt die gesamte Antwort zu
|
||||
// verwerfen.
|
||||
jarSize: z.number().nullable().optional(),
|
||||
// Optional: eine Abfuellung muss in ApiCore keinem Produkt zugeordnet sein.
|
||||
// Ist sie es, verknuepft der Sync die daraus entstandene Wabenhain-Charge
|
||||
// mit dem passenden synchronisierten Produkt (siehe apicore-sync.ts).
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
buildHarvestDescription,
|
||||
buildProductDescription,
|
||||
deriveWeightGrams,
|
||||
deriveJarWeightGrams,
|
||||
slugifyProductName,
|
||||
productSlugCandidates,
|
||||
} from "./apicore-mapping";
|
||||
|
||||
function makeProduct(overrides: Partial<ApiCoreProduct> = {}): ApiCoreProduct {
|
||||
@@ -157,6 +160,93 @@ describe("deriveWeightGrams", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("slugifyProductName", () => {
|
||||
test("macht aus einem Namen eine adresstaugliche Form", () => {
|
||||
expect(slugifyProductName("Waldhonig")).toBe("waldhonig");
|
||||
expect(slugifyProductName("Waldhonig, cremig")).toBe("waldhonig-cremig");
|
||||
});
|
||||
|
||||
test("schreibt Umlaute aus, statt sie zu verschlucken", () => {
|
||||
// Arrange & Act & Assert: eine blosse Filterung auf a-z machte aus
|
||||
// "Bluetenhonig" ein "bl-tenhonig".
|
||||
expect(slugifyProductName("Blütenhonig")).toBe("bluetenhonig");
|
||||
expect(slugifyProductName("Süßer Honig")).toBe("suesser-honig");
|
||||
});
|
||||
});
|
||||
|
||||
describe("productSlugCandidates", () => {
|
||||
test("stellt den Jahrgang voran, weil er die Jahrgaenge unterscheidet", () => {
|
||||
// Arrange & Act
|
||||
const candidates = productSlugCandidates("Waldhonig", 2026, "FH-26-01");
|
||||
|
||||
// Assert: erste Wahl ist die lesbare Adresse mit Jahreszahl.
|
||||
expect(candidates[0]).toBe("waldhonig-2026");
|
||||
});
|
||||
|
||||
test("haelt die Losnummer als Ausweichadresse bereit", () => {
|
||||
// Arrange & Act & Assert: deckt den Fall ab, dass derselbe Artikel im
|
||||
// selben Jahr mehrfach abgefuellt wurde.
|
||||
expect(productSlugCandidates("Waldhonig", 2026, "FH-26-02")).toEqual([
|
||||
"waldhonig-2026",
|
||||
"waldhonig-2026-fh-26-02",
|
||||
]);
|
||||
});
|
||||
|
||||
test("zwei Jahrgaenge desselben Namens kollidieren nicht", () => {
|
||||
// Arrange & Act
|
||||
const first = productSlugCandidates("Waldhonig", 2026, "FH-26-01")[0];
|
||||
const second = productSlugCandidates("Waldhonig", 2027, "FH-27-01")[0];
|
||||
|
||||
// Assert: genau das ist der Grund fuer die Jahreszahl — Product.slug ist
|
||||
// eindeutig, und ohne sie waere der zweite Jahrgang nicht anlegbar.
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
|
||||
test("faellt bei einem Namen ohne verwertbare Zeichen nicht auf eine leere Adresse zurueck", () => {
|
||||
// Arrange & Act & Assert: ein leerer Slug waere eine ungueltige URL.
|
||||
expect(productSlugCandidates("***", 2026, "")).toEqual(["produkt-2026"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveJarWeightGrams", () => {
|
||||
test("nimmt die Glasgroesse der Abfuellung", () => {
|
||||
// Arrange
|
||||
const source = makeProduct({ weight: 250 });
|
||||
|
||||
// Act & Assert: die Abfuellung sagt, was tatsaechlich in dieses Glas kam
|
||||
// — der Artikel nur, was ueblicherweise vorgesehen ist.
|
||||
expect(deriveJarWeightGrams(500, source)).toBe(500);
|
||||
});
|
||||
|
||||
test("faellt auf die Angaben am Artikel zurueck, wenn die Abfuellung keine Groesse fuehrt", () => {
|
||||
// Arrange
|
||||
const source = makeProduct({ weight: 250 });
|
||||
|
||||
// Act & Assert
|
||||
expect(deriveJarWeightGrams(null, source)).toBe(250);
|
||||
expect(deriveJarWeightGrams(0, source)).toBe(250);
|
||||
});
|
||||
|
||||
test("gibt 0 zurueck, wenn weder Abfuellung noch Artikel eine Menge kennen", () => {
|
||||
expect(deriveJarWeightGrams(null, makeProduct())).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildProductDescription mit Jahrgang", () => {
|
||||
test("nennt den Jahrgang zuerst", () => {
|
||||
// Arrange
|
||||
const source = makeProduct({ description: "Kräftig.", sku: "WH-500" });
|
||||
|
||||
// Act
|
||||
const description = buildProductDescription(source, 2026);
|
||||
|
||||
// Assert
|
||||
expect(description).toBe(
|
||||
"Automatisch aus ApiCore importiert (Jahrgang 2026 · Artikelnummer WH-500). Kräftig."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSellableStatus", () => {
|
||||
test("erkennt ACTIVE als verkaufsfaehig", () => {
|
||||
expect(isSellableStatus("ACTIVE")).toBe(true);
|
||||
|
||||
@@ -47,9 +47,10 @@ export function buildHarvestDescription(source: ApiCoreHarvestWithApiaryName): s
|
||||
* Aufforderung statt eines leeren Feldes — ein leerer Verkaufstext waere im
|
||||
* Shop nicht von einem vergessenen zu unterscheiden.
|
||||
*/
|
||||
export function buildProductDescription(source: ApiCoreProduct): string {
|
||||
export function buildProductDescription(source: ApiCoreProduct, vintageYear?: number): string {
|
||||
const own = source.description?.trim();
|
||||
const facts: string[] = [];
|
||||
if (vintageYear != null) facts.push(`Jahrgang ${vintageYear}`);
|
||||
if (source.sku?.trim()) facts.push(`Artikelnummer ${source.sku.trim()}`);
|
||||
if (source.salesUnit?.trim()) facts.push(`Verkaufseinheit ${source.salesUnit.trim()}`);
|
||||
|
||||
@@ -75,3 +76,58 @@ export function deriveWeightGrams(source: ApiCoreProduct): number {
|
||||
if (raw == null || !Number.isFinite(raw) || raw <= 0) return 0;
|
||||
return Math.round(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gewicht eines Glases aus dieser Abfuellung.
|
||||
*
|
||||
* Bevorzugt `jarSize` der Abfuellung gegenueber den Angaben am
|
||||
* ApiCore-Artikel: die Abfuellung sagt, was TATSAECHLICH in dieses Glas
|
||||
* gefuellt wurde, der Artikel nur, was ueblicherweise vorgesehen ist. Wird
|
||||
* ein Jahrgang einmal in 250-g- statt 500-g-Glaeser gefuellt, steht das an
|
||||
* der Abfuellung und nirgends sonst.
|
||||
*
|
||||
* Zur Einheit "Gramm" siehe den Kommentar an productSchema in
|
||||
* apicore-client.ts: ApiCore fuehrt jarSize mit Werten wie 500 und gibt sie
|
||||
* in der eigenen Oberflaeche als "g" aus.
|
||||
*/
|
||||
export function deriveJarWeightGrams(
|
||||
jarSize: number | null | undefined,
|
||||
source: ApiCoreProduct
|
||||
): number {
|
||||
if (jarSize != null && Number.isFinite(jarSize) && jarSize > 0) return Math.round(jarSize);
|
||||
return deriveWeightGrams(source);
|
||||
}
|
||||
|
||||
/** Aus "Waldhonig, cremig" wird "waldhonig-cremig". */
|
||||
export function slugifyProductName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, "ae")
|
||||
.replace(/ö/g, "oe")
|
||||
.replace(/ü/g, "ue")
|
||||
.replace(/ß/g, "ss")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Moegliche oeffentliche Adressen fuer ein synchronisiertes Produkt, in
|
||||
* absteigender Wuenschbarkeit — der Sync nimmt die erste noch freie.
|
||||
*
|
||||
* Der Jahrgang gehoert in die Adresse, weil genau er die Produkte
|
||||
* unterscheidet: "Waldhonig" heisst jedes Jahr gleich, ist aber jedes Jahr
|
||||
* ein anderes Erzeugnis mit eigenem Preis und eigenen Bewertungen. Ohne
|
||||
* Jahreszahl kollidierte der zweite Jahrgang mit dem ersten (Product.slug ist
|
||||
* eindeutig).
|
||||
*
|
||||
* Die Losnummer als zweite Stufe deckt den selteneren Fall ab, dass ein
|
||||
* Artikel im selben Jahr mehrfach abgefuellt wurde. Faellt auch die weg,
|
||||
* entscheidet der Aufrufer — hier wird bewusst nicht hochgezaehlt: eine
|
||||
* Adresse wie "waldhonig-2026-2" sagt niemandem etwas.
|
||||
*/
|
||||
export function productSlugCandidates(name: string, year: number, lotNumber: string): string[] {
|
||||
const base = slugifyProductName(name) || "produkt";
|
||||
const withYear = `${base}-${year}`;
|
||||
const lot = slugifyProductName(lotNumber);
|
||||
return lot ? [withYear, `${withYear}-${lot}`] : [withYear];
|
||||
}
|
||||
Reference in new issue
Block a user