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
@@ -1050,14 +1050,17 @@ router.post('/:id/opportunity', async (req, res, next) => {
|
||||
for (const consultant of targets) {
|
||||
const [created] = await conn.query(
|
||||
`INSERT INTO crm_opportunities
|
||||
(title, company, consultant, description, stage, probability, daily_rate,
|
||||
duration_days, fixed_price, start_date, end_date, next_step, tags, notes,
|
||||
owner_id, created_by)
|
||||
VALUES (?, ?, ?, ?, 'Erstgespraech Berater', 10, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(title, company, consultant, acquisition_id, description, stage, probability,
|
||||
daily_rate, duration_days, fixed_price, start_date, end_date, next_step, tags,
|
||||
notes, owner_id, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, 'Erstgespraech Berater', 10, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
entry.title,
|
||||
entry.company,
|
||||
consultant,
|
||||
// Jede der Opportunities kennt ihren Eintrag - sonst faende das
|
||||
// Zuruecknehmen der Uebernahme nur die erste.
|
||||
id,
|
||||
entry.description,
|
||||
entry.daily_rate,
|
||||
entry.duration_days,
|
||||
@@ -1078,7 +1081,8 @@ router.post('/:id/opportunity', async (req, res, next) => {
|
||||
* `opportunity_id` ist eine Spalte, also merkt sich der Eintrag die erste.
|
||||
* Sie entscheidet nur darueber, ob er wieder im Eingang auftaucht: wird sie
|
||||
* geloescht, laesst sich erneut uebernehmen. Die uebrigen bleiben davon
|
||||
* unberuehrt - dafuer steht ihre Herkunft in den Notizen.
|
||||
* unberuehrt - dafuer steht ihre Herkunft in den Notizen und in ihrer
|
||||
* eigenen `acquisition_id` (Rueckweg: POST /api/opportunities/:id/acquisition).
|
||||
*/
|
||||
await conn.query(
|
||||
'UPDATE crm_acquisitions SET opportunity_id = ?, converted_at = UTC_TIMESTAMP() WHERE id = ?',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -472,6 +472,26 @@ ALTER TABLE crm_acquisitions ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL A
|
||||
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);
|
||||
|
||||
/*
|
||||
* Rueckweg von der Opportunity in die Akquise.
|
||||
*
|
||||
* `crm_acquisitions.opportunity_id` merkt sich nur die erste Opportunity einer
|
||||
* Uebernahme - bei drei Beratern entstehen aber drei. Wer eine Uebernahme
|
||||
* zuruecknimmt, muss alle finden, deshalb zeigt jede Opportunity selbst auf
|
||||
* ihren Eintrag. Ohne Fremdschluessel, wie `deleted_by`: die Akquise loescht
|
||||
* nach 90 Tagen, und ein ADD COLUMN IF NOT EXISTS laesst sich wiederholen.
|
||||
*
|
||||
* Das UPDATE traegt den Verweis fuer Uebernahmen von vor dieser Spalte nach -
|
||||
* aber nur fuer die erste Opportunity, von den weiteren weiss die Datenbank
|
||||
* nichts. Es fasst nur leere Felder an und ist damit bei jedem Start harmlos.
|
||||
*/
|
||||
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS acquisition_id INT UNSIGNED NULL AFTER consultant;
|
||||
ALTER TABLE crm_opportunities ADD INDEX IF NOT EXISTS idx_crm_opp_acquisition (acquisition_id);
|
||||
UPDATE crm_opportunities o
|
||||
JOIN crm_acquisitions a ON a.opportunity_id = o.id
|
||||
SET o.acquisition_id = a.id
|
||||
WHERE o.acquisition_id IS NULL;
|
||||
|
||||
/*
|
||||
* Auswahllisten der Akquise. "Von", "Kanal" und "Berater" sind Dropdowns, deren
|
||||
* Inhalte in den Einstellungen gepflegt werden.
|
||||
|
||||
Reference in New Issue
Block a user