Modul 1 — CRM / Kundenstamm

1. Zweck & Scope

Das CRM-Modul ist die zentrale Parteien-Datenbank von Caja. Es verwaltet alle Akteure — Kunden, Leads, Lieferanten, Affiliates und Empfänger in der Dominikanischen Republik — in einer einzigen, normalisierten contacts-Tabelle, die alle anderen Module als Referenz nutzen. Ein realer Mensch kann gleichzeitig mehrere Rollen tragen (z. B. Kunde und Affiliate). Der Lead-Intake aus dem bestehenden Website-Kontaktformular (POST /api/send-contact) und aus WhatsApp wird direkt in diese Tabelle geleitet. Ein Deduplizierungs- und Konsolidierungs-Workflow (Golden-Record-Strategie, Fuzzy-Matching auf Name/Telefon/E-Mail) verhindert Dubletten und hält die Stammdaten sauber. Empfänger in der Dominikanischen Republik sind als eigene verknüpfte Entität (recipients) modelliert, da sie Zustelladresse, Telefon und Zolldaten tragen, aber häufig kein eigenes Caja-Login haben.


2. Domänenmodell

Entitäten und Beziehungen

contacts (1) ──── (n) contact_roles           -- Rollen-Bridge
contacts (1) ──── (n) recipients              -- Empfänger DR (1 Kunde kann mehrere Empfänger haben)
contacts (1) ──── (1) affiliates              -- wenn Rolle = affiliate
contacts (1) ──── (n) contact_merge_log       -- Merge-Historie (Golden Record)
contacts (1) ──── (n) contact_duplicates      -- offene Duplikat-Kandidaten
contacts (1) ──── (n) quotes                  -- Offerten
contacts (1) ──── (n) orders                  -- Aufträge
contacts (1) ──── (n) invoices                -- Rechnungen
contacts (1) ──── (n) movements               -- Finanzbewegungen
contacts (1) ──── (n) audit_log               -- Revisionseinträge (via source_id)

contacts — der "Golden Record". Jede natürliche oder juristische Partei.

contact_roles — Bridge-Tabelle: ein Kontakt kann gleichzeitig customer, lead, supplier, affiliate, recipient_dr sein. Rollen werden aktiviert/deaktiviert, nicht gelöscht.

recipients — Empfänger in der DR: eigene Adresse (Provinz, Municipio, Barrio, Strasse), Telefon(e), Ausweis-Nummer (Cédula), verknüpft mit dem Absender-Kontakt in CH (FK contact_id). Wird bei Auftrags-/Sendungserstellung referenziert.

contact_duplicates — KI/Fuzzy-generierte Duplikat-Paare mit Score; werden vom Benutzer aufgelöst (merge oder als Nicht-Duplikat markieren).

contact_merge_log — unveränderliches Journal jeder Merge-Operation (wer hat was wann zusammengeführt, welche IDs wurden zusammengeführt, welcher Golden Record entstand).

ER-Skizze (vereinfacht)

contacts
  id (PK, uuid)
  └── contact_roles (contact_id FK)
  └── recipients (contact_id FK)
  └── affiliates (contact_id FK, 1:1)
  └── contact_duplicates (contact_a_id FK, contact_b_id FK)
  └── contact_merge_log (source_id FK, target_id FK, merged_by FK)
  └── quotes.contact_id
  └── orders.contact_id
  └── movements.contact_id

3. Supabase-Schema

3.1 Enum: contact_role_type

CREATE TYPE contact_role_type AS ENUM (
  'customer',       -- DE: Kunde        | ES: Cliente       | EN: Customer
  'lead',           -- DE: Lead         | ES: Prospecto     | EN: Lead
  'supplier',       -- DE: Lieferant    | ES: Proveedor     | EN: Supplier
  'affiliate',      -- DE: Affiliate    | ES: Afiliado      | EN: Affiliate
  'recipient_dr'    -- DE: Empfänger DR | ES: Destinatario  | EN: Recipient DR
);

3.2 Enum: lead_source_type

F-15 / K-20 (kanonisch Dok 30 §2.1): lead_source ist eine text-Spalte mit CHECK auf genau die fünf Werte unten — kein Postgres-enum (erweiterbare Menge). Die Werte-Validierung bleibt damit erhalten (kein freier Text); das CREATE TYPE hier ist als Werte-Referenz zu lesen, in der Migration steht lead_source text check (lead_source in ('website_form','whatsapp','manual','import','referral')).

-- Werte-Referenz (kanonisch als TEXT+CHECK umgesetzt, siehe Hinweis oben):
--   'website_form' | 'whatsapp' | 'manual' | 'import' | 'referral'

3.3 Enum: contact_status

CREATE TYPE contact_status AS ENUM (
  'active',         -- normaler, aktiver Kontakt
  'inactive',       -- deaktiviert (kein Login, kein Versand)
  'lead',           -- noch nicht als Kunde qualifiziert
  'blocked',        -- gesperrt (z. B. Zahlungsausfall, Compliance)
  'merged'          -- wurde in einen anderen Golden Record gemergt (soft-delete)
);

