feat(projekte): Projekttyp "Abruf" fuer Abrechnung nach Aufwand

Abruf braucht keinen Budgettopf: es gibt kein zugesagtes Volumen und kein
Ablaufdatum, gebucht wird, was anfaellt. Der Satz kann deshalb nicht am
Topf haengen und steht am Projekt (pulse_projects.rate, Stunde oder Tag).
Der Umsatz sind die abrechenbaren Stunden mal diesem Satz; offen ist
nichts.

- Migration ergaenzt den ENUM-Wert und die Spalte rate
- Anlegen verlangt einen Satz und nimmt keine Toepfe an; ein Topf
  nachtraeglich anzulegen wird abgewiesen
- Wechsel auf Abruf nur, solange auf den Toepfen nichts gebucht ist -
  sonst wuerden Buchungen ihr Budget verlieren; leere Toepfe fallen weg
- Buchungsdialog, Projektkarte, Projektdetail und Projektreport zeigen
  Satz und Umsatz statt Budgetbalken

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-18 00:13:35 +02:00
co-authored by Claude Opus 5
parent 2af516ec20
commit 8c93df11d9
12 changed files with 182 additions and 35 deletions
+43 -8
View File
@@ -12,7 +12,9 @@ import {
} from '../lib/projects.js';
const router = express.Router();
const TYPES = ['billable', 'non_billable', 'fixed'];
const TYPES = ['billable', 'non_billable', 'fixed', 'abruf'];
/** Abruf rechnet nach Aufwand ab: kein Budgettopf, Satz am Projekt. */
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));
@@ -62,6 +64,10 @@ function validateProject(body, { creating }) {
if (!TYPES.includes(out.type)) errors.push('Unbekannter Projekttyp.');
}
if (creating || body.billing_unit !== undefined) out.billing_unit = body.billing_unit === 'day' ? 'day' : 'hour';
if (creating || body.rate !== undefined) {
out.rate = num(body.rate);
if (out.rate != null && !(Number.isFinite(out.rate) && out.rate >= 0)) errors.push('Satz ist ungültig.');
}
if (creating || body.start_date !== undefined) {
out.start_date = body.start_date;
if (!parseDate(out.start_date)) errors.push('Startdatum fehlt.');
@@ -159,13 +165,19 @@ router.post('/', requireRole('lead'), async (req, res, next) => {
const body = req.body || {};
const { out, errors } = validateProject(body, { creating: true });
const pots = [];
const potInputs = Array.isArray(body.pots) ? body.pots : [];
if (potInputs.length === 0) errors.push('Mindestens ein Budgettopf ist erforderlich.');
for (const input of potInputs) {
const result = validatePot(input, out, settings);
errors.push(...result.errors);
pots.push(result.pot);
// Abruf kommt ohne Topf, braucht dafuer aber einen Satz am Projekt.
const potInputs = NEEDS_POT(out.type) && Array.isArray(body.pots) ? body.pots : [];
if (NEEDS_POT(out.type)) {
if (potInputs.length === 0) errors.push('Mindestens ein Budgettopf ist erforderlich.');
for (const input of potInputs) {
const result = validatePot(input, out, settings);
errors.push(...result.errors);
pots.push(result.pot);
}
} else if (!out.rate) {
errors.push('Abruf: Stundensatz oder Tagessatz fehlt.');
}
if (out.type !== 'abruf') out.rate = null;
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(' ') });
@@ -212,7 +224,25 @@ router.put('/:id', requireRole('lead'), async (req, res, next) => {
{ ...body, start_date: body.start_date ?? project.start_date },
{ creating: false }
);
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
const nextType = out.type ?? project.type;
const [[pots]] = await pool.query(
`SELECT COUNT(*) AS n,
(SELECT COUNT(*) FROM pulse_time_entries e WHERE e.pot_id IN
(SELECT id FROM pulse_budget_pots WHERE project_id = ?)) AS booked
FROM pulse_budget_pots WHERE project_id = ?`,
[project.id, project.id]
);
if (nextType === 'abruf') {
// Der Satz muss stehen, sonst waeren die Buchungen nicht bewertbar.
if (!(out.rate ?? project.rate)) errors.push('Abruf: Stundensatz oder Tagessatz fehlt.');
if (Number(pots.booked) > 0) {
errors.push('Auf den Budgettöpfen dieses Projekts sind Zeiten gebucht - ein Wechsel auf Abruf würde sie vom Budget lösen.');
}
} else if (out.type) {
out.rate = null;
}
if (errors.length) return res.status(400).json({ error: [...new Set(errors)].join(' ') });
await withTransaction(async (conn) => {
const keys = Object.keys(out);
@@ -225,6 +255,8 @@ router.put('/:id', requireRole('lead'), async (req, res, next) => {
if (Array.isArray(body.member_ids)) await setMembers(conn, project.id, body.member_ids);
if (out.type && out.type !== project.type) {
if (out.type === 'non_billable') await conn.query('UPDATE pulse_budget_pots SET rate = NULL WHERE project_id = ?', [project.id]);
// Abruf kennt keine Toepfe; ungenutzte werden beim Wechsel entfernt.
if (out.type === 'abruf') await conn.query('DELETE FROM pulse_budget_pots WHERE project_id = ?', [project.id]);
await refreshBillable(conn, project.id);
await reassignEntries(conn, project.id);
}
@@ -267,6 +299,9 @@ router.post('/:id/pots', requireRole('lead'), async (req, res, next) => {
try {
const project = await findProject(Number(req.params.id));
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
if (!NEEDS_POT(project.type)) {
return res.status(400).json({ error: 'Abruf-Projekte rechnen nach Aufwand ab und haben keine Budgettöpfe.' });
}
const { pot, errors } = validatePot(req.body || {}, project, await getSettings());
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
await withTransaction(async (conn) => {