category report

This commit is contained in:
2026-08-19 16:48:01 +02:00
parent c63e1070e0
commit f8b0d52ff7
6 changed files with 551 additions and 9 deletions

View File

@@ -235,6 +235,11 @@ const API = {
if (anonymize) params.set('anonymize', 'true'); if (anonymize) params.set('anonymize', 'true');
if (groupByMonth) params.set('groupByMonth', 'true'); if (groupByMonth) params.set('groupByMonth', 'true');
return fetch('/api/reports/revenue?' + params.toString()).then(r => r.json()); return fetch('/api/reports/revenue?' + params.toString()).then(r => r.json());
},
getCategoryRevenue: (from, to, groupByMonth = false) => {
const params = new URLSearchParams({ from, to });
if (groupByMonth) params.set('groupByMonth', 'true');
return fetch('/api/reports/category-revenue?' + params.toString()).then(r => r.json());
} }
}, },

View File

@@ -3,10 +3,12 @@
* *
* 1. Accounts Receivable Aging (Stichtag frei wählbar) * 1. Accounts Receivable Aging (Stichtag frei wählbar)
* 2. Invoice-Level Revenue (Zeitraum frei wählbar, optional nach Monat gruppiert) * 2. Invoice-Level Revenue (Zeitraum frei wählbar, optional nach Monat gruppiert)
* 3. Revenue by Category (Parts/Labor/Subscription, optional Monate als Spalten)
* *
* Beide Reports können Kundennamen pseudonymisieren. Die Anonymisierung passiert * Die ersten beiden Reports können Kundennamen pseudonymisieren. Die Anonymisierung
* ausschließlich serverseitig — dieses Modul bekommt bei aktiver Option gar keine * passiert ausschließlich serverseitig — dieses Modul bekommt bei aktiver Option gar
* Klarnamen zu sehen und kann sie folglich auch nicht in ein CSV schreiben. * keine Klarnamen zu sehen und kann sie folglich auch nicht in ein CSV schreiben.
* Report 3 aggregiert über Kategorien und enthält gar keine Kundennamen.
* *
* Beträge kommen als numeric-Strings vom Server und werden nur zur Anzeige * Beträge kommen als numeric-Strings vom Server und werden nur zur Anzeige
* formatiert. Ins CSV gehen die Rohwerte, damit Excel sauber rechnen kann. * formatiert. Ins CSV gehen die Rohwerte, damit Excel sauber rechnen kann.
@@ -34,6 +36,11 @@ let revAnonymize = false;
let revGroupByMonth = false; let revGroupByMonth = false;
let revData = null; let revData = null;
let catFrom = startOfYear;
let catTo = today;
let catGroupByMonth = false;
let catData = null;
// State der aus accounting-view.js übernommenen Reports (1:1) // State der aus accounting-view.js übernommenen Reports (1:1)
let plStartDate = null; let plStartDate = null;
let plEndDate = null; let plEndDate = null;
@@ -190,6 +197,32 @@ export function renderReportsView() {
</div> </div>
<div id="rev-result"></div>`; <div id="rev-result"></div>`;
const categoryRevenueBody = `
<div class="flex flex-wrap items-end gap-3 mb-3">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">From</label>
<input type="date" id="cat-from" value="${catFrom}"
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">To</label>
<input type="date" id="cat-to" value="${catTo}"
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
</div>
<button onclick="window.reportsView.loadCategoryRevenue()"
class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">Run</button>
<button onclick="window.reportsView.exportCategoryRevenueCsv()"
class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">⬇ Export CSV</button>
<button onclick="window.reportsView.exportCategoryRevenuePdf()"
class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">📄 Export PDF</button>
<label class="flex items-center gap-1 pt-5 text-xs text-gray-600 cursor-pointer">
<input type="checkbox" id="cat-group-month" ${catGroupByMonth ? 'checked' : ''}
class="h-4 w-4 text-blue-600 border-gray-300 rounded">
Group by month
</label>
</div>
<div id="cat-result"></div>`;
// ── Ab hier 1:1 aus accounting-view.js injectReportsControls() ── // ── Ab hier 1:1 aus accounting-view.js injectReportsControls() ──
const profitLossBody = ` const profitLossBody = `
<div class="flex flex-wrap items-end gap-3 mb-3"> <div class="flex flex-wrap items-end gap-3 mb-3">
@@ -254,6 +287,7 @@ export function renderReportsView() {
<div class="space-y-4"> <div class="space-y-4">
${reportCard('Accounts Receivable Aging', arAgingBody)} ${reportCard('Accounts Receivable Aging', arAgingBody)}
${reportCard('Invoice-Level Revenue', revenueBody)} ${reportCard('Invoice-Level Revenue', revenueBody)}
${reportCard('Revenue by Category', categoryRevenueBody)}
${reportCard('Customer Revenue (Summary)', customerRevenueBody)} ${reportCard('Customer Revenue (Summary)', customerRevenueBody)}
${reportCard('Profit &amp; Loss', profitLossBody)} ${reportCard('Profit &amp; Loss', profitLossBody)}
${reportCard('Balance Sheet', balanceSheetBody)} ${reportCard('Balance Sheet', balanceSheetBody)}
@@ -571,6 +605,162 @@ export function exportRevenuePdf() {
window.open(url, '_blank'); window.open(url, '_blank');
} }
// ── Report 3: Revenue by Category ───────────────────────────────────
const MONTH_ABBR = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
/** 'YYYY-MM' → { m: 'Mar', y: '2026' } — reine String-Zerlegung, kein Date. */
function monthParts(monthKey) {
return { m: MONTH_ABBR[Number(monthKey.slice(5, 7)) - 1], y: monthKey.slice(0, 4) };
}
/**
* Spaltenmodell der Matrix: Monate in Jahresblöcken, je Block optional eine
* Jahressumme, dahinter Gesamt und Prozentanteil. Identisch zu
* categoryColumns() in report-pdf-service.js — Bildschirm, CSV und PDF zeigen
* damit dieselben Spalten in derselben Reihenfolge.
*/
function categoryColumns(data) {
const cols = [];
if (data.groupByMonth) {
for (const y of data.years) {
for (const m of y.months) cols.push({ type: 'month', key: m });
if (data.showYearColumns) cols.push({ type: 'year', key: y.year });
}
}
cols.push({ type: 'total' });
cols.push({ type: 'pct' });
return cols;
}
/** Wert einer Kategoriezeile in einer Spalte; null = Prozentspalte. */
function categoryCell(data, catKey, col) {
if (col.type === 'month') return data.cells[catKey][col.key];
if (col.type === 'year') return data.yearCells[catKey][col.key];
if (col.type === 'total') return data.categoryTotals[catKey].revenue;
return null;
}
/** Wert der TOTAL-Zeile in einer Spalte. */
function categoryTotalCell(data, col) {
if (col.type === 'month') return data.monthTotals[col.key];
if (col.type === 'year') return data.yearTotals[col.key];
if (col.type === 'total') return data.grandTotal;
return null;
}
/** Spaltenüberschrift für Bildschirm und CSV (dort einzeilig). */
function categoryColumnLabel(col, oneLine) {
if (col.type === 'month') {
const p = monthParts(col.key);
return oneLine ? `${p.m} ${p.y}` : `${p.m}<br><span class="font-normal text-gray-500">${p.y}</span>`;
}
if (col.type === 'year') {
return oneLine ? `${col.key} Total`
: `${col.key}<br><span class="font-normal text-gray-500">Total</span>`;
}
if (col.type === 'total') return 'Total';
return oneLine ? '% of Total' : '% of<br><span class="font-normal text-gray-500">Total</span>';
}
export async function loadCategoryRevenue() {
catFrom = document.getElementById('cat-from').value;
catTo = document.getElementById('cat-to').value;
catGroupByMonth = document.getElementById('cat-group-month')?.checked || false;
if (!catFrom || !catTo) return reportError('cat-result', 'Please select both a from and a to date.');
if (catFrom > catTo) return reportError('cat-result', 'The from date must not be after the to date.');
showLoading('cat-result', 'Loading category revenue…');
try {
const data = await window.API.reports.getCategoryRevenue(catFrom, catTo, catGroupByMonth);
if (data.error) return reportError('cat-result', data.error);
catData = data;
renderCategoryRevenue(data);
} catch (err) {
reportError('cat-result', err.message || 'Failed to load category revenue');
}
}
function renderCategoryRevenue(data) {
if (!data.hasData) {
document.getElementById('cat-result').innerHTML =
methodologyBox(data.methodology, false) +
'<p class="text-sm text-gray-500">No invoices found in this period.</p>';
return;
}
const cols = categoryColumns(data);
const colClass = c => c.type === 'year' ? ' bg-blue-50 font-semibold'
: c.type === 'total' ? ' bg-gray-100 font-semibold' : '';
const headHtml = `<th class="px-3 py-2 text-left font-medium text-gray-700">Category</th>` +
cols.map(c => `<th class="px-3 py-2 text-right font-medium text-gray-700 whitespace-nowrap${colClass(c)}">${categoryColumnLabel(c, false)}</th>`).join('');
let rowsHtml = '';
for (const cat of data.categories) {
const cells = cols.map(c => c.type === 'pct'
? `<td class="px-3 py-2 text-sm text-right text-gray-500">${data.categoryTotals[cat.key].pct}%</td>`
: `<td class="px-3 py-2 text-sm text-right whitespace-nowrap${colClass(c)}">${fmtMoney(categoryCell(data, cat.key, c))}</td>`
).join('');
rowsHtml += `<tr class="border-t hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-medium whitespace-nowrap">${escapeHtml(cat.label)}</td>${cells}</tr>`;
}
// 100.0 % per Definition (Gesamt/Gesamt), nicht als Summe der gerundeten
// Kategorieanteile.
const totalCells = cols.map(c => c.type === 'pct'
? `<td class="px-3 py-2 text-sm text-right">100.0%</td>`
: `<td class="px-3 py-2 text-sm text-right whitespace-nowrap${colClass(c)}">${fmtMoney(categoryTotalCell(data, c))}</td>`
).join('');
rowsHtml += `<tr class="border-t-2 border-gray-400 bg-gray-100 font-bold">
<td class="px-3 py-2 text-sm whitespace-nowrap">TOTAL (${data.invoiceCount} invoices)</td>${totalCells}</tr>`;
document.getElementById('cat-result').innerHTML =
methodologyBox(data.methodology, false) + `
<div class="overflow-x-auto border border-gray-200 rounded">
<table class="min-w-full text-sm">
<thead class="bg-gray-50"><tr>${headHtml}</tr></thead>
<tbody>${rowsHtml}</tbody>
</table>
</div>`;
}
export function exportCategoryRevenueCsv() {
if (!catData || !catData.hasData) {
return reportError('cat-result', 'Run the report first — there is nothing to export.');
}
const data = catData;
const cols = categoryColumns(data);
const lines = [];
lines.push(csvRow([`Revenue by Category ${data.from} to ${data.to}`]));
lines.push(csvRow([`Methodology: ${data.methodology}`]));
lines.push('');
lines.push(csvRow(['Category', ...cols.map(c => categoryColumnLabel(c, true))]));
for (const cat of data.categories) {
lines.push(csvRow([cat.label, ...cols.map(c => c.type === 'pct'
? data.categoryTotals[cat.key].pct
: categoryCell(data, cat.key, c))]));
}
lines.push(csvRow([`TOTAL (${data.invoiceCount} invoices)`,
...cols.map(c => c.type === 'pct' ? '100.0' : categoryTotalCell(data, c))]));
downloadCsv(`category-revenue-${data.from}_to_${data.to}.csv`, lines);
}
export function exportCategoryRevenuePdf() {
const fromEl = document.getElementById('cat-from');
const toEl = document.getElementById('cat-to');
if (!fromEl?.value || !toEl?.value) return alert('Please select both a from and a to date.');
if (fromEl.value > toEl.value) return alert('The from date must not be after the to date.');
const groupByMonth = document.getElementById('cat-group-month')?.checked || false;
let url = `/api/reports/category-revenue/pdf?from=${fromEl.value}&to=${toEl.value}`;
if (groupByMonth) url += '&groupByMonth=true';
window.open(url, '_blank');
}
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────
// Aus accounting-view.js übernommene Reports // Aus accounting-view.js übernommene Reports
// Funktionskörper 1:1; geändert wurden nur die onclick-Namespaces im // Funktionskörper 1:1; geändert wurden nur die onclick-Namespaces im
@@ -713,6 +903,9 @@ window.reportsView = {
loadRevenue, loadRevenue,
exportRevenueCsv, exportRevenueCsv,
exportRevenuePdf, exportRevenuePdf,
loadCategoryRevenue,
exportCategoryRevenueCsv,
exportCategoryRevenuePdf,
loadProfitLoss, loadProfitLoss,
loadBalanceSheet, loadBalanceSheet,
loadTaxSummary, loadTaxSummary,

View File

@@ -4,6 +4,7 @@
* Due-Diligence-Reports: * Due-Diligence-Reports:
* GET /api/reports/ar-aging?asOf=YYYY-MM-DD&anonymize=true|false * GET /api/reports/ar-aging?asOf=YYYY-MM-DD&anonymize=true|false
* GET /api/reports/revenue?from=YYYY-MM-DD&to=YYYY-MM-DD&anonymize=true|false&groupByMonth=true|false * GET /api/reports/revenue?from=YYYY-MM-DD&to=YYYY-MM-DD&anonymize=true|false&groupByMonth=true|false
* GET /api/reports/category-revenue?from=YYYY-MM-DD&to=YYYY-MM-DD&groupByMonth=true|false
* *
* Anonymisierung läuft ausschließlich serverseitig: bei anonymize=true verlassen * Anonymisierung läuft ausschließlich serverseitig: bei anonymize=true verlassen
* weder Kundenname noch customer_id oder bill_to_name den Server, damit auch ein * weder Kundenname noch customer_id oder bill_to_name den Server, damit auch ein
@@ -16,7 +17,8 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { pool } = require('../config/database'); const { pool } = require('../config/database');
const { renderArAgingPdf, renderRevenuePdf } = require('../services/report-pdf-service'); const { renderArAgingPdf, renderRevenuePdf, renderCategoryRevenuePdf } = require('../services/report-pdf-service');
const { QBO_LABOR_ID, QBO_PARTS_ID, QBO_SUBSCRIPTION_ID } = require('../services/qbo-service');
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────
// Helpers // Helpers
@@ -340,6 +342,209 @@ router.get('/revenue', async (req, res) => {
} }
}); });
// ────────────────────────────────────────────────────────────────────
// Report 3 — Revenue by Category (Parts / Labor / Subscription)
// ────────────────────────────────────────────────────────────────────
/**
* Die Kategorie einer Rechnungsposition steckt in invoice_items.qbo_item_id.
* Die IDs kommen aus qbo-service, damit es genau eine Quelle der Wahrheit gibt
* (dieselben Konstanten benutzt der QBO-Export).
*
* Alles, was keiner der drei bekannten IDs entspricht, läuft unter 'other'.
* Im Produktivbestand kommt das nicht vor — die Zeile erscheint im Report auch
* nur, wenn sie ungleich 0 ist, verschluckt aber im Fehlerfall keinen Umsatz.
*/
const REVENUE_CATEGORIES = [
{ key: 'parts', label: 'Parts', itemId: QBO_PARTS_ID },
{ key: 'labor', label: 'Labor', itemId: QBO_LABOR_ID },
{ key: 'subscription', label: 'Subscription', itemId: QBO_SUBSCRIPTION_ID }
];
const OTHER_CATEGORY = { key: 'other', label: 'Other' };
const CATEGORY_KEY_SQL = `
CASE btrim(COALESCE(ii.qbo_item_id, ''))
WHEN '${QBO_PARTS_ID}' THEN 'parts'
WHEN '${QBO_LABOR_ID}' THEN 'labor'
WHEN '${QBO_SUBSCRIPTION_ID}' THEN 'subscription'
ELSE 'other'
END`;
/**
* $1 = from, $2 = to (auf invoice_date) — exakt derselbe Rechnungsfilter wie im
* Invoice-Level-Report: kein Status-, kein Zahlungsfilter.
*
* Bemessungsgrundlage sind die Positionsbeträge, also der NETTO-Umsatz ohne
* Sales Tax. Steuer ist keiner Kategorie zurechenbar. Die Summe über alle
* Kategorien entspricht damit der Subtotal-Summe des Invoice-Level-Reports.
*
* Ein GROUPING-SETS-Lauf liefert jede benötigte Ebene: Zelle (Kategorie×Monat),
* Jahresspalte (Kategorie×Jahr), Kategoriesumme, Monatssumme, Jahressumme und
* Gesamtsumme. month_key/year_key/category_key sind nie NULL (invoice_date ist
* NOT NULL, das CASE hat einen ELSE-Zweig) — NULL bedeutet hier also
* eindeutig "über diese Dimension aggregiert" und ist kein Datenwert.
*
* Der Prozentanteil wird ebenfalls in SQL auf numeric gerechnet, nicht in JS.
*/
const CATEGORY_REVENUE_SQL = `
WITH lines AS (
SELECT
to_char(i.invoice_date, 'YYYY-MM') AS month_key,
to_char(i.invoice_date, 'YYYY') AS year_key,
${CATEGORY_KEY_SQL} AS category_key,
COALESCE(NULLIF(btrim(ii.amount), '')::numeric, 0) AS amount,
i.id AS invoice_id
FROM invoices i
JOIN invoice_items ii ON ii.invoice_id = i.id
WHERE i.invoice_date >= $1::date AND i.invoice_date <= $2::date
),
grouped AS (
SELECT
category_key,
month_key,
year_key,
-- COALESCE: bei leerem Zeitraum liefert die ()-Gruppe sonst NULL statt 0.00
COALESCE(SUM(amount), 0)::numeric(12,2) AS revenue,
COUNT(DISTINCT invoice_id)::integer AS invoice_count
FROM lines
GROUP BY GROUPING SETS (
(category_key, month_key),
(category_key, year_key),
(category_key),
(month_key),
(year_key),
()
)
),
grand AS (
SELECT COALESCE(SUM(amount), 0)::numeric(12,2) AS total FROM lines
)
SELECT
g.category_key,
g.month_key,
g.year_key,
g.revenue,
g.invoice_count,
CASE WHEN grand.total > 0
THEN round(100 * g.revenue / grand.total, 1)
ELSE 0 END::numeric(5,1) AS pct
FROM grouped g CROSS JOIN grand`;
/**
* Monatsspalten werden aus dem gewählten Zeitraum abgeleitet, nicht aus den
* gefundenen Daten: ein umsatzloser Monat soll als 0.00-Spalte sichtbar sein und
* nicht stillschweigend fehlen. Reine String-Arithmetik auf 'YYYY-MM' — kein
* Date-Objekt, damit keine Zeitzone das Ergebnis verschieben kann.
*/
function monthKeysInRange(from, to) {
const keys = [];
let y = Number(from.slice(0, 4));
let m = Number(from.slice(5, 7));
const lastY = Number(to.slice(0, 4));
const lastM = Number(to.slice(5, 7));
while (y < lastY || (y === lastY && m <= lastM)) {
keys.push(`${y}-${String(m).padStart(2, '0')}`);
if (++m > 12) { m = 1; y++; }
}
return keys;
}
/** Siehe fetchArAging — dieselbe Begründung für die gemeinsame Nutzung. */
async function fetchCategoryRevenue(from, to, groupByMonth) {
const result = await pool.query(CATEGORY_REVENUE_SQL, [from, to]);
const cells = {}; // category → month_key → revenue
const yearCells = {}; // category → year_key → revenue
const categoryTotals = {}; // category → { revenue, pct }
const monthTotals = {}; // month_key → revenue
const yearTotals = {}; // year_key → revenue
let grandTotal = '0.00';
let invoiceCount = 0;
const seenCategories = new Set();
for (const r of result.rows) {
if (r.category_key) {
seenCategories.add(r.category_key);
if (r.month_key) {
(cells[r.category_key] ||= {})[r.month_key] = r.revenue;
} else if (r.year_key) {
(yearCells[r.category_key] ||= {})[r.year_key] = r.revenue;
} else {
categoryTotals[r.category_key] = { revenue: r.revenue, pct: r.pct };
}
} else if (r.month_key) {
monthTotals[r.month_key] = r.revenue;
} else if (r.year_key) {
yearTotals[r.year_key] = r.revenue;
} else {
grandTotal = r.revenue;
invoiceCount = r.invoice_count;
}
}
const months = monthKeysInRange(from, to);
const years = [];
for (const key of months) {
const y = key.slice(0, 4);
const entry = years.find(e => e.year === y);
if (entry) entry.months.push(key);
else years.push({ year: y, months: [key] });
}
// 'Other' nur führen, wenn dort tatsächlich Umsatz gelandet ist.
// Die QBO-Item-IDs bleiben serverseitig; der Client braucht nur Key + Label.
const categories = REVENUE_CATEGORIES.map(c => ({ key: c.key, label: c.label }));
if (seenCategories.has(OTHER_CATEGORY.key)) categories.push({ ...OTHER_CATEGORY });
// Lücken auffüllen: HTML, CSV und PDF lesen danach dieselbe dichte Matrix
// und müssen nicht jeder für sich auf fehlende Zellen prüfen.
for (const c of categories) {
cells[c.key] ||= {};
yearCells[c.key] ||= {};
categoryTotals[c.key] ||= { revenue: '0.00', pct: '0.0' };
for (const m of months) cells[c.key][m] ||= '0.00';
for (const y of years) yearCells[c.key][y.year] ||= '0.00';
}
for (const m of months) monthTotals[m] ||= '0.00';
for (const y of years) yearTotals[y.year] ||= '0.00';
return {
from,
to,
groupByMonth,
// Bei einem Zeitraum innerhalb eines Jahres wäre die Jahresspalte eine
// Kopie der Gesamtspalte — dann bleibt sie weg.
showYearColumns: groupByMonth && years.length > 1,
methodology:
`Period filtered on invoice_date from ${from} to ${to} — same invoice selection as the ` +
`Invoice-Level Revenue report (no payment or status filter). Revenue is the net line-item ` +
`amount excluding sales tax, categorized by line item type; category totals therefore add ` +
`up to the invoice subtotals of the period. Percentages are shares of the period's total revenue.`,
categories,
months,
years,
cells,
yearCells,
categoryTotals,
monthTotals,
yearTotals,
grandTotal,
invoiceCount,
hasData: invoiceCount > 0
};
}
router.get('/category-revenue', async (req, res) => {
try {
const v = validateRevenueParams(req.query);
if (v.error) return res.status(400).json({ error: v.error });
res.json(await fetchCategoryRevenue(v.from, v.to, isTrue(req.query.groupByMonth)));
} catch (err) {
console.error('category-revenue error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────
// PDF-Exporte // PDF-Exporte
// //
@@ -379,4 +584,16 @@ router.get('/revenue/pdf', async (req, res) => {
} }
}); });
router.get('/category-revenue/pdf', async (req, res) => {
try {
const v = validateRevenueParams(req.query);
if (v.error) return res.status(400).json({ error: v.error });
const data = await fetchCategoryRevenue(v.from, v.to, isTrue(req.query.groupByMonth));
await renderCategoryRevenuePdf(res, data);
} catch (err) {
console.error('category-revenue pdf error:', err.message);
res.status(500).json({ error: err.message });
}
});
module.exports = router; module.exports = router;