3.4 Enum: duplicate_resolution

CREATE TYPE duplicate_resolution AS ENUM (
  'unresolved',     -- noch nicht aufgelöst
  'merged',         -- zusammengeführt (merge durchgeführt)
  'not_duplicate'   -- als Nicht-Duplikat bestätigt
);

3.5 Tabelle: contacts

CREATE TABLE contacts (
  id                  uuid PRIMARY KEY DEFAULT gen_random_uuid(),

  -- Name
  first_name          text NOT NULL,
  last_name           text NOT NULL,
  display_name        text GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED,
  company_name        text,                          -- bei Firmen-Kontakten

  -- Kontaktdaten
  phone_primary       text,                          -- +41-Format normalisiert
  phone_secondary     text,
  email               text,
  whatsapp_number     text,                          -- kann von phone_primary abweichen
  preferred_lang      text DEFAULT 'es' CHECK (preferred_lang IN ('de', 'es', 'en')),

  -- Status & Klassifikation
  status              contact_status NOT NULL DEFAULT 'lead',
  lead_source         text CHECK (lead_source IS NULL OR lead_source IN     -- F-15/K-20: TEXT+CHECK statt Postgres-enum
                        ('website_form','whatsapp','manual','import','referral')),
  lead_source_ref     text,                          -- z. B. referral_code, Formular-Session-ID
  notes               text,

  -- KYC / Ausweisdaten (befüllt via OCR-Modul)
  -- KYC (kanonisch Dok 30 §2.1, K-02): typisierte Felder statt generisch id_type/id_number
  cedula_number       text UNIQUE,                   -- DR-Cédula, stärkster Dedup-Key
  passport_number     text,
  id_doc_type         text,                          -- 'CEDULA'|'PASSPORT'|'RNC'|'OTHER' (Absender-Doktyp, Zoll)
  nationality         char(2),                       -- ISO 3166-1 alpha-2
  id_scan_storage_path text,                         -- Storage-Pfad Ausweis (privat, Bucket 'kyc')
  consent_onboarding_at  timestamptz,                -- Einwilligung (OCR/Drittland) gem. Onboarding-Vertrag
  consent_onboarding_ref text,

  -- Interne Felder
  golden_record_id    uuid REFERENCES contacts(id),  -- NULL = selbst Golden Record
  is_golden_record    boolean NOT NULL DEFAULT true,
  tags                text[],                        -- flexible Etiketten

  -- Audit
  created_at          timestamptz NOT NULL DEFAULT now(),
  updated_at          timestamptz NOT NULL DEFAULT now(),
  created_by          uuid REFERENCES auth.users(id),
  updated_by          uuid REFERENCES auth.users(id)
);

-- Indizes
CREATE INDEX idx_contacts_phone_primary   ON contacts (phone_primary);
CREATE INDEX idx_contacts_email           ON contacts (lower(email));
CREATE INDEX idx_contacts_last_name       ON contacts (lower(last_name));
CREATE INDEX idx_contacts_status          ON contacts (status);
CREATE INDEX idx_contacts_golden_record   ON contacts (golden_record_id) WHERE golden_record_id IS NOT NULL;

-- Normalisierung: Telefonnummer immer in E.164
ALTER TABLE contacts ADD CONSTRAINT chk_phone_primary_format
  CHECK (phone_primary IS NULL OR phone_primary ~ '^\+[1-9]\d{6,14}$');
-- F-16: gleiches E.164-Format auch für phone_secondary und whatsapp_number
ALTER TABLE contacts ADD CONSTRAINT chk_phone_secondary_format
  CHECK (phone_secondary IS NULL OR phone_secondary ~ '^\+[1-9]\d{6,14}$');
ALTER TABLE contacts ADD CONSTRAINT chk_whatsapp_number_format
  CHECK (whatsapp_number IS NULL OR whatsapp_number ~ '^\+[1-9]\d{6,14}$');

RLS-Skizze contacts (Rollenprüfung via has_role('CODE') / current_affiliate_id() aus user_roles, nicht über JWT-Claim):

  • has_role('ADMIN') (Marcel) bzw. has_role('BUCHHALTUNG') (Mariela): SELECT/INSERT/UPDATE/DELETE alle Zeilen
  • has_role('OPERATIONS') (Markus): SELECT alle; UPDATE (kein DELETE); INSERT
  • has_role('FAHRER') (Arkys): SELECT (nur Felder: id, display_name, phone_primary, status); kein INSERT/UPDATE/DELETE
  • current_affiliate_id() is not null (AFFILIATE): SELECT nur eigener Kontaktsatz (WHERE id = auth.uid()::uuid — sofern Mapping existiert); kein INSERT/UPDATE
  • has_role('READONLY'): SELECT alle (kein id_scan, kein cedula_number/passport_number)

3.6 Tabelle: contact_roles

