feat(akquise): Papierkorb fuer geloeschte Eintraege

Loeschen in der Akquise war ein echtes DELETE: der Eintrag stand danach
in keiner Liste, auch nicht im Archiv, und war nicht zurueckholbar. Jetzt
setzt es `deleted_at`/`deleted_by`, die Zeile bleibt 90 Tage stehen.

Der Papierkorb ist die eine Ausnahme von der Regel, dass diese Tabelle
keinen Zustand fuehrt: Loeschen von Hand ist eine Entscheidung, kein
Ablauf. Was dort liegt, blenden alle anderen Abfragen ueber NOT_DELETED
aus - Eingang, Archiv, Menuezahlen und Dashboard. Der Aufraeumlauf ueber
`found_at` laesst geloeschte Zeilen ausdruecklich liegen, sonst waere ein
alter Fund im Papierkorb sofort weg; der Papierkorb raeumt ab
`deleted_at`.

Zwei Rueckwege, deshalb zwei Routen. `/reopen` (bisher `/restore`) holt
aus dem Archiv in den Eingang und setzt das Funddatum auf heute.
`/restore` holt aus dem Papierkorb zurueck und laesst das Funddatum in
Ruhe - der Eintrag soll dort auftauchen, wo er hingehoert, also im
Archiv, wenn er alt ist. Die Meldung nennt das Ziel.