View File

@@ -24,7 +24,8 @@ async function generatePdfFromHtml(html, options = {}) {
const { const {
format = 'Letter', format = 'Letter',
margin = { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' }, margin = { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' },
printBackground = true printBackground = true,
landscape = false
} = options; } = options;
const browser = await getBrowser(); const browser = await getBrowser();
@@ -42,7 +43,8 @@ async function generatePdfFromHtml(html, options = {}) {
const pdf = await page.pdf({ const pdf = await page.pdf({
format, format,
printBackground, printBackground,
margin margin,
landscape
}); });
return pdf; return pdf;

View File

@@ -64,6 +64,9 @@ function fmtDate(v) {
* @param {string} o.tableHead <tr>…</tr> für <thead> * @param {string} o.tableHead <tr>…</tr> für <thead>
* @param {string} o.tableBody <tr>…</tr>-Folge für <tbody> * @param {string} o.tableBody <tr>…</tr>-Folge für <tbody>
* @param {string} [o.summaryBlock] optionaler Block oberhalb der Positionen * @param {string} [o.summaryBlock] optionaler Block oberhalb der Positionen
* @param {string} [o.extraStyles] report-spezifische CSS-Regeln, die die
* Template-Defaults überschreiben (z.B. für
* sehr breite Tabellen)
*/ */
async function buildReportHtml(o) { async function buildReportHtml(o) {
if (!o.methodology) { if (!o.methodology) {
@@ -82,6 +85,7 @@ async function buildReportHtml(o) {
.replace('{{ANONYMIZED_NOTE}}', o.anonymized ? ' (anonymized)' : '') .replace('{{ANONYMIZED_NOTE}}', o.anonymized ? ' (anonymized)' : '')
.replace('{{REPORT_META}}', escapeHtml(o.meta)) .replace('{{REPORT_META}}', escapeHtml(o.meta))
.replace('{{METHODOLOGY}}', escapeHtml(o.methodology)) .replace('{{METHODOLOGY}}', escapeHtml(o.methodology))
.replace('{{EXTRA_STYLES}}', o.extraStyles || '')
.replace('{{SUMMARY_BLOCK}}', o.summaryBlock || '') .replace('{{SUMMARY_BLOCK}}', o.summaryBlock || '')
.replace('{{DETAIL_TITLE}}', escapeHtml(o.detailTitle || 'Detail')) .replace('{{DETAIL_TITLE}}', escapeHtml(o.detailTitle || 'Detail'))
.replace('{{TABLE_HEAD}}', o.tableHead) .replace('{{TABLE_HEAD}}', o.tableHead)
@@ -90,8 +94,8 @@ async function buildReportHtml(o) {
} }
/** Erzeugt das PDF und schickt es als Download — wie renderReportPdf in accounting.js. */ /** Erzeugt das PDF und schickt es als Download — wie renderReportPdf in accounting.js. */
async function sendReportPdf(res, html, filename) { async function sendReportPdf(res, html, filename, pdfOptions = {}) {
const pdf = await generatePdfFromHtml(html); const pdf = await generatePdfFromHtml(html, pdfOptions);
const sanitized = filename.replace(/[^a-zA-Z0-9._-]/g, '-'); const sanitized = filename.replace(/[^a-zA-Z0-9._-]/g, '-');
res.set({ res.set({
'Content-Type': 'application/pdf', 'Content-Type': 'application/pdf',
@@ -273,9 +277,129 @@ async function renderRevenuePdf(res, data) {
`Invoice-Revenue-${data.from}-to-${data.to}${data.anonymized ? '-anonymized' : ''}`); `Invoice-Revenue-${data.from}-to-${data.to}${data.anonymized ? '-anonymized' : ''}`);
} }
// ────────────────────────────────────────────────────────────────────
// Report 3 — Revenue by Category
// ────────────────────────────────────────────────────────────────────
const MONTH_ABBR = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
/** 'YYYY-MM' → { m: 'Mar', y: '2026' } — reine String-Zerlegung, kein Date. */
function monthParts(monthKey) {
return { m: MONTH_ABBR[Number(monthKey.slice(5, 7)) - 1], y: monthKey.slice(0, 4) };
}
/**
* Spaltenmodell der Matrix: Monate in Jahresblöcken, je Block optional eine
* Jahressumme, dahinter Gesamt und Prozentanteil. Dieselbe Reihenfolge benutzt
* die Bildschirmansicht in reports-view.js — Screen, CSV und PDF zeigen so
* zwingend dieselben Spalten.
*/
function categoryColumns(data) {
const cols = [];
if (data.groupByMonth) {
for (const y of data.years) {
for (const m of y.months) cols.push({ type: 'month', key: m });
if (data.showYearColumns) cols.push({ type: 'year', key: y.year });
}
}
cols.push({ type: 'total' });
cols.push({ type: 'pct' });
return cols;
}
/** Wert einer Kategoriezeile in einer Spalte; null = Prozentspalte. */
function categoryCell(data, catKey, col) {
if (col.type === 'month') return data.cells[catKey][col.key];
if (col.type === 'year') return data.yearCells[catKey][col.key];
if (col.type === 'total') return data.categoryTotals[catKey].revenue;
return null;
}
/** Wert der TOTAL-Zeile in einer Spalte. */
function categoryTotalCell(data, col) {
if (col.type === 'month') return data.monthTotals[col.key];
if (col.type === 'year') return data.yearTotals[col.key];
if (col.type === 'total') return data.grandTotal;
return null;
}
async function renderCategoryRevenuePdf(res, data) {
const cols = categoryColumns(data);
const colCount = cols.length + 1; // + Kategoriespalte
const head = '<tr><th>Category</th>' + cols.map(c => {
if (c.type === 'month') {
const p = monthParts(c.key);
return `<th>${p.m}<br><span class="yr">${p.y}</span></th>`;
}
if (c.type === 'year') return `<th class="year-col">${escapeHtml(c.key)}<br><span class="yr">Total</span></th>`;
if (c.type === 'total') return '<th class="total-col">Total</th>';
return '<th>% of<br><span class="yr">Total</span></th>';
}).join('') + '</tr>';
let body;
if (!data.hasData) {
body = `<tr><td class="text" colspan="${colCount}">No invoices in this period.</td></tr>`;
} else {
body = data.categories.map(cat => {
const cells = cols.map(c => {
if (c.type === 'pct') return `<td class="pct">${data.categoryTotals[cat.key].pct}%</td>`;
const cls = c.type === 'year' ? ' class="year-col"'
: c.type === 'total' ? ' class="total-col"' : '';
return `<td${cls}>${money(categoryCell(data, cat.key, c))}</td>`;
}).join('');
return `<tr><td class="text cat">${escapeHtml(cat.label)}</td>${cells}</tr>`;
}).join('');
// Die 100.0 % stehen hier per Definition (Gesamt/Gesamt) und werden
// bewusst nicht aus den gerundeten Kategorieanteilen aufaddiert.
const totalCells = cols.map(c => {
if (c.type === 'pct') return '<td class="pct">100.0%</td>';
const cls = c.type === 'year' ? ' class="year-col"'
: c.type === 'total' ? ' class="total-col"' : '';
return `<td${cls}>${money(categoryTotalCell(data, c))}</td>`;
}).join('');
body += `<tr class="grand-total"><td class="text cat">TOTAL (${data.invoiceCount} invoices)</td>${totalCells}</tr>`;
}
// Breite Matrizen: Schrift und Innenabstand schrumpfen mit der Spaltenzahl,
// ab acht Spalten wird quer gedruckt. Ein Zeitraum von mehr als etwa zwei
// Jahren nach Monaten wird auch damit eng — das ist die praktische Grenze
// eines Letter-Blattes, nicht des Reports.
const fontSize = colCount <= 10 ? 10 : colCount <= 16 ? 8 : colCount <= 24 ? 7 : 6;
const extraStyles = `
.container { max-width: none; }
.items-table { font-size: ${fontSize}px; ${data.groupByMonth ? '' : 'width: 60%;'} }
.items-table th, .items-table td { padding: 3px 4px; }
.items-table th { text-align: right; vertical-align: bottom; }
.items-table th .yr { font-weight: normal; font-size: 0.85em; color: #555; }
.items-table td.cat { font-weight: bold; white-space: nowrap; }
.items-table td.pct, .items-table th:last-child { text-align: right; }
.items-table th.year-col, .items-table td.year-col { background-color: #eef2f7; font-weight: bold; }
.items-table th.total-col, .items-table td.total-col { background-color: #e8e8e8; font-weight: bold; }`;
const html = await buildReportHtml({
title: 'REVENUE BY CATEGORY',
anonymized: false,
meta: `Period: ${data.from} to ${data.to}`
+ (data.groupByMonth ? ' · by month' : ''),
methodology: data.methodology,
detailTitle: data.groupByMonth ? 'Revenue by Category and Month' : 'Revenue by Category',
tableHead: head,
tableBody: body,
extraStyles
});
await sendReportPdf(res, html,
`Category-Revenue-${data.from}-to-${data.to}`,
{ landscape: colCount >= 8 });
}
module.exports = { module.exports = {
buildReportHtml, buildReportHtml,
sendReportPdf, sendReportPdf,
renderArAgingPdf, renderArAgingPdf,
renderRevenuePdf renderRevenuePdf,
renderCategoryRevenuePdf
}; };

View File

@@ -30,6 +30,7 @@
.items-table tr.grand-total td { background-color: #e8e8e8; font-weight: bold; border-top: 2px solid #000; } .items-table tr.grand-total td { background-color: #e8e8e8; font-weight: bold; border-top: 2px solid #000; }
.summary-table { width: 55%; } .summary-table { width: 55%; }
tr { page-break-inside: avoid; } tr { page-break-inside: avoid; }
{{EXTRA_STYLES}}
</style> </style>
</head> </head>
<body> <body>