import express from 'express'; import { pool, withTransaction } from '../db.js'; import { getSettings } from '../lib/settings.js'; import { parseDate } from '../lib/dates.js'; import { isBillable, resolvePot } from '../lib/projects.js'; /* * Buchungen aus einem Fremdsystem uebernehmen (Harvest). * * Warum ein eigener Endpunkt: POST /api/entries bucht immer auf den * angemeldeten Benutzer - das ist richtig, denn jeder erfasst seine eigenen * Zeiten. Ein Import schreibt aber fuer viele Personen. Statt dafuer direkt in * die Datenbank zu schreiben und die Fachregeln zu umgehen, laeuft er hier * durch dieselben Pruefungen: Projekt aktiv, Person dem Projekt zugewiesen, * Budgettopf passend zum Leistungsdatum, hoechstens 24 Stunden am Tag. * * Wiedererkennung ueber harvest_id: ein zweiter Lauf legt nichts doppelt an, * sondern meldet die Buchung als "schon vorhanden". * * Eine Ausnahme von den Fachregeln: die Grenze von 24 Stunden je Tag gilt hier * nicht. In Harvest stehen Sammelbuchungen ("Nachbuchung DORA September und * Oktober", 80 Stunden auf einem Tag). Wer sie ablehnt, verliert die Stunden * und damit das Restbudget des Projekts; wer sie auf Tage verteilt, erfindet * Leistungsdaten. Sie kommen deshalb so herein, wie sie gebucht wurden. Im * Buchungsdialog bleibt die Tagesgrenze bestehen. */ const router = express.Router(); const MAX_BATCH = 1000; // Sammelbuchungen duerfen mehr als einen Tag umfassen - hoechstens aber 31 Tage, // damit Zahlendreher nicht unbemerkt durchgehen. const MAX_MINUTES = 31 * 24 * 60; router.post('/entries', async (req, res, next) => { const body = req.body || {}; const rows = Array.isArray(body.entries) ? body.entries : []; const dryRun = body.dry_run === true; if (rows.length === 0) return res.status(400).json({ error: 'Keine Buchungen uebergeben.' }); if (rows.length > MAX_BATCH) return res.status(400).json({ error: `Hoechstens ${MAX_BATCH} Buchungen pro Aufruf.` }); try { const settings = await getSettings(); const created = []; const skipped = []; const failed = []; for (const row of rows) { const harvestId = row.harvest_id == null ? null : Number(row.harvest_id); const label = harvestId ?? `${row.entry_date} ${row.minutes} min`; try { const userId = Number(row.user_id); const projectId = Number(row.project_id); const taskId = row.task_id == null || row.task_id === '' ? null : Number(row.task_id); const date = row.entry_date; const minutes = Number(row.minutes); const note = row.note == null ? null : String(row.note).trim().slice(0, 1000) || null; if (!parseDate(date)) throw new Error('Datum fehlt oder ist unguelltig.'); if (!Number.isInteger(minutes) || minutes < 1 || minutes > MAX_MINUTES) { throw new Error(`Minuten muessen zwischen 1 und ${MAX_MINUTES} liegen.`); } // Schon importiert? Dann nicht erneut anlegen. if (harvestId) { const [[dup]] = await pool.query('SELECT id FROM pulse_time_entries WHERE harvest_id = ? LIMIT 1', [harvestId]); if (dup) { skipped.push({ harvest_id: harvestId, entry_id: dup.id, reason: 'schon vorhanden' }); continue; } } const [[user]] = await pool.query("SELECT id, role FROM pulse_users WHERE id = ? AND is_active = 1 LIMIT 1", [userId]); if (!user) throw new Error('Benutzer nicht gefunden oder deaktiviert.'); if (user.role === 'admin') throw new Error('Administratoren buchen keine Zeiten.'); const id = await withTransaction(async (conn) => { const [[project]] = await conn.query('SELECT * FROM pulse_projects WHERE id = ? LIMIT 1', [projectId]); if (!project) throw new Error('Projekt nicht gefunden.'); if (!project.is_active) throw new Error('Projekt ist archiviert.'); const [member] = await conn.query('SELECT 1 FROM pulse_project_members WHERE project_id = ? AND user_id = ?', [projectId, userId]); if (!member.length) throw new Error('Person ist dem Projekt nicht zugewiesen.'); let task = null; if (taskId) { const [[found]] = await conn.query('SELECT * FROM pulse_tasks WHERE id = ? AND project_id = ? LIMIT 1', [taskId, projectId]); if (!found) throw new Error('Task gehoert nicht zu diesem Projekt.'); task = found; } const billable = isBillable(project, task); // Historische Uebernahme: abgelaufene Toepfe nehmen ihre eigenen Zeiten noch an. const pot = await resolvePot(conn, project, date, minutes, null, billable, { historic: true }); if (dryRun) return 0; const [result] = await conn.query( `INSERT INTO pulse_time_entries (user_id, project_id, task_id, pot_id, entry_date, minutes, note, billable, harvest_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [userId, projectId, task?.id ?? null, pot?.id ?? null, date, minutes, note, billable ? 1 : 0, harvestId] ); return result.insertId; }); created.push({ harvest_id: harvestId, entry_id: id }); } catch (err) { failed.push({ harvest_id: harvestId, label, error: err.message }); } } res.json({ dry_run: dryRun, requested: rows.length, created: created.length, skipped: skipped.length, failed: failed.length, details: { created, skipped, failed }, }); } catch (err) { next(err); } }); /** Was wurde bisher importiert? Fuer Kontrolle und Rueckbau. */ router.get('/entries', async (req, res, next) => { try { const [rows] = await pool.query( `SELECT e.harvest_id, e.id, e.entry_date, e.minutes, u.name AS user_name, p.code AS project_code FROM pulse_time_entries e JOIN pulse_users u ON u.id = e.user_id JOIN pulse_projects p ON p.id = e.project_id WHERE e.harvest_id IS NOT NULL ORDER BY e.entry_date, e.id` ); res.json({ count: rows.length, entries: rows }); } catch (err) { next(err); } }); /** * Rueckbau: entfernt genau die Buchungen, die dieser Import angelegt hat. * Ohne `harvest_ids` alle - handgetippte Zeiten bleiben unberuehrt, weil sie * keine harvest_id tragen. */ router.delete('/entries', async (req, res, next) => { try { const ids = Array.isArray(req.body?.harvest_ids) ? req.body.harvest_ids.map(Number).filter(Number.isFinite) : null; if (ids && ids.length === 0) return res.status(400).json({ error: 'Keine IDs uebergeben.' }); const [result] = ids ? await pool.query(`DELETE FROM pulse_time_entries WHERE harvest_id IN (${ids.map(() => '?').join(',')})`, ids) : await pool.query('DELETE FROM pulse_time_entries WHERE harvest_id IS NOT NULL'); res.json({ deleted: result.affectedRows }); } catch (err) { next(err); } }); export default router;