Flag ouverture/clôture discrepancies between consecutive years

Each year's ouverture (opening balance) should match the previous
year's calculated closing balance (that year's own ouverture + every
movement dated within it). The historical spreadsheets carry real
gaps here that were preserved as-is during migration -- this surfaces
them per compte, on the ouverture row(s) they affect, the same way
the per-ligne écart column already does, rather than correcting them.

New LedgerStatsController::reconciliationOuverture() endpoint (a
single grouped SQL aggregate over ouverture vs. non-ouverture lines
per year/compte, not per-node loading) backs a small badge shown only
on the affected ouverture row(s), scoped to whichever compte that
specific row's répartition touches.
This commit is contained in:
2026-09-04 21:21:57 +02:00
parent 831c0a0e62
commit 7eabcc6bd8
4 changed files with 116 additions and 0 deletions
@@ -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);
}
}