CREATE TABLE contact_roles (
  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  contact_id    uuid NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
  role          contact_role_type NOT NULL,
  active        boolean NOT NULL DEFAULT true,
  since         date,
  notes         text,
  created_at    timestamptz NOT NULL DEFAULT now(),
  created_by    uuid REFERENCES auth.users(id),

  UNIQUE (contact_id, role)  -- jede Rolle pro Kontakt nur einmal
);

CREATE INDEX idx_contact_roles_contact ON contact_roles (contact_id);
CREATE INDEX idx_contact_roles_role    ON contact_roles (role) WHERE active = true;

RLS: wie contacts (operatives Team darf Rollen anlegen/deaktivieren; affiliate/driver nur lesen).

3.7 Tabelle: recipients

Empfänger in der Dominikanischen Republik — verknüpft mit einem Absender-Kontakt in der Schweiz.

CREATE TABLE recipients (
  id                  uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  contact_id          uuid NOT NULL REFERENCES contacts(id) ON DELETE RESTRICT,
  -- Personendaten
  first_name          text NOT NULL,
  last_name           text NOT NULL,
  display_name        text GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED,
  phone_primary       text,
  phone_secondary     text,
  email               text,
  preferred_lang      text NOT NULL DEFAULT 'es'      -- F-07: Sprache der SHIPMENT_STATUS-Notification an den Empfänger-DR (kanonisch Dok 30 §2.3)
                        CHECK (preferred_lang IN ('de','es','en')),
  -- Ausweis (Cédula DR, für Zoll/Verzollung)
  cedula_number       text,
  cedula_scan_path    text,
  -- Zustelladresse DR
  province            text,           -- Provinz (Pflichtfeld bei Versand)
  municipality        text,
  neighborhood        text,
  street              text,           -- Strassenadresse
  address_reference   text,           -- Wegbeschreibung/Landmark
  -- Metadaten
  notes               text,
  active              boolean NOT NULL DEFAULT true,
  created_at          timestamptz NOT NULL DEFAULT now(),
  updated_at          timestamptz NOT NULL DEFAULT now(),
  created_by          uuid REFERENCES auth.users(id)
);

CREATE INDEX idx_recipients_contact    ON recipients (contact_id);
CREATE INDEX idx_recipients_cedula     ON recipients (cedula_number) WHERE cedula_number IS NOT NULL;
CREATE INDEX idx_recipients_province   ON recipients (province);

RLS recipients:

  • admin, operations: vollständiger Zugriff
  • driver: SELECT (nur Lieferfelder: display_name, phone_primary, province, municipality, neighborhood, street, address_reference) — für die Zustellliste
  • affiliate, readonly: kein Zugriff (Empfänger-Personendaten sind schützenswerte Daten nach revDSG)

3.8 Tabelle: contact_duplicates

CREATE TABLE contact_duplicates (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  contact_a_id    uuid NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
  contact_b_id    uuid NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
  score           numeric(4,3) NOT NULL CHECK (score BETWEEN 0 AND 1), -- 0.0–1.0
  match_fields    text[],              -- z. B. ['phone_primary', 'last_name']
  resolution      duplicate_resolution NOT NULL DEFAULT 'unresolved',
  resolved_by     uuid REFERENCES auth.users(id),
  resolved_at     timestamptz,
  notes           text,
  created_at      timestamptz NOT NULL DEFAULT now(),

  UNIQUE (contact_a_id, contact_b_id),
  CHECK (contact_a_id < contact_b_id)  -- kanonische Ordnung verhindert Spiegel-Paare
);

CREATE INDEX idx_dupl_resolution ON contact_duplicates (resolution) WHERE resolution = 'unresolved';
CREATE INDEX idx_dupl_score      ON contact_duplicates (score DESC);

RLS: admin, operations: vollständiger Zugriff. Andere Rollen: kein Zugriff.

3.9 Tabelle: contact_merge_log

Unveränderliches Revisionsjournal (OR 957 / GeBüV). Kein UPDATE, kein DELETE erlaubt.

CREATE TABLE contact_merge_log (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  source_id       uuid NOT NULL,   -- zusammengeführter (aufgehobener) Kontakt
  target_id       uuid NOT NULL,   -- Golden Record (überlebender Kontakt)
  merged_by       uuid NOT NULL REFERENCES auth.users(id),
  merged_at       timestamptz NOT NULL DEFAULT now(),
  snapshot_source jsonb NOT NULL,  -- vollständiger JSON-Snapshot des aufgehobenen Kontakts
  snapshot_target jsonb NOT NULL,  -- vollständiger JSON-Snapshot des Golden Records vor Merge
  field_decisions jsonb,           -- welche Felder vom Source übernommen wurden
  notes           text
);

CREATE INDEX idx_merge_log_source ON contact_merge_log (source_id);
CREATE INDEX idx_merge_log_target ON contact_merge_log (target_id);

