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
@@ -39,10 +39,36 @@ export async function initDatabase() {
|
||||
conn.release();
|
||||
}
|
||||
|
||||
await migrateAbrufType();
|
||||
await ensureAdminUser();
|
||||
await ensureInternalCustomer();
|
||||
}
|
||||
|
||||
/*
|
||||
* Projekttyp "Abruf" nachtraeglich ergaenzen: Abrechnung nach Aufwand, ohne
|
||||
* Budgettopf. Der Satz haengt deshalb am Projekt (pulse_projects.rate) und
|
||||
* nicht am Topf. CREATE TABLE IF NOT EXISTS laesst eine bestehende Tabelle
|
||||
* unberuehrt, deshalb hier.
|
||||
*/
|
||||
async function migrateAbrufType() {
|
||||
const [columns] = await pool.query(
|
||||
`SELECT COLUMN_NAME, COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pulse_projects'
|
||||
AND COLUMN_NAME IN ('type', 'rate')`
|
||||
);
|
||||
const type = columns.find((c) => c.COLUMN_NAME === 'type');
|
||||
if (type && !type.COLUMN_TYPE.includes("'abruf'")) {
|
||||
await pool.query(
|
||||
"ALTER TABLE pulse_projects MODIFY type ENUM('billable','non_billable','fixed','abruf') NOT NULL DEFAULT 'billable'"
|
||||
);
|
||||
console.log('[Pulse] Projekttyp "Abruf" ergaenzt.');
|
||||
}
|
||||
if (!columns.some((c) => c.COLUMN_NAME === 'rate')) {
|
||||
await pool.query('ALTER TABLE pulse_projects ADD COLUMN rate DECIMAL(10,2) NULL AFTER billing_unit');
|
||||
console.log('[Pulse] Spalte pulse_projects.rate ergaenzt.');
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureAdminUser() {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS n FROM pulse_users');
|
||||
if (rows[0].n > 0) return;
|
||||
|
||||
@@ -46,6 +46,7 @@ export async function loadEntries(settings, { from, to, userIds = null, customer
|
||||
u.name AS user_name, u.color AS user_color, u.is_external, u.company AS user_company, u.weekly_hours,
|
||||
u.manager_id,
|
||||
p.name AS project_name, p.code AS project_code, p.type AS project_type, p.billing_unit,
|
||||
p.rate AS project_rate,
|
||||
c.id AS customer_id, c.name AS customer_name, c.short_name AS customer_short, c.color AS customer_color,
|
||||
t.name AS task_name,
|
||||
b.label AS pot_label, b.rate AS pot_rate, b.budget_amount AS pot_amount
|
||||
@@ -72,12 +73,13 @@ export async function loadEntries(settings, { from, to, userIds = null, customer
|
||||
}
|
||||
|
||||
return rows.map((r) => {
|
||||
const project = { name: r.project_name, code: r.project_code, billing_unit: r.billing_unit };
|
||||
const project = { name: r.project_name, code: r.project_code, billing_unit: r.billing_unit, type: r.project_type, rate: r.project_rate };
|
||||
const hours = r.minutes / 60;
|
||||
let amount = 0;
|
||||
let fixedAmount = 0;
|
||||
if (r.billable && r.project_type === 'billable') {
|
||||
const rate = hourlyRate({ rate: r.pot_rate }, project, settings);
|
||||
// Billable rechnet ueber den Satz des Topfs ab, Abruf ueber den am Projekt.
|
||||
if (r.billable && (r.project_type === 'billable' || r.project_type === 'abruf')) {
|
||||
const rate = hourlyRate(r.project_type === 'abruf' ? null : { rate: r.pot_rate }, project, settings);
|
||||
amount = rate ? hours * rate : 0;
|
||||
}
|
||||
if (r.billable && r.project_type === 'fixed' && r.pot_amount && potMinutes.get(r.pot_id)) {
|
||||
|
||||
@@ -36,11 +36,15 @@ function prettyShort(customer) {
|
||||
return customer.short_name;
|
||||
}
|
||||
|
||||
/** Stundensatz je Stunde, auch wenn das Projekt nach Tagen abrechnet. */
|
||||
/**
|
||||
* Stundensatz je Stunde, auch wenn das Projekt nach Tagen abrechnet.
|
||||
*
|
||||
* Bei "Abruf" gibt es keinen Budgettopf - der Satz steht dann am Projekt.
|
||||
*/
|
||||
export function hourlyRate(pot, project, settings) {
|
||||
if (pot?.rate == null) return null;
|
||||
const rate = Number(pot.rate);
|
||||
return project.billing_unit === 'day' ? rate / Number(settings.hours_per_day || 8) : rate;
|
||||
const raw = pot?.rate ?? (project?.type === 'abruf' ? project.rate : null);
|
||||
if (raw == null) return null;
|
||||
return project.billing_unit === 'day' ? Number(raw) / Number(settings.hours_per_day || 8) : Number(raw);
|
||||
}
|
||||
|
||||
/** Buchbares Volumen eines Topfs in Stunden (null = unbegrenzt). */
|
||||
@@ -248,15 +252,24 @@ export async function loadProjects(settings, { where = '1=1', params = [] } = {}
|
||||
remaining_hours:
|
||||
totalBudgetHours == null ? null : round2(Math.max(0, totalBudgetHours - projectPots.reduce((s, pot) => s + pot.used_minutes, 0) / 60)),
|
||||
budget_value: round2(projectPots.reduce((s, pot) => s + (pot.budget_value || 0), 0)),
|
||||
earned: round2(projectPots.reduce((s, pot) => s + pot.earned, 0)),
|
||||
open: round2(projectPots.reduce((s, pot) => s + pot.open, 0)),
|
||||
/*
|
||||
* Abruf rechnet nach Aufwand ab: der Umsatz haengt nicht an Toepfen,
|
||||
* sondern an den abrechenbaren Stunden mal dem Satz am Projekt. Offen ist
|
||||
* dort nichts - es gibt kein zugesagtes Volumen.
|
||||
*/
|
||||
earned:
|
||||
project.type === 'abruf'
|
||||
? round2((Number(usage?.billable_minutes || 0) / 60) * (hourlyRate(null, project, settings) ?? 0))
|
||||
: round2(projectPots.reduce((s, pot) => s + pot.earned, 0)),
|
||||
open: project.type === 'abruf' ? 0 : round2(projectPots.reduce((s, pot) => s + pot.open, 0)),
|
||||
hourly_rate: hourlyRate(null, project, settings),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Betraege weg fuer die Rolle "user" - Mitarbeiter sehen Stunden, keine Preise. */
|
||||
export function stripAmounts(project) {
|
||||
const clean = { ...project, budget_value: null, earned: null, open: null };
|
||||
const clean = { ...project, budget_value: null, earned: null, open: null, rate: null, hourly_rate: null };
|
||||
clean.pots = project.pots.map((pot) => ({
|
||||
...pot,
|
||||
budget_amount: null,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -56,8 +56,11 @@ CREATE TABLE IF NOT EXISTS pulse_projects (
|
||||
code VARCHAR(40) NOT NULL,
|
||||
seq INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
name VARCHAR(190) NOT NULL,
|
||||
type ENUM('billable','non_billable','fixed') NOT NULL DEFAULT 'billable',
|
||||
-- abruf: Abrechnung nach Aufwand ohne Budget. Der Satz steht dann in rate,
|
||||
-- weil es keinen Budgettopf gibt, der ihn tragen koennte.
|
||||
type ENUM('billable','non_billable','fixed','abruf') NOT NULL DEFAULT 'billable',
|
||||
billing_unit ENUM('hour','day') NOT NULL DEFAULT 'hour',
|
||||
rate DECIMAL(10,2) NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NULL,
|
||||
tags TEXT NULL,
|
||||
|
||||
Reference in New Issue
Block a user