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

@@ -4,6 +4,7 @@
* Due-Diligence-Reports:
* 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/category-revenue?from=YYYY-MM-DD&to=YYYY-MM-DD&groupByMonth=true|false
*
* Anonymisierung läuft ausschließlich serverseitig: bei anonymize=true verlassen
* weder Kundenname noch customer_id oder bill_to_name den Server, damit auch ein
@@ -16,7 +17,8 @@
const express = require('express');
const router = express.Router();
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
@@ -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
//
@@ -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;

View File

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

View File

@@ -64,6 +64,9 @@ function fmtDate(v) {
* @param {string} o.tableHead <tr>…</tr> für <thead>
* @param {string} o.tableBody <tr>…</tr>-Folge für <tbody>
* @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) {
if (!o.methodology) {
@@ -82,6 +85,7 @@ async function buildReportHtml(o) {
.replace('{{ANONYMIZED_NOTE}}', o.anonymized ? ' (anonymized)' : '')
.replace('{{REPORT_META}}', escapeHtml(o.meta))
.replace('{{METHODOLOGY}}', escapeHtml(o.methodology))
.replace('{{EXTRA_STYLES}}', o.extraStyles || '')
.replace('{{SUMMARY_BLOCK}}', o.summaryBlock || '')
.replace('{{DETAIL_TITLE}}', escapeHtml(o.detailTitle || 'Detail'))
.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. */
async function sendReportPdf(res, html, filename) {
const pdf = await generatePdfFromHtml(html);
async function sendReportPdf(res, html, filename, pdfOptions = {}) {
const pdf = await generatePdfFromHtml(html, pdfOptions);
const sanitized = filename.replace(/[^a-zA-Z0-9._-]/g, '-');
res.set({
'Content-Type': 'application/pdf',
@@ -273,9 +277,129 @@ async function renderRevenuePdf(res, data) {
`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 = {
buildReportHtml,
sendReportPdf,
renderArAgingPdf,
renderRevenuePdf
renderRevenuePdf,
renderCategoryRevenuePdf
};