feat(opportunities): Uebernahme zurueck in die Akquise schieben

Im Dialog einer Opportunity aus der Akquise nimmt "Zurueck in die Akquise"
eine versehentliche Uebernahme zurueck: alle Opportunities aus dem Eintrag
wandern in den Papierkorb, der Eintrag steht mit seinem Funddatum wieder in
Eingang bzw. Archiv. Neue Spalte crm_opportunities.acquisition_id, damit bei
mehreren Beratern alle gefunden werden. Wiederherstellen haengt den Eintrag
wieder an die Opportunity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-17 13:12:03 +02:00
co-authored by Claude Opus 5
parent 0ad7b32dbf
commit 33dedaedfb
9 changed files with 270 additions and 19 deletions
+130 -6
View File
@@ -89,11 +89,13 @@ const SELECT_OPPORTUNITIES = `
owner.name AS owner_name,
owner.color AS owner_color,
owner.avatar AS owner_avatar,
deleter.name AS deleted_by_name
deleter.name AS deleted_by_name,
(src.id IS NOT NULL AND src.deleted_at IS NULL) AS acquisition_available
FROM crm_opportunities o
LEFT JOIN crm_users creator ON creator.id = o.created_by
LEFT JOIN crm_users owner ON owner.id = o.owner_id
LEFT JOIN crm_users deleter ON deleter.id = o.deleted_by
LEFT JOIN crm_acquisitions src ON src.id = o.acquisition_id
`;
/** Ab hier raeumt der Papierkorb selbst auf. */
@@ -136,6 +138,8 @@ function mapOpportunity(row, contacts) {
duration_days: row.duration_days === null ? null : Number(row.duration_days),
fixed_price: row.fixed_price === null ? null : Number(row.fixed_price),
probability: Number(row.probability),
// Aus der Abfrage kommt 0/1 - im Frontend soll es ein Schalter sein.
acquisition_available: Boolean(Number(row.acquisition_available)),
tags: parseTags(row.tags),
contacts: contacts ?? [],
};
@@ -552,7 +556,7 @@ router.post('/:id/restore', async (req, res, next) => {
try {
const [rows] = await pool.query(
'SELECT created_by, owner_id, deleted_at FROM crm_opportunities WHERE id = ? LIMIT 1',
'SELECT created_by, owner_id, acquisition_id, deleted_at FROM crm_opportunities WHERE id = ? LIMIT 1',
[id]
);
const existing = rows[0];
@@ -567,14 +571,134 @@ router.post('/:id/restore', async (req, res, next) => {
});
}
await pool.query(
'UPDATE crm_opportunities SET deleted_at = NULL, deleted_by = NULL WHERE id = ?',
[id]
);
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
await conn.query(
'UPDATE crm_opportunities SET deleted_at = NULL, deleted_by = NULL WHERE id = ?',
[id]
);
/*
* Kommt die Opportunity aus der Akquise, haengt der Eintrag wieder an ihr.
* Sonst stuende dasselbe Projekt nach einem "Zurueck in die Akquise" und
* dem Wiederherstellen doppelt da: als Opportunity und im Eingang.
*
* Nur wenn der Eintrag frei ist - nie uebernommen oder seine Opportunity
* weg. Haengt er an einer anderen, lebenden Opportunity, bleibt er dort.
* `converted_at` bleibt, wenn es schon gesetzt ist, sonst gilt jetzt.
*/
if (existing.acquisition_id !== null) {
await conn.query(
`UPDATE crm_acquisitions a
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
SET a.opportunity_id = ?,
a.converted_at = COALESCE(a.converted_at, UTC_TIMESTAMP())
WHERE a.id = ?
AND a.deleted_at IS NULL
AND (a.converted_at IS NULL OR opp.id IS NULL OR opp.id = ? OR opp.deleted_at IS NOT NULL)`,
[id, existing.acquisition_id, id]
);
}
await conn.commit();
} catch (err) {
await conn.rollback().catch(() => {});
throw err;
} finally {
conn.release();
}
res.json(await fetchOpportunity(id));
} catch (err) {
next(err);
}
});
/*
* POST /api/opportunities/:id/acquisition - zurueck in die Akquise
*
* Nimmt eine Uebernahme zurueck, die aus Versehen passiert ist. Der Eintrag der
* Akquise wird wieder frei (`converted_at` und `opportunity_id` leer) und steht
* dort, wo sein Funddatum ihn hinstellt: jung im Eingang, sonst im Archiv. Das
* Datum bleibt, wie es war - es ist der Tag der Bewerbung und zaehlt so in der
* Auswertung.
*
* Die Opportunities wandern in den Papierkorb, und zwar alle aus demselben
* Eintrag: bei drei Beratern sind bei der Uebernahme drei entstanden, und wer
* sie zuruecknimmt, meint alle. Papierkorb statt echtem Loeschen, weil an einer
* Opportunity inzwischen Ansprechpartner oder Mails haengen koennen. Wird eine
* davon wiederhergestellt, haengt der Eintrag wieder an ihr (siehe /restore).
*
* Alles in einer Transaktion - ein Eintrag im Eingang neben einer noch lebenden
* Opportunity waere genau die Dublette, die hier vermieden werden soll.
*/
router.post('/:id/acquisition', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Opportunity-ID.' });
const conn = await pool.getConnection();
try {
const [rows] = await conn.query(
'SELECT id, created_by, owner_id, acquisition_id, deleted_at FROM crm_opportunities WHERE id = ? LIMIT 1',
[id]
);
const existing = rows[0];
if (!existing) return res.status(404).json({ error: 'Opportunity nicht gefunden.' });
if (existing.deleted_at !== null) {
return res.status(409).json({ error: 'Diese Opportunity liegt im Papierkorb.' });
}
if (existing.acquisition_id === null) {
return res
.status(409)
.json({ error: 'Diese Opportunity wurde nicht aus der Akquise übernommen.' });
}
const [entries] = await conn.query(
'SELECT id, title, deleted_at FROM crm_acquisitions WHERE id = ? LIMIT 1',
[existing.acquisition_id]
);
const entry = entries[0];
if (!entry) {
return res
.status(409)
.json({ error: 'Der Eintrag in der Akquise existiert nicht mehr.' });
}
if (entry.deleted_at !== null) {
return res.status(409).json({
error: 'Der Eintrag liegt im Papierkorb der Akquise. Dort erst wiederherstellen.',
});
}
const [siblings] = await conn.query(
`SELECT id, created_by, owner_id FROM crm_opportunities
WHERE acquisition_id = ? AND deleted_at IS NULL`,
[entry.id]
);
// Wer eine davon nicht loeschen darf, darf die Uebernahme nicht zuruecknehmen.
if (!siblings.every((row) => mayModify(req.user, row))) {
return res.status(403).json({
error:
'Zurücknehmen darf nur, wer alle Opportunities aus diesem Eintrag löschen darf (Ersteller, Zuständiger oder Administrator).',
});
}
await conn.beginTransaction();
await conn.query(
`UPDATE crm_opportunities SET deleted_at = UTC_TIMESTAMP(), deleted_by = ?
WHERE acquisition_id = ? AND deleted_at IS NULL`,
[req.user.id, entry.id]
);
await conn.query(
'UPDATE crm_acquisitions SET converted_at = NULL, opportunity_id = NULL WHERE id = ?',
[entry.id]
);
await conn.commit();
res.json({ ok: true, acquisition_id: entry.id, trashed: siblings.map((row) => row.id) });
} catch (err) {
await conn.rollback().catch(() => {});
next(err);
} finally {
conn.release();
}
});
export default router;