jeremy 4 meses atrás
pai
commit
ee5088f749
2 arquivos alterados com 190 adições e 106 exclusões
  1. 149 85
      backend/src/routes/charges.js
  2. 41 21
      frontend/src/pages/Charges.jsx

+ 149 - 85
backend/src/routes/charges.js

@@ -31,6 +31,77 @@ const upload = multer({
   }
 });
 
+function toUtcDate(dateStr) {
+  const [y, m, d] = String(dateStr).split('-').map(Number);
+  return new Date(Date.UTC(y, m - 1, d));
+}
+
+function getYearRange(year) {
+  const y = parseInt(year, 10);
+  const yearStartDate = toUtcDate(`${y}-01-01`);
+  const yearEndDate = toUtcDate(`${y}-12-31`);
+  const yearDays = Math.round((yearEndDate - yearStartDate) / 86400000) + 1;
+  return {
+    year: y,
+    yearStartIso: `${y}-01-01`,
+    yearEndIso: `${y}-12-31`,
+    yearStartDate,
+    yearEndDate,
+    yearDays,
+  };
+}
+
+function overlapDaysInclusive(startDate, endDate, rangeStart, rangeEnd) {
+  const start = startDate > rangeStart ? startDate : rangeStart;
+  const end = endDate < rangeEnd ? endDate : rangeEnd;
+  if (end < start) return 0;
+  return Math.round((end - start) / 86400000) + 1;
+}
+
+function round2(value) {
+  return Math.round(Number(value || 0) * 100) / 100;
+}
+
+async function computeLeaseBilansForPropertyYear({ propertyId, year, userId }) {
+  const { yearStartIso, yearEndIso, yearStartDate, yearEndDate, yearDays } = getYearRange(year);
+
+  const leases = (await pool.query(`
+    SELECT l.*, t.first_name, t.last_name, t.email
+    FROM leases l
+    JOIN tenants t ON l.tenant_id = t.id
+    WHERE l.property_id = $1 AND l.user_id = $2
+      AND (l.start_date <= $3 AND (l.end_date IS NULL OR l.end_date >= $4))
+    ORDER BY l.start_date ASC
+  `, [propertyId, userId, yearEndIso, yearStartIso])).rows;
+
+  const leaseBilans = await Promise.all(leases.map(async lease => {
+    const provisions = (await pool.query(`
+      SELECT SUM(charges_paid) as total
+      FROM payments
+      WHERE lease_id = $1 AND period_year = $2
+    `, [lease.id, parseInt(year, 10)])).rows[0];
+
+    const leaseStartDate = toUtcDate(lease.start_date);
+    const leaseEndDate = lease.end_date ? toUtcDate(lease.end_date) : yearEndDate;
+    const occupiedDays = overlapDaysInclusive(leaseStartDate, leaseEndDate, yearStartDate, yearEndDate);
+    const occupancyRatio = yearDays > 0 ? occupiedDays / yearDays : 0;
+
+    return {
+      lease_id: lease.id,
+      tenant_name: `${lease.first_name} ${lease.last_name}`,
+      tenant_email: lease.email,
+      monthly_provision: lease.charges_amount,
+      provisions_percues: parseFloat(provisions?.total || 0),
+      occupied_days: occupiedDays,
+      year_days: yearDays,
+      occupancy_ratio: occupancyRatio,
+      occupancy_ratio_percent: round2(occupancyRatio * 100),
+    };
+  }));
+
+  return { leaseBilans, yearDays };
+}
+
 // List charges (filter by property_id, year)
 router.get('/', async (req, res) => {
   const { property_id, year } = req.query;
@@ -168,58 +239,36 @@ router.get('/bilan/:property_id/:year', async (req, res) => {
     GROUP BY category ORDER BY total DESC
   `, [property_id, year, req.userId])).rows;
 
-  // All leases on this property (active or that were active during the year)
-  const leases = (await pool.query(`
-    SELECT l.*, t.first_name, t.last_name, t.email
-    FROM leases l
-    JOIN tenants t ON l.tenant_id = t.id
-    WHERE l.property_id = $1 AND l.user_id = $2
-      AND (l.start_date <= $3 AND (l.end_date IS NULL OR l.end_date >= $4))
-  `, [property_id, req.userId, `${year}-12-31`, `${year}-01-01`])).rows;
-
-  // For each lease, get provisions perçues for the year
-  const leaseBilans = await Promise.all(leases.map(async lease => {
-    const provisions = (await pool.query(`
-      SELECT SUM(charges_paid) as total
-      FROM payments
-      WHERE lease_id = $1 AND period_year = $2
-    `, [lease.id, year])).rows[0];
-
-    return {
-      lease_id: lease.id,
-      tenant_name: `${lease.first_name} ${lease.last_name}`,
-      tenant_email: lease.email,
-      monthly_provision: lease.charges_amount,
-      provisions_percues: provisions?.total || 0
-    };
-  }));
+  const { leaseBilans } = await computeLeaseBilansForPropertyYear({
+    propertyId: parseInt(property_id, 10),
+    year,
+    userId: req.userId,
+  });
 
-  // Calculate total provisions across all tenants for proportional split
   const totalProvisions = leaseBilans.reduce((s, l) => s + l.provisions_percues, 0);
 
-  // Compute each tenant's share
+  // Compute each tenant's share based on actual occupancy in the year.
   const bilans = leaseBilans.map(l => {
-    const ratio = totalProvisions > 0 ? l.provisions_percues / totalProvisions : (leaseBilans.length > 0 ? 1 / leaseBilans.length : 0);
-    const quote_part_charges = totalChargesReelles * ratio;
+    const quote_part_charges = totalChargesReelles * l.occupancy_ratio;
     const trop_percu = l.provisions_percues - quote_part_charges;
     return {
       ...l,
-      ratio_percent: Math.round(ratio * 100 * 100) / 100,
-      quote_part_charges: Math.round(quote_part_charges * 100) / 100,
-      trop_percu: Math.round(trop_percu * 100) / 100
+      ratio_percent: l.occupancy_ratio_percent,
+      quote_part_charges: round2(quote_part_charges),
+      trop_percu: round2(trop_percu)
     };
   });
 
   res.json({
     property: prop,
     year: parseInt(year),
-    total_charges_reelles: Math.round(totalChargesReelles * 100) / 100,
-    total_deductible: Math.round(totalDeductible * 100) / 100,
-    total_non_recoverable: Math.round(totalNonRecoverable * 100) / 100,
+    total_charges_reelles: round2(totalChargesReelles),
+    total_deductible: round2(totalDeductible),
+    total_non_recoverable: round2(totalNonRecoverable),
     charges_count: totalChargesRow?.count || 0,
     charges_by_category: chargesByCategory,
     charges_non_recov_by_category: chargesNonRecovByCategory,
-    total_provisions_percues: Math.round(totalProvisions * 100) / 100,
+    total_provisions_percues: round2(totalProvisions),
     bilans
   });
 });
@@ -277,28 +326,22 @@ router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
     GROUP BY category ORDER BY total DESC
   `, [property_id, year, req.userId])).rows;
 
-  const leases = (await pool.query(`
-    SELECT l.*, t.first_name, t.last_name, t.email FROM leases l
-    JOIN tenants t ON l.tenant_id = t.id
-    WHERE l.property_id = $1 AND l.user_id = $2
-      AND (l.start_date <= $3 AND (l.end_date IS NULL OR l.end_date >= $4))
-  `, [property_id, req.userId, `${year}-12-31`, `${year}-01-01`])).rows;
+  const { leaseBilans } = await computeLeaseBilansForPropertyYear({
+    propertyId: parseInt(property_id, 10),
+    year,
+    userId: req.userId,
+  });
 
-  const leaseBilans = await Promise.all(leases.map(async lease => {
-    const provisions = (await pool.query(`SELECT SUM(charges_paid) as total FROM payments WHERE lease_id = $1 AND period_year = $2`, [lease.id, year])).rows[0];
-    return {
-      lease_id: lease.id,
-      tenant_name: `${lease.first_name} ${lease.last_name}`,
-      tenant_email: lease.email,
-      provisions_percues: provisions?.total || 0
-    };
-  }));
   const totalProvisions = leaseBilans.reduce((s, l) => s + l.provisions_percues, 0);
   const bilans = leaseBilans.map(l => {
-    const ratio = totalProvisions > 0 ? l.provisions_percues / totalProvisions : (leaseBilans.length > 0 ? 1 / leaseBilans.length : 0);
-    const quote_part = totalChargesReelles * ratio;
+    const quote_part = totalChargesReelles * l.occupancy_ratio;
     const trop_percu = l.provisions_percues - quote_part;
-    return { ...l, ratio_percent: Math.round(ratio * 100 * 100) / 100, quote_part_charges: Math.round(quote_part * 100) / 100, trop_percu: Math.round(trop_percu * 100) / 100 };
+    return {
+      ...l,
+      ratio_percent: l.occupancy_ratio_percent,
+      quote_part_charges: round2(quote_part),
+      trop_percu: round2(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' };
@@ -484,62 +527,83 @@ router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
   }
 });
 
-// Close annual exercise — create charge_regularization for each tenant
+// Close annual exercise for one lease (or all not-yet-closed leases if lease_id is omitted)
 router.post('/bilan/:property_id/:year/close', async (req, res) => {
   const { property_id, year } = req.params;
+  const leaseIdRaw = req.body?.lease_id || req.query?.lease_id;
+  const leaseId = leaseIdRaw ? parseInt(leaseIdRaw, 10) : null;
 
   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é' });
 
-  // Check not already closed for this property/year
-  const alreadyClosed = (await pool.query(
-    'SELECT id FROM charge_regularizations WHERE user_id = $1 AND property_id = $2 AND year = $3',
-    [req.userId, property_id, parseInt(year)]
-  )).rows[0];
-  if (alreadyClosed) return res.status(409).json({ error: `L'exercice ${year} a déjà été clôturé pour ce bien.` });
-
-  // Recompute bilan (recoverable only)
+  // Recompute recoverable charges for the property/year.
   const totalRow = (await pool.query(
     `SELECT SUM(amount) as total FROM charges WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable_type = 'recoverable'`,
     [property_id, year, req.userId]
   )).rows[0];
-  const totalCharges = totalRow?.total || 0;
+  const totalCharges = parseFloat(totalRow?.total || 0);
 
-  const leases = (await pool.query(`
-    SELECT l.*, t.first_name, t.last_name FROM leases l
-    JOIN tenants t ON l.tenant_id = t.id
-    WHERE l.property_id = $1 AND l.user_id = $2
-      AND (l.start_date <= $3 AND (l.end_date IS NULL OR l.end_date >= $4))
-  `, [property_id, req.userId, `${year}-12-31`, `${year}-01-01`])).rows;
+  const { leaseBilans } = await computeLeaseBilansForPropertyYear({
+    propertyId: parseInt(property_id, 10),
+    year,
+    userId: req.userId,
+  });
 
-  if (leases.length === 0) return res.status(400).json({ error: 'Aucun locataire sur cette période.' });
+  if (leaseBilans.length === 0) return res.status(400).json({ error: 'Aucun bail sur cette période.' });
 
-  const leaseBilans = await Promise.all(leases.map(async lease => {
-    const prov = (await pool.query('SELECT SUM(charges_paid) as total FROM payments WHERE lease_id = $1 AND period_year = $2', [lease.id, year])).rows[0];
-    return { lease_id: lease.id, tenant: `${lease.first_name} ${lease.last_name}`, provisions: prov?.total || 0 };
-  }));
-  const totalProvisions = leaseBilans.reduce((s, l) => s + l.provisions, 0);
+  let targets = leaseBilans;
+  if (leaseId) {
+    const selected = leaseBilans.find(l => l.lease_id === leaseId);
+    if (!selected) return res.status(404).json({ error: 'Bail non trouvé pour ce bien/période.' });
+
+    const alreadyClosedForLease = (await pool.query(
+      'SELECT id FROM charge_regularizations WHERE user_id = $1 AND lease_id = $2 AND year = $3 LIMIT 1',
+      [req.userId, leaseId, parseInt(year, 10)]
+    )).rows[0];
+    if (alreadyClosedForLease) {
+      return res.status(409).json({ error: `Le bail sélectionné est déjà clôturé pour ${year}.` });
+    }
+    targets = [selected];
+  } else {
+    const closedLeaseIds = (await pool.query(
+      'SELECT DISTINCT lease_id FROM charge_regularizations WHERE user_id = $1 AND property_id = $2 AND year = $3',
+      [req.userId, property_id, parseInt(year, 10)]
+    )).rows.map(r => r.lease_id);
+    targets = leaseBilans.filter(l => !closedLeaseIds.includes(l.lease_id));
+    if (targets.length === 0) {
+      return res.status(409).json({ error: `Tous les baux de ${year} sont déjà clôturés pour ce bien.` });
+    }
+  }
 
   const client = await pool.connect();
   try {
     await client.query('BEGIN');
     const results = [];
-    for (const l of leaseBilans) {
-      const ratio = totalProvisions > 0 ? l.provisions / totalProvisions : 1 / leaseBilans.length;
-      const quote_part = totalCharges * ratio;
+    for (const l of targets) {
+      const quote_part = totalCharges * l.occupancy_ratio;
       // Positive = trop-perçu (credit tenant), Negative = complément dû (debit tenant)
-      const trop_percu = Math.round((l.provisions - quote_part) * 100) / 100;
+      const trop_percu = round2(l.provisions_percues - quote_part);
+      const quotePartRounded = round2(quote_part);
       const label = trop_percu >= 0
-        ? `Régularisation charges ${year} : trop-perçu de ${trop_percu.toFixed(2)} € à déduire`
-        : `Régularisation charges ${year} : complément de ${Math.abs(trop_percu).toFixed(2)} € à appeler`;
+        ? `Régularisation charges ${year} (bail ${l.lease_id}) : trop-perçu de ${trop_percu.toFixed(2)} € à déduire (prorata occupation ${l.occupied_days}/${l.year_days} jours)`
+        : `Régularisation charges ${year} (bail ${l.lease_id}) : complément de ${Math.abs(trop_percu).toFixed(2)} € à appeler (prorata occupation ${l.occupied_days}/${l.year_days} jours)`;
       await client.query(
         'INSERT INTO charge_regularizations (lease_id, user_id, property_id, year, amount, notes) VALUES ($1, $2, $3, $4, $5, $6)',
-        [l.lease_id, req.userId, parseInt(property_id), parseInt(year), trop_percu, label]
+        [l.lease_id, req.userId, parseInt(property_id, 10), parseInt(year, 10), trop_percu, label]
       );
-      results.push({ lease_id: l.lease_id, tenant: l.tenant, trop_percu });
+      results.push({
+        lease_id: l.lease_id,
+        tenant: l.tenant_name,
+        occupied_days: l.occupied_days,
+        year_days: l.year_days,
+        ratio_percent: l.occupancy_ratio_percent,
+        quote_part_charges: quotePartRounded,
+        provisions_percues: round2(l.provisions_percues),
+        trop_percu,
+      });
     }
     await client.query('COMMIT');
-    res.json({ success: true, year: parseInt(year), property: prop.name, regularizations: results });
+    res.json({ success: true, year: parseInt(year, 10), property: prop.name, regularizations: results });
   } catch (e) {
     await client.query('ROLLBACK');
     throw e;

+ 41 - 21
frontend/src/pages/Charges.jsx

@@ -47,7 +47,7 @@ export default function Charges() {
   const [bilan, setBilan] = useState(null)
   const [bilanLoading, setBilanLoading] = useState(false)
   const [pdfLoading, setPdfLoading] = useState(false)
-  const [closeLoading, setCloseLoading] = useState(false)
+  const [closingLeaseId, setClosingLeaseId] = useState(null)
   const [loading, setLoading] = useState(false)
   const [editCharge, setEditCharge] = useState(null)
 
@@ -183,12 +183,16 @@ export default function Charges() {
             <span class="badge ${pos ? 'badge-green' : 'badge-orange'}">${pos ? '↩ Remboursement' : '↑ Appel de fonds'}</span>
           </div>
           <table class="inner-table">
+            <tr>
+              <td>Occupation</td>
+              <td class="amount">${b.occupied_days} / ${b.year_days} jours (${Number(b.ratio_percent || 0).toFixed(2)}%)</td>
+            </tr>
             <tr>
               <td>Provisions perçues</td>
               <td class="amount">${fmtEur(b.provisions_percues)}</td>
             </tr>
             <tr>
-              <td>Quote-part des charges réelles (${b.ratio_percent}%)</td>
+              <td>Quote-part des charges réelles (prorata occupation)</td>
               <td class="amount">${fmtEur(b.quote_part_charges)}</td>
             </tr>
             <tr class="${pos ? 'row-green' : 'row-orange'}">
@@ -312,25 +316,33 @@ export default function Charges() {
     finally { setPdfLoading(false) }
   }
 
-  const closeExercise = async () => {
-    if (!filterProperty || !filterYear) return
+  const closeLeaseExercise = async (leaseBilan) => {
+    if (!filterProperty || !filterYear || !leaseBilan?.lease_id) return
     if (!confirm(
-      `Clôturer l'exercice ${filterYear} pour ce bien ?\n\n` +
-      `Cela calculera la régularisation de charges (plus-value ou moins-value) pour chaque locataire ` +
-      `et la reportera sur le prochain loyer.\n\nCette opération est définitive.`
+      `Clôturer l'exercice ${filterYear} pour le bail de ${leaseBilan.tenant_name} ?\n\n` +
+      `Le calcul se fera au prorata d'occupation (${leaseBilan.occupied_days}/${leaseBilan.year_days} jours).\n\n` +
+      `Cette opération est définitive.`
     )) return
-    setCloseLoading(true)
+    setClosingLeaseId(leaseBilan.lease_id)
     try {
-      const { data } = await api.post(`/charges/bilan/${filterProperty}/${filterYear}/close`)
-      const lines = data.regularizations.map(r =>
-        `• ${r.tenant} : ${r.trop_percu >= 0 ? '-' : '+'}${Math.abs(r.trop_percu).toFixed(2)} € sur le prochain loyer`
-      ).join('\n')
-      toast.success(`Exercice ${filterYear} clôturé !\n${lines}`, { duration: 6000 })
-      setShowBilan(false)
+      const { data } = await api.post(`/charges/bilan/${filterProperty}/${filterYear}/close`, {
+        lease_id: leaseBilan.lease_id
+      })
+      const reg = data.regularizations?.[0]
+      if (reg) {
+        toast.success(
+          `Bail clôturé (${filterYear}) : ${reg.tenant} | Prorata ${reg.occupied_days}/${reg.year_days} jours | ` +
+          `${reg.trop_percu >= 0 ? '-' : '+'}${Math.abs(reg.trop_percu).toFixed(2)} € sur le prochain loyer`,
+          { duration: 6000 }
+        )
+      } else {
+        toast.success(`Bail clôturé pour ${filterYear}`)
+      }
+      await loadBilan()
     } catch (err) {
       toast.error(err.response?.data?.error || 'Erreur lors de la clôture')
     } finally {
-      setCloseLoading(false)
+      setClosingLeaseId(null)
     }
   }
 
@@ -585,10 +597,6 @@ export default function Charges() {
                 className="btn-secondary flex items-center gap-2 text-sm">
                 {pdfLoading ? '⏳ Génération…' : '📥 PDF complet'}
               </button>
-              <button onClick={closeExercise} disabled={closeLoading}
-                className="flex items-center gap-2 text-sm px-4 py-2 rounded-lg bg-orange-500 hover:bg-orange-600 text-white font-semibold transition-colors">
-                {closeLoading ? '⏳…' : '✅ Clôturer l\'exercice'}
-              </button>
             </div>
             {/* Summary cards */}
             <div className="grid grid-cols-2 gap-3">
@@ -664,7 +672,7 @@ export default function Charges() {
             {/* Par locataire */}
             {bilan.bilans.length > 0 ? (
               <div>
-                <h3 className="text-sm font-semibold text-gray-700 mb-3">Décompte par locataire</h3>
+                <h3 className="text-sm font-semibold text-gray-700 mb-3">Décompte par locataire (prorata d'occupation)</h3>
                 <div className="space-y-3">
                   {bilan.bilans.map(b => {
                     const isPos = b.trop_percu >= 0
@@ -679,13 +687,16 @@ export default function Charges() {
                             {isPos ? '↩ Remboursement' : '↑ Appel de fonds'}
                           </span>
                         </div>
+                        <div className="mb-3 text-xs text-gray-500 bg-gray-50 rounded-lg px-3 py-2">
+                          Occupation sur l'année: <span className="font-semibold text-gray-700">{b.occupied_days} / {b.year_days} jours ({Number(b.ratio_percent || 0).toFixed(2)}%)</span>
+                        </div>
                         <div className="grid grid-cols-3 gap-2 text-sm">
                           <div className="bg-gray-50 rounded-lg p-2 text-center">
                             <p className="text-xs text-gray-500">Provisions perçues</p>
                             <p className="font-semibold text-gray-800">{fmt(b.provisions_percues)}</p>
                           </div>
                           <div className="bg-gray-50 rounded-lg p-2 text-center">
-                            <p className="text-xs text-gray-500">Quote-part réelle ({b.ratio_percent}%)</p>
+                            <p className="text-xs text-gray-500">Quote-part réelle (prorata)</p>
                             <p className="font-semibold text-gray-800">{fmt(b.quote_part_charges)}</p>
                           </div>
                           <div className={`rounded-lg p-2 text-center ${isPos ? 'bg-green-50' : 'bg-orange-50'}`}>
@@ -697,6 +708,15 @@ export default function Charges() {
                             </p>
                           </div>
                         </div>
+                        <div className="mt-3 flex justify-end">
+                          <button
+                            onClick={() => closeLeaseExercise(b)}
+                            disabled={closingLeaseId === b.lease_id}
+                            className="flex items-center gap-2 text-sm px-3 py-2 rounded-lg bg-orange-500 hover:bg-orange-600 text-white font-semibold transition-colors disabled:opacity-60"
+                          >
+                            {closingLeaseId === b.lease_id ? '⏳ Clôture…' : '✅ Clôturer ce bail'}
+                          </button>
+                        </div>
                       </div>
                     )
                   })}