feat(akquise): geloeschte Opportunity gibt die Bewerbung ans Archiv zurueck
Wer eine Opportunity loescht, hat bisher auch die Bewerbung dahinter verloren:
aus der Akquise war sie mit der Uebernahme heraus, und die Opportunity gab es
nicht mehr. Der Eintrag stand nirgends.
Jetzt taucht er im Archiv auf - abgeleitet, nicht gespeichert: `converted_at`
bleibt stehen, sichtbar wird der Eintrag, solange seine Opportunity im
Papierkorb liegt oder endgueltig weg ist. Wird sie zurueckgeholt, verschwindet
der Eintrag von selbst wieder. Es gibt damit keinen Zustand, der nachgezogen
werden muesste und auseinanderlaufen kann.
Die Regel steht als IS_OPEN an einer Stelle und gilt fuer Liste, Zaehler und den
Aufraeumlauf: ein Rueckfaeller altert wie jeder andere Eintrag nach 90 Tagen
heraus, und seine Anzahl wird vorher in den Verlauf gesichert.
Ausserdem:
- Bearbeiten und erneutes Uebernehmen sind wieder erlaubt, sobald die
Opportunity weg ist. Gesperrt bleibt der Eintrag nur, solange sie lebt - dort
wird dann gepflegt.
- Der Eingang zeigt weiter nur, was nie uebernommen wurde.
- Die Zeile traegt ein Kennzeichen ("Opportunity im Papierkorb" bzw.
"Opportunity geloescht"), sonst stuende sie unerklaerlich im Archiv.
- Im Dashboard zaehlt eine Wandlung nur mit lebender Opportunity. Wird sie
geloescht, faellt die Bewerbung zurueck - dann soll die Quote nicht behaupten,
aus ihr sei eine Opportunity geworden.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6a81258ca0
commit
c380d05b42
@@ -19,6 +19,26 @@ router.use(requireAuth);
|
||||
* woher eine Opportunity kommt. Nur der Eingang und das Archiv zeigen sie nicht.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Ist die Opportunity zu einem Eintrag verschwunden - im Papierkorb oder
|
||||
* endgueltig geloescht -, faellt die Bewerbung ins Archiv zurueck. Sonst wuerde
|
||||
* sie einfach nicht mehr existieren: aus der Akquise ist sie mit der Uebernahme
|
||||
* heraus, und die Opportunity gibt es auch nicht mehr.
|
||||
*
|
||||
* Abgeleitet statt gespeichert: `converted_at` bleibt stehen. Wird die
|
||||
* Opportunity aus dem Papierkorb zurueckgeholt, verschwindet der Eintrag von
|
||||
* selbst wieder aus dem Archiv - es gibt keinen Zustand, der nachgezogen werden
|
||||
* muesste.
|
||||
*
|
||||
* `opp.id IS NULL` deckt zwei Faelle ab: nie uebernommen (opportunity_id ist
|
||||
* NULL) und endgueltig geloescht (der Fremdschluessel wurde dabei auf NULL
|
||||
* gesetzt). Zusammen mit converted_at ergibt das den Unterschied.
|
||||
*/
|
||||
const OPPORTUNITY_GONE = '(opp.id IS NULL OR opp.deleted_at IS NOT NULL)';
|
||||
|
||||
/** Bewerbung, die (wieder) in Eingang oder Archiv gehoert. */
|
||||
const IS_OPEN = `(a.converted_at IS NULL OR ${OPPORTUNITY_GONE})`;
|
||||
|
||||
/** Grenze zwischen Eingang und Archiv, in Tagen. */
|
||||
const ARCHIVE_AFTER_DAYS = 14;
|
||||
/** Ab hier loescht das Archiv selbst. */
|
||||
@@ -139,6 +159,8 @@ function serializeTags(value) {
|
||||
function mapAcquisition(row) {
|
||||
return {
|
||||
...row,
|
||||
// Aus der Abfrage kommt 0/1 - im Frontend soll es ein Schalter sein.
|
||||
opportunity_gone: Boolean(Number(row.opportunity_gone)),
|
||||
daily_rate: row.daily_rate === null ? null : Number(row.daily_rate),
|
||||
duration_days: row.duration_days === null ? null : Number(row.duration_days),
|
||||
fixed_price: row.fixed_price === null ? null : Number(row.fixed_price),
|
||||
@@ -148,9 +170,11 @@ function mapAcquisition(row) {
|
||||
|
||||
const SELECT_ACQUISITIONS = `
|
||||
SELECT a.*,
|
||||
creator.name AS created_by_name,
|
||||
opp.title AS opportunity_title,
|
||||
opp.stage AS opportunity_stage
|
||||
creator.name AS created_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_opportunities opp ON opp.id = a.opportunity_id
|
||||
@@ -190,20 +214,27 @@ async function purgeOldEntries() {
|
||||
await conn.beginTransaction();
|
||||
await conn.query(
|
||||
`INSERT INTO crm_acquisition_history (found_on, consultant, applications)
|
||||
SELECT found_at, COALESCE(consultant, ''), COUNT(*)
|
||||
FROM crm_acquisitions
|
||||
WHERE converted_at IS NULL
|
||||
AND found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||||
GROUP BY found_at, COALESCE(consultant, '')
|
||||
SELECT a.found_at, COALESCE(a.consultant, ''), COUNT(*)
|
||||
FROM crm_acquisitions a
|
||||
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
|
||||
WHERE ${IS_OPEN}
|
||||
AND a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||||
GROUP BY a.found_at, COALESCE(a.consultant, '')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
applications = crm_acquisition_history.applications + VALUES(applications)`,
|
||||
[DELETE_AFTER_DAYS]
|
||||
);
|
||||
|
||||
/*
|
||||
* Geloescht wird ueber dieselbe Regel: was in Eingang oder Archiv steht,
|
||||
* altert nach 90 Tagen heraus - auch ein Rueckfaeller. Eintraege mit
|
||||
* lebender Opportunity bleiben unberuehrt, sie gehoeren zu ihr.
|
||||
*/
|
||||
const [result] = await conn.query(
|
||||
`DELETE FROM crm_acquisitions
|
||||
WHERE converted_at IS NULL
|
||||
AND found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)`,
|
||||
`DELETE a FROM crm_acquisitions a
|
||||
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
|
||||
WHERE ${IS_OPEN}
|
||||
AND a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)`,
|
||||
[DELETE_AFTER_DAYS]
|
||||
);
|
||||
await conn.commit();
|
||||
@@ -248,6 +279,20 @@ async function loadProviderSuggestions() {
|
||||
return rows.map((row) => row.provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist die Opportunity zu einem Eintrag verschwunden? Dieselbe Frage wie
|
||||
* OPPORTUNITY_GONE, nur fuer eine einzelne Zeile - beim Bearbeiten und beim
|
||||
* erneuten Uebernehmen.
|
||||
*/
|
||||
async function opportunityGone(opportunityId) {
|
||||
if (opportunityId === null || opportunityId === undefined) return true;
|
||||
const [rows] = await pool.query(
|
||||
'SELECT deleted_at FROM crm_opportunities WHERE id = ? LIMIT 1',
|
||||
[opportunityId]
|
||||
);
|
||||
return !rows[0] || rows[0].deleted_at !== null;
|
||||
}
|
||||
|
||||
/** Aendern und loeschen darf der Ersteller und jeder Admin. */
|
||||
function mayModify(user, row) {
|
||||
return user.role === 'admin' || row.created_by === user.id;
|
||||
@@ -441,16 +486,25 @@ router.get('/', async (req, res, next) => {
|
||||
await purgeOldEntries();
|
||||
|
||||
const view = String(req.query.view ?? 'eingang');
|
||||
const where = ['a.converted_at IS NULL'];
|
||||
const where = [IS_OPEN];
|
||||
const params = [];
|
||||
|
||||
/*
|
||||
* Der Eingang zeigt nur, was noch nie uebernommen wurde. Eine Bewerbung,
|
||||
* deren Opportunity geloescht wurde, gehoert ins Archiv - unabhaengig davon,
|
||||
* wie alt sie ist: sie ist abgearbeitet, aber nicht verschwunden.
|
||||
*/
|
||||
if (view === 'eingang') {
|
||||
where.push('a.converted_at IS NULL');
|
||||
where.push('a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)');
|
||||
params.push(ARCHIVE_AFTER_DAYS);
|
||||
} else if (view === 'archiv') {
|
||||
// Die untere Grenze steht bewusst auch hier: der Loeschlauf raeumt nur
|
||||
// stuendlich, angezeigt werden soll nichts, was schon faellig ist.
|
||||
where.push('a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)');
|
||||
where.push(
|
||||
`(a.converted_at IS NOT NULL
|
||||
OR a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY))`
|
||||
);
|
||||
where.push('a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)');
|
||||
params.push(ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS);
|
||||
} else if (view !== 'alle') {
|
||||
@@ -467,10 +521,14 @@ router.get('/', async (req, res, next) => {
|
||||
// muesste sonst zwei Listen laden, um eine Zahl zu kennen.
|
||||
const [[counts]] = await pool.query(
|
||||
`SELECT
|
||||
SUM(found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)) AS eingang,
|
||||
SUM(found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||||
AND found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)) AS archiv
|
||||
FROM crm_acquisitions WHERE converted_at IS NULL`,
|
||||
SUM(a.converted_at IS NULL
|
||||
AND a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)) AS eingang,
|
||||
SUM((a.converted_at IS NOT NULL
|
||||
OR a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY))
|
||||
AND a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)) AS archiv
|
||||
FROM crm_acquisitions a
|
||||
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
|
||||
WHERE ${IS_OPEN}`,
|
||||
[ARCHIVE_AFTER_DAYS, ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS]
|
||||
);
|
||||
|
||||
@@ -530,7 +588,12 @@ router.patch('/:id', async (req, res, next) => {
|
||||
error: 'Diesen Eintrag darf nur ändern, wer ihn angelegt hat, oder ein Administrator.',
|
||||
});
|
||||
}
|
||||
if (existing.converted_at !== null) {
|
||||
/*
|
||||
* Gesperrt ist der Eintrag nur, solange seine Opportunity lebt - dort wird
|
||||
* dann gepflegt. Ist sie im Papierkorb oder geloescht, ist der Eintrag
|
||||
* wieder das Original und darf geaendert werden.
|
||||
*/
|
||||
if (existing.converted_at !== null && !(await opportunityGone(existing.opportunity_id))) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: 'Der Eintrag wurde schon in eine Opportunity übernommen.' });
|
||||
@@ -607,7 +670,8 @@ 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.converted_at !== null) {
|
||||
// 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({
|
||||
error: 'Aus diesem Eintrag wurde schon eine Opportunity.',
|
||||
opportunity_id: entry.opportunity_id,
|
||||
|
||||
@@ -73,10 +73,18 @@ router.get('/', async (req, res, next) => {
|
||||
[from, to]
|
||||
);
|
||||
|
||||
/*
|
||||
* Gezaehlt wird nur, wenn die Opportunity noch da ist. Wird sie geloescht,
|
||||
* faellt die Bewerbung ins Archiv zurueck - dann ist aus ihr eben keine
|
||||
* Opportunity geworden, und die Quote soll das nicht behaupten.
|
||||
*/
|
||||
const [convertedRows] = await pool.query(
|
||||
`SELECT ${BUCKETS[bucket]('converted_at')} AS bucket, COUNT(*) AS count
|
||||
FROM crm_acquisitions
|
||||
WHERE converted_at IS NOT NULL AND converted_at BETWEEN ? AND ?
|
||||
`SELECT ${BUCKETS[bucket]('a.converted_at')} AS bucket, COUNT(*) AS count
|
||||
FROM crm_acquisitions a
|
||||
JOIN crm_opportunities opp ON opp.id = a.opportunity_id
|
||||
WHERE a.converted_at IS NOT NULL
|
||||
AND opp.deleted_at IS NULL
|
||||
AND a.converted_at BETWEEN ? AND ?
|
||||
GROUP BY bucket`,
|
||||
[from, toExclusive]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user