docs: Design fuer kategoriebasierte Versandstaffeln und Hoechstbestellmenge
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m14s
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:
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`.
|
||||
Reference in new issue
Block a user