Modul 3 — Produkte & Preise
1. Zweck & Scope
Dieses Modul verwaltet den vollständigen Produktkatalog von Dominicano Express (Boxen, Fässer, Zafacones, Dienstleistungen) sowie alle effektiv-datierten Preislisten (Versandpreise, Depot-Sätze, Zonen-/Zielort-Zuschläge). Es ist der einzige Ort, aus dem Offerten (Modul 4) und Logistik/ERP (Modul 5) Preise beziehen. Ein Preis-Resolver liefert für jede Kombination (Produkt × Zone × Datum) den gültigen Preis deterministisch. Saisonale und monatliche Preisanpassungen werden ohne Datenverlust abgebildet — alle historischen Preise bleiben lesbar.
2. Domänenmodell
Entitäten und Beziehungen
box_products (Produktkatalog, unveränderliche physische Eigenschaften)
│
├──< price_lists (Preisliste, effective-dated: gueltig_ab / gueltig_bis)
│ │
│ └──< price_list_items (Preis pro Produkt × Zone in dieser Preisliste)
│
└──< product_depot_rates (Depot-Satz pro Produkt × Preisliste)
zones (Zielort-Gruppen: Zone + konkreter Zielort in DR)
└──< price_list_items (FK zone_id)
-- Downstream-Konsumenten (aus anderen Modulen, nur FK-Referenz, kein DDL hier):
quote_lines → price_list_items.id (eingefrorener Preis bei Offertenerstellung)
deposit_orders → product_depot_rates.id (eingefrorener Depot-Satz)
-- Hinweis (F-01): KEINE eigene order_items/order_lines-Tabelle. Aufträge übernehmen
-- keine Positionen; die Positionen bleiben in quote_lines der konvertierten Offerte,
-- die Boxen werden daraus materialisiert (Modul 5). Preis-Snapshot lebt in quote_lines.
ER-Übersicht (kompakt)
box_products1 — Nprice_list_items(überproduct_id)price_lists1 — Nprice_list_items(überprice_list_id)price_lists1 — Nproduct_depot_rates(überprice_list_id)zones1 — Nprice_list_items(überzone_id)price_list_itemsN — 1zones
Freeze-Prinzip: Offerten und Aufträge speichern price_list_item_id + unit_price_chf (Snapshot). Preisänderungen erzeugen eine neue price_lists-Zeile mit neuem gueltig_ab; alte Zeilen bleiben unberührt.
3. Supabase-Schema
3.1 Enum: product_category
CREATE TYPE product_category AS ENUM (
'box', -- Kartonbox (Mediana, Jumbo, Maxi, Mega)
'barrel', -- Fass (Barril 120L / 150L / 220L)
'bin', -- Zafacón (150L / 240L)
'service' -- Dienstleistung (Abholung, Sondertransport, etc.)
);
3.2 Tabelle: box_products
Katalog der physischen Produkte. Niemals löschen (historische Referenzen). Stattdessen active = false.
CREATE TABLE box_products (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code text NOT NULL UNIQUE, -- stabiler interner Code, NIE ändern
-- z.B. 'BOX_MEDIANA', 'BOX_JUMBO', 'BOX_MAXI', 'BOX_MEGA',
-- 'BARRIL_120', 'BARRIL_150', 'BARRIL_220',
-- 'ZAFACON_150', 'ZAFACON_240', 'SRV_ABHOLUNG'
category product_category NOT NULL,
-- Dimensionen (NULL bei service)
length_cm numeric(6,1),
width_cm numeric(6,1),
height_cm numeric(6,1),
volume_liters numeric(7,1), -- Nutzvolumen (relevant für Fässer/Bins)
max_weight_kg numeric(6,1), -- maximales Bruttogewicht
-- i18n-Labels (mehrsprachig, stabile Codes extern)
label_de text NOT NULL, -- z.B. 'Caja Mediana'
label_es text NOT NULL,
label_en text NOT NULL,
description_de text,
description_es text,
description_en text,
-- Depot/Pfand
default_deposit_chf numeric(12,2) NOT NULL DEFAULT 0, -- Depot-Vorschlag (K-07; Logistik nutzt es)
is_returnable boolean NOT NULL DEFAULT false, -- F-22: EINZIGE Quelle für „returnable" (Fass/Bin=true, Karton=false); boxes.is_returnable wird hieraus bei Materialisierung initialisiert
-- Steuerung
active boolean NOT NULL DEFAULT true,
sort_order smallint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_box_products_category ON box_products(category);
CREATE INDEX idx_box_products_active ON box_products(active);
Initiale Stammdaten (Seed):
| code | category | label_de | label_es | label_en | Volume/Dim |
|---|---|---|---|---|---|
BOX_MEDIANA | box | Caja Mediana | Caja Mediana | Medium Box | — |
BOX_JUMBO | box | Caja Jumbo | Caja Jumbo | Jumbo Box | — |
BOX_MAXI | box | Caja Maxi | Caja Maxi | Maxi Box | — |
BOX_MEGA | box | Caja Mega | Caja Mega | Mega Box | — |
BARRIL_120 | barrel | Fass 120 L | Barril 120 L | Barrel 120 L | 120 L |
BARRIL_150 | barrel | Fass 150 L | Barril 150 L | Barrel 150 L | 150 L |
BARRIL_220 | barrel | Fass 220 L | Barril 220 L | Barrel 220 L | 220 L |
ZAFACON_150 | bin | Zafacón 150 L | Zafacón 150 L | Bin 150 L | 150 L |
ZAFACON_240 | bin | Zafacón 240 L | Zafacón 240 L | Bin 240 L | 240 L |
SRV_ABHOLUNG | service | Abholung CH | Recogida CH | Collection CH | — |
🔲 Zu bestätigen: genaue Dimensionen (cm, kg) für jede Box/Fass-Variante; vollständige Service-Arten.
RLS box_products:
-- Lesen: alle authentifizierten Nutzer (und öffentliche API für Offerten-Widget)
CREATE POLICY "bp_read_all" ON box_products FOR SELECT USING (true);
-- Schreiben: nur Marcel (ADMIN) und Mariela (BUCHHALTUNG)
CREATE POLICY "bp_write_admin" ON box_products FOR ALL
USING ((has_role('ADMIN') or has_role('BUCHHALTUNG')));
3.3 Tabelle: zones
Zielort-Gruppen für die Preisdifferenzierung. Entscheid 2026-06-28 (Teil 3): ganz DR = EIN Preis — keine geografische Zonen-Staffelung. Es gibt eine Default-DR-Zone (DR), der Preis ist zonenunabhängig. Die Zonen-Mechanik bleibt schema-seitig erhalten (Tabelle + FK + Resolver-Fallback), wird aktuell aber nicht zur Preisdifferenzierung genutzt; eine spätere Aufteilung in mehrere Zonen ist ohne Schema-Migration möglich.
CREATE TABLE zones (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code text NOT NULL UNIQUE, -- aktuell genutzt: 'DR' (eine Default-DR-Zone); 'CH_DEPOT' (Depot CH)
-- (schema-ready für künftige Zonen, z.B. 'SDQ','STI','PROV')
label_de text NOT NULL,
label_es text NOT NULL,
label_en text NOT NULL,
country_code char(2) NOT NULL DEFAULT 'DO', -- ISO 3166-1
active boolean NOT NULL DEFAULT true,
sort_order smallint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
✅ Entschieden (2026-06-28, Teil 3) — eine DR-Zone: Statt einer geografischen Zonen-Taxonomie gilt ein DR-weiter Preis. Seed: genau eine aktive DR-Zone (code='DR', country_code='DO'); der Versandpreis je Produkt wird einmal gegen diese Zone (oder zonenunabhängig, zone_id IS NULL) gepflegt. Die mehrstufige Zonen-Taxonomie (Santo Domingo, Santiago, Provinzen, Sonderzonen) ist damit nicht Teil des Starts, bleibt aber schema-seitig jederzeit aktivierbar.
RLS zones: Lesen: alle; Schreiben: admin/manager.
3.4 Tabelle: price_lists
Effective-datierte Preisliste (Kopf). Eine Preisliste ist eine Zeitscheibe gültiger Preise.
CREATE TABLE price_lists (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL, -- z.B. 'Standardtarif Juli 2026', 'Saisontarif Winter 2026'
description text,
valid_from date NOT NULL, -- inklusiv; Preis-Resolver: >= valid_from
valid_until date, -- NULL = unbegrenzt; inklusiv: <= valid_until
is_default boolean NOT NULL DEFAULT false, -- genau eine DEFAULT-Preisliste pro Zeitpunkt
created_by uuid REFERENCES auth.users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pl_valid_range CHECK (valid_until IS NULL OR valid_until >= valid_from)
);
-- Nur eine DEFAULT-Preisliste pro Datum (partieller Unique-Index)
CREATE UNIQUE INDEX idx_price_lists_one_default
ON price_lists(is_default)
WHERE is_default = true;
-- Schnellzugriff für Resolver
CREATE INDEX idx_price_lists_valid_from ON price_lists(valid_from);
CREATE INDEX idx_price_lists_valid_until ON price_lists(valid_until);
RLS price_lists: Lesen: alle auth. Nutzer; Schreiben: admin/manager.
3.5 Tabelle: price_list_items
Einzelpreis: Produkt × Zone in einer Preisliste. Dieser Datensatz wird als Snapshot in Offerten und Aufträgen eingefroren.
CREATE TABLE price_list_items (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
price_list_id uuid NOT NULL REFERENCES price_lists(id) ON DELETE RESTRICT,
product_id uuid NOT NULL REFERENCES box_products(id) ON DELETE RESTRICT,
zone_id uuid REFERENCES zones(id) ON DELETE RESTRICT, -- NULL = zonenunabhängig
unit_price_chf numeric(10,2) NOT NULL CHECK (unit_price_chf >= 0),
currency char(3) NOT NULL DEFAULT 'CHF',
-- Optionale Metadaten
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Pro Preisliste: ein Preis je Produkt+Zone-Kombination
CONSTRAINT pli_unique_product_zone
UNIQUE (price_list_id, product_id, zone_id)
);
CREATE INDEX idx_pli_price_list ON price_list_items(price_list_id);
CREATE INDEX idx_pli_product ON price_list_items(product_id);
CREATE INDEX idx_pli_zone ON price_list_items(zone_id);
RLS price_list_items: Lesen: alle auth. Nutzer; Schreiben: admin/manager.
3.6 Tabelle: product_depot_rates
Depot-Satz (Pfand) pro Produkt und Preisliste. Wird bei Depot-Erfassung eingefroren.
CREATE TABLE product_depot_rates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
price_list_id uuid NOT NULL REFERENCES price_lists(id) ON DELETE RESTRICT,
product_id uuid NOT NULL REFERENCES box_products(id) ON DELETE RESTRICT,
depot_chf numeric(10,2) NOT NULL CHECK (depot_chf >= 0),
-- Beispiele aus dem DEPOT-Sheet: Depot-Satz pro Box-Einheit in CHF
currency char(3) NOT NULL DEFAULT 'CHF',
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pdr_unique_product_per_list
UNIQUE (price_list_id, product_id)
);
CREATE INDEX idx_pdr_price_list ON product_depot_rates(price_list_id);
CREATE INDEX idx_pdr_product ON product_depot_rates(product_id);
RLS product_depot_rates: Lesen: alle auth. Nutzer; Schreiben: admin/manager.
3.7 Hilfs-View: v_current_prices
View für den häufigsten Anwendungsfall — aktuelle Preise nach heutigem Datum.
CREATE OR REPLACE VIEW v_current_prices AS
SELECT
pli.id AS price_list_item_id,
pl.id AS price_list_id,
pl.name AS price_list_name,
bp.code AS product_code,
bp.label_de,
bp.label_es,
bp.label_en,
bp.category,
z.code AS zone_code,
z.label_de AS zone_label_de,
pli.unit_price_chf,
pdr.depot_chf
FROM price_list_items pli
JOIN price_lists pl ON pl.id = pli.price_list_id
JOIN box_products bp ON bp.id = pli.product_id
LEFT JOIN zones z ON z.id = pli.zone_id
LEFT JOIN product_depot_rates pdr
ON pdr.price_list_id = pli.price_list_id
AND pdr.product_id = pli.product_id
WHERE
pl.valid_from <= current_date
AND (pl.valid_until IS NULL OR pl.valid_until >= current_date)
AND bp.active = true;
4. Kern-Workflows
4.1 Preis-Resolver — Preis zu Datum X für Produkt Y in Zone Z
Der Resolver ist eine serverseitige TypeScript-Funktion (Supabase Edge Function oder API-Route), kein Client-Code.
// Typen (Illustration)
interface ResolvePrice {
productId: string;
zoneId: string | null;
date: string; // ISO 8601
priceListId?: string; // Override: spezifische Liste erzwingen
}
interface ResolvedPrice {
priceListItemId: string;
priceListId: string;
priceListName: string;
unitPriceChf: number;
depotChf: number;
resolvedAt: string;
}
Resolver-Logik (Reihenfolge):
- Falls
priceListIdübergeben: direkt diese Liste verwenden → Items fürproductId+zoneIdabrufen. Nicht gefunden → Fehler (expliziter Override muss vollständig sein). - Alle Preislisten laden, deren
valid_from <= dateUND (valid_until IS NULLODERvalid_until >= date). - Mehrere Treffer: die Liste mit dem jüngsten
valid_fromgewinnt (aktuellster Tarif). - Innerhalb der Preisliste: Item mit
product_id = productIdUNDzone_id = zoneIdsuchen. Kein Treffer mit Zone → Fallback aufzone_id IS NULL(zonenunabhängiger Grundpreis). Immer noch kein Treffer → FehlerPRICE_NOT_FOUND. - Depot-Satz aus
product_depot_ratesfür dieselbeprice_list_id+product_id. - Ergebnis als
ResolvedPricezurückgeben inkl.priceListItemIdfür den Freeze-Snapshot.
Edge-Cases:
PRICE_NOT_FOUND: Offerte blockiert, Fehlermeldung mit Hinweis auf fehlende Konfiguration.- Überlappende Preislisten (gleicher Zeitraum, beide nicht
is_default): jüngeresvalid_fromgewinnt; bei gleichemvalid_from→ FehlerAMBIGUOUS_PRICE→ Admin muss bereinigen. - Preisliste ohne Depot-Satz für Produkt: Depot-Satz =
null(kein Depot für dieses Produkt).
4.2 Neue Preisliste erstellen (Preisrunde)
- Admin/Manager öffnet "Neue Preisliste" → gibt Name,
valid_from, optionalvalid_untilein. - System prüft auf Überlappungen mit bestehenden Preislisten (gleiches Produkt + Zone bereits abgedeckt). Warnung, kein Blocker.
- Preisliste als
draftanlegen (noch nicht aktiv). - Items befüllen: Formular oder Bulk-Import via CSV (Spalten:
product_code,zone_code,unit_price_chf,depot_chf). - Validierung: kein negativer Preis; jedes aktive Produkt sollte mindestens einen Preis haben → Warnung für fehlende Produkte.
- Aktivieren:
is_defaultsetzen (optional), vorherige Standardliste abschliessen (valid_until = valid_from - 1 Tag), Status → aktiv. - Audit-Log-Eintrag:
entity_type = 'price_list',action = 'activate',changed_by,changed_at.
4.3 Einzelpreis im Nachhinein korrigieren
- Korrektur an einem Item einer bereits aktiven Preisliste ist verboten (Revisionssicherheit; bestehende Offerten referenzieren diese Items).
- Stattdessen: neue Preisliste mit
valid_from = heuteerstellen, korrigierte Items eintragen. - Bestehende Offerten behalten den eingefrorenen
price_list_item_id-Snapshot. - Neue Offerten ab heute verwenden automatisch die neue Liste (Resolver).
4.4 Preis für Offerte / Auftrag einfrieren
- Beim Erstellen einer Offerte: Resolver aufrufen mit Produkt + Zone + Offertendatum.
price_list_item_id+unit_price_chf+depot_chfinquote_itemsspeichern.- Manueller Preis-Override auf Quote-Ebene möglich (
override_price_chf), separat begründet. - Bei Offerte → Auftrag: Preise aus
quote_items1:1 übernehmen (kein erneuter Resolver-Aufruf). - Depot-Satz aus
product_depot_ratesseparat indeposit_orderseinfrieren.
4.5 Depot-Satz anzeigen / aktualisieren
- Depot-Sätze sind Teil der Preisliste (via
product_depot_rates). - Anzeige: im Produktdetail-Screen als "Aktueller Depot-Satz" (aus
v_current_prices). - Historische Depot-Sätze bleiben über ältere Preislisten abrufbar.
5. UI-Screens (Mobile/Tablet-First)
5.1 Produktkatalog-Übersicht
Zweck: Alle Produkte auf einen Blick; schneller Zugriff auf Preis- und Depot-Info.
- Handy (375 px): Karten-Layout (eine Karte pro Produkt). Karte zeigt: Icon (Box/Fass/Bin),
label_de, Kategorie-Badge, aktueller Preis ab CHF X. Tap → Produktdetail. Filter-Chips oben (Alle | Box | Fass | Bin | Service). Kein horizontaler Scroll. - Tablet (768 px): 2-Spalten-Grid mit denselben Karten; Filter-Leiste links als vertikale Sidebar-Sektion.
- Desktop: 3-Spalten-Grid; Tabellen-Ansicht umschaltbar (TanStack Table); Inline-Bearbeitung von
label_de/es/en,active,sort_order. - Empty State: "Noch keine Produkte angelegt — Katalog importieren oder manuell hinzufügen." mit CTA-Button.
- Aktion Hinzufügen: FAB (Floating Action Button, ≥44 px) am Handy; "Neues Produkt"-Button oben rechts ab Tablet.
5.2 Produktdetail
Zweck: Alle Eigenschaften, aktueller Preis nach Zone, Depot-Satz, Preis-Historie.
- Handy: Bottom-Sheet mit Tabs: "Info" | "Preise" | "Depot" | "Historie".
- Tab "Preise": Kacheln pro Zone (code + aktueller Preis). inputmode numeric für Schnellerfassung.
- Tab "Depot": Depot-Satz aktuell + letzter Änderungsmonat.
- Tablet/Desktop: 2-Paneel-Layout links Detail, rechts Preistabelle.
5.3 Preislisten-Verwaltung
Zweck: Überblick aller Preislisten, neue erstellen, bestehende aktivieren/deaktivieren.
- Handy: Liste der Preislisten, sortiert nach
valid_fromabsteigend. Jede Zeile zeigt Name, Gültigkeitszeitraum, Badge (Aktiv / Draft / Abgelaufen). Swipe-links: "Als Standard setzen". Tap → Preislisten-Detail. - Tablet: TanStack Table mit Zeilen für Preislisten; ausklappbare Detailzeile zeigt Items-Vorschau.
- Desktop: Master-Detail: Preisliste links, Items rechts bearbeitbar.
- Neu-Formular: Name,
valid_from(Date-Picker),valid_until(optional), Notiz. Anschliessend Weiterleitung zu Items-Befüllung.
5.4 Preislisten-Items befüllen / importieren
Zweck: Preise für alle Produkt-Zonen-Kombinationen einer Preisliste pflegen.
- Handy: Accordion pro Produkt; je Produkt alle Zonen als Inline-Inputs (inputmode=numeric, ≥44 px). Speichern per Zeile via Checkmark (Action-State-Form + Toast).
- Tablet: Raster: Produkte als Zeilen, Zonen als Spalten; Zellinhalt = CHF-Betrag (editierbar).
- Desktop: dieselbe Raster-Ansicht mit Bulk-Paste aus Zwischenablage und CSV-Import (Drag & Drop).
- CSV-Import: Spalten
product_code,zone_code,unit_price_chf,depot_chf. Vorschau-Tabelle vor dem Speichern. Validierungsfehler zeilengenau anzeigen. - Fehlende Preise: fehlende Produkt-Zonen-Kombinationen rot markiert mit "Kein Preis — Resolver schlägt fehl".
5.5 Preis-Resolver Testscreen (Admin)
Zweck: Kontrollieren, welcher Preis für Datum X / Produkt Y / Zone Z gilt (Debugging).
- Einfaches Formular: Datum, Produkt-Dropdown, Zonen-Dropdown → "Preis berechnen".
- Ergebnis: Preisliste (Name + Gültigkeitszeitraum),
unit_price_chf,depot_chf,price_list_item_id. - Nur auf Desktop/Tablet zugänglich (Admin-Bereich); Handy: kein eigener Screen, aber im Produktdetail "Preis zu Datum prüfen"-Link.
6. Integrationen & Verbindungen zu anderen Modulen
6.1 Modul 4 — Offerten
- Resolver liefert
price_list_item_id+unit_price_chf+depot_chfbeim Anlegen einer Offerte. quote_items.price_list_item_id(FK) +quote_items.unit_price_chf(Snapshot) werden geschrieben.- Preis-Override auf Offerte:
quote_items.override_price_chf; Begründung erforderlich (Audit-Log). - Preisliste bei Offertendatum → Resolver mit
date = quote.created_at::date.
6.2 Modul 5 — Logistik/ERP
- Keine
order_items/order_lines-Tabelle (F-01): Aufträge übernehmen keine eingefrorenen Positionen. Der Preis-Snapshot lebt inquote_lines.price_list_item_id+quote_lines.unit_price_chfder konvertierten Offerte; Modul 5 materialisiert die Boxen direkt ausquote_lines(je Position Nboxes). deposit_orders.depot_rate_id(FK aufproduct_depot_rates.id; eingefroren bei Depot-Anlage).- Box-UUID-Erstellung (Modul 5) referenziert
box_products.idviaboxes.box_product_id.
6.3 Modul 6 — Finanzen
- Kein direkter FK vom
movements-Ledger zu Preislisten; Preisinfos kommen viaquote_lines/deposit_orders(Modul 5 / Modul 6-Brücke). - Depot-Satz treibt
deposit_orders.depot_chf_per_unit; Auto-Posting liest diesen Wert.
6.4 Modul 1 — CRM
- Kein direkter FK; aber
contacts.preferred_zone(🔲 zu bestätigen) könnte Zonen-Vorauswahl im Offerten-Flow steuern.
6.5 Modul 7 — Affiliates
- Rabatte/Affiliate-Codes (Modul 7) werden auf den vom Resolver gelieferten Basispreis angewendet (Reihenfolge: Resolver → Basis → Rabatt → Finalbetrag).
6.6 Modul 8 — Plattform / i18n
- Alle Labels (
label_de/es/en,zone.label_de/es/en) werden via i18n-Key-Lookup des UI-Layers ausgegeben. - Stabile Codes (
box_products.code,zones.code) werden niemals an den Endnutzer exponiert; nur Labels. - Audit-Log-Eintrag bei jeder Preislisten-Änderung via Supabase Trigger oder API-Middleware.
7. Validierungen & Edge-Cases
| Situation | Verhalten |
|---|---|
unit_price_chf < 0 | DB-Constraint schlägt fehl; UI-Validierung vor Submit |
valid_from > valid_until | DB-Constraint pl_valid_range; UI-Datepicker blockiert |
| Preisliste ohne Item für ein aktives Produkt | Warnung beim Aktivieren, kein Blocker (Produkt evtl. nicht relevant) |
| Resolver: kein Treffer für Produkt+Zone | Fehler PRICE_NOT_FOUND; Offerte nicht erstellbar bis behoben |
| Resolver: Ambiguität (zwei Listen gleicher Priorität) | Fehler AMBIGUOUS_PRICE; Admin muss bereinigen |
| Item einer aktiven Preisliste ändern | UI verbietet Update; nur Neu-Preisliste möglich |
| Produkt deaktivieren mit laufenden Offerten | Produkt bleibt in bestehenden Offerten/Aufträgen referenzierbar; nur active = false blockiert Neuanlage |
Preisliste mit valid_until in der Vergangenheit | Status "Abgelaufen"; kein Resolver-Treffer mehr; historische Offerten unverändert |
| Zone gelöscht (in Zukunft) | ON DELETE RESTRICT verhindert Löschung wenn price_list_items referenzieren |
CSV-Import: unbekannter product_code | Zeilenfehler "Produkt nicht gefunden"; Rest importierbar |
| Depot-Satz = 0 | Erlaubt (manche Service-Typen haben kein Depot) |
Mehrere Zonen-Preise, keine Fallback-Zeile (zone_id IS NULL) | Resolver meldet PRICE_NOT_FOUND für Produkte ohne Zonen-Treffer — Fallback explizit anlegen |
8. Compliance- und Sicherheits-Hinweise
8.1 Revisionssicherheit (OR 957 / GeBüV)
- Preislisten sind unveränderliche Zeitscheiben: nach Aktivierung kein Update der Items erlaubt (Trigger oder RLS
FOR UPDATE USING (false)aufprice_list_itemsnach Aktivierung). - Jede Preislisten-Aktivierung, Deaktivierung und Item-Anlage →
audit_log-Eintrag (entity_type = 'price_list'/'price_list_item';action,changed_by,old_value,new_valueals JSONB). - Offerten und Aufträge speichern
unit_price_chfals Snapshot — unabhängig von späteren Preisänderungen. product_depot_ratesanalog unveränderlich nach Aktivierung.
8.2 Datenschutz (revDSG)
- Preislistendaten sind keine Personendaten; revDSG nicht direkt betroffen.
- Indirekt: Exportfunktion (Preislisten-PDF) darf keine personenbezogenen Daten enthalten.
8.3 Zugriffskontrolle (RLS)
- Lesen: alle authentifizierten Nutzer (Rollen: admin, manager, operator, driver, affiliate, read_only) dürfen Preisdaten lesen — notwendig für Offertenerstellung und Tracking.
- Schreiben (Anlegen/Aktivieren): nur
admin(Marcel) undmanager(Mariela). - Löschen: für
price_list_itemsundproduct_depot_ratesmit Referenzen:ON DELETE RESTRICT; physisches Löschen nur durchadminund nur wenn keine Downstream-Referenzen. - Öffentliche API: kein anonymer Zugriff auf Preise (kein
anon-Key-Zugriff); alle Abfragen erfordern Session-Token.
8.4 MWST
- Versandleistung ist MwSt-BEFREIT (Entscheid 2026-06-28, Teil 3). Die Fracht-/Versandleistung (grenzüberschreitende Beförderung CH → DR, Ausfuhr) fällt unter die echte Steuerbefreiung nach Art. 23 MWSTG → 0 % und trägt den
vat_rates-CodeEXEMPT.price_list_items.unit_price_chfist damit der Verkaufspreis der (befreiten) Versandleistung — es wird kein MwSt aufgeschlagen und nichts herausgerechnet. Der frühere Ansatz „Bruttopreis inkl. MWST, wird herausgerechnet" gilt für die Versandleistung nicht mehr. - System bleibt MwSt-fähig. Das MWST-System ist weiterhin aktiv (
vat_enabled=true) — der Normalsatz (8.1 %,STD) gilt nur für allfällige steuerbare Inland-Zusatzleistungen (z. B. rein innerschweizerische Leistungen ohne Ausfuhrbezug). Für die Fracht-/Versandleistung ist der Defaultvat_code='EXEMPT'(0 %). Der je Leistung anzuwendende Satz ist ein Konfigurationswert je Produkt/Leistung (DefaultEXEMPTfür Fracht), nicht ein globaler Aufschlag auf die Preisliste. - Rundung (Entscheid 2026-06-28): Preise/Beträge immer auf ganze CHF aufrunden (
ceil) — inkl. Volumen-Tarif fürOTRO/Übergrösse; keine Rappen. (BeiEXEMPTentfällt jede MwSt-Herausrechnung; derceil-Verkaufspreis ist zugleich der gebuchte Betrag.)
9. Offene Punkte
✅ Zonen-Taxonomie DR — ENTSCHIEDEN (2026-06-28, Teil 3): ganz DR = EIN Preis, keine geografische Zonen-Staffelung. Eine aktive Default-DR-Zone (code='DR'); Preis zonenunabhängig. Die Zonen-Mechanik bleibt schema-seitig erhalten (Tabelle/FK/Resolver-Fallback), wird aber nicht genutzt — eine spätere Aufteilung in mehrere Zonen ist ohne Schema-Migration möglich (§3.3).
🔲 Boxen-Dimensionen: Genaue Abmessungen (Länge × Breite × Höhe in cm) und Maximalgewicht (kg) für Caja Mediana / Jumbo / Maxi / Mega; analoge Daten für Barril 120/150/220 L und Zafacón 150/240 L.
🔲 Depot-Sätze je Produkttyp (CHF): Aktuell gültige Depot-Sätze pro Box-/Fass-Variante für den ersten Seed. Notwendig für die initiale product_depot_rates-Befüllung.
🔲 Lifecycle Rückgabe/Verfall der Box: Gilt der Depot-Satz als rückzahlbares Pfand (Rückgabe OK-Box) oder als Einmalgebühr? Frist für Rückgabe? Dies beeinflusst das Depot-Lifecycle-Modell in Modul 5.
✅ Fallback-Preis ohne Zonendifferenzierung — entschärft durch Ein-Zonen-Entscheid (2026-06-28, Teil 3): Da ganz DR ein Preis ist, genügt ein Eintrag je Produkt (gegen die DR-Zone oder zonenunabhängig zone_id IS NULL). Das Fehlerrisiko fehlender Zonenkonfigurationen entfällt praktisch; der zone_id IS NULL-Fallback im Resolver (§4.1) bleibt als Sicherheitsnetz erhalten.
🔲 Service-Produkte im Preiskatalog: Welche Services (Abholung, Sondertransport, Versicherung, etc.) sollen als box_products der Kategorie service geführt werden? Werden sie in Offerten als Zeilen addiert oder als Pauschalgebühren behandelt?
🔲 Mehrwährungs-Unterstützung: Sind Preise in DOP (Dominikanischer Peso) für Empfänger-DR jemals relevant, oder ist CHF die ausschliessliche Währung aller Preislisten?