RLS: admin: SELECT; kein INSERT/UPDATE/DELETE via API (nur via Server-Side Function mit erhöhten Privilegien).

3.10 Lookup-Tabelle: contact_role_labels (i18n)

CREATE TABLE contact_role_labels (
  role    contact_role_type PRIMARY KEY,
  de      text NOT NULL,
  es      text NOT NULL,
  en      text NOT NULL
);

INSERT INTO contact_role_labels VALUES
  ('customer',      'Kunde',       'Cliente',      'Customer'),
  ('lead',          'Lead',        'Prospecto',    'Lead'),
  ('supplier',      'Lieferant',   'Proveedor',    'Supplier'),
  ('affiliate',     'Affiliate',   'Afiliado',     'Affiliate'),
  ('recipient_dr',  'Empfänger DR','Destinatario', 'Recipient DR');

4. Kern-Workflows

4.1 Lead-Intake via Website-Formular

  1. Besucher sendet Formular /api/send-contact (Felder: name, surname, phone, email, type, message, _honey).
  2. API-Route prüft Honeypot (_honey muss leer sein); schlägt fehl → 200 ohne Aktion (Spam-Schutz).
  3. Telefonnummer wird in E.164 normalisiert (CH-Default +41 wenn keine Ländervorwahl vorhanden).
  4. Fuzzy-Matching-Check: Suche in contacts nach phone_primary (exakt) UND (first_name ILIKE % + last_name ILIKE %) + email (exakt). Wenn Treffer mit Score ≥ 0.85 → vorhandener Datensatz wird aktualisiert (Status bleibt, updated_at aktualisiert), kein Duplikat angelegt.
  5. Kein Treffer oder Score < 0.85 → neuer contacts-Datensatz mit status = 'lead', lead_source = 'website_form'.
  6. contact_roles-Eintrag: role = 'lead' (und bei type = 'Programa Afiliación' zusätzlich role = 'affiliate').
  7. audit_log-Eintrag (Ereignis: contact.created oder contact.updated_from_lead).
  8. Benachrichtigung an Mariela/Marcel via E-Mail/WhatsApp (Notification-Modul, Plattform).

Edge-Cases:

  • phone leer oder ungültig → Lead trotzdem anlegen, aber phone_primary = NULL + Flag needs_verification = true.
  • email leer → erlaubt (Pflichtfeld nur name, surname, phone, message laut Website).
  • Identischer Datensatz doppelt gesendet (Race Condition) → Unique-Constraint auf (phone_primary, lead_source) mit ON CONFLICT DO UPDATE (Upsert) verhindert Duplikat.

4.2 Lead-Intake via WhatsApp

  1. Eingehende WhatsApp-Nachricht wird manuell oder via API (z. B. WhatsApp Business API) erfasst.
  2. Caja zeigt "Neuer Lead erfassen"-Formular (Bottom-Sheet) mit vorausgefüllter Telefonnummer aus WhatsApp-Absender.
  3. Mitarbeiter ergänzt Name, ggf. E-Mail, Sprache, Notiz.
  4. Fuzzy-Matching wie in 4.1 Schritt 4–6; lead_source = 'whatsapp'.
  5. Weiter wie 4.1 Schritt 7–8.

4.3 Manuelle Kontaktanlage

  1. Mitarbeiter öffnet "Neuer Kontakt" (+ Button in Kontaktliste oder Fab am Handy).
  2. Formular: Vorname*, Nachname*, Telefon, E-Mail, Sprache, Rollen (Checkboxen), Notiz.
  3. On-Submit: Fuzzy-Check; bei möglichen Duplikaten → Warndialog mit Duplikat-Vorschau (Score + Übereinstimmungsfelder); Mitarbeiter wählt "Trotzdem anlegen" oder "Bestehenden öffnen".
  4. Anlage mit lead_source = 'manual', Status gemäss ausgewählten Rollen (wenn customerstatus = 'active').

4.4 Konvertierung Lead → Kunde

  1. Lead erhält erste Offerte oder erteilt Auftrag → Status automatisch auf active gesetzt.
  2. contact_roles: Eintrag role = 'customer' wird angelegt (falls noch nicht vorhanden), role = 'lead' bleibt erhalten (historisch) mit active = false.
  3. audit_log-Eintrag: contact.converted_to_customer.

4.5 Empfänger-Anlage (Empfänger DR)

  1. Beim Erstellen eines Auftrags wählt Mitarbeiter "Empfänger hinzufügen".
  2. Suche in recipients (Freitext auf display_name oder Cédula) — wenn gefunden → direkt verknüpfen.
  3. Neuer Empfänger: Formular (Vorname*, Nachname*, Provinz*, Telefon, Cédula, Adresse). Cédula-Scan via Kamera (→ OCR-Modul befüllt Felder vor).
  4. Empfänger wird gespeichert und mit dem Absender-Kontakt (contact_id) verknüpft.
  5. Ein Kontakt kann mehrere Empfänger haben (z. B. Vater sendet an Mutter + Schwester in DR).

