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_products 1 — N price_list_items (über product_id)
  • price_lists 1 — N price_list_items (über price_list_id)
  • price_lists 1 — N product_depot_rates (über price_list_id)
  • zones 1 — N price_list_items (über zone_id)
  • price_list_items N — 1 zones

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):

codecategorylabel_delabel_eslabel_enVolume/Dim
BOX_MEDIANAboxCaja MedianaCaja MedianaMedium Box
BOX_JUMBOboxCaja JumboCaja JumboJumbo Box
BOX_MAXIboxCaja MaxiCaja MaxiMaxi Box
BOX_MEGAboxCaja MegaCaja MegaMega Box
BARRIL_120barrelFass 120 LBarril 120 LBarrel 120 L120 L
BARRIL_150barrelFass 150 LBarril 150 LBarrel 150 L150 L
BARRIL_220barrelFass 220 LBarril 220 LBarrel 220 L220 L
ZAFACON_150binZafacón 150 LZafacón 150 LBin 150 L150 L
ZAFACON_240binZafacón 240 LZafacón 240 LBin 240 L240 L
SRV_ABHOLUNGserviceAbholung CHRecogida CHCollection 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):

  1. Falls priceListId übergeben: direkt diese Liste verwenden → Items für productId + zoneId abrufen. Nicht gefunden → Fehler (expliziter Override muss vollständig sein).
  2. Alle Preislisten laden, deren valid_from <= date UND (valid_until IS NULL ODER valid_until >= date).
  3. Mehrere Treffer: die Liste mit dem jüngsten valid_from gewinnt (aktuellster Tarif).
  4. Innerhalb der Preisliste: Item mit product_id = productId UND zone_id = zoneId suchen. Kein Treffer mit Zone → Fallback auf zone_id IS NULL (zonenunabhängiger Grundpreis). Immer noch kein Treffer → Fehler PRICE_NOT_FOUND.
  5. Depot-Satz aus product_depot_rates für dieselbe price_list_id + product_id.
  6. Ergebnis als ResolvedPrice zurückgeben inkl. priceListItemId fü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üngeres valid_from gewinnt; bei gleichem valid_from → Fehler AMBIGUOUS_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)

  1. Admin/Manager öffnet "Neue Preisliste" → gibt Name, valid_from, optional valid_until ein.
  2. System prüft auf Überlappungen mit bestehenden Preislisten (gleiches Produkt + Zone bereits abgedeckt). Warnung, kein Blocker.
  3. Preisliste als draft anlegen (noch nicht aktiv).
  4. Items befüllen: Formular oder Bulk-Import via CSV (Spalten: product_code, zone_code, unit_price_chf, depot_chf).
  5. Validierung: kein negativer Preis; jedes aktive Produkt sollte mindestens einen Preis haben → Warnung für fehlende Produkte.
  6. Aktivieren: is_default setzen (optional), vorherige Standardliste abschliessen (valid_until = valid_from - 1 Tag), Status → aktiv.
  7. Audit-Log-Eintrag: entity_type = 'price_list', action = 'activate', changed_by, changed_at.

4.3 Einzelpreis im Nachhinein korrigieren

  1. Korrektur an einem Item einer bereits aktiven Preisliste ist verboten (Revisionssicherheit; bestehende Offerten referenzieren diese Items).
  2. Stattdessen: neue Preisliste mit valid_from = heute erstellen, korrigierte Items eintragen.
  3. Bestehende Offerten behalten den eingefrorenen price_list_item_id-Snapshot.
  4. Neue Offerten ab heute verwenden automatisch die neue Liste (Resolver).

4.4 Preis für Offerte / Auftrag einfrieren

  1. Beim Erstellen einer Offerte: Resolver aufrufen mit Produkt + Zone + Offertendatum.
  2. price_list_item_id + unit_price_chf + depot_chf in quote_items speichern.
  3. Manueller Preis-Override auf Quote-Ebene möglich (override_price_chf), separat begründet.
  4. Bei Offerte → Auftrag: Preise aus quote_items 1:1 übernehmen (kein erneuter Resolver-Aufruf).
  5. Depot-Satz aus product_depot_rates separat in deposit_orders einfrieren.

4.5 Depot-Satz anzeigen / aktualisieren

  1. Depot-Sätze sind Teil der Preisliste (via product_depot_rates).
  2. Anzeige: im Produktdetail-Screen als "Aktueller Depot-Satz" (aus v_current_prices).
  3. 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_from absteigend. 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_chf beim 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 in quote_lines.price_list_item_id + quote_lines.unit_price_chf der konvertierten Offerte; Modul 5 materialisiert die Boxen direkt aus quote_lines (je Position N boxes).
  • deposit_orders.depot_rate_id (FK auf product_depot_rates.id; eingefroren bei Depot-Anlage).
  • Box-UUID-Erstellung (Modul 5) referenziert box_products.id via boxes.box_product_id.

6.3 Modul 6 — Finanzen

  • Kein direkter FK vom movements-Ledger zu Preislisten; Preisinfos kommen via quote_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

SituationVerhalten
unit_price_chf < 0DB-Constraint schlägt fehl; UI-Validierung vor Submit
valid_from > valid_untilDB-Constraint pl_valid_range; UI-Datepicker blockiert
Preisliste ohne Item für ein aktives ProduktWarnung beim Aktivieren, kein Blocker (Produkt evtl. nicht relevant)
Resolver: kein Treffer für Produkt+ZoneFehler 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 ändernUI verbietet Update; nur Neu-Preisliste möglich
Produkt deaktivieren mit laufenden OffertenProdukt bleibt in bestehenden Offerten/Aufträgen referenzierbar; nur active = false blockiert Neuanlage
Preisliste mit valid_until in der VergangenheitStatus "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_codeZeilenfehler "Produkt nicht gefunden"; Rest importierbar
Depot-Satz = 0Erlaubt (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) auf price_list_items nach Aktivierung).
  • Jede Preislisten-Aktivierung, Deaktivierung und Item-Anlage → audit_log-Eintrag (entity_type = 'price_list' / 'price_list_item'; action, changed_by, old_value, new_value als JSONB).
  • Offerten und Aufträge speichern unit_price_chf als Snapshot — unabhängig von späteren Preisänderungen.
  • product_depot_rates analog 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) und manager (Mariela).
  • Löschen: für price_list_items und product_depot_rates mit Referenzen: ON DELETE RESTRICT; physisches Löschen nur durch admin und 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-Code EXEMPT. price_list_items.unit_price_chf ist 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 Default vat_code='EXEMPT' (0 %). Der je Leistung anzuwendende Satz ist ein Konfigurationswert je Produkt/Leistung (Default EXEMPT fü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ür OTRO/Übergrösse; keine Rappen. (Bei EXEMPT entfällt jede MwSt-Herausrechnung; der ceil-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?