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:
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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' : '' }}<template v-if="reconciliationByEntree.get(item.id).resteAVerser > 0"> · reste {{ formatEur(reconciliationByEntree.get(item.id).resteAVerser) }}</template><template v-if="reconciliationByEntree.get(item.id).surVerse > 0"> · sur-versé {{ formatEur(reconciliationByEntree.get(item.id).surVerse) }}</template></span>
|
||||
<span
|
||||
v-if="item.type === 'ouverture' && ouvertureEcart(item)"
|
||||
class="figli-recon-badge is-anomalie"
|
||||
:title="'Écart avec la clôture calculée de ' + (item.date.slice(0, 4) - 1) + ' : ' + ouvertureEcart(item).detail"
|
||||
>⚠ écart clôture {{ item.date.slice(0, 4) - 1 }} ({{ ouvertureEcart(item).comptes }} compte{{ ouvertureEcart(item).comptes > 1 ? 's' : '' }})</span>
|
||||
</td>
|
||||
<td class="amount" :class="montantClass(item.montant_ht)">{{ formatEur(item.montant_ht) }}</td>
|
||||
<td class="amount">{{ formatEur(item.montant_ttc) }}</td>
|
||||
|
||||
Reference in New Issue
Block a user