category report
This commit is contained in:
@@ -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());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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 & Loss', profitLossBody)}
|
${reportCard('Profit & 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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user