Erster Menuepunkt und Startansicht; das Logo oben links fuehrt dorthin zurueck. Vier Kennzahlen (Bewerbungen, daraus Opportunities samt Quote, gewonnene Projekte, aktuell beim Kunden), ein Saeulendiagramm mit denselben drei Reihen ueber der Zeit, die Bewerbungen je Berater, die naechsten Termine und alle Opportunities in Kundenvorstellung bzw. Interview Kunde. Zeitraum ueber vier Vorgaben oder frei, Einteilung nach Tag, Monat, Quartal oder Jahr. Bewerbung heisst dabei: ein Eintrag in der Akquise - jeder Eintrag bedeutet, dass ein Profil raus ist (so vom Nutzer festgelegt). Zwei Dinge dafuer am Datenmodell: - crm_opportunities.won_at haelt den Zeitpunkt des Wechsels nach "Beauftragt". Die Phase allein sagt nur, wie es jetzt steht, nicht wann es passiert ist. Beim Wechsel heraus wird das Datum geleert, sonst zaehlt das Dashboard einen Gewinn, den es nicht gibt. - Die Akquise loescht nach 90 Tagen - damit waere jede Jahresauswertung falsch. Der Aufraeumlauf sichert deshalb vorher Tag, Berater und Anzahl in crm_acquisition_history, in derselben Transaktion wie das Loeschen. Gesichert wird nur, was verschwindet; die Auswertung addiert beide Quellen und kann sich damit nicht ueberschneiden. Projektdaten sind nach dem Loeschen weiterhin weg. Termine und die Phasenlisten kommen nicht aus einem eigenen Endpunkt: Termine und Opportunities laedt die App ohnehin komplett, das Dashboard filtert sie selbst. Diagramme von Hand (SVG-freie Balken plus Tailwind-Uebergaenge) statt einer Bibliothek - drei Darstellungen rechtfertigen keine weitere Abhaengigkeit, und so kommen die Farben aus denselben Tokens wie der Rest. Die Zahlen laufen hoch, die Saeulen wachsen; wer Bewegung im System abgeschaltet hat, bekommt sofort den Endwert. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
495 lines
26 KiB
SQL
495 lines
26 KiB
SQL
-- perinco CRM - Schema fuer MariaDB-Datenbank perinco_CRM
|
|
-- Idempotent: kann jederzeit erneut ausgefuehrt werden.
|
|
|
|
CREATE TABLE IF NOT EXISTS crm_users (
|
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
username VARCHAR(64) NOT NULL,
|
|
email VARCHAR(190) NULL,
|
|
name VARCHAR(190) NOT NULL,
|
|
role ENUM('admin','user') NOT NULL DEFAULT 'user',
|
|
password_hash VARCHAR(255) NOT NULL,
|
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY uq_crm_users_username (username),
|
|
UNIQUE KEY uq_crm_users_email (email)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- Persoenliche Farbe fuer die Kalenderansicht. NULL = automatisch aus der
|
|
-- Palette (siehe userColor() im Frontend). ADD COLUMN IF NOT EXISTS haelt das
|
|
-- Statement idempotent (MariaDB-Erweiterung).
|
|
ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS color VARCHAR(9) NULL AFTER role;
|
|
|
|
-- Benutzerfoto als Data-URL oder externe URL - wie bei crm_leads.avatar.
|
|
ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS avatar LONGTEXT NULL AFTER color;
|
|
|
|
CREATE TABLE IF NOT EXISTS crm_leads (
|
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
salutation VARCHAR(32) NULL,
|
|
first_name VARCHAR(120) NOT NULL,
|
|
last_name VARCHAR(120) NOT NULL,
|
|
company VARCHAR(190) NULL,
|
|
position VARCHAR(190) NULL,
|
|
email VARCHAR(190) NULL,
|
|
phone VARCHAR(64) NULL,
|
|
mobile VARCHAR(64) NULL,
|
|
linkedin VARCHAR(255) NULL,
|
|
website VARCHAR(255) NULL,
|
|
street VARCHAR(190) NULL,
|
|
zip VARCHAR(24) NULL,
|
|
city VARCHAR(120) NULL,
|
|
country VARCHAR(120) NULL,
|
|
-- Art des Kontakts. Frueher ein Vertriebsstatus (Neu .. Verloren); die
|
|
-- Umstellung bestehender Datenbanken macht migrateLeadKind() in db.js.
|
|
kind ENUM('Recruiter','Partner','Endkunde') NOT NULL DEFAULT 'Recruiter',
|
|
source VARCHAR(120) NULL,
|
|
tags LONGTEXT NULL, -- JSON-Array als Text (MariaDB-kompatibel)
|
|
notes TEXT NULL,
|
|
avatar LONGTEXT NULL, -- Data-URL oder externe URL
|
|
owner_id INT UNSIGNED NULL, -- zustaendiger Benutzer
|
|
created_by INT UNSIGNED NULL, -- wer den Kontakt angelegt hat
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
KEY idx_crm_leads_name (last_name, first_name),
|
|
KEY idx_crm_leads_company (company),
|
|
KEY idx_crm_leads_kind (kind),
|
|
KEY idx_crm_leads_owner (owner_id),
|
|
KEY idx_crm_leads_creator (created_by),
|
|
CONSTRAINT fk_crm_leads_owner FOREIGN KEY (owner_id) REFERENCES crm_users (id) ON DELETE SET NULL,
|
|
CONSTRAINT fk_crm_leads_creator FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
/*
|
|
* Kontakte aus dem Postfach landen nicht direkt im Kontaktbestand, sondern in
|
|
* einem Vorbereich: mail_pool = 1. Die Kontaktansicht zeigt nur mail_pool = 0,
|
|
* der Vorbereich steht als eigener Menuepunkt "Aus Mails" daneben. Erst die
|
|
* Uebernahme dort erzeugt einen richtigen Kontakt (mail_pool = 0).
|
|
*
|
|
* Bewusst eine Spalte statt einer zweiten Tabelle: Mailverknuepfungen
|
|
* (crm_mail_links) und Vorschlaege (crm_mail_contact_candidates) zeigen per
|
|
* Fremdschluessel auf crm_leads und bleiben damit auch fuer den Vorbereich
|
|
* gueltig. Die Einmal-Umstellung bestehender Zeilen macht db.js.
|
|
*/
|
|
ALTER TABLE crm_leads ADD COLUMN IF NOT EXISTS mail_pool TINYINT(1) NOT NULL DEFAULT 0 AFTER source;
|
|
ALTER TABLE crm_leads ADD INDEX IF NOT EXISTS idx_crm_leads_mail_pool (mail_pool);
|
|
|
|
/*
|
|
* Einverstaendnis in Werbemails. Ohne Haken kommt die Adresse in keine
|
|
* Mailingliste - Vorgabe ist deshalb 0, auch bei Kontakten, die aus dem
|
|
* Vorbereich uebernommen werden: eine Mail von jemandem ist keine Einwilligung.
|
|
*
|
|
* `mail_consent_at` haelt fest, wann der Haken gesetzt wurde. Bei einer
|
|
* Beschwerde ist die Frage nicht "gibt es ein Einverstaendnis", sondern "seit
|
|
* wann" - ohne Zeitpunkt ist das Kennzeichen als Nachweis wertlos.
|
|
*/
|
|
ALTER TABLE crm_leads ADD COLUMN IF NOT EXISTS mail_consent TINYINT(1) NOT NULL DEFAULT 0 AFTER mail_pool;
|
|
ALTER TABLE crm_leads ADD COLUMN IF NOT EXISTS mail_consent_at DATETIME NULL AFTER mail_consent;
|
|
|
|
-- Wer steht mit wem in Kontakt. Ungerichtet gedacht; Duplikate werden ueber
|
|
-- den Unique-Key auf dem sortierten Paar verhindert (siehe Backend).
|
|
CREATE TABLE IF NOT EXISTS crm_lead_connections (
|
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
from_lead_id INT UNSIGNED NOT NULL,
|
|
to_lead_id INT UNSIGNED NOT NULL,
|
|
type ENUM('Kollege','Vorgesetzter','Kunde','Partner','Empfehlung','Netzwerk','Sonstige')
|
|
NOT NULL DEFAULT 'Netzwerk',
|
|
label VARCHAR(190) NULL,
|
|
strength TINYINT UNSIGNED NOT NULL DEFAULT 2, -- 1 = lose, 2 = normal, 3 = eng
|
|
created_by INT UNSIGNED NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY uq_crm_conn_pair (from_lead_id, to_lead_id),
|
|
KEY idx_crm_conn_to (to_lead_id),
|
|
CONSTRAINT fk_crm_conn_from FOREIGN KEY (from_lead_id) REFERENCES crm_leads (id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_crm_conn_to FOREIGN KEY (to_lead_id) REFERENCES crm_leads (id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_crm_conn_user FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- Vertriebschancen. Ansprechpartner haengen als n:m an den Kontakten, damit ein
|
|
-- Kontakt in mehreren Opportunities auftauchen kann.
|
|
CREATE TABLE IF NOT EXISTS crm_opportunities (
|
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
title VARCHAR(190) NOT NULL,
|
|
company VARCHAR(190) NULL,
|
|
description TEXT NULL,
|
|
-- Phasen des Vermittlungsprozesses. Bis zum 18.08.2026 stand hier ein
|
|
-- allgemeiner Vertriebstrichter (Neu .. Verloren); die Umstellung bestehender
|
|
-- Datenbanken macht migrateOpportunityStages() in db.js.
|
|
stage ENUM('Neu','Beworben','Erstgespraech Berater','Kundenvorstellung','Interview Kunde','Zusage','Beauftragt','Absage')
|
|
NOT NULL DEFAULT 'Neu',
|
|
probability TINYINT UNSIGNED NOT NULL DEFAULT 10, -- 0-100 %
|
|
daily_rate DECIMAL(10,2) NULL, -- Tagessatz in Euro
|
|
duration_days DECIMAL(6,1) NULL, -- Personentage, halbe moeglich
|
|
fixed_price DECIMAL(12,2) NULL, -- Festpreis; Alternative zu Tagessatz x Laufzeit
|
|
start_date DATE NULL,
|
|
end_date DATE NULL,
|
|
last_interaction_at DATE NULL,
|
|
last_interaction_note VARCHAR(500) NULL,
|
|
next_step VARCHAR(500) NULL,
|
|
tags LONGTEXT NULL, -- JSON-Array als Text, wie bei crm_leads
|
|
notes TEXT NULL,
|
|
owner_id INT UNSIGNED NULL,
|
|
created_by INT UNSIGNED NULL,
|
|
deleted_at DATETIME NULL, -- im Papierkorb (UTC)
|
|
deleted_by INT UNSIGNED NULL, -- wer; bewusst ohne Fremdschluessel, s.u.
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
KEY idx_crm_opp_deleted (deleted_at),
|
|
KEY idx_crm_opp_stage (stage),
|
|
KEY idx_crm_opp_owner (owner_id),
|
|
KEY idx_crm_opp_interaction (last_interaction_at),
|
|
CONSTRAINT fk_crm_opp_owner FOREIGN KEY (owner_id) REFERENCES crm_users (id) ON DELETE SET NULL,
|
|
CONSTRAINT fk_crm_opp_creator FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- Festpreisprojekte rechnen nicht ueber Tagessatz x Personentage. Ist der
|
|
-- Projektpreis gesetzt, gilt er als Volumen (siehe opportunityVolume()).
|
|
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS fixed_price DECIMAL(12,2) NULL AFTER duration_days;
|
|
|
|
/*
|
|
* Papierkorb der Opportunities. Loeschen setzt `deleted_at`, die Zeile bleibt -
|
|
* eine Opportunity traegt Verlauf, Ansprechpartner und Mailbezuege, und ein
|
|
* Fehlklick soll das nicht kosten. Nach 90 Tagen raeumt das Backend selbst auf,
|
|
* vorher laesst sich der Papierkorb von Hand leeren.
|
|
*
|
|
* `deleted_by` traegt bewusst keinen Fremdschluessel: Spalten lassen sich per
|
|
* ADD COLUMN IF NOT EXISTS idempotent nachziehen, ein Fremdschluessel nicht.
|
|
*/
|
|
/*
|
|
* Zeitpunkt des Gewinns. Ohne ihn liesse sich "wie viele Projekte haben wir im
|
|
* Mai gewonnen" nicht beantworten - die Phase allein sagt nur, wie es jetzt
|
|
* steht. Gesetzt beim Wechsel nach 'Beauftragt', geleert beim Wechsel heraus.
|
|
*/
|
|
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS won_at DATETIME NULL AFTER stage;
|
|
ALTER TABLE crm_opportunities ADD INDEX IF NOT EXISTS idx_crm_opp_won (won_at);
|
|
|
|
/*
|
|
* Zeitpunkt des Gewinns. Ohne ihn liesse sich "wie viele Projekte haben wir im
|
|
* Mai gewonnen" nicht beantworten - die Phase allein sagt nur, wie es jetzt
|
|
* steht. Gesetzt beim Wechsel nach 'Beauftragt', geleert beim Wechsel heraus.
|
|
*/
|
|
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS won_at DATETIME NULL AFTER stage;
|
|
ALTER TABLE crm_opportunities ADD INDEX IF NOT EXISTS idx_crm_opp_won (won_at);
|
|
|
|
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL AFTER created_by;
|
|
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS deleted_by INT UNSIGNED NULL AFTER deleted_at;
|
|
ALTER TABLE crm_opportunities ADD INDEX IF NOT EXISTS idx_crm_opp_deleted (deleted_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS crm_opportunity_contacts (
|
|
opportunity_id INT UNSIGNED NOT NULL,
|
|
lead_id INT UNSIGNED NOT NULL,
|
|
role VARCHAR(120) NULL, -- z. B. "Entscheider", "Fachbereich"
|
|
PRIMARY KEY (opportunity_id, lead_id),
|
|
KEY idx_crm_opp_contacts_lead (lead_id),
|
|
CONSTRAINT fk_crm_oc_opp FOREIGN KEY (opportunity_id) REFERENCES crm_opportunities (id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_crm_oc_lead FOREIGN KEY (lead_id) REFERENCES crm_leads (id) ON DELETE CASCADE
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- Gemeinsamer Teamkalender: alle Benutzer sehen alle Termine.
|
|
-- description darf Mention-Tokens der Form @[Anzeigename](lead:12) bzw.
|
|
-- @[Anzeigename](user:3) enthalten - das Frontend rendert sie klickbar.
|
|
CREATE TABLE IF NOT EXISTS crm_events (
|
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
title VARCHAR(190) NOT NULL,
|
|
description TEXT NULL,
|
|
location VARCHAR(190) NULL,
|
|
starts_at DATETIME NOT NULL,
|
|
ends_at DATETIME NOT NULL,
|
|
all_day TINYINT(1) NOT NULL DEFAULT 0,
|
|
created_by INT UNSIGNED NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
KEY idx_crm_events_start (starts_at),
|
|
KEY idx_crm_events_creator (created_by),
|
|
CONSTRAINT fk_crm_events_creator FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- Ein Termin kann mehrere Teilnehmer haben; der Primaerschluessel verhindert
|
|
-- Doppeleintraege.
|
|
CREATE TABLE IF NOT EXISTS crm_event_participants (
|
|
event_id INT UNSIGNED NOT NULL,
|
|
user_id INT UNSIGNED NOT NULL,
|
|
PRIMARY KEY (event_id, user_id),
|
|
KEY idx_crm_event_participants_user (user_id),
|
|
CONSTRAINT fk_crm_ep_event FOREIGN KEY (event_id) REFERENCES crm_events (id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_crm_ep_user FOREIGN KEY (user_id) REFERENCES crm_users (id) ON DELETE CASCADE
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- ===========================================================================
|
|
-- Allgemeine Einstellungen des CRM als Schluessel/Wert. Bewusst ohne
|
|
-- Geheimnisse: Zugangsdaten stehen ausschliesslich in der .env auf dem Server,
|
|
-- damit ein Datenbank-Dump keine Secrets enthaelt.
|
|
-- ===========================================================================
|
|
CREATE TABLE IF NOT EXISTS crm_settings (
|
|
key_name VARCHAR(100) NOT NULL,
|
|
value TEXT NULL,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (key_name)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- ===========================================================================
|
|
-- M365-Postfachanbindung (crm@perinco.de)
|
|
-- ===========================================================================
|
|
|
|
/*
|
|
* Gespiegelte Nachrichten aus dem Postfach. Die Graph-ID ist zu lang fuer einen
|
|
* Unique-Index (utf8mb4), deshalb liegt daneben ihr SHA-256 als Hex - der traegt
|
|
* die Eindeutigkeit. Zeiten stehen in UTC, so wie Graph sie liefert. Die
|
|
* Umrechnung macht das Frontend.
|
|
*
|
|
* ROW_FORMAT=DYNAMIC steht ausdruecklich da: bei COMPACT legt InnoDB von jeder
|
|
* langen Spalte 768 Bytes in die Zeile, und die Summe aus mehreren VARCHAR(512+)
|
|
* und fuenf LONGTEXT reisst dann die Zeilengrenze.
|
|
*/
|
|
CREATE TABLE IF NOT EXISTS crm_mail_messages (
|
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
graph_id VARCHAR(1024) NOT NULL,
|
|
graph_id_hash CHAR(64) NOT NULL,
|
|
internet_message_id VARCHAR(512) NULL,
|
|
conversation_id VARCHAR(512) NULL,
|
|
subject VARCHAR(512) NULL,
|
|
from_name VARCHAR(190) NULL,
|
|
from_email VARCHAR(190) NULL,
|
|
to_json LONGTEXT NULL, -- [{name,email}] als JSON-Text
|
|
cc_json LONGTEXT NULL,
|
|
bcc_json LONGTEXT NULL,
|
|
received_at DATETIME NOT NULL, -- UTC
|
|
sent_at DATETIME NULL, -- UTC
|
|
body_preview VARCHAR(512) NULL,
|
|
body_text LONGTEXT NULL, -- aus HTML gewandelt, Zeilen erhalten
|
|
body_html LONGTEXT NULL, -- entschaerftes HTML fuer die Anzeige
|
|
has_attachments TINYINT(1) NOT NULL DEFAULT 0,
|
|
importance VARCHAR(16) NULL,
|
|
web_link VARCHAR(1024) NULL, -- Outlook-Web-Link
|
|
is_read_in_mailbox TINYINT(1) NOT NULL DEFAULT 0,
|
|
is_deleted TINYINT(1) NOT NULL DEFAULT 0, -- im Postfach geloescht
|
|
crm_deleted_at DATETIME NULL, -- im CRM in den Papierkorb gelegt (UTC)
|
|
crm_deleted_by INT UNSIGNED NULL, -- wer; bewusst ohne Fremdschluessel, s.u.
|
|
crm_purged TINYINT(1) NOT NULL DEFAULT 0, -- Papierkorb geleert
|
|
first_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY uq_crm_mail_graph (graph_id_hash),
|
|
KEY idx_crm_mail_received (received_at),
|
|
KEY idx_crm_mail_from (from_email),
|
|
KEY idx_crm_mail_conversation (conversation_id(191)),
|
|
KEY idx_crm_mail_crm_deleted (crm_deleted_at)
|
|
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
/*
|
|
* Papierkorb des CRM. Zwei Stufen, weil sie zwei verschiedene Dinge bedeuten:
|
|
*
|
|
* crm_deleted_at - liegt im Papierkorb, laesst sich zurueckholen.
|
|
* crm_purged - Papierkorb geleert. Die Zeile bleibt als Merker stehen,
|
|
* ihr Inhalt wird geleert. Ohne diesen Merker wuerde der
|
|
* naechste Abgleich die Mail aus dem Postfach - dort ist sie
|
|
* ja weiterhin vorhanden - sofort wieder einlesen.
|
|
*
|
|
* crm_deleted_by traegt bewusst keinen Fremdschluessel: Spalten lassen sich per
|
|
* ADD COLUMN IF NOT EXISTS idempotent nachziehen, ein Fremdschluessel nicht.
|
|
* Verschwindet der Benutzer, bleibt hier eine Id ohne Namen stehen - der Merker
|
|
* ist die Loeschung selbst, nicht die Person.
|
|
*/
|
|
ALTER TABLE crm_mail_messages ADD COLUMN IF NOT EXISTS crm_deleted_at DATETIME NULL AFTER is_deleted;
|
|
ALTER TABLE crm_mail_messages ADD COLUMN IF NOT EXISTS crm_deleted_by INT UNSIGNED NULL AFTER crm_deleted_at;
|
|
ALTER TABLE crm_mail_messages ADD COLUMN IF NOT EXISTS crm_purged TINYINT(1) NOT NULL DEFAULT 0 AFTER crm_deleted_by;
|
|
ALTER TABLE crm_mail_messages ADD INDEX IF NOT EXISTS idx_crm_mail_crm_deleted (crm_deleted_at);
|
|
|
|
/*
|
|
* Verknuepfung einer Mail mit Kontakt und/oder Opportunity. Absichtlich eine
|
|
* eigene Tabelle statt eines Textanhangs im Notizfeld: so laesst sich eine
|
|
* Verknuepfung wieder loesen, und dieselbe Mail kann an mehreren Stellen haengen.
|
|
* Mehrere NULL-Werte sind in einem Unique-Index erlaubt - damit sperren die
|
|
* beiden Schluessel genau die Doppelung desselben Paares.
|
|
*/
|
|
CREATE TABLE IF NOT EXISTS crm_mail_links (
|
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
message_id INT UNSIGNED NOT NULL,
|
|
lead_id INT UNSIGNED NULL,
|
|
opportunity_id INT UNSIGNED NULL,
|
|
origin ENUM('manuell','automatisch') NOT NULL DEFAULT 'manuell',
|
|
note VARCHAR(500) NULL,
|
|
created_by INT UNSIGNED NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY uq_crm_mail_link_lead (message_id, lead_id),
|
|
UNIQUE KEY uq_crm_mail_link_opp (message_id, opportunity_id),
|
|
KEY idx_crm_mail_link_lead (lead_id),
|
|
KEY idx_crm_mail_link_opp (opportunity_id),
|
|
CONSTRAINT fk_crm_ml_message FOREIGN KEY (message_id) REFERENCES crm_mail_messages (id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_crm_ml_lead FOREIGN KEY (lead_id) REFERENCES crm_leads (id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_crm_ml_opp FOREIGN KEY (opportunity_id) REFERENCES crm_opportunities (id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_crm_ml_user FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
/*
|
|
* Kontakt-Vorschlaege aus Mails. Bewusst NICHT direkt als Kontakt angelegt:
|
|
* das Altsystem hat dafuer Platzhalter-Adressen wie name@kontakt-aus-email.local
|
|
* erfunden und damit die Kontaktliste vermuellt. Hier entsteht ein Kontakt erst
|
|
* bei der Uebernahme - automatisch nur, wenn eine echte E-Mail-Adresse vorliegt.
|
|
*/
|
|
CREATE TABLE IF NOT EXISTS crm_mail_contact_candidates (
|
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
message_id INT UNSIGNED NOT NULL,
|
|
dedupe_hash CHAR(64) NOT NULL, -- aus E-Mail bzw. Name+Telefon
|
|
first_name VARCHAR(120) NULL,
|
|
last_name VARCHAR(120) NULL,
|
|
salutation VARCHAR(32) NULL,
|
|
email VARCHAR(190) NULL,
|
|
phone VARCHAR(64) NULL,
|
|
mobile VARCHAR(64) NULL,
|
|
company VARCHAR(190) NULL,
|
|
position VARCHAR(190) NULL,
|
|
street VARCHAR(190) NULL,
|
|
zip VARCHAR(24) NULL,
|
|
city VARCHAR(120) NULL,
|
|
website VARCHAR(255) NULL,
|
|
linkedin VARCHAR(255) NULL,
|
|
origin ENUM('absender','empfaenger','signatur','text') NOT NULL DEFAULT 'text',
|
|
confidence TINYINT UNSIGNED NOT NULL DEFAULT 50, -- 0-100
|
|
status ENUM('offen','uebernommen','verworfen','dublette') NOT NULL DEFAULT 'offen',
|
|
lead_id INT UNSIGNED NULL, -- gesetzt bei uebernommen und dublette
|
|
decided_by INT UNSIGNED NULL,
|
|
decided_at DATETIME NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY uq_crm_mail_cand (message_id, dedupe_hash),
|
|
KEY idx_crm_mail_cand_status (status),
|
|
KEY idx_crm_mail_cand_email (email),
|
|
CONSTRAINT fk_crm_mc_message FOREIGN KEY (message_id) REFERENCES crm_mail_messages (id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_crm_mc_lead FOREIGN KEY (lead_id) REFERENCES crm_leads (id) ON DELETE SET NULL,
|
|
CONSTRAINT fk_crm_mc_user FOREIGN KEY (decided_by) REFERENCES crm_users (id) ON DELETE SET NULL
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
/*
|
|
* Zustand der Delta-Synchronisation - genau eine Zeile. `delta_link` ist die
|
|
* URL, die Graph fuer den naechsten Aufruf zurueckgegeben hat. `has_more`
|
|
* unterscheidet nextLink (Seite folgt noch) von deltaLink (aktueller Stand).
|
|
* Damit ueberlebt der Abgleich Neustarts und braucht keinen Schreibzugriff auf
|
|
* das Postfach - anders als der isRead-Ansatz des Altsystems.
|
|
*/
|
|
CREATE TABLE IF NOT EXISTS crm_mail_sync_state (
|
|
id TINYINT UNSIGNED NOT NULL DEFAULT 1,
|
|
mailbox VARCHAR(190) NULL,
|
|
delta_link TEXT NULL,
|
|
has_more TINYINT(1) NOT NULL DEFAULT 0,
|
|
last_run_at DATETIME NULL,
|
|
last_success_at DATETIME NULL,
|
|
last_error TEXT NULL,
|
|
last_new_messages INT UNSIGNED NOT NULL DEFAULT 0,
|
|
last_candidates INT UNSIGNED NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (id)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
INSERT IGNORE INTO crm_mail_sync_state (id) VALUES (1);
|
|
|
|
-- ===========================================================================
|
|
-- Akquise: gefundene Projekte, bevor daraus eine Opportunity wird
|
|
--
|
|
-- Der Eingang ist eine Arbeitsliste, kein Archiv: was aelter als 14 Tage ist,
|
|
-- erscheint in der Unteransicht "Archiv", was aelter als 90 Tage ist, wird
|
|
-- geloescht. Beides ergibt sich aus `found_at` - es gibt bewusst keine
|
|
-- Zustandsspalte, die von einem Job gepflegt werden muesste und auseinander
|
|
-- laufen kann.
|
|
--
|
|
-- `converted_at` ist der Merker fuer "in eine Opportunity uebernommen". Er ist
|
|
-- die Wahrheit, nicht `opportunity_id`: wird die Opportunity spaeter geloescht,
|
|
-- faellt der Verweis auf NULL, der Eintrag soll aber nicht wieder im Eingang
|
|
-- auftauchen.
|
|
-- ===========================================================================
|
|
CREATE TABLE IF NOT EXISTS crm_acquisitions (
|
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
found_at DATE NOT NULL, -- Spalte "Datum": wann gefunden
|
|
sender VARCHAR(190) NULL, -- "Von": wer es geschickt hat
|
|
provider VARCHAR(190) NULL, -- "Anbieter": Vermittler, Portal
|
|
channel VARCHAR(60) NULL, -- "Kanal": Mail, Telefon, Portal, ...
|
|
title VARCHAR(190) NOT NULL, -- Projekttitel
|
|
consultant VARCHAR(190) NULL, -- "Berater", fuer den es passt
|
|
link VARCHAR(1024) NULL, -- Ausschreibung; macht den Titel klickbar
|
|
-- Ab hier: steht nicht in der Liste, wandert aber in die Opportunity.
|
|
company VARCHAR(190) NULL, -- Endkunde
|
|
description TEXT NULL,
|
|
daily_rate DECIMAL(10,2) NULL,
|
|
duration_days DECIMAL(6,1) NULL,
|
|
fixed_price DECIMAL(12,2) NULL,
|
|
start_date DATE NULL,
|
|
end_date DATE NULL,
|
|
next_step VARCHAR(500) NULL,
|
|
notes TEXT NULL,
|
|
tags LONGTEXT NULL, -- JSON-Array als Text, wie bei crm_leads
|
|
opportunity_id INT UNSIGNED NULL,
|
|
converted_at DATETIME NULL,
|
|
created_by INT UNSIGNED NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
KEY idx_crm_acq_found (found_at),
|
|
KEY idx_crm_acq_converted (converted_at),
|
|
CONSTRAINT fk_crm_acq_opp FOREIGN KEY (opportunity_id) REFERENCES crm_opportunities (id) ON DELETE SET NULL,
|
|
CONSTRAINT fk_crm_acq_user FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
/*
|
|
* Auswahllisten der Akquise. "Von", "Kanal" und "Berater" sind Dropdowns, deren
|
|
* Inhalte in den Einstellungen gepflegt werden.
|
|
*
|
|
* "Anbieter" gehoert ausdruecklich nicht dazu: dort wird frei getippt, mit
|
|
* Vorschlaegen aus den bisherigen Eintraegen. Neue Vermittler tauchen zu oft auf,
|
|
* als dass sich das Eintragen an einer gepflegten Liste aufhalten sollte.
|
|
*
|
|
* Bewusst eine Tabelle mit Feldkennzeichen statt drei Tabellen: die Felder
|
|
* verhalten sich gleich, und jede weitere Liste ist damit ein Wert im ENUM statt
|
|
* einer neuen Tabelle samt Route.
|
|
*
|
|
* Der Unique-Schluessel greift ueber die Kollation utf8mb4_unicode_ci auch bei
|
|
* abweichender Gross-/Kleinschreibung: "mail" ist neben "Mail" kein neuer Wert.
|
|
*/
|
|
/*
|
|
* Verdichteter Verlauf der Akquise. Jeder Eintrag in crm_acquisitions ist eine
|
|
* versendete Bewerbung; nach 90 Tagen wird er geloescht (siehe dort). Damit das
|
|
* Dashboard trotzdem ueber Jahre rechnen kann, wird vor dem Loeschen genau das
|
|
* gesichert, was die Auswertung braucht: Tag, Berater, Anzahl.
|
|
*
|
|
* Bewusst nur Zahlen, keine Projektdaten - "geloescht" bleibt damit auch
|
|
* geloescht. Der Primaerschluessel erlaubt kein NULL, deshalb steht fuer "ohne
|
|
* Berater" der leere Text.
|
|
*/
|
|
CREATE TABLE IF NOT EXISTS crm_acquisition_history (
|
|
found_on DATE NOT NULL,
|
|
consultant VARCHAR(190) NOT NULL DEFAULT '',
|
|
applications INT UNSIGNED NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (found_on, consultant)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
/*
|
|
* Verdichteter Verlauf der Akquise. Jeder Eintrag in crm_acquisitions ist eine
|
|
* versendete Bewerbung; nach 90 Tagen wird er geloescht (siehe dort). Damit das
|
|
* Dashboard trotzdem ueber Jahre rechnen kann, wird vor dem Loeschen genau das
|
|
* gesichert, was die Auswertung braucht: Tag, Berater, Anzahl.
|
|
*
|
|
* Bewusst nur Zahlen, keine Projektdaten - "geloescht" bleibt damit auch
|
|
* geloescht. Der Primaerschluessel erlaubt kein NULL, deshalb steht fuer "ohne
|
|
* Berater" der leere Text.
|
|
*/
|
|
CREATE TABLE IF NOT EXISTS crm_acquisition_history (
|
|
found_on DATE NOT NULL,
|
|
consultant VARCHAR(190) NOT NULL DEFAULT '',
|
|
applications INT UNSIGNED NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (found_on, consultant)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
CREATE TABLE IF NOT EXISTS crm_acquisition_options (
|
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
field ENUM('sender','channel','consultant') NOT NULL,
|
|
value VARCHAR(190) NOT NULL,
|
|
created_by INT UNSIGNED NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY uq_crm_acq_opt (field, value),
|
|
CONSTRAINT fk_crm_acq_opt_user FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|