4.6 Duplikat-Erkennung und Merge (Golden Record)

  1. Automatische Erkennung (Hintergrundprozess / DB-Funktion, z. B. via Supabase Edge Function oder Cron):
    • Berechne Fuzzy-Score für alle contacts-Paare nach: Telefon (exakter Treffer: 1.0), E-Mail (exakter Treffer: 0.9), last_name + first_name (Trigram-Ähnlichkeit ≥ 0.7: 0.6).
    • Paare mit Gesamt-Score ≥ 0.75 → Eintrag in contact_duplicates (wenn noch nicht vorhanden).
  2. Review-Queue: Admin/Operations sieht Liste offener Duplikat-Paare, sortiert nach Score (höchste zuerst).
  3. Merge-Workflow: a. Mitarbeiter wählt Golden Record (welcher Datensatz "überlebt"). b. Für jedes Feld: System schlägt vor, welcher Wert übernommen wird (nicht-leere Werte bevorzugt; bei Konflikt manuelle Auswahl per Toggle). c. Bestätigung → Server-Side Function:
    • Golden Record wird mit entschiedenen Feldern aktualisiert.
    • Source-Kontakt: status = 'merged', golden_record_id = target.id, is_golden_record = false.
    • Alle FKs in orders, quotes, movements, invoices werden auf target.id umgebogen (Batch-Update).
    • recipients des Source-Kontakts werden auf target.id übertragen.
    • contact_merge_log-Eintrag mit JSON-Snapshots (unveränderlich).
    • contact_duplicates.resolution = 'merged'.
  4. Nicht-Duplikat: Mitarbeiter markiert Paar als not_duplicate → wird aus Queue entfernt, Score wird ignoriert.

Edge-Cases beim Merge:

  • Source hat offene Aufträge/Rechnungen → Warnung, aber Merge trotzdem möglich (alle FKs werden migriert).
  • Source ist Auth-User → auth.users-Eintrag kann nicht automatisch migriert werden: 🔲 zu bestätigen (manueller Schritt oder Deaktivierung des Source-Auth-Users nötig).
  • Gleichzeitiger Merge zweier Paare mit demselben Kontakt → contact_a_id < contact_b_id-Constraint + Unique verhindert inkonsistente Paare; Transaktion sichert Atomarität.

5. UI-Screens (Mobile/Tablet-First)

5.1 Kontaktliste

Zweck: Übersicht aller Kontakte mit Suche, Filter nach Rolle/Status und Schnellaktionen.

Handy (375px):

  • Bottom-Tab-Bar: Icon "CRM" / "Kontakte".
  • Vollbreite Suchleiste (oben, sticky), Chip-Filter unterhalb (Rollen: Alle / Kunden / Leads / Lieferanten / Affiliates).
  • Liste als Karten (kein Horizontal-Scroll): je Karte → Avatar-Initiale + Name + Telefon + Rollen-Badges + Status-Dot. Swipe-Left → "Anrufen" | "WhatsApp". Swipe-Right → "Bearbeiten".
  • FAB (unten rechts, 56px, thumb-optimiert): "+ Kontakt".
  • Infinite Scroll (keine Paginierung sichtbar).

Tablet (768px+):

  • Sidebar sichtbar; Kontaktliste als zweispaltige Karten-Grid (Master-Detail-Layout).
  • Klick auf Karte → Detail-Panel rechts (kein neuer Screen).

Desktop (1024px+):

  • Dreispalten-Layout: Filter-Sidebar | Kontaktliste | Kontaktdetail.
  • Tabellen-Ansicht optional (TanStack Table mit sortierbaren Spalten: Name, Telefon, Rollen, Status, Letzte Aktivität).

5.2 Kontaktdetail

Zweck: Vollständige Ansicht + Bearbeitung eines Kontakts, verknüpfte Empfänger, Aktivitäten.

Handy:

  • Header: Avatar-Initiale (gross), Name, Telefon (Tap to Call), WhatsApp-Button.
  • Tab-Bar (horizontal scrollbar): Info | Empfänger | Offerten | Aufträge | Finanzen | Dokumente.
  • Jede Tab als Akkordeon-Sektion (kein Horizontal-Scroll).
  • "Bearbeiten" als Bottom-Sheet-Formular (nicht neuer Screen) — alle Felder in Eingaben ≥ 44px Höhe; inputmode="tel" für Telefon.

Tablet/Desktop:

  • Detail als fixed Panel oder eigene Seite; Tabs als vertikale Sub-Navigation.
  • Empfänger-Liste als Kacheln im Tab "Empfänger" mit "+ Empfänger"-Button.

5.3 Kontakt anlegen / bearbeiten

