feat(akquise): mehrere Berater je Eintrag, je Berater eine Opportunity

Ein gefundenes Projekt passt oft zu mehr als einem Berater, und fuer
jeden geht eine eigene Bewerbung heraus. Das Feld "Berater" liess bisher
nur einen Namen zu.

Gespeichert wird eine kommagetrennte Liste in crm_acquisitions.consultant
(auf VARCHAR(500) erweitert, Migration in db.js) und keine eigene
Zuordnungstabelle: die Namen sind reiner Text, die Auswahlliste in
crm_acquisition_options ist eine Pflegehilfe und kein Fremdschluessel.
Suche, Sortierung und Anzeige der Liste arbeiten damit weiter auf einem
Feld. Die Funktionen zum Zerlegen stehen in acquisitionRules.js, das
Gegenstueck fuer das Frontend in src/types.ts, geprueft von
server/test/acquisition.test.mjs.

Zerlegt wird an vier Stellen:

- Uebernahme: je Berater eine Opportunity, alle in einer Transaktion.
  Ihre Titel tragen den Namen in Klammern, weil die Opportunity kein
  eigenes Feld fuer den Berater hat und in Pipeline und Liste sonst
  identische Zeilen stehen; bei einem einzigen Berater bleibt der Titel
  unveraendert. In den Notizen steht neben dem eigenen Berater auch, mit
  wem er sich das Projekt teilt. Der Eintrag merkt sich die erste
  Opportunity - sie entscheidet nur darueber, ob er wieder im Eingang
  auftaucht.
- Verlauf (crm_acquisition_history): ein Berater je Zeile. Ein Eintrag
  mit drei Beratern sind drei Bewerbungen, "Nico, Lisa" waere sonst ein
  eigener Schluessel neben "Nico" und "Lisa".
- Dashboard: dieselbe Zaehlung. Die Summe der Balken liegt damit ueber
  der Gesamtzahl "Bewerbungen", die weiter die Eintraege zaehlt.
- Auswahlliste pflegen: Umbenennen ersetzt den Namen innerhalb der
  Liste, Loeschen zaehlt auch, wer nur einer von mehreren ist. Ein
  Gleichheitsvergleich uebersah beides.

In der Oberflaeche haengt das Auswahlfeld jeweils einen Namen an, die
Gewaehlten stehen als Plaketten darunter und lassen sich einzeln
entfernen; ein bereits gewaehlter Berater verschwindet aus der Liste.
Bewusst kein <select multiple>: Strg-Klick ist mit der Maus unauffindbar
und mit einer Beruehrung gar nicht bedienbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-09 10:35:43 +02:00
co-authored by Claude Opus 5
parent a866cd9a52
commit 62dce64739
18 changed files with 684 additions and 91 deletions
+92
View File
@@ -0,0 +1,92 @@
/*
* Pruefungen der Berater-Liste: laeuft mit `npm test` im Verzeichnis server/.
*
* Ohne Test-Framework, wie mail.test.mjs - reines Node.
*
* Geprueft wird das Zerlegen und Zusammensetzen des Feldes "Berater". Es steckt
* an vier Stellen (Speichern, Verlauf, Auswertung, Uebernahme in Opportunities),
* und ein Fehler darin faellt nicht auf: die Liste zeigt weiter Namen, nur die
* Zahlen im Dashboard oder die Anzahl der Opportunities waeren falsch.
*
* Das Gegenstueck im Frontend (src/types.ts) ist dieselbe Logik in TypeScript;
* wer hier etwas aendert, muss dort mitziehen.
*/
const { splitConsultants, joinConsultants, normalizeConsultants, CONSULTANT_SEPARATOR } =
await import('../src/acquisitionRules.js');
let failures = 0;
function check(name, condition, detail = '') {
if (condition) {
console.log(` ok ${name}${detail ? ` - ${detail}` : ''}`);
} else {
console.log(` FAIL ${name}${detail ? ` - ${detail}` : ''}`);
failures += 1;
}
}
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
console.log('\nZerlegen');
check('Einzelner Name', same(splitConsultants('Nico'), ['Nico']));
check('Mehrere Namen', same(splitConsultants('Nico, Lisa'), ['Nico', 'Lisa']));
check(
'Ohne Leerzeichen hinter dem Komma',
same(splitConsultants('Nico,Lisa,Gunther'), ['Nico', 'Lisa', 'Gunther'])
);
check(
'Umlaute und zusammengesetzte Namen bleiben ganz',
same(splitConsultants('Niklas Mülln, Luisa Sauerwald'), ['Niklas Mülln', 'Luisa Sauerwald'])
);
check('Leeres Feld', same(splitConsultants(''), []));
check('null', same(splitConsultants(null), []));
check('Nur Kommas', same(splitConsultants(' , , '), []));
check('Leeres Stueck faellt weg', same(splitConsultants('Nico, , Lisa'), ['Nico', 'Lisa']));
check(
'Dublette faellt weg, erste Schreibweise bleibt',
same(splitConsultants('Nico, nico, NICO'), ['Nico'])
);
console.log('\nZusammensetzen');
check('Mehrere Namen', joinConsultants(['Nico', 'Lisa']) === `Nico${CONSULTANT_SEPARATOR}Lisa`);
check('Leere Liste wird null', joinConsultants([]) === null);
check('Nur Leerzeichen wird null', joinConsultants([' ', '']) === null);
console.log('\nVereinheitlichen (so kommt es aus dem Formular)');
check('Getrimmt und normiert', normalizeConsultants(' Nico ,Lisa ') === 'Nico, Lisa');
check('Dubletten weg', normalizeConsultants('Nico, Nico') === 'Nico');
check('Leer wird null', normalizeConsultants(' ') === null);
check(
'Zweimal vereinheitlichen aendert nichts',
normalizeConsultants(normalizeConsultants('Nico,Lisa, nico')) === 'Nico, Lisa'
);
/*
* Das Zaehlen je Berater, wie es der Verlauf und das Dashboard machen: ein
* Eintrag mit drei Beratern sind drei Bewerbungen.
*/
console.log('\nZaehlen je Berater');
const rows = [
{ consultant: 'Nico, Lisa', count: 1 },
{ consultant: 'Nico', count: 2 },
{ consultant: null, count: 4 },
];
const perConsultant = new Map();
for (const row of rows) {
const names = splitConsultants(row.consultant);
for (const name of names.length > 0 ? names : ['']) {
perConsultant.set(name, (perConsultant.get(name) ?? 0) + row.count);
}
}
check('Berater in zwei Eintraegen', perConsultant.get('Nico') === 3);
check('Berater in einem Eintrag', perConsultant.get('Lisa') === 1);
check('Ohne Berater bleibt eine eigene Gruppe', perConsultant.get('') === 4);
check(
'Summe liegt ueber der Zahl der Eintraege',
[...perConsultant.values()].reduce((total, value) => total + value, 0) === 8
);
console.log(
failures === 0 ? '\nAlle Pruefungen bestanden.' : `\n${failures} Pruefung(en) fehlgeschlagen.`
);
process.exit(failures === 0 ? 0 : 1);