docs: Design fuer kategoriebasierte Versandstaffeln und Hoechstbestellmenge
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m14s

Haelt die freigegebene Loesung fest: Versandstaffeln haengen kuenftig an der
Produktkategorie statt an einer einzigen globalen Mengenstaffel, weil ein
Honigglas deutlich mehr Versandkosten verursacht als eine Kerze. Bei
gemischten Warenkoerben gilt die teuerste Kategorie, nicht die Summe.

Ausserdem wird die Hoechstbestellmenge je Position im Adminportal pflegbar
(globaler Standardwert plus optionale Uebersteuerung je Produkt) statt als
widerspruechliche Konstanten im Stepper und im Checkout-Schema zu stehen.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-22 23:16:48 +02:00
1 parent c35d61bfd6
commit 2659db64bd
1 file changed
+184
@@ -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`.