Im Zeitreport stehen interne Projekte und Abwesenheiten zwischen den Kundenzahlen: 2025 sind das 19.451 von 45.762 Stunden, und die Auslastung zeigt 146 Prozent, weil Urlaub und Krankheit mitzaehlen. Ein Haken neben der Billable-Aufteilung blendet Projekte vom Typ "nicht abrechenbar" aus. Er wirkt auf den ganzen Report - Summen, Auslastung, alle vier Gruppierungen - und auf die Exporte, die denselben Report bauen; im PDF-Kopf steht dann "Nicht abrechenbare Projekte: ausgeblendet". Gefiltert wird in loadEntries, damit Kunden-, Projekt- und Taskliste dieselbe Grundmenge sehen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
108 lines
4.5 KiB
JavaScript
108 lines
4.5 KiB
JavaScript
import { pool } from '../db.js';
|
|
import { displayName, hourlyRate } from './projects.js';
|
|
import { userFilter } from './access.js';
|
|
|
|
/*
|
|
* Buchungen fuer Auswertungen laden - mit allem, was Dashboard, Reports,
|
|
* Kundenansicht und Exporte brauchen, und mit dem Betrag je Buchung:
|
|
*
|
|
* amount abrechenbarer Betrag (Stunden x Stundensatz des Topfs);
|
|
* nur fuer Projekte vom Typ "billable"
|
|
* fixed_amount Festpreisprojekte: Stunden x virtueller Stundensatz
|
|
* (Topfbudget / alle auf dem Topf gebuchten Stunden). Nur
|
|
* intern, in Reports per Schalter zuschaltbar.
|
|
*/
|
|
export async function loadEntries(settings, { from, to, userIds = null, customerId, projectId, taskId, skipNonBillableProjects = false } = {}) {
|
|
// Administratoren sind keine Mitarbeiter - ihre Zeiten zaehlen nirgends mit.
|
|
const where = ["u.role <> 'admin'"];
|
|
const params = [];
|
|
if (from) {
|
|
where.push('e.entry_date >= ?');
|
|
params.push(from);
|
|
}
|
|
if (to) {
|
|
where.push('e.entry_date <= ?');
|
|
params.push(to);
|
|
}
|
|
const users = userFilter(userIds);
|
|
where.push(users.sql);
|
|
params.push(...users.params);
|
|
if (customerId) {
|
|
where.push('p.customer_id = ?');
|
|
params.push(Number(customerId));
|
|
}
|
|
if (projectId) {
|
|
where.push('e.project_id = ?');
|
|
params.push(Number(projectId));
|
|
}
|
|
if (taskId) {
|
|
where.push('e.task_id = ?');
|
|
params.push(Number(taskId));
|
|
}
|
|
// Fuer Auswertungen, die nur das Kundengeschaeft zeigen sollen: interne
|
|
// Projekte und Abwesenheiten bleiben draussen.
|
|
if (skipNonBillableProjects) where.push("p.type <> 'non_billable'");
|
|
|
|
const [rows] = await pool.query(
|
|
`SELECT e.id, e.user_id, e.project_id, e.task_id, e.pot_id, e.entry_date, e.minutes, e.note, e.billable,
|
|
e.created_at, e.updated_at,
|
|
u.name AS user_name, u.color AS user_color, u.avatar AS user_avatar, 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, t.rate AS task_rate,
|
|
b.label AS pot_label, b.rate AS pot_rate, b.budget_amount AS pot_amount,
|
|
b.order_number AS pot_order_number
|
|
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
|
|
JOIN pulse_customers c ON c.id = p.customer_id
|
|
LEFT JOIN pulse_tasks t ON t.id = e.task_id
|
|
LEFT JOIN pulse_budget_pots b ON b.id = e.pot_id
|
|
WHERE ${where.join(' AND ')}
|
|
ORDER BY e.entry_date, e.id`,
|
|
params
|
|
);
|
|
|
|
// Virtueller Stundensatz braucht die Stunden des ganzen Topfs, nicht nur des Zeitraums.
|
|
const fixedPots = [...new Set(rows.filter((r) => r.project_type === 'fixed' && r.pot_id).map((r) => r.pot_id))];
|
|
const potMinutes = new Map();
|
|
if (fixedPots.length) {
|
|
const [usage] = await pool.query(
|
|
`SELECT pot_id, SUM(minutes) AS minutes FROM pulse_time_entries WHERE pot_id IN (${fixedPots.map(() => '?').join(',')}) GROUP BY pot_id`,
|
|
fixedPots
|
|
);
|
|
for (const u of usage) potMinutes.set(u.pot_id, Number(u.minutes));
|
|
}
|
|
|
|
return rows.map((r) => {
|
|
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;
|
|
// Billable rechnet ueber den Satz des Topfs ab, Abruf ueber den am Projekt.
|
|
// Traegt der Task einen eigenen Satz (z. B. "vor Ort"), gewinnt dieser.
|
|
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,
|
|
r.task_rate == null ? null : { rate: r.task_rate }
|
|
);
|
|
amount = rate ? hours * rate : 0;
|
|
}
|
|
if (r.billable && r.project_type === 'fixed' && r.pot_amount && potMinutes.get(r.pot_id)) {
|
|
fixedAmount = (Number(r.pot_amount) / (potMinutes.get(r.pot_id) / 60)) * hours;
|
|
}
|
|
return {
|
|
...r,
|
|
billable: Boolean(r.billable),
|
|
is_external: Boolean(r.is_external),
|
|
project_display: displayName(settings, project, { name: r.customer_name, short_name: r.customer_short }),
|
|
amount: Math.round(amount * 100) / 100,
|
|
fixed_amount: Math.round(fixedAmount * 100) / 100,
|
|
};
|
|
});
|
|
}
|