diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml index 5b37a50..30de5eb 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml @@ -47,3 +47,10 @@ figli_compta_ledger.api_annees: _controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::annees' requirements: _permission: 'access content' + +figli_compta_ledger.api_reconciliation_ouverture: + path: '/lignes/api/reconciliation-ouverture' + defaults: + _controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::reconciliationOuverture' + requirements: + _permission: 'access content' diff --git a/web/modules/custom/figli_compta_ledger/js/home.js b/web/modules/custom/figli_compta_ledger/js/home.js index c869f41..86c1c33 100644 --- a/web/modules/custom/figli_compta_ledger/js/home.js +++ b/web/modules/custom/figli_compta_ledger/js/home.js @@ -119,6 +119,16 @@ return json.annees || []; } + // { "2023": { "Bachir": -0.03, ... }, ... } -- years where the actual + // ouverture doesn't match the previous year's calculated closing + // balance (that year's own ouverture + every movement dated within + // it). Small dataset (a handful of year boundaries), fetched once. + async function fetchOuvertureEcarts() { + const res = await fetch('/lignes/api/reconciliation-ouverture', { headers: { Accept: 'application/json' } }); + if (!res.ok) throw new Error('/lignes/api/reconciliation-ouverture a répondu ' + res.status); + return res.json(); + } + async function fetchYearTotals(annee) { const res = await fetch('/lignes/api/totaux?annee=' + encodeURIComponent(annee), { headers: { Accept: 'application/json' } }); if (!res.ok) throw new Error('/lignes/api/totaux a répondu ' + res.status); @@ -182,6 +192,7 @@ allComptes: ['Sandrine', 'Maud', 'Ouidade', 'Chloé', 'Bachir', 'Valentin', 'EXT.', 'EXT.WEB'], allClientsList: [], allYearsList: [], + ouvertureEcarts: {}, filterCompte: '', filterClient: '', filterType: '', @@ -316,6 +327,22 @@ formatEur(v) { return v === null || v === undefined ? '' : EUR.format(v); }, + // Écart between this ouverture row's year and the previous year's + // calculated closing balance -- restricted to whichever compte(s) + // this specific row's répartition actually touches (ouverture lines + // are entered one per compte, so showing every compte's écart on + // every row would just repeat the same list). Null if this row's + // compte(s) reconcile cleanly. + ouvertureEcart(item) { + if (item.type !== 'ouverture' || !item.date) return null; + const annee = item.date.slice(0, 4); + const ecarts = this.ouvertureEcarts[annee]; + if (!ecarts) return null; + const relevant = Object.keys(item.parCompte).filter((c) => ecarts[c] !== undefined); + if (!relevant.length) return null; + const detail = relevant.map((compte) => compte + ' : ' + this.formatEur(ecarts[compte])).join(', '); + return { detail, comptes: relevant.length }; + }, // jj/mm/aa -- shorter than the API's ISO yyyy-mm-dd, saves column // width in a table already packed with 8 compte columns. formatDate(iso) { @@ -638,6 +665,7 @@ // "whatever happens to be loaded right now". fetchClientNames().then((names) => { this.allClientsList = names; }).catch(() => {}); fetchYearsList().then((years) => { this.allYearsList = years; }).catch(() => {}); + fetchOuvertureEcarts().then((ecarts) => { this.ouvertureEcarts = ecarts; }).catch(() => {}); jQuery(document).on('dialog:afterclose', () => this.reloadWindow()); await this.$nextTick(); const wrap = this.$refs.tableWrap; diff --git a/web/modules/custom/figli_compta_ledger/src/Controller/LedgerStatsController.php b/web/modules/custom/figli_compta_ledger/src/Controller/LedgerStatsController.php index d27060b..2e3a34b 100644 --- a/web/modules/custom/figli_compta_ledger/src/Controller/LedgerStatsController.php +++ b/web/modules/custom/figli_compta_ledger/src/Controller/LedgerStatsController.php @@ -96,4 +96,80 @@ class LedgerStatsController extends ControllerBase { return new JsonResponse(['annees' => $annees]); } + /** + * GET /lignes/api/reconciliation-ouverture -- per compte, checks that + * each year's "ouverture" (opening balance) matches the *calculated* + * closing balance of the previous year (that year's own ouverture plus + * every movement dated within it). The historical spreadsheets carried + * real year-to-year gaps that were preserved as-is during migration + * (never corrected) -- this surfaces them instead of hiding them, same + * principle as the per-ligne écart column. + * + * Response: { "2023": { "Bachir": -12.34, "EXT.WEB": 5 }, ... } -- only + * years with at least one compte off by more than a cent, and never the + * very first year on record (nothing to compare it against). + */ + public function reconciliationOuverture() { + $connection = \Drupal::database(); + $query = $connection->select('node__field_date_ligne', 'd'); + $query->innerJoin('node__field_type_ligne', 't', 't.entity_id = d.entity_id'); + $query->innerJoin('node__field_repartition', 'r', 'r.entity_id = d.entity_id'); + $query->innerJoin('paragraph__field_montant', 'm', 'm.entity_id = r.field_repartition_target_id'); + $query->innerJoin('paragraph__field_compte', 'c', 'c.entity_id = r.field_repartition_target_id'); + $query->innerJoin('taxonomy_term_field_data', 'tc', 'tc.tid = c.field_compte_target_id'); + $query->condition('d.bundle', 'ligne_comptable'); + $query->addExpression('SUBSTRING(d.field_date_ligne_value, 1, 4)', 'annee'); + $query->addExpression("CASE WHEN t.field_type_ligne_value = 'ouverture' THEN 1 ELSE 0 END", 'is_ouverture'); + $query->addField('tc', 'name', 'compte'); + $query->addExpression('SUM(m.field_montant_value)', 'total'); + $query->groupBy('annee'); + $query->groupBy('is_ouverture'); + $query->groupBy('compte'); + $rows = $query->execute()->fetchAll(); + + $ouverture = []; + $mouvement = []; + foreach ($rows as $row) { + if ($row->is_ouverture) { + $ouverture[$row->annee][$row->compte] = ($ouverture[$row->annee][$row->compte] ?? 0) + (float) $row->total; + } + else { + $mouvement[$row->annee][$row->compte] = ($mouvement[$row->annee][$row->compte] ?? 0) + (float) $row->total; + } + } + + $annees = array_unique(array_merge(array_keys($ouverture), array_keys($mouvement))); + sort($annees); + + $result = []; + foreach ($annees as $i => $annee) { + if ($i === 0) { + continue; + } + $precedente = (string) ((int) $annee - 1); + if (empty($ouverture[$annee]) || (!isset($ouverture[$precedente]) && !isset($mouvement[$precedente]))) { + continue; + } + $comptes = array_unique(array_merge( + array_keys($ouverture[$annee]), + array_keys($ouverture[$precedente] ?? []), + array_keys($mouvement[$precedente] ?? []) + )); + $ecarts = []; + foreach ($comptes as $compte) { + $ouvertureReelle = $ouverture[$annee][$compte] ?? 0; + $clotureCalculee = ($ouverture[$precedente][$compte] ?? 0) + ($mouvement[$precedente][$compte] ?? 0); + $ecart = round($ouvertureReelle - $clotureCalculee, 2); + if (abs($ecart) > 0.01) { + $ecarts[$compte] = $ecart; + } + } + if ($ecarts) { + $result[$annee] = $ecarts; + } + } + + return new JsonResponse($result); + } + } diff --git a/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig b/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig index 9e57b25..907637d 100644 --- a/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig +++ b/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig @@ -123,6 +123,11 @@ :title="reconciliationByEntree.get(item.id).detail" @click="toggleEntreeFilter(item.id)" >{{ reconciliationByEntree.get(item.id).count }} sortie{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }} liée{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }} · reste {{ formatEur(reconciliationByEntree.get(item.id).resteAVerser) }} · sur-versé {{ formatEur(reconciliationByEntree.get(item.id).surVerse) }} + ⚠ écart clôture {{ item.date.slice(0, 4) - 1 }} ({{ ouvertureEcart(item).comptes }} compte{{ ouvertureEcart(item).comptes > 1 ? 's' : '' }})