fix(akquise): Zahl am Menuepunkt Archiv folgte der alten Regel

Die Liste zeigte die zurueckgefallene Bewerbung, der Zaehler am Menue nicht: er
filterte weiter auf "nie uebernommen". Genau der Fehler, den zwei Kopien
derselben Regel machen.

Fristen und Bedingungen stehen jetzt in server/src/acquisitionRules.js und
werden von der Liste, den Zaehlern, dem Aufraeumlauf und der Menuezahl
gemeinsam benutzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-18 15:54:50 +02:00
co-authored by Claude Opus 5
parent c380d05b42
commit c59115c728
3 changed files with 70 additions and 44 deletions
+44
View File
@@ -0,0 +1,44 @@
/*
* Regeln der Akquise an einer Stelle: Fristen und die Frage, was ueberhaupt in
* Eingang oder Archiv gehoert.
*
* Sie werden von routes/acquisitions.js (Listen, Zaehler, Aufraeumlauf) und von
* routes/badges.js (Zahl am Menuepunkt) gebraucht. Lagen sie in beiden Dateien,
* wuerde eine Aenderung die andere Stelle vergessen - genau das ist beim
* Zurueckfallen einer Bewerbung passiert: die Liste zeigte den Eintrag, der
* Zaehler am Menue nicht.
*
* Die Zeichenketten sind SQL-Bedingungen und erwarten die Aliase `a` fuer
* crm_acquisitions und `opp` fuer die per LEFT JOIN angehaengte Opportunity.
*/
/** Grenze zwischen Eingang und Archiv, in Tagen. */
export const ARCHIVE_AFTER_DAYS = 14;
/** Ab hier loescht das Archiv selbst. */
export const DELETE_AFTER_DAYS = 90;
/**
* Die Opportunity zu einem Eintrag ist verschwunden - im Papierkorb oder
* endgueltig geloescht. `opp.id IS NULL` deckt beides ab: nie uebernommen
* (opportunity_id ist NULL) und endgueltig geloescht (der Fremdschluessel wurde
* dabei auf NULL gesetzt).
*/
export const OPPORTUNITY_GONE = '(opp.id IS NULL OR opp.deleted_at IS NOT NULL)';
/**
* Bewerbung, die (wieder) in Eingang oder Archiv gehoert: nie uebernommen, oder
* die Opportunity ist weg. Abgeleitet statt gespeichert - wird die Opportunity
* zurueckgeholt, verschwindet der Eintrag von selbst wieder.
*/
export const IS_OPEN = `(a.converted_at IS NULL OR ${OPPORTUNITY_GONE})`;
/**
* Steht im Archiv: alles Uebernommene, dessen Opportunity weg ist (unabhaengig
* vom Alter - es ist abgearbeitet, aber nicht verschwunden), plus alles, was
* aelter als die Archivfrist ist. Erwartet die Frist als Platzhalter.
*/
export const IN_ARCHIVE = '(a.converted_at IS NOT NULL OR a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY))';
/** Untere Grenze: was schon faellig ist, wird nicht mehr gezeigt. */
export const NOT_DUE = 'a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)';
+13 -35
View File
@@ -1,6 +1,14 @@
import express from 'express';
import { pool } from '../db.js';
import { requireAuth } from '../middleware/auth.js';
import {
ARCHIVE_AFTER_DAYS,
DELETE_AFTER_DAYS,
IN_ARCHIVE,
IS_OPEN,
NOT_DUE,
OPPORTUNITY_GONE,
} from '../acquisitionRules.js';
const router = express.Router();
router.use(requireAuth);
@@ -19,31 +27,6 @@ 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. */
const DELETE_AFTER_DAYS = 90;
/*
* Felder, deren Inhalte als Auswahlliste in den Einstellungen gepflegt werden.
* Die Werte selbst stehen in crm_acquisition_options, nicht hier - deshalb
@@ -501,11 +484,8 @@ router.get('/', async (req, res, next) => {
} 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.converted_at IS NOT NULL
OR a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY))`
);
where.push('a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)');
where.push(IN_ARCHIVE);
where.push(NOT_DUE);
params.push(ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS);
} else if (view !== 'alle') {
return res.status(400).json({ error: 'Unbekannte Ansicht.' });
@@ -521,14 +501,12 @@ router.get('/', async (req, res, next) => {
// muesste sonst zwei Listen laden, um eine Zahl zu kennen.
const [[counts]] = await pool.query(
`SELECT
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
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]
);
+13 -9
View File
@@ -2,6 +2,13 @@ import express from 'express';
import { pool } from '../db.js';
import { requireAuth } from '../middleware/auth.js';
import { Match } from '../scanner/models/index.js';
import {
ARCHIVE_AFTER_DAYS,
DELETE_AFTER_DAYS,
IN_ARCHIVE,
IS_OPEN,
NOT_DUE,
} from '../acquisitionRules.js';
const router = express.Router();
router.use(requireAuth);
@@ -19,24 +26,21 @@ router.use(requireAuth);
* ohne Zahl ist besser als ein Menue mit Fehlermeldung.
*/
/** Grenze zwischen Eingang und Archiv der Akquise - wie in routes/acquisitions.js. */
const ACQUISITION_ARCHIVE_AFTER_DAYS = 14;
const ACQUISITION_DELETE_AFTER_DAYS = 90;
// GET /api/badges
router.get('/', async (req, res, next) => {
try {
const [[counts]] = await pool.query(
`SELECT
(SELECT COUNT(*) FROM crm_acquisitions
WHERE converted_at IS NULL
AND found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)
AND found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)) AS acquisition_archive,
/* Dieselbe Regel wie die Liste - sie steht in acquisitionRules.js,
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,
(SELECT COUNT(*) FROM crm_opportunities
WHERE deleted_at IS NOT NULL) AS opportunity_trash,
(SELECT COUNT(*) FROM crm_mail_messages
WHERE crm_deleted_at IS NOT NULL AND crm_purged = 0) AS mail_trash`,
[ACQUISITION_ARCHIVE_AFTER_DAYS, ACQUISITION_DELETE_AFTER_DAYS]
[ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS]
);
let scannerRejected = null;