feat(akquise): gefundene Projekte als Eingang vor den Opportunities
Neuer Menuepunkt ueber den Opportunities, mit dem Unterpunkt "Archiv". Die Liste traegt genau die Spalten, die beim Durchgehen zaehlen: Datum, Von, Anbieter, Kanal, Projekttitel, Berater, ein Knopf fuer die Uebernahme und die Symbole fuer Bearbeiten und Loeschen. Eingabe steht ueber der Liste und ist bewusst kein Dialog - Projekte kommen in Serie herein. Nach dem Eintragen bleiben Datum, Anbieter, Kanal und Berater stehen, damit drei Projekte aus einer Mail nicht dreimal dieselben Angaben kosten. Von, Anbieter und Berater schlagen frueher benutzte Werte vor. Was in der Liste keine Spalte hat, aber in der Opportunity vorkommt (Endkunde, Beschreibung, Tagessatz, Laufzeit, Projektpreis, Zeitraum, naechster Schritt, Notizen), klappt darunter auf. Eingang, Archiv und Loeschung ergeben sich aus dem Funddatum - 14 bzw. 90 Tage - und aus converted_at. Es gibt keine Zustandsspalte, die ein Job pflegen muesste und die von der Wirklichkeit abweichen kann. Der Loeschlauf raeumt beim Abruf der Liste, hoechstens einmal pro Stunde; die Abfragen filtern Faelliges ohnehin heraus. Die Uebernahme legt Opportunity und Merker in einer Transaktion an. Anbieter, Von, Kanal, Berater und Link haben in der Opportunity kein Feld und wandern als Herkunftsvermerk in die Notizen. Der Titel wird nur dann zum Link, wenn eine http(s)-Adresse hinterlegt ist: "hays.de/projekt/1" ergaenzt das Backend zu https://, und die Anzeige laesst nichts anderes durch als http und https. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8741a9ae0c
commit
b8a78f609c
@@ -0,0 +1,508 @@
|
||||
import express from 'express';
|
||||
import { pool } from '../db.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
/*
|
||||
* Akquise: gefundene Projekte, aus denen eine Opportunity werden kann.
|
||||
*
|
||||
* Drei Zustaende, alle aus `found_at` und `converted_at` abgeleitet - es gibt
|
||||
* keine Spalte, die ein Job pflegen muesste:
|
||||
*
|
||||
* Eingang ("Akquise") found_at >= heute - 14 Tage, nicht uebernommen
|
||||
* Archiv aelter als 14 Tage, nicht uebernommen
|
||||
* weg aelter als 90 Tage (wird geloescht) oder uebernommen
|
||||
*
|
||||
* Uebernommene Eintraege bleiben in der Tabelle stehen: sie sind der Nachweis,
|
||||
* woher eine Opportunity kommt. Nur der Eingang und das Archiv zeigen sie nicht.
|
||||
*/
|
||||
|
||||
/** Grenze zwischen Eingang und Archiv, in Tagen. */
|
||||
const ARCHIVE_AFTER_DAYS = 14;
|
||||
/** Ab hier loescht das Archiv selbst. */
|
||||
const DELETE_AFTER_DAYS = 90;
|
||||
|
||||
const CHANNELS = new Set([
|
||||
'Mail',
|
||||
'Telefon',
|
||||
'Portal',
|
||||
'LinkedIn',
|
||||
'XING',
|
||||
'Empfehlung',
|
||||
'Sonstiges',
|
||||
]);
|
||||
|
||||
// `link` fehlt hier absichtlich: der Wert wird eigens geprueft (normalizeLink).
|
||||
const TEXT_FIELDS = [
|
||||
'sender',
|
||||
'provider',
|
||||
'title',
|
||||
'consultant',
|
||||
'company',
|
||||
'description',
|
||||
'next_step',
|
||||
'notes',
|
||||
];
|
||||
const DATE_FIELDS = ['found_at', 'start_date', 'end_date'];
|
||||
const NUMBER_FIELDS = [
|
||||
['daily_rate', 'Der Tagessatz', 1_000_000],
|
||||
['duration_days', 'Die Laufzeit', 100_000],
|
||||
['fixed_price', 'Der Projektpreis', 1_000_000_000],
|
||||
];
|
||||
|
||||
/** Laengengrenzen der Spalten - abgewiesen statt stillschweigend abgeschnitten. */
|
||||
const MAX_LENGTH = {
|
||||
sender: 190,
|
||||
provider: 190,
|
||||
title: 190,
|
||||
consultant: 190,
|
||||
link: 1024,
|
||||
company: 190,
|
||||
next_step: 500,
|
||||
};
|
||||
|
||||
function emptyToNull(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
const text = String(value).trim();
|
||||
return text === '' ? null : text;
|
||||
}
|
||||
|
||||
/** Akzeptiert `YYYY-MM-DD` (auch mit angehaengter Uhrzeit) oder null. */
|
||||
function normalizeDate(value) {
|
||||
if (value === null || value === undefined || String(value).trim() === '') return { value: null };
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(value).trim());
|
||||
if (!match) return { error: 'Ungültiges Datum (erwartet JJJJ-MM-TT).' };
|
||||
|
||||
const [, year, month, day] = match;
|
||||
const probe = new Date(Number(year), Number(month) - 1, Number(day));
|
||||
if (
|
||||
probe.getFullYear() !== Number(year) ||
|
||||
probe.getMonth() !== Number(month) - 1 ||
|
||||
probe.getDate() !== Number(day)
|
||||
) {
|
||||
return { error: 'Dieses Datum gibt es nicht.' };
|
||||
}
|
||||
return { value: `${year}-${month}-${day}` };
|
||||
}
|
||||
|
||||
function normalizeNumber(value, label, max) {
|
||||
if (value === null || value === undefined || String(value).trim() === '') return { value: null };
|
||||
const number = Number(String(value).replace(',', '.'));
|
||||
if (!Number.isFinite(number) || number < 0) return { error: `${label} muss eine Zahl ab 0 sein.` };
|
||||
if (number > max) return { error: `${label} ist unrealistisch hoch.` };
|
||||
return { value: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Nur http und https. Ohne diese Pruefung liesse sich ein `javascript:`-Verweis
|
||||
* hinterlegen, den der Titel in der Liste anklickbar macht.
|
||||
*/
|
||||
function normalizeLink(value) {
|
||||
const text = emptyToNull(value);
|
||||
if (text === null) return { value: null };
|
||||
|
||||
// Ohne Schema ist "hays.de/projekt/123" gemeint - das ergaenzen wir.
|
||||
const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(text) ? text : `https://${text}`;
|
||||
let url;
|
||||
try {
|
||||
url = new URL(candidate);
|
||||
} catch {
|
||||
return { error: 'Der Link ist keine gültige Adresse.' };
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return { error: 'Der Link muss mit http:// oder https:// beginnen.' };
|
||||
}
|
||||
if (candidate.length > MAX_LENGTH.link) return { error: 'Der Link ist zu lang (max. 1024 Zeichen).' };
|
||||
return { value: candidate };
|
||||
}
|
||||
|
||||
function parseTags(raw) {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((tag) => typeof tag === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function serializeTags(value) {
|
||||
if (!Array.isArray(value)) return null;
|
||||
return JSON.stringify([...new Set(value.map((tag) => String(tag).trim()).filter(Boolean))]);
|
||||
}
|
||||
|
||||
/** DECIMAL kommt als String aus mysql2 - im Frontend sollen es Zahlen sein. */
|
||||
function mapAcquisition(row) {
|
||||
return {
|
||||
...row,
|
||||
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),
|
||||
tags: parseTags(row.tags),
|
||||
};
|
||||
}
|
||||
|
||||
const SELECT_ACQUISITIONS = `
|
||||
SELECT a.*,
|
||||
creator.name AS created_by_name,
|
||||
opp.title AS opportunity_title,
|
||||
opp.stage AS opportunity_stage
|
||||
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
|
||||
`;
|
||||
|
||||
async function fetchAcquisition(id) {
|
||||
const [rows] = await pool.query(`${SELECT_ACQUISITIONS} WHERE a.id = ? LIMIT 1`, [id]);
|
||||
return rows[0] ? mapAcquisition(rows[0]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alles aelter als 90 Tage entfernen. Uebernommene Eintraege bleiben - sie
|
||||
* gehoeren zur Opportunity, nicht zum Archiv.
|
||||
*
|
||||
* Das laeuft beim Abruf der Liste, hoechstens aber einmal pro Stunde je Prozess.
|
||||
* Ein eigener Zeitplan waere eine weitere Stelle, die ausfallen kann; und die
|
||||
* Abfragen filtern die Altlasten ohnehin heraus, der Loeschlauf raeumt nur
|
||||
* hinterher.
|
||||
*/
|
||||
let lastPurgeAt = 0;
|
||||
async function purgeOldEntries() {
|
||||
if (Date.now() - lastPurgeAt < 3_600_000) return;
|
||||
lastPurgeAt = Date.now();
|
||||
|
||||
const [result] = await pool.query(
|
||||
`DELETE FROM crm_acquisitions
|
||||
WHERE converted_at IS NULL
|
||||
AND found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)`,
|
||||
[DELETE_AFTER_DAYS]
|
||||
);
|
||||
if (result.affectedRows > 0) {
|
||||
console.log(
|
||||
`[Akquise] ${result.affectedRows} Eintrag/Eintraege aelter als ${DELETE_AFTER_DAYS} Tage geloescht.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Aendern und loeschen darf der Ersteller und jeder Admin. */
|
||||
function mayModify(user, row) {
|
||||
return user.role === 'admin' || row.created_by === user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Felder aus dem Anfragekoerper pruefen. `partial` laesst fehlende Felder weg
|
||||
* (PATCH), sonst werden alle gesetzt (POST).
|
||||
*/
|
||||
function readFields(body, { partial }) {
|
||||
const columns = {};
|
||||
|
||||
for (const field of TEXT_FIELDS) {
|
||||
if (partial && body?.[field] === undefined) continue;
|
||||
const value = emptyToNull(body?.[field]);
|
||||
if (MAX_LENGTH[field] && value !== null && value.length > MAX_LENGTH[field]) {
|
||||
return { error: `Der Wert für "${field}" ist zu lang (max. ${MAX_LENGTH[field]} Zeichen).` };
|
||||
}
|
||||
columns[field] = value;
|
||||
}
|
||||
|
||||
if (!partial || body?.link !== undefined) {
|
||||
const link = normalizeLink(body?.link);
|
||||
if (link.error) return { error: link.error };
|
||||
columns.link = link.value;
|
||||
}
|
||||
|
||||
if (!partial || body?.channel !== undefined) {
|
||||
const channel = emptyToNull(body?.channel);
|
||||
if (channel !== null && !CHANNELS.has(channel)) {
|
||||
return { error: 'Unbekannter Kanal.' };
|
||||
}
|
||||
columns.channel = channel;
|
||||
}
|
||||
|
||||
for (const field of DATE_FIELDS) {
|
||||
if (partial && body?.[field] === undefined) continue;
|
||||
const result = normalizeDate(body?.[field]);
|
||||
if (result.error) return { error: result.error };
|
||||
columns[field] = result.value;
|
||||
}
|
||||
|
||||
for (const [field, label, max] of NUMBER_FIELDS) {
|
||||
if (partial && body?.[field] === undefined) continue;
|
||||
const result = normalizeNumber(body?.[field], label, max);
|
||||
if (result.error) return { error: result.error };
|
||||
columns[field] = result.value;
|
||||
}
|
||||
|
||||
if (!partial || body?.tags !== undefined) {
|
||||
columns.tags = serializeTags(body?.tags) ?? '[]';
|
||||
}
|
||||
|
||||
if (columns.title !== undefined && !columns.title) {
|
||||
return { error: 'Ein Projekttitel ist erforderlich.' };
|
||||
}
|
||||
if (columns.start_date && columns.end_date && columns.end_date < columns.start_date) {
|
||||
return { error: 'Das Projektende darf nicht vor dem Start liegen.' };
|
||||
}
|
||||
|
||||
return { columns };
|
||||
}
|
||||
|
||||
// GET /api/acquisitions?view=eingang|archiv|alle
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
await purgeOldEntries();
|
||||
|
||||
const view = String(req.query.view ?? 'eingang');
|
||||
const where = ['a.converted_at IS NULL'];
|
||||
const params = [];
|
||||
|
||||
if (view === 'eingang') {
|
||||
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.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)');
|
||||
params.push(ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS);
|
||||
} else if (view !== 'alle') {
|
||||
return res.status(400).json({ error: 'Unbekannte Ansicht.' });
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`${SELECT_ACQUISITIONS} WHERE ${where.join(' AND ')}
|
||||
ORDER BY a.found_at DESC, a.id DESC`,
|
||||
params
|
||||
);
|
||||
|
||||
// Beide Zaehler in einem Rutsch: die Oberflaeche zeigt sie am Menue und
|
||||
// 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`,
|
||||
[ARCHIVE_AFTER_DAYS, ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS]
|
||||
);
|
||||
|
||||
/*
|
||||
* Vorschlaege fuer die Eingabe, aus dem gesamten Bestand - nicht nur aus der
|
||||
* gerade gezeigten Liste. Wer denselben Anbieter zum zwanzigsten Mal
|
||||
* eintraegt, soll ihn nicht zum zwanzigsten Mal ausschreiben muessen.
|
||||
*/
|
||||
const [suggestionRows] = await pool.query(
|
||||
// Klammern sind Pflicht: ORDER BY und LIMIT in einem UNION-Zweig gehoeren
|
||||
// sonst zur Gesamtabfrage.
|
||||
`(SELECT 'provider' AS field, provider AS value FROM crm_acquisitions
|
||||
WHERE provider IS NOT NULL GROUP BY provider ORDER BY MAX(found_at) DESC LIMIT 60)
|
||||
UNION ALL
|
||||
(SELECT 'consultant', consultant FROM crm_acquisitions
|
||||
WHERE consultant IS NOT NULL GROUP BY consultant ORDER BY MAX(found_at) DESC LIMIT 60)
|
||||
UNION ALL
|
||||
(SELECT 'sender', sender FROM crm_acquisitions
|
||||
WHERE sender IS NOT NULL GROUP BY sender ORDER BY MAX(found_at) DESC LIMIT 60)`
|
||||
);
|
||||
const suggestions = { providers: [], consultants: [], senders: [] };
|
||||
const bucket = { provider: 'providers', consultant: 'consultants', sender: 'senders' };
|
||||
for (const row of suggestionRows) suggestions[bucket[row.field]].push(row.value);
|
||||
|
||||
res.json({
|
||||
archive_after_days: ARCHIVE_AFTER_DAYS,
|
||||
delete_after_days: DELETE_AFTER_DAYS,
|
||||
suggestions,
|
||||
counts: {
|
||||
eingang: Number(counts.eingang ?? 0),
|
||||
archiv: Number(counts.archiv ?? 0),
|
||||
},
|
||||
entries: rows.map(mapAcquisition),
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/acquisitions
|
||||
router.post('/', async (req, res, next) => {
|
||||
const parsed = readFields(req.body, { partial: false });
|
||||
if (parsed.error) return res.status(400).json({ error: parsed.error });
|
||||
|
||||
const columns = parsed.columns;
|
||||
// Ohne Angabe gilt heute: gefunden wurde es dann, wenn es eingetragen wird.
|
||||
if (columns.found_at === null) columns.found_at = new Date().toISOString().slice(0, 10);
|
||||
columns.created_by = req.user.id;
|
||||
|
||||
try {
|
||||
const keys = Object.keys(columns);
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO crm_acquisitions (${keys.join(', ')})
|
||||
VALUES (${keys.map(() => '?').join(', ')})`,
|
||||
keys.map((key) => columns[key])
|
||||
);
|
||||
res.status(201).json(await fetchAcquisition(result.insertId));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/acquisitions/:id
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Akquise-ID.' });
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT * FROM crm_acquisitions WHERE id = ? LIMIT 1', [id]);
|
||||
const existing = rows[0];
|
||||
if (!existing) return res.status(404).json({ error: 'Eintrag nicht gefunden.' });
|
||||
if (!mayModify(req.user, existing)) {
|
||||
return res.status(403).json({
|
||||
error: 'Diesen Eintrag darf nur ändern, wer ihn angelegt hat, oder ein Administrator.',
|
||||
});
|
||||
}
|
||||
if (existing.converted_at !== null) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: 'Der Eintrag wurde schon in eine Opportunity übernommen.' });
|
||||
}
|
||||
|
||||
const parsed = readFields(req.body, { partial: true });
|
||||
if (parsed.error) return res.status(400).json({ error: parsed.error });
|
||||
|
||||
const columns = parsed.columns;
|
||||
if ('title' in columns && !columns.title) {
|
||||
return res.status(400).json({ error: 'Der Projekttitel darf nicht leer sein.' });
|
||||
}
|
||||
|
||||
// Datumsgrenzen gegen den gespeicherten Stand pruefen, nicht nur gegen das,
|
||||
// was gerade mitgeschickt wurde.
|
||||
const start = 'start_date' in columns ? columns.start_date : existing.start_date;
|
||||
const end = 'end_date' in columns ? columns.end_date : existing.end_date;
|
||||
if (start && end && end < start) {
|
||||
return res.status(400).json({ error: 'Das Projektende darf nicht vor dem Start liegen.' });
|
||||
}
|
||||
|
||||
const keys = Object.keys(columns);
|
||||
if (keys.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
|
||||
|
||||
await pool.query(
|
||||
`UPDATE crm_acquisitions SET ${keys.map((key) => `${key} = ?`).join(', ')} WHERE id = ?`,
|
||||
[...keys.map((key) => columns[key]), id]
|
||||
);
|
||||
res.json(await fetchAcquisition(id));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/acquisitions/:id
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Akquise-ID.' });
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT created_by FROM crm_acquisitions WHERE id = ? LIMIT 1',
|
||||
[id]
|
||||
);
|
||||
const existing = rows[0];
|
||||
if (!existing) return res.status(404).json({ error: 'Eintrag nicht gefunden.' });
|
||||
if (!mayModify(req.user, existing)) {
|
||||
return res.status(403).json({
|
||||
error: 'Diesen Eintrag darf nur löschen, wer ihn angelegt hat, oder ein Administrator.',
|
||||
});
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM crm_acquisitions WHERE id = ?', [id]);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* POST /api/acquisitions/:id/opportunity
|
||||
*
|
||||
* Aus dem Eintrag wird eine Opportunity in der Phase "Neu". Beides in einer
|
||||
* Transaktion: entweder die Opportunity steht und der Eintrag ist als
|
||||
* uebernommen vermerkt, oder es bleibt alles wie es war. Sonst koennte eine
|
||||
* Opportunity entstehen, die der Eingang weiterhin anbietet.
|
||||
*/
|
||||
router.post('/:id/opportunity', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Akquise-ID.' });
|
||||
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
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) {
|
||||
return res.status(409).json({
|
||||
error: 'Aus diesem Eintrag wurde schon eine Opportunity.',
|
||||
opportunity_id: entry.opportunity_id,
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
const origin = [
|
||||
['Anbieter', entry.provider],
|
||||
['Von', entry.sender],
|
||||
['Kanal', entry.channel],
|
||||
['Berater', entry.consultant],
|
||||
['Link', entry.link],
|
||||
]
|
||||
.filter(([, value]) => value)
|
||||
.map(([label, value]) => `${label}: ${value}`);
|
||||
|
||||
const notes = [
|
||||
entry.notes,
|
||||
origin.length > 0 ? `Aus der Akquise vom ${entry.found_at}\n${origin.join('\n')}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
await conn.beginTransaction();
|
||||
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 (?, ?, ?, 'Neu', 10, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
entry.title,
|
||||
entry.company,
|
||||
entry.description,
|
||||
entry.daily_rate,
|
||||
entry.duration_days,
|
||||
entry.fixed_price,
|
||||
entry.start_date,
|
||||
entry.end_date,
|
||||
entry.next_step,
|
||||
entry.tags ?? '[]',
|
||||
notes || null,
|
||||
req.user.id,
|
||||
req.user.id,
|
||||
]
|
||||
);
|
||||
|
||||
await conn.query(
|
||||
'UPDATE crm_acquisitions SET opportunity_id = ?, converted_at = UTC_TIMESTAMP() WHERE id = ?',
|
||||
[created.insertId, id]
|
||||
);
|
||||
await conn.commit();
|
||||
|
||||
res.status(201).json({ ok: true, opportunity_id: created.insertId });
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {});
|
||||
next(err);
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -351,3 +351,49 @@ CREATE TABLE IF NOT EXISTS crm_mail_sync_state (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT IGNORE INTO crm_mail_sync_state (id) VALUES (1);
|
||||
|
||||
-- ===========================================================================
|
||||
-- Akquise: gefundene Projekte, bevor daraus eine Opportunity wird
|
||||
--
|
||||
-- Der Eingang ist eine Arbeitsliste, kein Archiv: was aelter als 14 Tage ist,
|
||||
-- erscheint in der Unteransicht "Archiv", was aelter als 90 Tage ist, wird
|
||||
-- geloescht. Beides ergibt sich aus `found_at` - es gibt bewusst keine
|
||||
-- Zustandsspalte, die von einem Job gepflegt werden muesste und auseinander
|
||||
-- laufen kann.
|
||||
--
|
||||
-- `converted_at` ist der Merker fuer "in eine Opportunity uebernommen". Er ist
|
||||
-- die Wahrheit, nicht `opportunity_id`: wird die Opportunity spaeter geloescht,
|
||||
-- faellt der Verweis auf NULL, der Eintrag soll aber nicht wieder im Eingang
|
||||
-- auftauchen.
|
||||
-- ===========================================================================
|
||||
CREATE TABLE IF NOT EXISTS crm_acquisitions (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
found_at DATE NOT NULL, -- Spalte "Datum": wann gefunden
|
||||
sender VARCHAR(190) NULL, -- "Von": wer es geschickt hat
|
||||
provider VARCHAR(190) NULL, -- "Anbieter": Vermittler, Portal
|
||||
channel VARCHAR(60) NULL, -- "Kanal": Mail, Telefon, Portal, ...
|
||||
title VARCHAR(190) NOT NULL, -- Projekttitel
|
||||
consultant VARCHAR(190) NULL, -- "Berater", fuer den es passt
|
||||
link VARCHAR(1024) NULL, -- Ausschreibung; macht den Titel klickbar
|
||||
-- Ab hier: steht nicht in der Liste, wandert aber in die Opportunity.
|
||||
company VARCHAR(190) NULL, -- Endkunde
|
||||
description TEXT NULL,
|
||||
daily_rate DECIMAL(10,2) NULL,
|
||||
duration_days DECIMAL(6,1) NULL,
|
||||
fixed_price DECIMAL(12,2) NULL,
|
||||
start_date DATE NULL,
|
||||
end_date DATE NULL,
|
||||
next_step VARCHAR(500) NULL,
|
||||
notes TEXT NULL,
|
||||
tags LONGTEXT NULL, -- JSON-Array als Text, wie bei crm_leads
|
||||
opportunity_id INT UNSIGNED NULL,
|
||||
converted_at DATETIME NULL,
|
||||
created_by INT UNSIGNED NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_crm_acq_found (found_at),
|
||||
KEY idx_crm_acq_converted (converted_at),
|
||||
CONSTRAINT fk_crm_acq_opp FOREIGN KEY (opportunity_id) REFERENCES crm_opportunities (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_crm_acq_user FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
@@ -8,6 +8,7 @@ import leadRoutes from './routes/leads.js';
|
||||
import connectionRoutes from './routes/connections.js';
|
||||
import eventRoutes from './routes/events.js';
|
||||
import opportunityRoutes from './routes/opportunities.js';
|
||||
import acquisitionRoutes from './routes/acquisitions.js';
|
||||
import scannerRoutes from './routes/scanner.js';
|
||||
import mailRoutes from './routes/mail.js';
|
||||
import { requireAuth } from './middleware/auth.js';
|
||||
@@ -47,6 +48,7 @@ app.use('/api/leads', leadRoutes);
|
||||
app.use('/api/connections', connectionRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
app.use('/api/opportunities', opportunityRoutes);
|
||||
app.use('/api/acquisitions', acquisitionRoutes);
|
||||
|
||||
// Projekt Scanner. Komplett hinter der CRM-Anmeldung - der urspruengliche
|
||||
// HTTP-Basic-Auth-Zugang des Scanners entfaellt damit.
|
||||
|
||||
Reference in New Issue
Block a user