diff --git a/docs/superpowers/specs/2026-09-22-versandstaffel-je-kategorie-design.md b/docs/superpowers/specs/2026-09-22-versandstaffel-je-kategorie-design.md new file mode 100644 index 0000000..6cb79f1 --- /dev/null +++ b/docs/superpowers/specs/2026-09-22-versandstaffel-je-kategorie-design.md @@ -0,0 +1,184 @@ +# Versandstaffel je Artikelkategorie und konfigurierbare Höchstbestellmenge + +Datum: 2026-09-22 +Status: freigegeben, Implementierung ausstehend + +## Problem + +Die Versandkosten richten sich heute nach einer einzigen, global gepflegten +Mengenstaffel: `resolveShippingCostCents` in `src/lib/shipping-calc.ts` +bekommt die Summe aller Stück über alle Positionen hinweg und sucht dazu die +passende Zeile aus `ShippingTier`. Das setzt voraus, dass jedes Stück +ungefähr gleich viel Versandkosten verursacht. Das trifft nicht zu: ein +500-Gramm-Honigglas wiegt ein Vielfaches einer Bienenwachskerze, und eine +Staffel, die für Honig kostendeckend ist, verteuert Kerzenbestellungen ohne +Grund. + +Zweitens ist die Höchstbestellmenge je Position nirgends pflegbar. Sie steckt +als Vorgabewert `max = 10` im `QuantityStepper` und als `.max(20)` im +Checkout-Schema — zwei Zahlen im Code, die sich widersprechen und die der +Betreiber nicht ändern kann, wenn eine Sorte knapp wird. + +## Ziele + +1. Versandstaffeln je Produktkategorie (HONIG, KERZEN, PROPOLIS) pflegbar + machen, mit einer Standardstaffel für Kategorien ohne eigene Zeilen. +2. Die Höchstbestellmenge je Position im Adminportal festlegen: ein globaler + Standardwert plus eine optionale Übersteuerung je Produkt. + +Nicht Ziel: gewichtsgenaue Versandberechnung, Versandklassen unabhängig von +der Verkaufskategorie, getrennte Pakete je Kategorie. + +## Entscheidungen + +### Kategorie statt Gewicht + +Die Staffeln hängen an der Kategorie, nicht am Gewicht. Die Kategorie liegt +bereits an jedem Produkt (`Product.category`), ist im Adminportal vertraut und +lässt sich Kunden in Stück statt in Gramm erklären. Eine gewichtsbasierte +Staffel wäre genauer, verlangt vom Betreiber aber das Denken in Gramm und vom +Shop eine weniger anschauliche Preisauskunft. + +### Gemischter Warenkorb: die teuerste Kategorie gewinnt + +Enthält eine Bestellung mehrere Kategorien, wird je Kategorie gestaffelt und +anschließend der höchste dieser Preise berechnet — nicht ihre Summe. Es geht +ein Paket hinaus, also wird ein Versandpreis berechnet. Das Addieren würde +gemischte Bestellungen bestrafen, obwohl sie den Betrieb kaum mehr kosten. +Der Preis ist dadurch nie niedriger als der teuerste Einzelbestandteil und +nie höher als die Summe. + +### Standardstaffel als Rückfallebene + +`ShippingTier.category` ist optional. Zeilen ohne Kategorie bilden die +Standardstaffel und gelten für jede Kategorie, für die keine eigenen Zeilen +gepflegt sind. So genügt es, für Honig abweichende Staffeln anzulegen, +während Kerzen und Propolis sich die Standardstaffel teilen. + +### Höchstmenge: global mit Produkt-Übersteuerung + +Ein globaler Standardwert deckt den Normalfall ab, ein optionales Feld am +Produkt die Ausnahme (knappe Sorte). Das entspricht dem Muster, das +`Product.vatRatePercent` gegenüber dem Satz der Kategorie bereits verwendet: +leer bedeutet "es gilt der allgemeine Wert", und eine spätere Änderung des +allgemeinen Werts wirkt ohne Nachpflege auf alle Produkte. + +## Datenmodell + +In `prisma/schema.prisma`: + +- `ShippingTier.category ProductCategory?` — null bedeutet Standardstaffel. + Dazu `@@index([category, minQuantity])`, weil künftig je Kategorie gesucht + wird. +- `Product.maxOrderQuantity Int?` — null bedeutet "globaler Standardwert". + +Der globale Standardwert wird als Registry-Schlüssel `shipping.maxOrderQuantity` +in `src/lib/content-registry.ts` geführt, Gruppe `shipping`. Dafür bekommt +`ContentFieldType` den neuen Typ `number`, der im Admin-Formular ein +Zahlenfeld rendert und beim Speichern als ganze Zahl größer null geprüft +wird. Eine eigene Einstellungstabelle wäre für einen einzelnen Integer zu +schwer; die Gruppe "Versand" existiert im Adminbereich bereits. + +Gelesen wird der Wert über einen Parser analog zu `parseShippingRegion`: ein +unlesbarer oder fehlender Wert fällt auf die im Code hinterlegte Vorgabe von +20 Stück zurück, statt den Checkout zu blockieren. Zwanzig ist der Wert, den +das Checkout-Schema heute schon kennt, die Umstellung ändert also zunächst +nichts am Verhalten. + +## Berechnung + +`src/lib/shipping-calc.ts` wechselt die Signatur von +`(tiers, totalQuantity)` auf `(tiers, items)`, wobei ein Item aus Kategorie +und Menge besteht. Der Ablauf: + +1. Mengen je Kategorie summieren. +2. Je Kategorie die Staffeln dieser Kategorie heranziehen; gibt es keine, die + Standardstaffeln (Kategorie null). +3. Innerhalb der gewählten Staffeln greift die bisherige Zuordnung + unverändert, einschließlich des Verhaltens oberhalb der höchsten + gepflegten Staffel. +4. Das Ergebnis ist das Maximum der Kategoriepreise. + +Sonderfälle: ein leerer Warenkorb kostet null; sind überhaupt keine Staffeln +gepflegt, gilt weiterhin `FALLBACK_SHIPPING_CENTS`. + +`getNextShippingTier` bezieht sich künftig auf diejenige Kategorie, die den +Preis bestimmt. Andernfalls verspräche der Hinweis "noch X Stück bis zur +nächsten Stufe" im Warenkorb eine Schwelle, die den gezeigten Preis gar nicht +beeinflusst. + +Der Warenkorb muss die Kategorie kennen: `CartItem` in `src/store/cart-store.ts` +bekommt das Feld `category`, gefüllt von `toCartItem` in +`src/components/product-card.tsx`. Eine Migration bestehender Warenkörbe aus +localStorage ist nicht nötig, weil der Shop noch nicht live ist und die +Datenbank vor dem Start neu aufgesetzt wird. + +Bekannte Einschränkung: wird die Kategorie eines Produkts im Adminbereich +geändert, während ein Warenkorb offen ist, zeigt die Vorschau kurzzeitig den +alten Versandpreis. Der Server rechnet beim Anlegen der Bestellung mit den +Kategorien aus der Datenbank, der Kunde wird also korrekt belastet, und der +Zustand heilt sich, sobald der Artikel erneut hinzugefügt wird. + +## Durchsetzung der Höchstmenge + +Die Grenze wird an drei Stellen durchgesetzt, weil die ersten beiden allein +umgehbar wären: + +1. Im `QuantityStepper` als obere Grenze, gebildet aus dem kleineren Wert von + Lagerbestand und wirksamer Höchstmenge. Das ist reine Bedienführung. +2. Im Checkout-Schema in `src/lib/validation.ts` als harte, vom Produkt + unabhängige Obergrenze von 999 Stück je Position — sie schützt gegen + absurde Mengen, bevor überhaupt Produkte geladen werden. Sie ersetzt das + heutige `.max(20)`, das dort zur festen Grenze nicht mehr taugt, sobald + der Betreiber den Standardwert selbst höher setzen kann. +3. In `src/app/api/checkout/route.ts` je Position gegen + `maxOrderQuantity ?? globaler Standard`. Das ist die verbindliche + Prüfung, denn erst dort sind die echten Produktdaten bekannt. Eine + Überschreitung führt zu einer Antwort mit Status 400 und einer + lokalisierten Meldung. + +Eine reine UI-Begrenzung genügt nicht: die Checkout-Anfrage lässt sich frei +stellen, und ohne serverseitige Prüfung wäre die Höchstmenge eine Anzeige +ohne Wirkung. + +## Adminportal + +- `src/app/admin/versand/page.tsx` gruppiert die Liste nach Kategorie und + zeigt die Standardstaffeln als eigenen Block. +- `src/app/admin/versand/neu/page.tsx` und + `src/app/admin/versand/[id]/page.tsx` bekommen ein Auswahlfeld + "Kategorie (leer = alle)". +- `src/app/admin/actions/shipping.ts` und `shippingTierSchema` in + `src/lib/validation.ts` nehmen das Feld auf; ein unbekannter Wert wird + abgelehnt statt stillschweigend als Standardstaffel gespeichert. +- Das Produktformular unter `src/app/admin/produkte/` bekommt das Feld + "Max. Bestellmenge (leer = Standard)". Es wird in + `adminProductNumbersSchema` wie `vatRatePercent` behandelt: der leere + String wird zu null und darf nicht als Menge null verstanden werden. +- Admin, Inhalte, Gruppe Versand: das neue Zahlenfeld + "Standard-Höchstbestellmenge". + +## Storefront + +`src/app/versand-zahlung/page.tsx` stellt die Staffeln je Kategorie dar und +nennt ausdrücklich, dass bei gemischten Bestellungen die teuerste Staffel +gilt. Ohne diesen Satz wirkt der berechnete Preis willkürlich, sobald ein +Warenkorb mehrere Kategorien enthält. + +Warenkorb, Warenkorb-Schublade und Checkout-Formular übergeben der +Berechnung künftig die Positionen statt der Gesamtmenge. + +## Tests + +- `src/lib/shipping-calc.test.ts`: Staffel je Kategorie, Rückfall auf die + Standardstaffel, gemischter Warenkorb ergibt das Maximum, leerer Warenkorb + ergibt null, keine Staffeln ergibt den Notfallpreis. +- Anzupassen wegen der geänderten Signatur: + `src/app/warenkorb/warenkorb-content.test.tsx` und + `src/app/checkout/checkout-form.test.tsx`. +- Neu: die Checkout-Route lehnt eine Menge oberhalb des globalen Standards ab + und ebenso oberhalb einer produktspezifischen Übersteuerung. +- `prisma/seed-demo.ts` legt eigene Honig-Staffeln und Standardstaffeln für + die übrigen Kategorien an. +- Neue Texte in `src/i18n/dictionaries/de.ts`, `src/i18n/dictionaries/en.ts` + und `src/i18n/dictionary-type.ts`.