feat(saetze): Satz je Task plus Import-Endpunkt fuer Harvest
Satz je Task Ein Task kann einen eigenen Satz tragen; leer bedeutet weiter "Satz des Budgettopfs" bzw. bei Abruf "Satz des Projekts". Damit lassen sich "remote" und "vor Ort" im selben Projekt abrechnen, ohne dafuer getrennte Projekte anzulegen. Das Budget bleibt in Stunden - gemischte Saetze aendern nichts daran, wann ein Topf voll ist. Der Umsatz wird je Task-Satz aufsummiert: loadProjects gruppiert den Topfverbrauch nach Task-Satz und gibt das Ergebnis an potStats, Abruf rechnet je Task. Mitarbeiter sehen keine Saetze, auch nicht die am Task. Import POST /api/imports/entries (nur Administrator) schreibt Buchungen fuer beliebige Personen - POST /api/entries bucht bewusst nur auf den angemeldeten Benutzer. Der Import laeuft durch dieselben Regeln: Projekt aktiv, Person zugewiesen, Topf passend zum Datum, maximal 24 h am Tag. dry_run schreibt nichts. Jede uebernommene Buchung traegt ihre harvest_id (unique). Ein zweiter Lauf legt nichts doppelt an. DELETE /api/imports/entries entfernt genau die uebernommenen Zeiten; handgetippte bleiben unberuehrt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7606df9280
commit
7e7188d476
@@ -0,0 +1,149 @@
|
||||
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".
|
||||
*/
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MAX_BATCH = 1000;
|
||||
|
||||
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 > 24 * 60) throw new Error('Minuten muessen zwischen 1 und 1440 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 [[day]] = await conn.query(
|
||||
'SELECT COALESCE(SUM(minutes), 0) AS total FROM pulse_time_entries WHERE user_id = ? AND entry_date = ?',
|
||||
[userId, date]
|
||||
);
|
||||
if (Number(day.total) + minutes > 24 * 60) throw new Error('An diesem Tag waeren es mehr als 24 Stunden.');
|
||||
|
||||
const billable = isBillable(project, task);
|
||||
const pot = await resolvePot(conn, project, date, minutes, null, billable);
|
||||
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;
|
||||
@@ -19,6 +19,20 @@ const NEEDS_POT = (type) => type !== 'abruf';
|
||||
const str = (v, max) => (v == null ? null : String(v).trim().slice(0, max) || null);
|
||||
const num = (v) => (v === '' || v == null ? null : Number(v));
|
||||
|
||||
/**
|
||||
* Satz am Task: leer bedeutet "Satz des Budgettopfs". Ein eigener Satz ist nur
|
||||
* bei abrechenbaren Tasks sinnvoll - auf nicht abrechenbaren hat er keine
|
||||
* Wirkung und wird deshalb verworfen, damit im Formular nichts Falsches
|
||||
* stehen bleibt.
|
||||
*/
|
||||
function taskRate(value, billable) {
|
||||
if (!billable) return null;
|
||||
const rate = num(value);
|
||||
if (rate == null) return null;
|
||||
if (!Number.isFinite(rate) || rate < 0 || rate > 100000) return { error: 'Satz am Task: 0 bis 100.000.' };
|
||||
return Math.round(rate * 100) / 100;
|
||||
}
|
||||
|
||||
function validatePot(input, project, settings) {
|
||||
const errors = [];
|
||||
const pot = {
|
||||
@@ -178,6 +192,15 @@ router.post('/', requireRole('lead'), async (req, res, next) => {
|
||||
errors.push('Abruf: Stundensatz oder Tagessatz fehlt.');
|
||||
}
|
||||
if (out.type !== 'abruf') out.rate = null;
|
||||
// Tasks samt Satz vorab pruefen, damit ein Fehler eine 400 gibt und nicht
|
||||
// mitten in der Transaktion auffaellt.
|
||||
const taskInputs = [];
|
||||
for (const task of (Array.isArray(body.tasks) ? body.tasks : []).filter((t) => str(t?.name, 160))) {
|
||||
const billable = out.type === 'non_billable' ? 0 : task.billable === false ? 0 : 1;
|
||||
const rate = taskRate(task.rate, billable);
|
||||
if (rate?.error) errors.push(rate.error);
|
||||
else taskInputs.push({ name: str(task.name, 160), billable, rate });
|
||||
}
|
||||
const [customers] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [out.customer_id || 0]);
|
||||
if (out.customer_id && !customers[0]) errors.push('Kunde nicht gefunden.');
|
||||
if (errors.length) return res.status(400).json({ error: [...new Set(errors)].join(' ') });
|
||||
@@ -198,12 +221,12 @@ router.post('/', requireRole('lead'), async (req, res, next) => {
|
||||
[projectId, pot.label, pot.valid_from, pot.valid_to, pot.budget_amount, pot.rate, pot.budget_hours, pot.notes]
|
||||
);
|
||||
}
|
||||
const tasks = (Array.isArray(body.tasks) ? body.tasks : []).filter((t) => str(t?.name, 160));
|
||||
for (const task of tasks) {
|
||||
await conn.query('INSERT INTO pulse_tasks (project_id, name, billable) VALUES (?, ?, ?)', [
|
||||
for (const task of taskInputs) {
|
||||
await conn.query('INSERT INTO pulse_tasks (project_id, name, billable, rate) VALUES (?, ?, ?, ?)', [
|
||||
projectId,
|
||||
str(task.name, 160),
|
||||
out.type === 'non_billable' ? 0 : task.billable === false ? 0 : 1,
|
||||
task.name,
|
||||
task.billable,
|
||||
task.rate,
|
||||
]);
|
||||
}
|
||||
await setMembers(conn, projectId, body.member_ids);
|
||||
@@ -368,10 +391,14 @@ router.post('/:id/tasks', requireRole('lead'), async (req, res, next) => {
|
||||
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
|
||||
const name = str(req.body?.name, 160);
|
||||
if (!name) return res.status(400).json({ error: 'Taskname fehlt.' });
|
||||
await pool.query('INSERT INTO pulse_tasks (project_id, name, billable) VALUES (?, ?, ?)', [
|
||||
const billable = project.type === 'non_billable' ? 0 : req.body?.billable === false ? 0 : 1;
|
||||
const rate = taskRate(req.body?.rate, billable);
|
||||
if (rate?.error) return res.status(400).json({ error: rate.error });
|
||||
await pool.query('INSERT INTO pulse_tasks (project_id, name, billable, rate) VALUES (?, ?, ?, ?)', [
|
||||
project.id,
|
||||
name,
|
||||
project.type === 'non_billable' ? 0 : req.body?.billable === false ? 0 : 1,
|
||||
billable,
|
||||
rate,
|
||||
]);
|
||||
await respondProject(req, res, project.id, 201);
|
||||
} catch (err) {
|
||||
@@ -388,8 +415,16 @@ router.put('/:id/tasks/:taskId', requireRole('lead'), async (req, res, next) =>
|
||||
if (!name) return res.status(400).json({ error: 'Taskname fehlt.' });
|
||||
const billable = req.body?.billable !== undefined ? (req.body.billable ? 1 : 0) : rows[0].billable;
|
||||
const active = req.body?.is_active !== undefined ? (req.body.is_active ? 1 : 0) : rows[0].is_active;
|
||||
const rate = req.body?.rate !== undefined ? taskRate(req.body.rate, billable) : taskRate(rows[0].rate, billable);
|
||||
if (rate?.error) return res.status(400).json({ error: rate.error });
|
||||
await withTransaction(async (conn) => {
|
||||
await conn.query('UPDATE pulse_tasks SET name = ?, billable = ?, is_active = ? WHERE id = ?', [name, billable, active, rows[0].id]);
|
||||
await conn.query('UPDATE pulse_tasks SET name = ?, billable = ?, rate = ?, is_active = ? WHERE id = ?', [
|
||||
name,
|
||||
billable,
|
||||
rate,
|
||||
active,
|
||||
rows[0].id,
|
||||
]);
|
||||
if (billable !== rows[0].billable) {
|
||||
await refreshBillable(conn, projectId);
|
||||
await reassignEntries(conn, projectId);
|
||||
|
||||
Reference in New Issue
Block a user