new reports
This commit is contained in:
327
src/routes/reports.js
Normal file
327
src/routes/reports.js
Normal file
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Reports Routes — /api/reports/*
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* Anonymisierung läuft ausschließlich serverseitig: bei anonymize=true verlassen
|
||||
* weder Kundenname noch customer_id oder bill_to_name den Server, damit auch ein
|
||||
* CSV-Export beim Käufer keine Klarnamen enthält.
|
||||
*
|
||||
* Beträge bleiben durchgängig numeric (exakte Dezimalarithmetik in Postgres) und
|
||||
* werden als Strings an den Client gereicht — kein Float-Zwischenschritt, keine
|
||||
* Rundungsfehler. Auch alle Summen werden in SQL gebildet, nicht in JS.
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { pool } = require('../config/database');
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function todayISO() {
|
||||
return new Date().toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Zahlungsziel aus dem Freitext-Feld invoices.terms.
|
||||
* Identische Logik wie im Frontend (invoice-view.js getTermDays).
|
||||
* Im Produktivbestand kommen nur 'Net 30' / 'Net 14' / 'Net 15' vor — der
|
||||
* ELSE-Zweig greift dort nie, bleibt aber als Absicherung für neue Freitexte.
|
||||
*/
|
||||
const TERM_DAYS_SQL = `
|
||||
CASE
|
||||
WHEN lower(COALESCE(i.terms, '')) LIKE '%receipt%' THEN 0
|
||||
WHEN lower(COALESCE(i.terms, '')) ~ 'net[[:space:]]*[0-9]+'
|
||||
THEN (substring(lower(i.terms) from 'net[[:space:]]*([0-9]+)'))::int
|
||||
ELSE 14
|
||||
END`;
|
||||
|
||||
const UNASSIGNED_PSEUDONYM = 'Customer (unassigned)';
|
||||
|
||||
/**
|
||||
* Deterministisches Pseudonym-Mapping über den GESAMTEN Kundenstamm, nicht nur
|
||||
* über die Zeilen des jeweiligen Reports.
|
||||
*
|
||||
* Das ist bewusst so: würde pro Report dicht von 001 an durchnummeriert, wäre
|
||||
* "Customer 001" im AR-Aging ein anderer Kunde als "Customer 001" im Revenue-
|
||||
* Report — der Käufer könnte Forderungsrisiko und Umsatz desselben Kunden nicht
|
||||
* zusammenführen und würde beim Versuch falsche Schlüsse ziehen. Über den
|
||||
* Kundenstamm nummeriert bleibt ein Pseudonym stabil über beide Reports und über
|
||||
* beliebige Zeiträume hinweg. Preis dafür sind Lücken in der Nummernfolge, die
|
||||
* keine zusätzliche Information preisgeben.
|
||||
*
|
||||
* Rechnungen ohne customer_id (bill_to_name-Fallback) lassen sich nicht stabil
|
||||
* nummerieren und laufen alle unter einem Sammelpseudonym.
|
||||
*/
|
||||
async function buildPseudonymMap() {
|
||||
const result = await pool.query('SELECT id FROM customers ORDER BY id ASC');
|
||||
const map = new Map();
|
||||
result.rows.forEach((r, idx) => {
|
||||
map.set(Number(r.id), `Customer ${String(idx + 1).padStart(3, '0')}`);
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ersetzt Klarnamen durch Pseudonyme und entfernt alle verbleibenden
|
||||
* Identifikatoren aus der Payload — customer_id wäre sonst ein Rückkanal,
|
||||
* über den sich die Pseudonymisierung trivial rückgängig machen ließe.
|
||||
*/
|
||||
function anonymizeRows(rows, map) {
|
||||
return rows.map(r => {
|
||||
const { customer_id, ...rest } = r;
|
||||
const label = customer_id != null
|
||||
? (map.get(Number(customer_id)) || UNASSIGNED_PSEUDONYM)
|
||||
: UNASSIGNED_PSEUDONYM;
|
||||
return { ...rest, customer_name: label };
|
||||
});
|
||||
}
|
||||
|
||||
function isTrue(v) {
|
||||
return v === 'true' || v === true;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Report 1 — Accounts Receivable Aging
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const AR_BUCKETS = ['current', 'd1_30', 'd31_60', 'd61_90', 'd90_plus'];
|
||||
|
||||
/**
|
||||
* $1 = Stichtag (asOf).
|
||||
*
|
||||
* Der Stichtagsfilter sitzt bewusst INNERHALB der CTE paid_as_of. Stünde
|
||||
* "WHERE p.payment_date <= $1" im Hauptquery, würde der LEFT JOIN zum INNER
|
||||
* JOIN degradieren und alle nie bezahlten Rechnungen verschlucken. So zählen
|
||||
* nur Zahlungen bis zum Stichtag; alles danach existiert für den Report nicht.
|
||||
*/
|
||||
const AR_BASE_CTE = `
|
||||
WITH paid_as_of AS (
|
||||
SELECT pi.invoice_id, SUM(pi.amount) AS paid_amount
|
||||
FROM payment_invoices pi
|
||||
JOIN payments p ON p.id = pi.payment_id
|
||||
WHERE p.payment_date <= $1::date
|
||||
GROUP BY pi.invoice_id
|
||||
),
|
||||
base AS (
|
||||
SELECT
|
||||
i.id,
|
||||
i.invoice_number,
|
||||
i.invoice_date,
|
||||
i.terms,
|
||||
i.customer_id,
|
||||
COALESCE(c.name, i.bill_to_name) AS customer_name,
|
||||
i.total::numeric(12,2) AS original_amount,
|
||||
COALESCE(pa.paid_amount, 0)::numeric(12,2) AS paid_amount,
|
||||
(i.total - COALESCE(pa.paid_amount, 0))::numeric(12,2) AS open_amount,
|
||||
(i.invoice_date + (${TERM_DAYS_SQL})) AS due_date
|
||||
FROM invoices i
|
||||
LEFT JOIN customers c ON c.id = i.customer_id
|
||||
LEFT JOIN paid_as_of pa ON pa.invoice_id = i.id
|
||||
WHERE i.invoice_date <= $1::date
|
||||
),
|
||||
aged AS (
|
||||
SELECT
|
||||
b.*,
|
||||
($1::date - b.due_date) AS days_past_due,
|
||||
CASE
|
||||
WHEN $1::date - b.due_date <= 0 THEN 'current'
|
||||
WHEN $1::date - b.due_date <= 30 THEN 'd1_30'
|
||||
WHEN $1::date - b.due_date <= 60 THEN 'd31_60'
|
||||
WHEN $1::date - b.due_date <= 90 THEN 'd61_90'
|
||||
ELSE 'd90_plus'
|
||||
END AS bucket
|
||||
FROM base b
|
||||
WHERE b.open_amount > 0
|
||||
)`;
|
||||
|
||||
router.get('/ar-aging', async (req, res) => {
|
||||
try {
|
||||
const asOf = req.query.asOf || todayISO();
|
||||
if (!DATE_RE.test(asOf)) {
|
||||
return res.status(400).json({ error: 'asOf must be a valid date (YYYY-MM-DD)' });
|
||||
}
|
||||
const doAnonymize = isTrue(req.query.anonymize);
|
||||
|
||||
const rowsResult = await pool.query(
|
||||
`${AR_BASE_CTE}
|
||||
SELECT id, invoice_number, invoice_date, terms, customer_id, customer_name,
|
||||
original_amount, paid_amount, open_amount, due_date, days_past_due, bucket
|
||||
FROM aged
|
||||
ORDER BY customer_name NULLS LAST, invoice_date, id`,
|
||||
[asOf]
|
||||
);
|
||||
|
||||
// Summen in SQL: GROUPING SETS liefert Bucket-Zeilen + Gesamtzeile (bucket IS NULL)
|
||||
const sumsResult = await pool.query(
|
||||
`${AR_BASE_CTE}
|
||||
SELECT
|
||||
bucket,
|
||||
COUNT(*)::integer AS invoice_count,
|
||||
SUM(original_amount)::numeric(12,2) AS original_amount,
|
||||
SUM(open_amount)::numeric(12,2) AS open_amount
|
||||
FROM aged
|
||||
GROUP BY GROUPING SETS ((bucket), ())`,
|
||||
[asOf]
|
||||
);
|
||||
|
||||
const buckets = {};
|
||||
for (const key of AR_BUCKETS) {
|
||||
buckets[key] = { invoice_count: 0, original_amount: '0.00', open_amount: '0.00' };
|
||||
}
|
||||
let totals = { invoice_count: 0, original_amount: '0.00', open_amount: '0.00' };
|
||||
|
||||
for (const r of sumsResult.rows) {
|
||||
const entry = {
|
||||
invoice_count: r.invoice_count,
|
||||
original_amount: r.original_amount,
|
||||
open_amount: r.open_amount
|
||||
};
|
||||
if (r.bucket === null) totals = entry;
|
||||
else if (buckets[r.bucket]) buckets[r.bucket] = entry;
|
||||
}
|
||||
|
||||
const rows = doAnonymize
|
||||
? anonymizeRows(rowsResult.rows, await buildPseudonymMap())
|
||||
: rowsResult.rows;
|
||||
|
||||
res.json({
|
||||
asOf,
|
||||
anonymized: doAnonymize,
|
||||
methodology:
|
||||
`Aging basis: invoice_date + payment terms (no separate due-date field exists). ` +
|
||||
`Open amounts are net of payments with payment_date on or before ${asOf}; ` +
|
||||
`later payments are excluded. Invoices dated after ${asOf} are not included.` +
|
||||
(doAnonymize
|
||||
? ' Customer names are replaced by pseudonyms; the same pseudonym denotes the same customer across both reports and all periods.'
|
||||
: ''),
|
||||
buckets,
|
||||
totals,
|
||||
rows
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('ar-aging error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Report 2 — Invoice-Level Revenue
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* $1 = from, $2 = to (beide auf invoice_date).
|
||||
* Bezahlt/Offen spiegeln hier den AKTUELLEN Zahlungsstand (keine
|
||||
* Stichtagsbegrenzung) — anders als im AR-Aging. Steht so in der Methodik-Zeile.
|
||||
*/
|
||||
const REVENUE_FROM_SQL = `
|
||||
FROM invoices i
|
||||
LEFT JOIN customers c ON c.id = i.customer_id
|
||||
LEFT JOIN (
|
||||
SELECT invoice_id, SUM(amount) AS paid_amount
|
||||
FROM payment_invoices
|
||||
GROUP BY invoice_id
|
||||
) pa ON pa.invoice_id = i.id
|
||||
WHERE i.invoice_date >= $1::date AND i.invoice_date <= $2::date`;
|
||||
|
||||
router.get('/revenue', async (req, res) => {
|
||||
try {
|
||||
const { from, to } = req.query;
|
||||
if (!from || !to) {
|
||||
return res.status(400).json({ error: 'from and to are required' });
|
||||
}
|
||||
if (!DATE_RE.test(from) || !DATE_RE.test(to)) {
|
||||
return res.status(400).json({ error: 'from and to must be valid dates (YYYY-MM-DD)' });
|
||||
}
|
||||
if (from > to) {
|
||||
return res.status(400).json({ error: 'from must not be after to' });
|
||||
}
|
||||
const doAnonymize = isTrue(req.query.anonymize);
|
||||
const groupByMonth = isTrue(req.query.groupByMonth);
|
||||
|
||||
const rowsResult = await pool.query(
|
||||
`SELECT
|
||||
i.id,
|
||||
i.invoice_number,
|
||||
i.invoice_date,
|
||||
i.customer_id,
|
||||
COALESCE(c.name, i.bill_to_name) AS customer_name,
|
||||
i.subtotal::numeric(12,2) AS subtotal,
|
||||
i.tax_amount::numeric(12,2) AS tax_amount,
|
||||
i.total::numeric(12,2) AS total,
|
||||
COALESCE(pa.paid_amount, 0)::numeric(12,2) AS paid_amount,
|
||||
(i.total - COALESCE(pa.paid_amount, 0))::numeric(12,2) AS open_amount,
|
||||
i.payment_status,
|
||||
to_char(i.invoice_date, 'YYYY-MM') AS month_key
|
||||
${REVENUE_FROM_SQL}
|
||||
ORDER BY i.invoice_date, i.id`,
|
||||
[from, to]
|
||||
);
|
||||
|
||||
const sumsResult = await pool.query(
|
||||
`SELECT
|
||||
to_char(i.invoice_date, 'YYYY-MM') AS month_key,
|
||||
COUNT(*)::integer AS invoice_count,
|
||||
SUM(i.subtotal)::numeric(12,2) AS subtotal,
|
||||
SUM(i.tax_amount)::numeric(12,2) AS tax_amount,
|
||||
SUM(i.total)::numeric(12,2) AS total,
|
||||
SUM(COALESCE(pa.paid_amount, 0))::numeric(12,2) AS paid_amount,
|
||||
SUM(i.total - COALESCE(pa.paid_amount, 0))::numeric(12,2) AS open_amount
|
||||
${REVENUE_FROM_SQL}
|
||||
GROUP BY GROUPING SETS ((to_char(i.invoice_date, 'YYYY-MM')), ())
|
||||
ORDER BY month_key NULLS LAST`,
|
||||
[from, to]
|
||||
);
|
||||
|
||||
const emptyTotals = {
|
||||
invoice_count: 0, subtotal: '0.00', tax_amount: '0.00',
|
||||
total: '0.00', paid_amount: '0.00', open_amount: '0.00'
|
||||
};
|
||||
let totals = emptyTotals;
|
||||
const months = [];
|
||||
|
||||
for (const r of sumsResult.rows) {
|
||||
const entry = {
|
||||
invoice_count: r.invoice_count,
|
||||
subtotal: r.subtotal,
|
||||
tax_amount: r.tax_amount,
|
||||
total: r.total,
|
||||
paid_amount: r.paid_amount,
|
||||
open_amount: r.open_amount
|
||||
};
|
||||
if (r.month_key === null) totals = entry;
|
||||
else months.push({ month_key: r.month_key, ...entry });
|
||||
}
|
||||
|
||||
const rows = doAnonymize
|
||||
? anonymizeRows(rowsResult.rows, await buildPseudonymMap())
|
||||
: rowsResult.rows;
|
||||
|
||||
res.json({
|
||||
from,
|
||||
to,
|
||||
anonymized: doAnonymize,
|
||||
groupByMonth,
|
||||
methodology:
|
||||
`Period filtered on invoice_date from ${from} to ${to}. ` +
|
||||
`Paid and open amounts reflect the current payment status as of report generation, ` +
|
||||
`not the end of the period.` +
|
||||
(doAnonymize
|
||||
? ' Customer names are replaced by pseudonyms; the same pseudonym denotes the same customer across both reports and all periods.'
|
||||
: ''),
|
||||
months,
|
||||
totals,
|
||||
rows
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('revenue error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user