Handy:

  • Bottom-Sheet (80 % Bildschirmhöhe, swipe to dismiss).
  • Felder: Vorname*, Nachname*, Telefon (inputmode tel), E-Mail, Sprache (Segment: DE|ES|EN), Rollen (Checkbox-Gruppe mit grossen Touch-Targets), Notiz.
  • On-Submit: Spinner → Duplikat-Warnung (falls Score ≥ 0.75): inline Card mit "Trotzdem speichern" | "Bestehenden öffnen".
  • Toast: "Kontakt gespeichert" oder Fehlermeldung.

Tablet/Desktop:

  • Modales Dialog-Formular (460px), gleiche Felder.

5.4 Empfänger anlegen / bearbeiten

Handy:

  • Bottom-Sheet innerhalb des Kontaktdetail-Tabs "Empfänger".
  • Felder: Vorname*, Nachname*, Provinz* (Select mit DR-Provinzen), Municipio (abhängig von Provinz), Barrio, Strasse, Wegbeschreibung, Telefon, Cédula.
  • Kamera-Button neben Cédula-Feld: öffnet Camera-Capture → OCR-Modul (Modul 2) befüllt Felder vor.

Tablet/Desktop:

  • Zweispaltiges Formular im Modal.

5.5 Duplikat-Review-Queue

Zweck: Offene Duplikat-Paare auflösen.

Handy:

  • Erreichbar via Admin-Menü (Drawer) → "Duplikate".
  • Liste als Karten: links Kontakt A (Name, Telefon, E-Mail), rechts Kontakt B, Score-Badge in der Mitte.
  • Tap auf Karte → Merge-Screen (Bottom-Sheet oder neuer Screen).

Merge-Screen (Handy):

  • Zwei Spalten nebeneinander (horizontal scrollbar innerhalb des Sheets erlaubt — Ausnahme hier sinnvoll): jede Zeile = ein Feld, je ein Radio-Button pro Seite.
  • "Als Duplikat bestätigen & Zusammenführen" (primär, gross) | "Kein Duplikat" (sekundär).
  • Konfirmations-Dialog vor endgültigem Merge.

Tablet/Desktop:

  • Dreispalten-Layout: Feld | Wert A | Wert B. Radio-Gruppe pro Zeile.

5.6 Lead-Schnellerfassung (WhatsApp-Intake)

Zweck: Eingehender WhatsApp-Lead in unter 30 Sekunden erfassen.

Handy:

  • Quick-Action aus Bottom-Tab-Bar oder via "+" Menu → "Lead aus WhatsApp".
  • Minimales Formular: Telefon* (vorausgefüllt wenn via WhatsApp-Intent), Vorname*, Nachname*, Sprache (Segment).
  • Duplikat-Check live (on-blur Telefon) → Toast "Kontakt bereits vorhanden" mit Link zum bestehenden Datensatz.
  • Speichern → Toast "Lead erfasst" mit Link zum neuen Kontakt.

6. Integrationen & Verbindungen zu anderen Modulen

Geteilte Kern-Entitäten:

  • contacts — referenziert von: quotes.contact_id, orders.contact_id, invoices.contact_id, movements.contact_id, affiliates.contact_id, deposit_orders.contact_id.
  • recipients — referenziert von: orders.recipient_id, shipments.recipient_id.

Events und automatische Aktionen:

EreignisAuslöserZiel-Modul
contact.created (Lead)Website-Formular / WhatsAppPlattform: Benachrichtigung an Mariela/Marcel
contact.converted_to_customerErste Offerte / AuftragModul 4 Offerten: Kontakt-Selector freigeschaltet
contact.role_affiliate_addedRollenzuweisungModul 7 Affiliate: affiliates-Datensatz anlegen
contact.mergedMerge abgeschlossenalle Module: FKs umgebogen, audit_log-Eintrag
recipient.createdEmpfänger-AnlageModul 5 Logistik: Empfänger in Sendungs-Formular verfügbar

WhatsApp-Lead-Intake (Modul 8 Plattform): WhatsApp-Webhook → API-Route → CRM-Intake-Funktion (wie 4.2).

OCR-Ausweisscan (Modul 2, self-hosted): Extraktion von Vorname/Nachname/Ausweis-Nr. aus Bild → vorausgefüllte Felder in contacts (cedula_number/passport_number/id_doc_type/id_scan_storage_path) oder recipients (cedula_number, cedula_scan_path). OCR-Ergebnis wird immer manuell bestätigt vor Speicherung.

Offerten-Modul (4): Kontakt-Selector zeigt nur contacts mit status IN ('active', 'lead') und role IN ('customer', 'lead').

Finanzen-Modul (6): movements.contact_id → FK auf contacts. Debitoren-Report gruppiert nach Kontakt.

Affiliate-Modul (7): Wenn contact_roles.role = 'affiliate' gesetzt wird → automatisch affiliates-Datensatz anlegen (Modul 7 übernimmt).


7. Validierungen & Edge-Cases

