Beim Nachziehen der Harvest-Historie sind zwei Regeln im Weg gestanden,
die fuer eine Uebernahme nicht passen:
- Beim Anlegen verlangte die API auch fuer nicht abrechenbare Projekte
einen Satz, obwohl validateProject ihn direkt danach verwirft. Nur
Abruf rechnet ueber den Satz am Projekt ab, also gilt die Pflicht auch
nur dort.
- Der Importweg liess hoechstens 24 Stunden je Tag zu. In Harvest stehen
Sammelbuchungen ("Nachbuchung DORA September und Oktober", 80 Stunden
auf einem Tag). Sie abzulehnen verliert die Stunden und verfaelscht das
Restbudget, sie zu verteilen erfindet Leistungsdaten - deshalb kommen
sie so herein, wie sie gebucht wurden. Eine Buchung darf jetzt bis zu
31 Tage umfassen, damit Zahlendreher weiter auffallen. Im
Buchungsdialog bleibt die Tagesgrenze bestehen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
157 lines
6.8 KiB
JavaScript
157 lines
6.8 KiB
JavaScript
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;
|