Dazu `DELETE /:id/permanent` und `POST /trash/empty`. Im Papierkorb wird
nicht gepflegt: Bearbeiten und Uebernehmen antworten 409.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-03 10:17:21 +02:00
co-authored by Claude Opus 5
parent f319b8207b
commit 6e66bd39d7
17 changed files with 732 additions and 87 deletions
+17
View File
@@ -10,6 +10,10 @@
*
* Die Zeichenketten sind SQL-Bedingungen und erwarten die Aliase `a` fuer
* crm_acquisitions und `opp` fuer die per LEFT JOIN angehaengte Opportunity.
*
* `NOT_DELETED` gehoert zu jeder dieser Abfragen dazu: der Papierkorb ist eine
* dritte Liste, und was dort liegt, hat in Eingang, Archiv und Auswertung
* nichts zu suchen.
*/
/** Grenze zwischen Eingang und Archiv, in Tagen. */
@@ -18,6 +22,19 @@ export const ARCHIVE_AFTER_DAYS = 14;
/** Ab hier loescht das Archiv selbst. */
export const DELETE_AFTER_DAYS = 90;
/** So lange liegt ein von Hand geloeschter Eintrag im Papierkorb. */
export const TRASH_DELETE_AFTER_DAYS = 90;
/**
* Nicht im Papierkorb. Steht in jeder Abfrage von Eingang, Archiv, Zaehlern und
* Auswertung: ein geloeschter Eintrag ist ueberall weg, nur nicht in der
* Datenbank.
*/
export const NOT_DELETED = 'a.deleted_at IS NULL';
/** Liegt im Papierkorb. */
export const IN_TRASH = 'a.deleted_at IS NOT NULL';
/**
* Die Opportunity zu einem Eintrag ist verschwunden - im Papierkorb oder
* endgueltig geloescht. `opp.id IS NULL` deckt beides ab: nie uebernommen
+217 -36
View File
@@ -5,9 +5,12 @@ import {
ARCHIVE_AFTER_DAYS,
DELETE_AFTER_DAYS,
IN_ARCHIVE,
IN_TRASH,
IS_OPEN,
NOT_DELETED,
NOT_DUE,
OPPORTUNITY_GONE,
TRASH_DELETE_AFTER_DAYS,
} from '../acquisitionRules.js';
const router = express.Router();
@@ -16,13 +19,18 @@ router.use(requireAuth);
/*
* Akquise: gefundene Projekte, aus denen eine Opportunity werden kann.
*
* Drei Zustaende, alle aus `found_at` und `converted_at` abgeleitet - es gibt
* keine Spalte, die ein Job pflegen muesste:
* Die Listen ergeben sich aus `found_at` und `converted_at` - es gibt keine
* Spalte, die ein Job pflegen muesste:
*
* Eingang ("Akquise") found_at >= heute - 14 Tage, nicht uebernommen
* Archiv aelter als 14 Tage, nicht uebernommen
* weg aelter als 90 Tage (wird geloescht) oder uebernommen
*
* Dazu kommt der Papierkorb, und der ist die eine Ausnahme: Loeschen von Hand
* ist eine Entscheidung, kein Ablauf, deshalb steht sie in `deleted_at`. Was
* dort liegt, taucht in keiner der drei Listen und in keiner Auswertung auf -
* dafuer sorgt `NOT_DELETED` in jeder Abfrage.
*
* Uebernommene Eintraege bleiben in der Tabelle stehen: sie sind der Nachweis,
* woher eine Opportunity kommt. Nur der Eingang und das Archiv zeigen sie nicht.
*/
@@ -154,12 +162,14 @@ function mapAcquisition(row) {
const SELECT_ACQUISITIONS = `
SELECT a.*,
creator.name AS created_by_name,
deleter.name AS deleted_by_name,
opp.title AS opportunity_title,
opp.stage AS opportunity_stage,
opp.deleted_at AS opportunity_deleted_at,
${OPPORTUNITY_GONE} AS opportunity_gone
FROM crm_acquisitions a
LEFT JOIN crm_users creator ON creator.id = a.created_by
LEFT JOIN crm_users deleter ON deleter.id = a.deleted_by
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
`;
@@ -170,7 +180,8 @@ async function fetchAcquisition(id) {
/**
* Alles aelter als 90 Tage entfernen. Uebernommene Eintraege bleiben - sie
* gehoeren zur Opportunity, nicht zum Archiv.
* gehoeren zur Opportunity, nicht zum Archiv. Was im Papierkorb liegt, bleibt
* ebenfalls liegen: dort laeuft die eigene Frist ab `deleted_at`.
*
* Das laeuft beim Abruf der Liste, hoechstens aber einmal pro Stunde je Prozess.
* Ein eigener Zeitplan waere eine weitere Stelle, die ausfallen kann; und die
@@ -201,6 +212,7 @@ async function purgeOldEntries() {
FROM crm_acquisitions a
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
WHERE ${IS_OPEN}
AND ${NOT_DELETED}
AND a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)
GROUP BY a.found_at, COALESCE(a.consultant, '')
ON DUPLICATE KEY UPDATE
@@ -217,11 +229,30 @@ async function purgeOldEntries() {
`DELETE a FROM crm_acquisitions a
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
WHERE ${IS_OPEN}
AND ${NOT_DELETED}
AND a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)`,
[DELETE_AFTER_DAYS]
);
/*
* Der Papierkorb raeumt sich nach derselben Frist, aber ab dem Loeschen:
* ein alter Fund, den jemand heute wegwirft, soll nicht sofort verschwinden.
* Ohne Verlaufseintrag - von Hand geloescht heisst, es soll nicht mitzaehlen.
*/
const [purgedTrash] = await conn.query(
`DELETE FROM crm_acquisitions
WHERE deleted_at IS NOT NULL
AND deleted_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)`,
[TRASH_DELETE_AFTER_DAYS]
);
await conn.commit();
if (purgedTrash.affectedRows > 0) {
console.log(
`[Akquise] ${purgedTrash.affectedRows} Eintrag/Eintraege nach ${TRASH_DELETE_AFTER_DAYS} Tagen aus dem Papierkorb geloescht.`
);
}
if (result.affectedRows > 0) {
console.log(
`[Akquise] ${result.affectedRows} Eintrag/Eintraege aelter als ${DELETE_AFTER_DAYS} Tage geloescht (Anzahl im Verlauf gesichert).`
@@ -463,13 +494,64 @@ router.delete('/options/:id', async (req, res, next) => {
}
});
// GET /api/acquisitions?view=eingang|archiv|alle
/**
* Fristen, Auswahllisten und alle drei Zaehler - dasselbe Beiwerk fuer jede
* Ansicht. Die Zahlen kommen mit der Liste, weil die Oberflaeche sie am Menue
* zeigt und sonst drei Listen laden muesste, um drei Zahlen zu kennen.
*/
async function listMeta() {
const [[counts]] = await pool.query(
`SELECT
SUM(${IS_OPEN} AND ${NOT_DELETED} AND a.converted_at IS NULL AND ${NOT_DUE}) AS eingang,
SUM(${IS_OPEN} AND ${NOT_DELETED} AND ${IN_ARCHIVE} AND ${NOT_DUE}) AS archiv,
SUM(${IN_TRASH}) AS papierkorb
FROM crm_acquisitions a
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id`,
// Reihenfolge der Platzhalter: Eingangsfrist, Archivfrist, Loeschfrist.
[ARCHIVE_AFTER_DAYS, ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS]
);
// Auswahllisten und Anbieter-Vorschlaege kommen mit: die Eingabe braucht sie
// sofort, und ein zweiter Aufruf nur fuer ein paar kurze Listen waere
// verschenkt.
return {
archive_after_days: ARCHIVE_AFTER_DAYS,
delete_after_days: DELETE_AFTER_DAYS,
trash_delete_after_days: TRASH_DELETE_AFTER_DAYS,
options: await loadOptions(),
provider_suggestions: await loadProviderSuggestions(),
counts: {
eingang: Number(counts.eingang ?? 0),
archiv: Number(counts.archiv ?? 0),
papierkorb: Number(counts.papierkorb ?? 0),
},
};
}
// GET /api/acquisitions?view=eingang|archiv|papierkorb|alle
router.get('/', async (req, res, next) => {
try {
await purgeOldEntries();
const view = String(req.query.view ?? 'eingang');
const where = [IS_OPEN];
/*
* Der Papierkorb ist die einzige Liste, die geloeschte Eintraege zeigt -
* und die einzige ohne `IS_OPEN`: ob die Opportunity noch lebt, sagt hier
* nichts, weggeworfen ist weggeworfen.
*/
if (view === 'papierkorb') {
const [trash] = await pool.query(
`${SELECT_ACQUISITIONS} WHERE ${IN_TRASH}
ORDER BY a.deleted_at DESC, a.id DESC`
);
return res.json({
...(await listMeta()),
entries: trash.map(mapAcquisition),
});
}
const where = [IS_OPEN, NOT_DELETED];
const params = [];
/*
@@ -497,31 +579,8 @@ router.get('/', async (req, res, next) => {
params
);
// Beide Zaehler in einem Rutsch: die Oberflaeche zeigt sie am Menue und
// muesste sonst zwei Listen laden, um eine Zahl zu kennen.
const [[counts]] = await pool.query(
`SELECT
SUM(a.converted_at IS NULL AND ${NOT_DUE}) AS eingang,
SUM(${IN_ARCHIVE} AND ${NOT_DUE}) AS archiv
FROM crm_acquisitions a
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
WHERE ${IS_OPEN}`,
// Reihenfolge der Platzhalter: Eingangsfrist, Archivfrist, Loeschfrist.
[ARCHIVE_AFTER_DAYS, ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS]
);
// Auswahllisten und Anbieter-Vorschlaege kommen mit: die Eingabe braucht sie
// sofort, und ein zweiter Aufruf nur fuer ein paar kurze Listen waere
// verschenkt.
res.json({
archive_after_days: ARCHIVE_AFTER_DAYS,
delete_after_days: DELETE_AFTER_DAYS,
options: await loadOptions(),
provider_suggestions: await loadProviderSuggestions(),
counts: {
eingang: Number(counts.eingang ?? 0),
archiv: Number(counts.archiv ?? 0),
},
...(await listMeta()),
entries: rows.map(mapAcquisition),
});
} catch (err) {
@@ -566,6 +625,12 @@ router.patch('/:id', async (req, res, next) => {
error: 'Diesen Eintrag darf nur ändern, wer ihn angelegt hat, oder ein Administrator.',
});
}
// Im Papierkorb wird nichts gepflegt - erst zurückholen, dann ändern.
if (existing.deleted_at !== null) {
return res
.status(409)
.json({ error: 'Der Eintrag liegt im Papierkorb. Erst wiederherstellen, dann bearbeiten.' });
}
/*
* Gesperrt ist der Eintrag nur, solange seine Opportunity lebt - dort wird
* dann gepflegt. Ist sie im Papierkorb oder geloescht, ist der Eintrag
@@ -607,9 +672,10 @@ router.patch('/:id', async (req, res, next) => {
});
/*
* POST /api/acquisitions/:id/restore
* POST /api/acquisitions/:id/reopen
*
* Zurueck in den Eingang. Eingang und Archiv trennt allein das Funddatum -
* Zurueck in den Eingang. Nicht zu verwechseln mit `/restore`: das holt aus dem
* Papierkorb zurueck, hier geht es um einen Eintrag, der nur alt ist. Eingang und Archiv trennt allein das Funddatum -
* deshalb setzt das Wiederherstellen es auf heute. Eine eigene Spalte
* "wiederhergestellt" waere ein zweiter Zustand, der zum Datum driften kann.
*
@@ -619,7 +685,7 @@ router.patch('/:id', async (req, res, next) => {
* verloren - sie ist geloescht oder im Papierkorb, und der Eintrag ist wieder
* das Original.
*/
router.post('/:id/restore', async (req, res, next) => {
router.post('/:id/reopen', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Akquise-ID.' });
@@ -630,11 +696,16 @@ router.post('/:id/restore', async (req, res, next) => {
if (!mayModify(req.user, existing)) {
return res.status(403).json({
error:
'Diesen Eintrag darf nur wiederherstellen, wer ihn angelegt hat, oder ein Administrator.',
'Diesen Eintrag darf nur zurückholen, wer ihn angelegt hat, oder ein Administrator.',
});
}
if (existing.deleted_at !== null) {
return res
.status(409)
.json({ error: 'Der Eintrag liegt im Papierkorb - dort steht das Wiederherstellen.' });
}
// Lebt die Opportunity noch, gehoert der Eintrag zu ihr und steht ohnehin
// in keiner der beiden Listen - dann gibt es nichts wiederherzustellen.
// in keiner der beiden Listen - dann gibt es nichts zurueckzuholen.
if (existing.converted_at !== null && !(await opportunityGone(existing.opportunity_id))) {
return res
.status(409)
@@ -653,18 +724,85 @@ router.post('/:id/restore', async (req, res, next) => {
}
});
// DELETE /api/acquisitions/:id
/*
* POST /api/acquisitions/trash/empty - Papierkorb endgueltig leeren
*
* Vor den Routen mit :id registriert, damit "trash" nicht als Id gelesen wird -
* dasselbe Muster wie bei den Opportunities.
*
* Ohne Verlaufseintrag: von Hand geloescht heisst, die Bewerbung soll in der
* Auswertung nicht mitzaehlen.
*/
router.post('/trash/empty', async (req, res, next) => {
try {
const [result] = await pool.query(
'DELETE FROM crm_acquisitions WHERE deleted_at IS NOT NULL'
);
res.json({ ok: true, purged: result.affectedRows });
} catch (err) {
next(err);
}
});
/*
* DELETE /api/acquisitions/:id - in den Papierkorb legen
*
* Bewusst kein echtes Loeschen mehr: vorher war ein Fehlklick endgueltig, der
* Eintrag stand danach in keiner Liste, auch nicht im Archiv. Endgueltig
* entfernt ihn erst `DELETE /:id/permanent`, das Leeren des Papierkorbs oder
* der Ablauf der Frist.
*/
router.delete('/:id', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Akquise-ID.' });
try {
const [rows] = await pool.query(
'SELECT created_by FROM crm_acquisitions WHERE id = ? LIMIT 1',
'SELECT created_by, deleted_at FROM crm_acquisitions WHERE id = ? LIMIT 1',
[id]
);
const existing = rows[0];
if (!existing) return res.status(404).json({ error: 'Eintrag nicht gefunden.' });
if (existing.deleted_at !== null) {
return res.status(409).json({ error: 'Dieser Eintrag liegt schon im Papierkorb.' });
}
if (!mayModify(req.user, existing)) {
return res.status(403).json({
error: 'Diesen Eintrag darf nur löschen, wer ihn angelegt hat, oder ein Administrator.',
});
}
await pool.query(
'UPDATE crm_acquisitions SET deleted_at = UTC_TIMESTAMP(), deleted_by = ? WHERE id = ?',
[req.user.id, id]
);
res.json({ ok: true });
} catch (err) {
next(err);
}
});
/*
* DELETE /api/acquisitions/:id/permanent - endgueltig loeschen
*
* Nur aus dem Papierkorb heraus. Wer einen Eintrag direkt aus Eingang oder
* Archiv endgueltig loeschen koennte, haette den Papierkorb gleich wieder
* ausgehebelt.
*/
router.delete('/:id/permanent', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Akquise-ID.' });
try {
const [rows] = await pool.query(
'SELECT created_by, deleted_at FROM crm_acquisitions WHERE id = ? LIMIT 1',
[id]
);
const existing = rows[0];
if (!existing) return res.status(404).json({ error: 'Eintrag nicht gefunden.' });
if (existing.deleted_at === null) {
return res.status(409).json({ error: 'Dieser Eintrag liegt nicht im Papierkorb.' });
}
if (!mayModify(req.user, existing)) {
return res.status(403).json({
error: 'Diesen Eintrag darf nur löschen, wer ihn angelegt hat, oder ein Administrator.',
@@ -678,6 +816,44 @@ router.delete('/:id', async (req, res, next) => {
}
});
/*
* POST /api/acquisitions/:id/restore - aus dem Papierkorb zurueckholen
*
* Das Funddatum bleibt, wie es war: der Eintrag soll dort wieder auftauchen, wo
* er hingehoert - im Eingang, wenn er jung ist, sonst im Archiv. Aus dem Archiv
* in den Eingang holt ihn danach `/reopen`.
*/
router.post('/:id/restore', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Akquise-ID.' });
try {
const [rows] = await pool.query(
'SELECT created_by, deleted_at FROM crm_acquisitions WHERE id = ? LIMIT 1',
[id]
);
const existing = rows[0];
if (!existing) return res.status(404).json({ error: 'Eintrag nicht gefunden.' });
if (existing.deleted_at === null) {
return res.status(409).json({ error: 'Dieser Eintrag liegt nicht im Papierkorb.' });
}
if (!mayModify(req.user, existing)) {
return res.status(403).json({
error:
'Diesen Eintrag darf nur wiederherstellen, wer ihn angelegt hat, oder ein Administrator.',
});
}
await pool.query(
'UPDATE crm_acquisitions SET deleted_at = NULL, deleted_by = NULL WHERE id = ?',
[id]
);
res.json(await fetchAcquisition(id));
} catch (err) {
next(err);
}
});
/*
* POST /api/acquisitions/:id/opportunity
*
@@ -696,6 +872,11 @@ router.post('/:id/opportunity', async (req, res, next) => {
const [rows] = await conn.query('SELECT * FROM crm_acquisitions WHERE id = ? LIMIT 1', [id]);
const entry = rows[0];
if (!entry) return res.status(404).json({ error: 'Eintrag nicht gefunden.' });
if (entry.deleted_at !== null) {
return res
.status(409)
.json({ error: 'Der Eintrag liegt im Papierkorb. Erst wiederherstellen, dann übernehmen.' });
}
// Noch einmal uebernehmen darf man, wenn die erste Opportunity weg ist.
if (entry.converted_at !== null && !(await opportunityGone(entry.opportunity_id))) {
return res.status(409).json({
+8 -3
View File
@@ -6,7 +6,9 @@ import {
ARCHIVE_AFTER_DAYS,
DELETE_AFTER_DAYS,
IN_ARCHIVE,
IN_TRASH,
IS_OPEN,
NOT_DELETED,
NOT_DUE,
} from '../acquisitionRules.js';
@@ -14,8 +16,8 @@ const router = express.Router();
router.use(requireAuth);
/*
* Zaehler fuer die Seitenleiste: Akquise-Archiv, Papierkorb der Opportunities,
* Papierkorb der Mails und die unpassenden Scanner-Treffer.
* Zaehler fuer die Seitenleiste: Akquise-Archiv und deren Papierkorb, Papierkorb
* der Opportunities, Papierkorb der Mails und die unpassenden Scanner-Treffer.
*
* Ein Endpunkt statt vier: die Zahlen stehen alle im Menue und sollen zusammen
* kommen. Die Ansichten selbst laden weiter ihre eigenen Daten - hier gibt es
@@ -35,7 +37,9 @@ router.get('/', async (req, res, next) => {
damit Zahl und Liste nicht auseinanderlaufen. */
(SELECT COUNT(*) FROM crm_acquisitions a
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
WHERE ${IS_OPEN} AND ${IN_ARCHIVE} AND ${NOT_DUE}) AS acquisition_archive,
WHERE ${IS_OPEN} AND ${NOT_DELETED} AND ${IN_ARCHIVE}
AND ${NOT_DUE}) AS acquisition_archive,
(SELECT COUNT(*) FROM crm_acquisitions a WHERE ${IN_TRASH}) AS acquisition_trash,
(SELECT COUNT(*) FROM crm_opportunities
WHERE deleted_at IS NOT NULL) AS opportunity_trash,
(SELECT COUNT(*) FROM crm_mail_messages
@@ -52,6 +56,7 @@ router.get('/', async (req, res, next) => {
res.json({
acquisition_archive: Number(counts.acquisition_archive),
acquisition_trash: Number(counts.acquisition_trash),
opportunity_trash: Number(counts.opportunity_trash),
mail_trash: Number(counts.mail_trash),
scanner_rejected: scannerRejected,
+5 -2
View File
@@ -60,7 +60,8 @@ router.get('/', async (req, res, next) => {
const [applicationRows] = await pool.query(
`SELECT ${BUCKETS[bucket]('found_at')} AS bucket, COUNT(*) AS count
FROM crm_acquisitions
WHERE found_at BETWEEN ? AND ?
WHERE deleted_at IS NULL
AND found_at BETWEEN ? AND ?
GROUP BY bucket`,
[from, to]
);
@@ -83,6 +84,7 @@ router.get('/', async (req, res, next) => {
FROM crm_acquisitions a
JOIN crm_opportunities opp ON opp.id = a.opportunity_id
WHERE a.converted_at IS NOT NULL
AND a.deleted_at IS NULL
AND opp.deleted_at IS NULL
AND a.converted_at BETWEEN ? AND ?
GROUP BY bucket`,
@@ -102,7 +104,8 @@ router.get('/', async (req, res, next) => {
`SELECT consultant, SUM(count) AS count FROM (
SELECT COALESCE(NULLIF(TRIM(consultant), ''), '') AS consultant, COUNT(*) AS count
FROM crm_acquisitions
WHERE found_at BETWEEN ? AND ?
WHERE deleted_at IS NULL
AND found_at BETWEEN ? AND ?
GROUP BY 1
UNION ALL
SELECT consultant, SUM(applications)
+21
View File
@@ -424,15 +424,36 @@ CREATE TABLE IF NOT EXISTS crm_acquisitions (
opportunity_id INT UNSIGNED NULL,
converted_at DATETIME 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_acq_found (found_at),
KEY idx_crm_acq_converted (converted_at),
KEY idx_crm_acq_deleted (deleted_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;
/*
* Papierkorb der Akquise. Loeschen setzt `deleted_at`, die Zeile bleibt stehen -
* vorher war es ein echtes DELETE, und ein Fehlklick war damit endgueltig: der
* Eintrag tauchte in keiner Liste mehr auf, auch nicht im Archiv.
*
* Der Papierkorb hat seine eigene Uhr (`deleted_at`), nicht die des Funddatums:
* ein von Hand geloeschter Eintrag liegt 90 Tage dort, gleich wie alt der Fund
* ist. Der Aufraeumlauf ueber `found_at` laesst geloeschte Zeilen deshalb
* ausdruecklich liegen - sonst wuerde ein alter Fund im Papierkorb sofort
* verschwinden.
*
* `deleted_by` traegt bewusst keinen Fremdschluessel: Spalten lassen sich per
* ADD COLUMN IF NOT EXISTS idempotent nachziehen, ein Fremdschluessel nicht.
*/
ALTER TABLE crm_acquisitions ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL AFTER created_by;
ALTER TABLE crm_acquisitions ADD COLUMN IF NOT EXISTS deleted_by INT UNSIGNED NULL AFTER deleted_at;
ALTER TABLE crm_acquisitions ADD INDEX IF NOT EXISTS idx_crm_acq_deleted (deleted_at);
/*
* Auswahllisten der Akquise. "Von", "Kanal" und "Berater" sind Dropdowns, deren
* Inhalte in den Einstellungen gepflegt werden.