Feld / SituationRegel
phone_primary FormatE.164-Regex ^\+[1-9]\d{6,14}$; CH-Default +41 wenn nur Ziffern ohne Vorwahl
email FormatStandard RFC-5322-Regex; kein Pflichtfeld
Vorname/Nachnamemin. 1 Zeichen, max. 100 Zeichen; Trimming
preferred_langNur de, es, en; Default es
Rollen-Konsistenzrecipient_dr-Rolle darf nicht auf demselben Kontakt wie supplier sein (CH-Kontakt ≠ DR-Empfänger); recipients ist die korrekte Entität für Empfänger DR
Merge: Gold Record = eigener Kontaktcontacts.golden_record_id IS NULL für aktive Kontakte; bei Merge wird Source auf golden_record_id = target.id gesetzt — verhindert zirkuläre Referenz via CHECK-Constraint
Merge: Auth-UserWenn source ein auth.users-Eintrag hat: Merge-Funktion gibt Warnung zurück, kein automatischer Schritt — manuelle Deaktivierung durch Admin
Duplicate Score ThresholdScore ≥ 0.85 → automatisch in Queue (Warn-Level), ≥ 0.95 → proaktive Warnung beim Anlegen
Telefon-UniquenessKein harter UNIQUE-Constraint auf phone_primary (Familien teilen oft Telefone); stattdessen Soft-Warning bei Dublette
status = 'merged'Datensatz kann weder in Selektoren noch in neuen FK-Referenzen erscheinen; Queries filtern WHERE is_golden_record = true
Lösch-SchutzKontakte mit verknüpften Aufträgen/Rechnungen/Bewegungen dürfen nicht gelöscht werden (ON DELETE RESTRICT auf FKs); nur status = 'inactive' oder Merge möglich
Leere Duplikat-QueueEmpty-State mit Illustration "Alles sauber" anzeigen

8. Compliance- und Sicherheits-Hinweise

revDSG / DSG (Schweiz, 2023)

  • contacts enthält Personendaten nach Art. 5 revDSG (Name, Telefon, E-Mail). Supabase-Datenbank muss in der EU gehostet sein (Supabase: eu-central-1 / Frankfurt empfohlen) oder explizite Einwilligung für Übermittlung in Drittland.
  • id_scan_storage_path und cedula_scan_path sind besonders schützenswerte Daten (Ausweiskopien). Supabase Storage Bucket kyc muss privat sein (kein Public Access); Zugriff nur via signierte URLs mit kurzer TTL (max. 60 Minuten).
  • cedula_number/passport_number (Ausweisziffern) gelten als besonders schützenswerte Daten: RLS einschränken (nur has_role('ADMIN')/has_role('OPERATIONS')), in API-Responses nicht standardmässig mitsenden (Field-Level-Security via View oder explizite Column-Auswahl).
  • Datenschutzrichtlinie muss KYC-Zweck der Ausweiserfassung (Zoll/Empfänger-Matching) explizit nennen.
  • Auskunfts- und Löschbegehren (Art. 32 revDSG): status = 'inactive' reicht nicht; bei berechtigtem Löschbegehren muss ein Export und anschliessend physische Löschung möglich sein — ausser Retentionspflicht nach OR 957 kollidiert (Aufbewahrungspflicht für buchungsrelevante Kontakte: 10 Jahre).

OR 957 / GeBüV (Buchführung, Revisionssicherheit)

  • contact_merge_log ist unveränderliches Revisionsjournal: DELETE und UPDATE via RLS für alle Rollen gesperrt. Einträge dürfen nur via privilegierte Server-Side Function (Supabase service_role) angelegt werden.
  • audit_log-Einträge für alle CRM-Mutationen (Create/Update/Merge/Delete) sind Pflicht — gilt als Nachweis für Treuhänder.
  • Kontakte, die in Buchungsvorgängen (movements) referenziert sind, unterliegen der 10-jährigen Aufbewahrungspflicht und dürfen nicht physisch gelöscht werden.

KYC (Know Your Customer)

  • Cédula- und Ausweis-Scan dienen dem Zoll-/Empfänger-Matching (kein formales AML-Programm erforderlich bei reiner Warenlogistik).
  • OCR-Ergebnisse werden immer vor Speicherung manuell bestätigt (kein vollautomatischer Schritt).
  • Ausweisdaten werden nicht an Dritte weitergegeben ausser an DR-Zollbehörden (im Rahmen der Sendungs-Dokumentation).

9. Offene Punkte

🔲 Auth-User-Merge: Was passiert, wenn der zusammenzuführende ("Source"-)Kontakt einen auth.users-Eintrag hat (z. B. ein Affiliate mit Login)? Soll der Source-Auth-Account deaktiviert und auf den Target-Account umgeleitet werden, oder wird der Merge nur für nicht-angemeldete Kontakte erlaubt? → Entscheid Marcel/Mariela erforderlich.

🔲 Telefon-Uniqueness-Policy: Soll phone_primary einen Soft-Unique-Index erhalten (Warn-Level bei Dublette) oder einen harten UNIQUE-Constraint? Familien teilen oft eine Nummer — Erfahrungswerte aus der bisherigen Excel-Nutzung prüfen.

