customer report
This commit is contained in:
@@ -5,7 +5,12 @@
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const accountingService = require('../services/accounting-service');
|
||||
const { pool } = require('../config/database');
|
||||
const { generatePdfFromHtml, getLogoHtml } = require('../services/pdf-service');
|
||||
const { formatDate, formatMoney } = require('../utils/helpers');
|
||||
const multer = require('multer');
|
||||
|
||||
// Limit aus ENV, default 5 MB. Akzeptierte Werte z.B. "5", "10", "20"
|
||||
@@ -124,6 +129,113 @@ router.post('/sales-tax/periods/:id/mark-external', async (req, res) => {
|
||||
} catch (err) { handleQboError(err, res, 'sales-tax-mark-external'); }
|
||||
});
|
||||
|
||||
// ─── Customer Revenue Report ──────────────────────────────────────
|
||||
|
||||
router.get('/reports/customer-revenue', async (req, res) => {
|
||||
try {
|
||||
const { startDate, endDate } = req.query;
|
||||
if (!startDate || !endDate) {
|
||||
return res.status(400).json({ error: 'startDate and endDate are required' });
|
||||
}
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
c.id,
|
||||
c.name AS customer_name,
|
||||
COUNT(i.id)::integer AS invoice_count,
|
||||
COALESCE(SUM(i.subtotal), 0)::numeric(10,2) AS total_revenue,
|
||||
(SELECT COALESCE(SUM(i2.subtotal), 0)::numeric(10,2)
|
||||
FROM invoices i2
|
||||
WHERE i2.invoice_date >= $1 AND i2.invoice_date <= $2
|
||||
) AS grand_total
|
||||
FROM customers c
|
||||
JOIN invoices i ON i.customer_id = c.id
|
||||
WHERE i.invoice_date >= $1 AND i.invoice_date <= $2
|
||||
GROUP BY c.id, c.name
|
||||
HAVING COUNT(i.id) > 0
|
||||
ORDER BY total_revenue DESC`,
|
||||
[startDate, endDate]
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
console.error('customer-revenue error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/reports/customer-revenue/pdf', async (req, res) => {
|
||||
try {
|
||||
const { startDate, endDate } = req.query;
|
||||
if (!startDate || !endDate) {
|
||||
return res.status(400).json({ error: 'startDate and endDate are required' });
|
||||
}
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
c.name AS customer_name,
|
||||
COUNT(i.id)::integer AS invoice_count,
|
||||
COALESCE(SUM(i.subtotal), 0)::numeric(10,2) AS total_revenue,
|
||||
(SELECT COALESCE(SUM(i2.subtotal), 0)::numeric(10,2)
|
||||
FROM invoices i2
|
||||
WHERE i2.invoice_date >= $1 AND i2.invoice_date <= $2
|
||||
) AS grand_total
|
||||
FROM customers c
|
||||
JOIN invoices i ON i.customer_id = c.id
|
||||
WHERE i.invoice_date >= $1 AND i.invoice_date <= $2
|
||||
GROUP BY c.id, c.name
|
||||
HAVING COUNT(i.id) > 0
|
||||
ORDER BY total_revenue DESC`,
|
||||
[startDate, endDate]
|
||||
);
|
||||
|
||||
const rows = result.rows;
|
||||
const grandTotal = rows.length > 0 ? parseFloat(rows[0].grand_total) || 0 : 0;
|
||||
const totalInvoices = rows.reduce((s, r) => s + parseInt(r.invoice_count), 0);
|
||||
|
||||
let rowsHtml = '';
|
||||
let rank = 0;
|
||||
for (const r of rows) {
|
||||
rank++;
|
||||
const rev = parseFloat(r.total_revenue) || 0;
|
||||
const pct = grandTotal > 0 ? ((rev / grandTotal) * 100).toFixed(1) : '0.0';
|
||||
rowsHtml += `<tr>
|
||||
<td class="name">${rank}. ${r.customer_name}</td>
|
||||
<td class="number">${r.invoice_count}</td>
|
||||
<td class="number">$${formatMoney(rev)}</td>
|
||||
<td class="number">${pct}%</td>
|
||||
</tr>`;
|
||||
}
|
||||
rowsHtml += `<tr class="footer-row">
|
||||
<td class="total-label">TOTAL (${rows.length} customers)</td>
|
||||
<td class="total-amount">${totalInvoices}</td>
|
||||
<td class="total-amount">$${formatMoney(grandTotal)}</td>
|
||||
<td class="total-amount">100.0%</td>
|
||||
</tr>`;
|
||||
|
||||
const templatePath = path.join(__dirname, '..', '..', 'templates', 'customer-revenue-template.html');
|
||||
let html = await fs.readFile(templatePath, 'utf-8');
|
||||
const logoHTML = await getLogoHtml();
|
||||
const dateRange = `${startDate} to ${endDate}`;
|
||||
const generated = new Date().toISOString().split('T')[0];
|
||||
|
||||
html = html
|
||||
.replace('{{LOGO_HTML}}', logoHTML)
|
||||
.replace('{{DATE_RANGE}}', dateRange)
|
||||
.replace('{{GENERATED_DATE}}', generated)
|
||||
.replace('{{ROWS}}', rowsHtml);
|
||||
|
||||
const pdf = await generatePdfFromHtml(html);
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Length': pdf.length,
|
||||
'Content-Disposition': `attachment; filename="Customer-Revenue-${startDate}-to-${endDate}.pdf"`
|
||||
});
|
||||
res.end(pdf, 'binary');
|
||||
} catch (err) {
|
||||
console.error('customer-revenue pdf error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// Phase 2 Lieferung 1 — Sync + Cache-Reads
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user