category report
This commit is contained in:
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user