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:
co-authored by
Claude Opus 5
parent
0ad7b32dbf
commit
33dedaedfb
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user