|
|
@@ -284,23 +284,24 @@ router.get('/bilan/:property_id/:year', async (req, res) => {
|
|
|
});
|
|
|
});
|
|
|
|
|
|
-// Export PDF bilan + invoices
|
|
|
+// Export PDF bilan per lease (lease_id required as query param)
|
|
|
router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
|
|
|
const { property_id, year } = req.params;
|
|
|
+ const leaseId = req.query.lease_id ? parseInt(req.query.lease_id, 10) : null;
|
|
|
+ if (!leaseId) return res.status(400).json({ error: 'lease_id requis' });
|
|
|
|
|
|
const prop = (await pool.query('SELECT * FROM properties WHERE id = $1 AND user_id = $2', [property_id, req.userId])).rows[0];
|
|
|
if (!prop) return res.status(403).json({ error: 'Bien non autorisé' });
|
|
|
|
|
|
- // Chercher le propriétaire lié au bail actif sur ce bien, sinon fallback profil gestionnaire
|
|
|
+ // Owner info: prefer the owner linked to this specific lease, fallback to manager profile
|
|
|
const ownerRow = (await pool.query(`
|
|
|
SELECT o.type as o_type, o.first_name, o.last_name, o.company_name, o.siret,
|
|
|
o.address, o.zip_code, o.city, o.email, o.phone
|
|
|
FROM leases l
|
|
|
JOIN owners o ON l.owner_id = o.id
|
|
|
- WHERE l.property_id = $1 AND l.user_id = $2 AND o.id IS NOT NULL
|
|
|
- ORDER BY l.active DESC, l.start_date DESC
|
|
|
+ WHERE l.id = $1 AND l.user_id = $2 AND o.id IS NOT NULL
|
|
|
LIMIT 1
|
|
|
- `, [property_id, req.userId])).rows[0];
|
|
|
+ `, [leaseId, req.userId])).rows[0];
|
|
|
|
|
|
let ownerFullName, ownerAddress, ownerEmail, ownerPhone, ownerSiret;
|
|
|
if (ownerRow) {
|
|
|
@@ -343,22 +344,22 @@ router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
|
|
|
userId: req.userId,
|
|
|
});
|
|
|
|
|
|
- const totalProvisions = leaseBilans.reduce((s, l) => s + l.provisions_percues, 0);
|
|
|
- const bilans = leaseBilans.map(l => {
|
|
|
- const quote_part = totalChargesReelles * l.occupancy_ratio;
|
|
|
- const trop_percu = l.provisions_percues - quote_part;
|
|
|
- return {
|
|
|
- ...l,
|
|
|
- ratio_percent: l.occupancy_ratio_percent,
|
|
|
- quote_part_charges: round2(quote_part),
|
|
|
- trop_percu: round2(trop_percu)
|
|
|
- };
|
|
|
- });
|
|
|
+ const leaseBilan = leaseBilans.find(l => l.lease_id === leaseId);
|
|
|
+ if (!leaseBilan) return res.status(404).json({ error: 'Bail non trouvé pour ce bien/période.' });
|
|
|
+
|
|
|
+ const quote_part = totalChargesReelles * leaseBilan.occupancy_ratio;
|
|
|
+ const trop_percu = round2(leaseBilan.provisions_percues - quote_part);
|
|
|
+ const bilan = {
|
|
|
+ ...leaseBilan,
|
|
|
+ ratio_percent: leaseBilan.occupancy_ratio_percent,
|
|
|
+ quote_part_charges: round2(quote_part),
|
|
|
+ trop_percu,
|
|
|
+ };
|
|
|
|
|
|
const catLabels = { eau: 'Eau', gaz: 'Gaz', electricite: 'Électricité', entretien: 'Entretien / Réparations', ascenseur: 'Ascenseur', ordures: 'Ordures ménagères', assurance: 'Assurance', autre: 'Autre' };
|
|
|
const fmtEur = v => `${Number(v).toFixed(2)} EUR`;
|
|
|
const fmtDate = d => d ? new Date(d + 'T00:00:00').toLocaleDateString('fr-FR') : '—';
|
|
|
- const solde = totalProvisions - totalChargesReelles;
|
|
|
+ const isPos = trop_percu >= 0;
|
|
|
|
|
|
try {
|
|
|
// --- Step 1: Generate bilan PDF with pdfkit ---
|
|
|
@@ -371,50 +372,83 @@ router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
|
|
|
|
|
|
const W = doc.page.width - 100;
|
|
|
const blue = '#2563eb'; const red = '#dc2626'; const green = '#15803d'; const orange = '#c2410c'; const gray = '#6b7280';
|
|
|
+ const accentColor = isPos ? green : orange;
|
|
|
|
|
|
// Header
|
|
|
- doc.fontSize(20).fillColor('#111827').text(`Bilan annuel de charges ${year}`, { align: 'left' });
|
|
|
+ doc.fontSize(20).fillColor('#111827').text(`Décompte de charges ${year}`, { align: 'left' });
|
|
|
doc.fontSize(11).fillColor(gray).text(`${prop.name} — ${prop.address || ''}`, { align: 'left' });
|
|
|
doc.moveDown(0.5);
|
|
|
doc.fontSize(10).fillColor(gray).text(`Document généré le ${new Date().toLocaleDateString('fr-FR')}`, { align: 'right' });
|
|
|
doc.moveDown(0.8);
|
|
|
|
|
|
- // Bailleur info
|
|
|
+ // Bailleur / Locataire info side by side
|
|
|
doc.moveTo(50, doc.y).lineTo(50 + W, doc.y).strokeColor('#e5e7eb').lineWidth(1).stroke();
|
|
|
doc.moveDown(0.5);
|
|
|
- doc.fontSize(9).fillColor(gray).text('BAILLEUR', { continued: false });
|
|
|
- doc.fontSize(10).fillColor('#111827').font('Helvetica-Bold').text(ownerFullName);
|
|
|
+ const halfW = (W - 20) / 2;
|
|
|
+ const infoY = doc.y;
|
|
|
+
|
|
|
+ doc.fontSize(9).fillColor(gray).text('BAILLEUR', 50, infoY);
|
|
|
+ doc.fontSize(10).fillColor('#111827').font('Helvetica-Bold').text(ownerFullName, 50, infoY + 12);
|
|
|
doc.font('Helvetica');
|
|
|
- if (ownerSiret) doc.fontSize(10).fillColor('#374151').text(`SIRET : ${ownerSiret}`);
|
|
|
- if (ownerAddress) doc.fontSize(10).fillColor('#374151').text(ownerAddress);
|
|
|
- if (ownerEmail) doc.fontSize(10).fillColor('#374151').text(ownerEmail);
|
|
|
- if (ownerPhone) doc.fontSize(10).fillColor('#374151').text(ownerPhone);
|
|
|
- doc.moveDown(0.5);
|
|
|
+ let bY = infoY + 24;
|
|
|
+ if (ownerSiret) { doc.fontSize(9).fillColor('#374151').text(`SIRET : ${ownerSiret}`, 50, bY); bY += 12; }
|
|
|
+ if (ownerAddress) { doc.fontSize(9).fillColor('#374151').text(ownerAddress, 50, bY); bY += 12; }
|
|
|
+ if (ownerEmail) { doc.fontSize(9).fillColor('#374151').text(ownerEmail, 50, bY); bY += 12; }
|
|
|
+ if (ownerPhone) { doc.fontSize(9).fillColor('#374151').text(ownerPhone, 50, bY); bY += 12; }
|
|
|
+
|
|
|
+ const tenantX = 50 + halfW + 20;
|
|
|
+ doc.fontSize(9).fillColor(gray).text('LOCATAIRE', tenantX, infoY);
|
|
|
+ doc.fontSize(10).fillColor('#111827').font('Helvetica-Bold').text(bilan.tenant_name, tenantX, infoY + 12);
|
|
|
+ doc.font('Helvetica');
|
|
|
+ if (bilan.tenant_email) doc.fontSize(9).fillColor('#374151').text(bilan.tenant_email, tenantX, infoY + 24);
|
|
|
+
|
|
|
+ doc.y = Math.max(bY, infoY + 36) + 8;
|
|
|
doc.moveTo(50, doc.y).lineTo(50 + W, doc.y).strokeColor('#e5e7eb').lineWidth(1).stroke();
|
|
|
doc.moveDown(1);
|
|
|
|
|
|
- // Summary boxes
|
|
|
- doc.fontSize(11).fillColor('#111827').text('Synthèse', { underline: true });
|
|
|
+ // Occupancy info
|
|
|
+ doc.fontSize(10).fillColor(gray).text(
|
|
|
+ `Période d'occupation prise en compte : ${bilan.occupied_days} jour${bilan.occupied_days > 1 ? 's' : ''} sur ${bilan.year_days} (${Number(bilan.ratio_percent || 0).toFixed(2)}%)`,
|
|
|
+ { align: 'left' }
|
|
|
+ );
|
|
|
+ doc.moveDown(1);
|
|
|
+
|
|
|
+ // Tenant summary boxes
|
|
|
+ doc.fontSize(11).fillColor('#111827').text('Décompte individuel', { underline: true });
|
|
|
doc.moveDown(0.5);
|
|
|
const bx = 50; const bw = (W - 20) / 3;
|
|
|
- [[`Charges réelles`, fmtEur(totalChargesReelles), `${totalChargesRow?.count || 0} facture(s)`, red],
|
|
|
- [`Provisions perçues`, fmtEur(totalProvisions), 'de tous les locataires', blue],
|
|
|
- [solde >= 0 ? 'Trop-perçu global' : 'Solde insuffisant', `${solde >= 0 ? '+' : '-'}${fmtEur(Math.abs(solde))}`, solde >= 0 ? 'à restituer' : 'à appeler', solde >= 0 ? green : orange]
|
|
|
+ [
|
|
|
+ ['Provisions perçues', fmtEur(bilan.provisions_percues), `sur l'exercice ${year}`, blue],
|
|
|
+ [`Quote-part charges\n(prorata ${Number(bilan.ratio_percent).toFixed(2)}%)`, fmtEur(bilan.quote_part_charges), `sur ${fmtEur(totalChargesReelles)} total`, red],
|
|
|
+ [isPos ? 'Trop-perçu à restituer' : 'Solde restant dû', fmtEur(Math.abs(trop_percu)), isPos ? 'En votre faveur' : 'À votre charge', accentColor],
|
|
|
].forEach(([label, val, note, color], i) => {
|
|
|
const x = bx + i * (bw + 10); const y = doc.y;
|
|
|
- doc.rect(x, y, bw, 60).stroke('#e5e7eb');
|
|
|
- doc.fontSize(9).fillColor(gray).text(label, x + 8, y + 8, { width: bw - 16 });
|
|
|
- doc.fontSize(14).fillColor(color).text(val, x + 8, y + 22, { width: bw - 16 });
|
|
|
- doc.fontSize(8).fillColor(gray).text(note, x + 8, y + 42, { width: bw - 16 });
|
|
|
+ doc.rect(x, y, bw, 68).stroke('#e5e7eb');
|
|
|
+ doc.fontSize(8).fillColor(gray).text(label, x + 8, y + 8, { width: bw - 16 });
|
|
|
+ doc.fontSize(13).fillColor(color).text(val, x + 8, y + 28, { width: bw - 16 });
|
|
|
+ doc.fontSize(8).fillColor(gray).text(note, x + 8, y + 50, { width: bw - 16 });
|
|
|
});
|
|
|
- doc.moveDown(4.5);
|
|
|
+ doc.moveDown(5);
|
|
|
+
|
|
|
+ // Conclusion banner
|
|
|
+ const bannerY = doc.y;
|
|
|
+ doc.rect(50, bannerY, W, 36).fillAndStroke(isPos ? '#f0fdf4' : '#fff7ed', isPos ? '#bbf7d0' : '#fed7aa');
|
|
|
+ doc.fontSize(11).fillColor(accentColor).font('Helvetica-Bold').text(
|
|
|
+ isPos
|
|
|
+ ? `Montant à vous restituer : ${fmtEur(Math.abs(trop_percu))}`
|
|
|
+ : `Montant à régler : ${fmtEur(Math.abs(trop_percu))}`,
|
|
|
+ 62, bannerY + 12, { width: W - 24 }
|
|
|
+ );
|
|
|
+ doc.font('Helvetica');
|
|
|
+ doc.y = bannerY + 46;
|
|
|
+ doc.moveDown(1.5);
|
|
|
|
|
|
// Charges by category
|
|
|
if (chargesByCategory.length > 0) {
|
|
|
- doc.fontSize(11).fillColor('#111827').text('Répartition par catégorie', { underline: true });
|
|
|
+ doc.fontSize(11).fillColor('#111827').text('Répartition des charges par catégorie', { underline: true });
|
|
|
doc.moveDown(0.5);
|
|
|
const colWidths = [220, 100, 80];
|
|
|
- const headers = ['Catégorie', 'Montant', '%'];
|
|
|
+ const headers = ['Catégorie', 'Montant total', '%'];
|
|
|
let tx = 50; let ty = doc.y;
|
|
|
doc.fontSize(9).fillColor(gray);
|
|
|
headers.forEach((h, i) => { doc.text(h, tx, ty, { width: colWidths[i] }); tx += colWidths[i]; });
|
|
|
@@ -435,37 +469,10 @@ router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
|
|
|
doc.moveDown(1.5);
|
|
|
}
|
|
|
|
|
|
- // Per tenant bilan
|
|
|
- if (bilans.length > 0) {
|
|
|
- doc.fontSize(11).fillColor('#111827').text('Décompte par locataire', { underline: true });
|
|
|
- doc.moveDown(0.5);
|
|
|
- bilans.forEach(b => {
|
|
|
- const isPos = b.trop_percu >= 0;
|
|
|
- const color = isPos ? green : orange;
|
|
|
- const bY = doc.y;
|
|
|
- doc.rect(50, bY, W, 90).stroke('#e5e7eb');
|
|
|
- doc.fontSize(11).fillColor('#111827').font('Helvetica-Bold').text(b.tenant_name, 62, bY + 10, { width: W - 24 });
|
|
|
- doc.font('Helvetica');
|
|
|
- if (b.tenant_email) doc.fontSize(9).fillColor(gray).text(b.tenant_email, 62, bY + 24);
|
|
|
- const badge = isPos ? 'Remboursement' : 'Appel de fonds';
|
|
|
- doc.fontSize(9).fillColor(color).text(badge, 62, bY + 38);
|
|
|
- const cols = [['Provisions perçues', fmtEur(b.provisions_percues)], [`Quote-part (${b.ratio_percent}%)`, fmtEur(b.quote_part_charges)], [isPos ? 'Trop-perçu' : 'Solde dû', fmtEur(Math.abs(b.trop_percu))]];
|
|
|
- const cw = (W - 24) / 3;
|
|
|
- cols.forEach(([lbl, val], i) => {
|
|
|
- const cx = 62 + i * (cw + 8);
|
|
|
- doc.fontSize(8).fillColor(gray).text(lbl, cx, bY + 54, { width: cw });
|
|
|
- doc.fontSize(10).fillColor(i === 2 ? color : '#111827').font('Helvetica-Bold').text(val, cx, bY + 66, { width: cw });
|
|
|
- doc.font('Helvetica');
|
|
|
- });
|
|
|
- doc.y = bY + 100;
|
|
|
- doc.moveDown(0.3);
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- // Charges list
|
|
|
+ // Charges list (detail)
|
|
|
if (chargesList.length > 0) {
|
|
|
doc.addPage();
|
|
|
- doc.fontSize(13).fillColor('#111827').text(`Détail des charges ${year}`, { underline: true });
|
|
|
+ doc.fontSize(13).fillColor('#111827').text(`Détail des charges récupérables ${year}`, { underline: true });
|
|
|
doc.moveDown(0.5);
|
|
|
const hdrs = ['Date', 'Catégorie', 'Libellé', 'Montant', 'Facture'];
|
|
|
const cws2 = [70, 100, 180, 80, 100];
|
|
|
@@ -479,7 +486,7 @@ router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
|
|
|
if (doc.y > doc.page.height - 80) { doc.addPage(); }
|
|
|
hx = 50; const row = doc.y;
|
|
|
doc.fontSize(9).fillColor('#111827');
|
|
|
- [fmtDate(c.date), catLabels[c.category] || c.category, c.label, fmtEur(c.amount), c.invoice_name ? `📎 Voir annexe` : '—'].forEach((v, i) => {
|
|
|
+ [fmtDate(c.date), catLabels[c.category] || c.category, c.label, fmtEur(c.amount), c.invoice_name ? '📎 Voir annexe' : '—'].forEach((v, i) => {
|
|
|
doc.text(v, hx, row, { width: cws2[i] }); hx += cws2[i];
|
|
|
});
|
|
|
doc.moveDown(0.5);
|
|
|
@@ -492,12 +499,10 @@ router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
|
|
|
// --- Step 2: Merge bilan + invoice files with pdf-lib ---
|
|
|
const mergedPdf = await LibPDFDocument.create();
|
|
|
|
|
|
- // Copy bilan pages
|
|
|
const bilanPdf = await LibPDFDocument.load(bilanBuf);
|
|
|
const bilanPages = await mergedPdf.copyPages(bilanPdf, bilanPdf.getPageIndices());
|
|
|
bilanPages.forEach(p => mergedPdf.addPage(p));
|
|
|
|
|
|
- // Append each invoice
|
|
|
for (const charge of chargesList) {
|
|
|
if (!charge.invoice_path) continue;
|
|
|
const filePath = path.join(uploadDir, charge.invoice_path);
|
|
|
@@ -524,11 +529,11 @@ router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
|
|
|
page.drawImage(img, { x: 0, y: 0, width: img.width, height: img.height });
|
|
|
} catch { /* skip unreadable images */ }
|
|
|
}
|
|
|
- // webp not supported by pdf-lib natively — skipped
|
|
|
}
|
|
|
|
|
|
const pdfBytes = await mergedPdf.save();
|
|
|
- const filename = `bilan_charges_${year}_${prop.name.replace(/[^a-z0-9]/gi, '_')}.pdf`;
|
|
|
+ const tenantSlug = bilan.tenant_name.replace(/[^a-z0-9]/gi, '_');
|
|
|
+ const filename = `decompte_charges_${year}_${tenantSlug}.pdf`;
|
|
|
res.setHeader('Content-Type', 'application/pdf');
|
|
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
|
|
res.send(Buffer.from(pdfBytes));
|