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_sourceist einetext-Spalte mitCHECKauf genau die fünf Werte unten — kein Postgres-enum(erweiterbare Menge). Die Werte-Validierung bleibt damit erhalten (kein freier Text); dasCREATE TYPEhier ist als Werte-Referenz zu lesen, in der Migration stehtlead_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 Zeilenhas_role('OPERATIONS')(Markus): SELECT alle; UPDATE (kein DELETE); INSERThas_role('FAHRER')(Arkys): SELECT (nur Felder: id, display_name, phone_primary, status); kein INSERT/UPDATE/DELETEcurrent_affiliate_id() is not null(AFFILIATE): SELECT nur eigener Kontaktsatz (WHERE id = auth.uid()::uuid — sofern Mapping existiert); kein INSERT/UPDATEhas_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 Zugriffdriver: SELECT (nur Lieferfelder: display_name, phone_primary, province, municipality, neighborhood, street, address_reference) — für die Zustelllisteaffiliate,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
- Besucher sendet Formular
/api/send-contact(Felder:name,surname,phone,email,type,message,_honey). - API-Route prüft Honeypot (
_honeymuss leer sein); schlägt fehl → 200 ohne Aktion (Spam-Schutz). - Telefonnummer wird in E.164 normalisiert (CH-Default
+41wenn keine Ländervorwahl vorhanden). - Fuzzy-Matching-Check: Suche in
contactsnachphone_primary(exakt) UND(first_name ILIKE % + last_name ILIKE %)+email(exakt). Wenn Treffer mit Score ≥ 0.85 → vorhandener Datensatz wird aktualisiert (Status bleibt,updated_ataktualisiert), kein Duplikat angelegt. - Kein Treffer oder Score < 0.85 → neuer
contacts-Datensatz mitstatus = 'lead',lead_source = 'website_form'. contact_roles-Eintrag:role = 'lead'(und beitype = 'Programa Afiliación'zusätzlichrole = 'affiliate').audit_log-Eintrag (Ereignis:contact.createdodercontact.updated_from_lead).- Benachrichtigung an Mariela/Marcel via E-Mail/WhatsApp (Notification-Modul, Plattform).
Edge-Cases:
phoneleer oder ungültig → Lead trotzdem anlegen, aberphone_primary = NULL+ Flagneeds_verification = true.emailleer → erlaubt (Pflichtfeld nurname,surname,phone,messagelaut Website).- Identischer Datensatz doppelt gesendet (Race Condition) → Unique-Constraint auf
(phone_primary, lead_source)mitON CONFLICT DO UPDATE(Upsert) verhindert Duplikat.
4.2 Lead-Intake via WhatsApp
- Eingehende WhatsApp-Nachricht wird manuell oder via API (z. B. WhatsApp Business API) erfasst.
- Caja zeigt "Neuer Lead erfassen"-Formular (Bottom-Sheet) mit vorausgefüllter Telefonnummer aus WhatsApp-Absender.
- Mitarbeiter ergänzt Name, ggf. E-Mail, Sprache, Notiz.
- Fuzzy-Matching wie in 4.1 Schritt 4–6;
lead_source = 'whatsapp'. - Weiter wie 4.1 Schritt 7–8.
4.3 Manuelle Kontaktanlage
- Mitarbeiter öffnet "Neuer Kontakt" (+ Button in Kontaktliste oder Fab am Handy).
- Formular: Vorname*, Nachname*, Telefon, E-Mail, Sprache, Rollen (Checkboxen), Notiz.
- On-Submit: Fuzzy-Check; bei möglichen Duplikaten → Warndialog mit Duplikat-Vorschau (Score + Übereinstimmungsfelder); Mitarbeiter wählt "Trotzdem anlegen" oder "Bestehenden öffnen".
- Anlage mit
lead_source = 'manual', Status gemäss ausgewählten Rollen (wenncustomer→status = 'active').
4.4 Konvertierung Lead → Kunde
- Lead erhält erste Offerte oder erteilt Auftrag → Status automatisch auf
activegesetzt. contact_roles: Eintragrole = 'customer'wird angelegt (falls noch nicht vorhanden),role = 'lead'bleibt erhalten (historisch) mitactive = false.audit_log-Eintrag:contact.converted_to_customer.
4.5 Empfänger-Anlage (Empfänger DR)
- Beim Erstellen eines Auftrags wählt Mitarbeiter "Empfänger hinzufügen".
- Suche in
recipients(Freitext aufdisplay_nameoder Cédula) — wenn gefunden → direkt verknüpfen. - Neuer Empfänger: Formular (Vorname*, Nachname*, Provinz*, Telefon, Cédula, Adresse). Cédula-Scan via Kamera (→ OCR-Modul befüllt Felder vor).
- Empfänger wird gespeichert und mit dem Absender-Kontakt (
contact_id) verknüpft. - Ein Kontakt kann mehrere Empfänger haben (z. B. Vater sendet an Mutter + Schwester in DR).
4.6 Duplikat-Erkennung und Merge (Golden Record)
- 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).
- Berechne Fuzzy-Score für alle
- Review-Queue: Admin/Operations sieht Liste offener Duplikat-Paare, sortiert nach Score (höchste zuerst).
- 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,invoiceswerden auftarget.idumgebogen (Batch-Update). recipientsdes Source-Kontakts werden auftarget.idübertragen.contact_merge_log-Eintrag mit JSON-Snapshots (unveränderlich).contact_duplicates.resolution = 'merged'.
- 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:
| Ereignis | Auslöser | Ziel-Modul |
|---|---|---|
contact.created (Lead) | Website-Formular / WhatsApp | Plattform: Benachrichtigung an Mariela/Marcel |
contact.converted_to_customer | Erste Offerte / Auftrag | Modul 4 Offerten: Kontakt-Selector freigeschaltet |
contact.role_affiliate_added | Rollenzuweisung | Modul 7 Affiliate: affiliates-Datensatz anlegen |
contact.merged | Merge abgeschlossen | alle Module: FKs umgebogen, audit_log-Eintrag |
recipient.created | Empfänger-Anlage | Modul 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 / Situation | Regel |
|---|---|
phone_primary Format | E.164-Regex ^\+[1-9]\d{6,14}$; CH-Default +41 wenn nur Ziffern ohne Vorwahl |
email Format | Standard RFC-5322-Regex; kein Pflichtfeld |
| Vorname/Nachname | min. 1 Zeichen, max. 100 Zeichen; Trimming |
preferred_lang | Nur de, es, en; Default es |
| Rollen-Konsistenz | recipient_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 Kontakt | contacts.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-User | Wenn source ein auth.users-Eintrag hat: Merge-Funktion gibt Warnung zurück, kein automatischer Schritt — manuelle Deaktivierung durch Admin |
| Duplicate Score Threshold | Score ≥ 0.85 → automatisch in Queue (Warn-Level), ≥ 0.95 → proaktive Warnung beim Anlegen |
| Telefon-Uniqueness | Kein 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-Schutz | Kontakte 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-Queue | Empty-State mit Illustration "Alles sauber" anzeigen |
8. Compliance- und Sicherheits-Hinweise
revDSG / DSG (Schweiz, 2023)
contactsenthä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_pathundcedula_scan_pathsind besonders schützenswerte Daten (Ausweiskopien). Supabase Storage Bucketkycmuss 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 (nurhas_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_logist unveränderliches Revisionsjournal:DELETEundUPDATEvia RLS für alle Rollen gesperrt. Einträge dürfen nur via privilegierte Server-Side Function (Supabaseservice_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):
- In der Empfänger-Ansicht Aktion „Als Kunde anlegen".
- System sucht via Fuzzy-Match (Cédula → exakt; sonst Name+Telefon) einen bestehenden
contacts-Golden-Record. Treffer → verknüpfen; kein Treffer → neuencontacts-Satz aus den Empfängerdaten erzeugen (first_name,last_name,phone_primary,cedula_number,email,alias). contact_roles: Rollecustomersetzen,status='active'.- Die
recipients-Zeile bleibt bestehen (Zustelldaten/Historie) und wird überrecipients.promoted_contact_id(Dok 30 §2.3) mit dem neuen Kunden verknüpft. audit_log-Eintragrecipient.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).