fix(opportunities): Berater als eigenes Feld statt nur in den Notizen
Bei der Uebernahme aus der Akquise verschwand die wichtigste Angabe nach dem Titel: fuer wen das Projekt laeuft. Die Opportunity hatte kein Feld dafuer, also schrieb die Uebernahme den Namen in den Herkunftsvermerk der Notizen - in Pipeline, Liste und Kacheln war er damit nicht zu sehen, und suchen liess sich nur im Notiztext. crm_opportunities.consultant traegt ihn jetzt selbst. Genau ein Name je Opportunity: passt ein Fund zu drei Beratern, entstehen bei der Uebernahme drei Opportunities. Sichtbar in Pipeline-Karte, Kachel, Papierkorb und als sortierbare Spalte direkt hinter dem Titel; die Suche fasst ihn mit ein, und im Dialog kommt die Auswahl aus derselben Liste wie in der Akquise (Einstellungen -> Akquise), damit nicht "Nico" und "Nico N." nebeneinander entstehen. Bestehende Opportunities holt migrateOpportunityConsultant() beim Start aus ihren Notizen nach - nur dort, wo die Spalte leer ist. Damit ist der Lauf wiederholbar und ueberschreibt nichts, was seither von Hand gesetzt wurde; ein Merker in crm_settings ist dafuer nicht noetig. Der Text in den Notizen bleibt stehen: er ist der Herkunftsnachweis samt Anbieter, Kanal und Link. Der Namenszusatz im Titel entfaellt wieder. Er war gestern dazugekommen, damit sich mehrere Opportunities aus einem Fund unterscheiden liessen - mit einer eigenen Spalte ist er nur noch Rauschen. Nebenbei: der Block, der won_at nachzieht, stand zweimal in schema.sql. Idempotent und damit harmlos, aber eine Kopie - jetzt einmal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8403f1c39d
commit
93fe8cfd73
@@ -52,6 +52,7 @@ export async function initDatabase() {
|
||||
await migrateOpportunityStages();
|
||||
await migrateMailPool();
|
||||
await widenAcquisitionConsultant();
|
||||
await migrateOpportunityConsultant();
|
||||
await seedAcquisitionOptions();
|
||||
await dropProviderOptions();
|
||||
await ensureAdminUser();
|
||||
@@ -254,6 +255,42 @@ async function dropProviderOptions() {
|
||||
* kurz ist, und ist damit bei jedem weiteren Start ein Blick in
|
||||
* INFORMATION_SCHEMA und sonst nichts.
|
||||
*/
|
||||
/*
|
||||
* Berater aus den Notizen in die eigene Spalte holen - einmalig.
|
||||
*
|
||||
* Vor dem 09.09.2026 hatte die Opportunity kein Feld dafuer, und die Uebernahme
|
||||
* aus der Akquise schrieb den Namen in die Notizen ("Berater: Nico"). Der Text
|
||||
* bleibt dort stehen: er ist der Herkunftsnachweis samt Anbieter, Kanal und
|
||||
* Link, und ihn herauszuschneiden waere ein Eingriff in gepflegte Notizen.
|
||||
*
|
||||
* Angefasst werden nur Zeilen, deren Spalte noch leer ist. Damit ist der Lauf
|
||||
* gefahrlos wiederholbar und ueberschreibt nichts, was seither von Hand gesetzt
|
||||
* wurde - deshalb braucht es hier auch keinen Merker in crm_settings.
|
||||
*/
|
||||
async function migrateOpportunityConsultant() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, notes FROM crm_opportunities
|
||||
WHERE (consultant IS NULL OR consultant = '')
|
||||
AND notes LIKE '%Berater: %'`
|
||||
);
|
||||
if (rows.length === 0) return;
|
||||
|
||||
let filled = 0;
|
||||
for (const row of rows) {
|
||||
// Die Zeile steht als eigene Zeile im Herkunftsblock; alles bis zum
|
||||
// Zeilenende ist der Name.
|
||||
const match = /^Berater: (.+)$/m.exec(String(row.notes ?? ''));
|
||||
const name = match?.[1]?.trim().slice(0, 190);
|
||||
if (!name) continue;
|
||||
await pool.query('UPDATE crm_opportunities SET consultant = ? WHERE id = ?', [name, row.id]);
|
||||
filled++;
|
||||
}
|
||||
|
||||
if (filled > 0) {
|
||||
console.log(`[Opportunities] Berater bei ${filled} Opportunity(s) aus den Notizen uebernommen.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function widenAcquisitionConsultant() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT CHARACTER_MAXIMUM_LENGTH AS length FROM INFORMATION_SCHEMA.COLUMNS
|
||||
|
||||
@@ -1008,10 +1008,13 @@ router.post('/:id/opportunity', async (req, res, next) => {
|
||||
|
||||
/*
|
||||
* Was in der Liste steht, aber in der Opportunity kein Feld hat (Anbieter,
|
||||
* Kanal, Absender, Link, Berater), wandert in die Notizen. Sonst wuesste
|
||||
* hinterher niemand mehr, woher das Projekt kam. In den Notizen steht der
|
||||
* Berater dieser Opportunity, nicht die ganze Liste - und dazu, mit wem er
|
||||
* sich das Projekt teilt.
|
||||
* Kanal, Absender, Link), wandert in die Notizen. Sonst wuesste hinterher
|
||||
* niemand mehr, woher das Projekt kam.
|
||||
*
|
||||
* Der Berater steht seit dem 09.09.2026 in einer eigenen Spalte und wird
|
||||
* hier trotzdem noch genannt: die Notiz ist der Herkunftsnachweis und soll
|
||||
* fuer sich lesbar bleiben. Dazu, mit wem er sich das Projekt teilt - das
|
||||
* hat sonst keinen Platz.
|
||||
*/
|
||||
const buildNotes = (consultant) => {
|
||||
const others = targets.filter((name) => name !== null && name !== consultant);
|
||||
@@ -1037,25 +1040,24 @@ router.post('/:id/opportunity', async (req, res, next) => {
|
||||
};
|
||||
|
||||
/*
|
||||
* Bei mehreren Beratern kommt der Name in den Titel: in Pipeline und Liste
|
||||
* standen sonst drei Zeilen mit identischem Text, und die Opportunity hat
|
||||
* kein eigenes Feld fuer den Berater. Bei einem einzigen Berater bleibt der
|
||||
* Titel unveraendert - das ist der Normalfall und braucht keinen Zusatz.
|
||||
* Der Titel bleibt der Titel - auch bei drei Beratern. Bis der Berater eine
|
||||
* eigene Spalte hatte, wurde sein Name angehaengt, damit sich die drei
|
||||
* Opportunities in Pipeline und Liste unterscheiden liessen; jetzt steht er
|
||||
* dort in einer eigenen Spalte und der Zusatz waere nur Rauschen.
|
||||
*/
|
||||
const titleFor = (consultant) =>
|
||||
targets.length > 1 && consultant ? `${entry.title} (${consultant})`.slice(0, 190) : entry.title;
|
||||
|
||||
await conn.beginTransaction();
|
||||
const opportunityIds = [];
|
||||
for (const consultant of targets) {
|
||||
const [created] = await conn.query(
|
||||
`INSERT INTO crm_opportunities
|
||||
(title, company, 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, 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, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
titleFor(consultant),
|
||||
entry.title,
|
||||
entry.company,
|
||||
consultant,
|
||||
entry.description,
|
||||
entry.daily_rate,
|
||||
entry.duration_days,
|
||||
|
||||
@@ -18,7 +18,21 @@ const STAGES = new Set([
|
||||
'Absage',
|
||||
]);
|
||||
|
||||
const TEXT_FIELDS = ['title', 'company', 'description', 'last_interaction_note', 'next_step', 'notes'];
|
||||
const TEXT_FIELDS = [
|
||||
'title',
|
||||
'company',
|
||||
// Fuer welchen Berater das Projekt laeuft. Gepflegt wird die Auswahl unter
|
||||
// Einstellungen -> Akquise; geprueft wird hier nur die Laenge, damit ein aus
|
||||
// der Liste entfernter Name eine alte Opportunity nicht unaenderbar macht.
|
||||
'consultant',
|
||||
'description',
|
||||
'last_interaction_note',
|
||||
'next_step',
|
||||
'notes',
|
||||
];
|
||||
|
||||
/** Laengengrenze der Spalte - abgewiesen statt stillschweigend abgeschnitten. */
|
||||
const CONSULTANT_MAX_LENGTH = 190;
|
||||
const DATE_FIELDS = ['start_date', 'end_date', 'last_interaction_at'];
|
||||
const NUMBER_FIELDS = ['daily_rate', 'duration_days', 'fixed_price'];
|
||||
|
||||
@@ -303,6 +317,13 @@ router.post('/', async (req, res, next) => {
|
||||
return res.status(400).json({ error: 'Zuständiger Benutzer existiert nicht.' });
|
||||
}
|
||||
|
||||
const consultant = emptyToNull(req.body?.consultant);
|
||||
if (consultant !== null && consultant.length > CONSULTANT_MAX_LENGTH) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: `Der Berater ist zu lang (max. ${CONSULTANT_MAX_LENGTH} Zeichen).` });
|
||||
}
|
||||
|
||||
const contacts = await normalizeContacts(req.body?.contacts ?? []);
|
||||
if (contacts.error) return res.status(400).json({ error: contacts.error });
|
||||
|
||||
@@ -313,13 +334,14 @@ router.post('/', async (req, res, next) => {
|
||||
// Wer direkt in 'Beauftragt' anlegt, hat gerade gewonnen - dann steht das
|
||||
// Datum sofort.
|
||||
`INSERT INTO crm_opportunities
|
||||
(title, company, description, stage, won_at, probability, daily_rate, duration_days,
|
||||
fixed_price, start_date, end_date, last_interaction_at, last_interaction_note,
|
||||
next_step, tags, notes, owner_id, created_by)
|
||||
VALUES (?, ?, ?, ?, ${stage === 'Beauftragt' ? 'UTC_TIMESTAMP()' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(title, company, consultant, description, stage, won_at, probability, daily_rate,
|
||||
duration_days, fixed_price, start_date, end_date, last_interaction_at,
|
||||
last_interaction_note, next_step, tags, notes, owner_id, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ${stage === 'Beauftragt' ? 'UTC_TIMESTAMP()' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
title,
|
||||
emptyToNull(req.body?.company),
|
||||
consultant,
|
||||
emptyToNull(req.body?.description),
|
||||
stage,
|
||||
Math.round(probability.value ?? 10),
|
||||
@@ -381,6 +403,11 @@ router.patch('/:id', async (req, res, next) => {
|
||||
if (field === 'title' && !value) {
|
||||
return res.status(400).json({ error: 'Der Titel darf nicht leer sein.' });
|
||||
}
|
||||
if (field === 'consultant' && value !== null && value.length > CONSULTANT_MAX_LENGTH) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: `Der Berater ist zu lang (max. ${CONSULTANT_MAX_LENGTH} Zeichen).` });
|
||||
}
|
||||
fields.push(`${field} = ?`);
|
||||
values.push(value);
|
||||
}
|
||||
|
||||
+14
-6
@@ -27,7 +27,7 @@ ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS avatar LONGTEXT NULL AFTER color;
|
||||
-- Persoenliche Darstellung. Sie haengt am Benutzer und nicht am Browser: wer
|
||||
-- sich an einem anderen Rechner anmeldet, findet sein Design wieder, und zwei
|
||||
-- Benutzer am selben Rechner kommen sich nicht in die Quere.
|
||||
-- theme = Farbwelt ('perinco', 'pixel', 'outdoor'); NULL = 'perinco'
|
||||
-- theme = Farbwelt ('perinco', 'pixel', 'outdoor', 'cyberpunk'); NULL = 'perinco'
|
||||
-- theme_mode = 'light' oder 'dark'; NULL = der Systemeinstellung folgen
|
||||
-- Bewusst VARCHAR und kein ENUM: ein weiteres Theme soll eine Zeile im
|
||||
-- Frontend sein und keine Datenbankmigration. Geprueft wird in userFields.js.
|
||||
@@ -177,12 +177,20 @@ ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS won_at DATETIME NULL AFTE
|
||||
ALTER TABLE crm_opportunities ADD INDEX IF NOT EXISTS idx_crm_opp_won (won_at);
|
||||
|
||||
/*
|
||||
* Zeitpunkt des Gewinns. Ohne ihn liesse sich "wie viele Projekte haben wir im
|
||||
* Mai gewonnen" nicht beantworten - die Phase allein sagt nur, wie es jetzt
|
||||
* steht. Gesetzt beim Wechsel nach 'Beauftragt', geleert beim Wechsel heraus.
|
||||
* Fuer welchen Berater das Projekt laeuft.
|
||||
*
|
||||
* Bis zum 09.09.2026 stand der Berater nur in den Notizen: die Uebernahme aus
|
||||
* der Akquise schrieb ihn dorthin, weil die Opportunity kein Feld dafuer hatte.
|
||||
* In Pipeline, Liste und Kacheln war damit nicht zu sehen, fuer wen ein Projekt
|
||||
* laeuft - neben dem Titel die wichtigste Angabe. Bestehende Opportunities aus
|
||||
* der Akquise holt migrateOpportunityConsultant() in db.js aus ihren Notizen
|
||||
* nach.
|
||||
*
|
||||
* Genau ein Name je Opportunity: passt ein Fund zu drei Beratern, entstehen bei
|
||||
* der Uebernahme drei Opportunities. Deshalb 190 Zeichen wie die uebrigen
|
||||
* Namensfelder und nicht 500 wie in der Akquise, wo eine Liste darin steht.
|
||||
*/
|
||||
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS won_at DATETIME NULL AFTER stage;
|
||||
ALTER TABLE crm_opportunities ADD INDEX IF NOT EXISTS idx_crm_opp_won (won_at);
|
||||
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS consultant VARCHAR(190) NULL AFTER company;
|
||||
|
||||
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL AFTER created_by;
|
||||
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS deleted_by INT UNSIGNED NULL AFTER deleted_at;
|
||||
|
||||
Reference in New Issue
Block a user