category report
This commit is contained in:
@@ -235,6 +235,11 @@ const API = {
|
||||
if (anonymize) params.set('anonymize', 'true');
|
||||
if (groupByMonth) params.set('groupByMonth', 'true');
|
||||
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());
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
*
|
||||
* 1. Accounts Receivable Aging (Stichtag frei wählbar)
|
||||
* 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
|
||||
* ausschließlich serverseitig — dieses Modul bekommt bei aktiver Option gar keine
|
||||
* Klarnamen zu sehen und kann sie folglich auch nicht in ein CSV schreiben.
|
||||
* Die ersten beiden Reports können Kundennamen pseudonymisieren. Die Anonymisierung
|
||||
* passiert ausschließlich serverseitig — dieses Modul bekommt bei aktiver Option gar
|
||||
* 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
|
||||
* formatiert. Ins CSV gehen die Rohwerte, damit Excel sauber rechnen kann.
|
||||
@@ -34,6 +36,11 @@ let revAnonymize = false;
|
||||
let revGroupByMonth = false;
|
||||
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)
|
||||
let plStartDate = null;
|
||||
let plEndDate = null;
|
||||
@@ -190,6 +197,32 @@ export function renderReportsView() {
|
||||
</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() ──
|
||||
const profitLossBody = `
|
||||
<div class="flex flex-wrap items-end gap-3 mb-3">
|
||||
@@ -254,6 +287,7 @@ export function renderReportsView() {
|
||||
<div class="space-y-4">
|
||||
${reportCard('Accounts Receivable Aging', arAgingBody)}
|
||||
${reportCard('Invoice-Level Revenue', revenueBody)}
|
||||
${reportCard('Revenue by Category', categoryRevenueBody)}
|
||||
${reportCard('Customer Revenue (Summary)', customerRevenueBody)}
|
||||
${reportCard('Profit & Loss', profitLossBody)}
|
||||
${reportCard('Balance Sheet', balanceSheetBody)}
|
||||
@@ -571,6 +605,162 @@ export function exportRevenuePdf() {
|
||||
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
|
||||
// Funktionskörper 1:1; geändert wurden nur die onclick-Namespaces im
|
||||
@@ -713,6 +903,9 @@ window.reportsView = {
|
||||
loadRevenue,
|
||||
exportRevenueCsv,
|
||||
exportRevenuePdf,
|
||||
loadCategoryRevenue,
|
||||
exportCategoryRevenueCsv,
|
||||
exportCategoryRevenuePdf,
|
||||
loadProfitLoss,
|
||||
loadBalanceSheet,
|
||||
loadTaxSummary,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
.items-table tr.grand-total td { background-color: #e8e8e8; font-weight: bold; border-top: 2px solid #000; }
|
||||
.summary-table { width: 55%; }
|
||||
tr { page-break-inside: avoid; }
|
||||
{{EXTRA_STYLES}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user