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:
co-authored by
Claude Opus 5
parent
2af516ec20
commit
8c93df11d9
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user