🔲 Duplikat-Score-Algorithmus: Soll Fuzzy-Matching via PostgreSQL pg_trgm (Trigram, bereits in Supabase verfügbar) umgesetzt werden, oder soll ein dedizierter Score via Edge Function berechnet werden? pg_trgm ist einfacher, eine Edge Function erlaubt komplexere Gewichtung (Telefon vor Name vor E-Mail). → Technische Entscheidung vor Implementierung.

🔲 Box-Rückgabe / Depot-Lifecycle: In welchem Status und nach welcher Frist gilt eine ausgeliehene Leerbox als "verloren" bzw. wird das Depot einbehalten? (im Excel nicht modelliert — betrifft auch Depot-Modul 5) → Entscheid Markus/Marcel.

🔲 Affiliate-Lead-Intake-Feld im Website-Formular: Das bestehende Formular hat kein Referral-Code-Feld. Soll dieses nachgerüstet werden (z. B. als verstecktes URL-Parameter ?ref=CODE → automatisch in lead_source_ref übernommen)? → Entscheid Marcel.

🔲 DSGVO vs. revDSG: Hosting-Region: Muss die Supabase-Instanz in der EU liegen (EU-Kunden, DSGVO-Relevanz wenn EU-Empfänger)? Aktuell: Supabase-Projekt-Region bestätigen. → Technische Verifikation.

🔲 Mehrsprachige Empfänger-Adressen: Sollen DR-Adressen auf Spanisch erfasst werden (Pflichtsprache für Zolldokumente) und zusätzlich eine DE-/EN-Übersetzung möglich sein, oder gilt Spanisch als einzige Sprache für Empfänger-Felder? → Entscheid Mariela.


IST-Paritäts-Nachträge (Welle 2)

Aus der Feature-Paritäts-Analyse (docs/legacy-ist/95-caja-gap-analyse.md). Die Person-Entflechtung (contacts/app_users/recipients), Mehrfach-Rollen und Format-CHECKs sind bereits umgesetzt; hier die verbliebenen Person-Features.

Empfänger → Kunde befördern (BR-P15)

Ein DR-Empfänger (recipients) kann selbst zum aktiven Kunden/Absender werden (IST: 2-stufige RelatedPerson-Hierarchie, Beförderung Empfänger→Hauptkunde):

  1. In der Empfänger-Ansicht Aktion „Als Kunde anlegen".
  2. System sucht via Fuzzy-Match (Cédula → exakt; sonst Name+Telefon) einen bestehenden contacts-Golden-Record. Treffer → verknüpfen; kein Treffer → neuen contacts-Satz aus den Empfängerdaten erzeugen (first_name, last_name, phone_primary, cedula_number, email, alias).
  3. contact_roles: Rolle customer setzen, status='active'.
  4. Die recipients-Zeile bleibt bestehen (Zustelldaten/Historie) und wird über recipients.promoted_contact_id (Dok 30 §2.3) mit dem neuen Kunden verknüpft.
  5. audit_log-Eintrag recipient.promoted_to_customer.

Damit ist die IST-Beförderung abgebildet, ohne die Trennung recipients (Logistik) ↔ contacts (Geschäfts-Partei) aufzugeben. 2-Ebenen-Hierarchie (BR-P15): Ebene 1 = contacts (Absender/Kunde), Ebene 2 = recipients (Empfänger, contact_id→Absender); ein Absender-Bezug pro recipients-Zeile.

E-Mail-Eindeutigkeit vs. Dedup (BR-P03)

Entscheid 2026-06-28 (#20): Eindeutigkeit von E-Mail UND Telefon angestrebt — mit Dubletten-Warnung. Beim Anlegen/Ändern prüft das System auf bestehende E-Mail/Telefonnummer und zeigt bei Treffer eine Warnung + Merge-Vorschlag (kein stilles Duplikat). Umgesetzt über den Dedup-Workflow (§4.6) statt hartem DB-UNIQUE (erlaubt bewusste Ausnahmen wie eine geteilte Familien-Nummer nach Bestätigung); cedula_number bleibt harter Unique-Key.

Verlauf-Tab im Kontaktdetail (BR-13/14)

Das Kontaktdetail (§5.2) erhält einen Tab „Verlauf" (die in §5.2 genannten „Aktivitäten"), der audit_log gefiltert auf den Kontakt (table_name='contacts', row_pk=id) als chronologische Änderungs-Timeline rendert (Akteur · Zeit · Feld-Diff). Wiederverwendbare Komponente, spezifiziert in Modul 17 §11.3. Erfüllt das IST-„Historial de cambios" pro Objekt.

Alias & Telefon-Mapping

contacts.alias/recipients.alias (IST People.Alias, Dok 30 §2.1/§2.3) ist Teil von Suche + Dedup. Die drei IST-Mobilnummern werden auf phone_primary/phone_secondary/whatsapp_number abgebildet (Migrations-Mapping, Dok 30 §2.1).