Phase 2 : filtrage server-side de /lignes (compte, client, type, signalement, écarts, recherche libre)

Remplace le "charge tout puis filtre côté client" (filteredRows()) par un
nouvel endpoint /lignes/api/lignes (LedgerRowsController), qui pousse tous
les filtres de la barre d'outils dans une seule requête Entity/Field Query
API (pas de SQL brut) -- y compris à travers la relation
field_repartition -> field_compte (paragraph -> taxonomie), confirmé
fonctionner empiriquement avant d'écrire le contrôleur. `rows` ne contient
donc plus que ce qui est à la fois dans la fenêtre de dates ET dans les
filtres actifs ; la fenêtre glissante elle-même (loadOlder/loadNewer/
ensureScrollable) est inchangée, seul ce qui la peuple change.

Ajouts :
- Nouveau filtre "Libellé / Détails" (recherche plein texte sur
  field_notes ou le titre), débouncé côté client (350ms).
- filterYear (l'"Année" dédiée) passe par le même endpoint via son
  paramètre "annee".
- mergeChangedRows() (le merge du polling) tient maintenant compte des
  filtres actifs : une ligne qui ne correspond plus après un changement
  est retirée de `rows`, une ligne qui correspond nouvellement est ajoutée
  -- polling lui-même reste global/non filtré, seul le merge est
  filter-aware.

JSON:API reste utilisé pour ce que l'endroit filtré ne couvre pas : le
groupe entrée/sorties liées (fetchLignesByNids) et le polling
(fetchChangedSince), tous deux indépendants d'une plage de dates+filtres.

Testé en local : chaque filtre individuellement et combiné (compte+q,
client+type), widening de fenêtre sous filtre restrictif, restauration
combinée depuis le hash au reload, modale d'édition + reloadWindow après
fermeture, polling sans erreur.
This commit is contained in:
2026-09-08 14:04:35 +02:00
parent 7bcf65d25b
commit f7e7ae3265
4 changed files with 388 additions and 91 deletions
@@ -0,0 +1,184 @@
<?php
namespace Drupal\figli_compta_ledger\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\node\NodeInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
/**
* GET /lignes/api/lignes -- server-side filtered replacement for the old
* "load a date-range window via JSON:API, then filter client-side"
* approach in home.js. Every toolbar filter (compte, client, type,
* signalement, écarts, recherche libre) is pushed into a single Drupal
* Entity/Field Query API query -- not raw SQL, so entity access checks
* apply natively -- rather than fetching everything in range and
* discarding what doesn't match on the client. Confirmed empirically
* (drush php:eval against dev) that a condition can traverse
* field_repartition (entity_reference_revisions to paragraphs) into the
* paragraph's own field_compte (entity_reference to taxonomy_term) as a
* dotted relationship path -- that was the one open technical question
* before writing this. Date range (or "annee" in its place) stays
* required -- the sliding window itself isn't going away, only what
* populates it.
*/
class LedgerRowsController extends ControllerBase {
/**
* Mirrors LedgerActionsController::LINKABLE_TYPES.
*/
const LINKABLE_TYPES = ['versement', 'achat', 'hebergement', 'sous_traitant'];
public function index(Request $request) {
$annee = $request->query->get('annee');
if ($annee) {
if (!preg_match('/^\d{4}$/', $annee)) {
return new JsonResponse(['error' => 'Paramètre "annee" invalide.'], 400);
}
$start = $annee . '-01-01';
$end = ((int) $annee + 1) . '-01-01';
}
else {
$start = $request->query->get('start');
$end = $request->query->get('end');
if (!$start || !$end || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $start) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $end)) {
return new JsonResponse(['error' => 'Paramètres "start"/"end" invalides.'], 400);
}
}
$storage = $this->entityTypeManager()->getStorage('node');
$query = $storage->getQuery()
->accessCheck(TRUE)
->condition('type', 'ligne_comptable')
->condition('field_date_ligne', $start, '>=')
->condition('field_date_ligne', $end, '<')
->sort('field_date_ligne')
->sort('nid');
$compte = array_filter(explode(',', (string) $request->query->get('compte', '')));
if ($compte) {
$tids = $this->termIdsByNames('compte', $compte);
// No matching term at all (typo, renamed compte) still runs the
// query with an impossible condition rather than short-circuiting
// to an empty response -- fails the same visible "0 rows" way as
// an ordinary empty date range, instead of a silent special case.
$query->condition('field_repartition.entity.field_compte.target_id', $tids ?: [0], 'IN');
}
$client = trim((string) $request->query->get('client', ''));
if ($client !== '') {
$tids = $this->termIdsByNames('client', [$client]);
$query->condition('field_client', $tids ?: [0], 'IN');
}
$type = array_filter(explode(',', (string) $request->query->get('type', '')));
if ($type) {
$query->condition('field_type_ligne', array_values($type), 'IN');
}
$flag = array_filter(explode(',', (string) $request->query->get('flag', '')));
if ($flag) {
$tids = $this->termIdsByNames('flag', $flag);
$query->condition('field_flag.target_id', $tids ?: [0], 'IN');
}
$q = trim((string) $request->query->get('q', ''));
if ($q !== '') {
// Mirrors buildRows()'s `libelle: attrs.field_notes || attrs.title`
// fallback in home.js -- a line with no notes shows its title, so
// the search has to match either, not just field_notes.
$group = $query->orConditionGroup()
->condition('field_notes', $q, 'CONTAINS')
->condition('title', $q, 'CONTAINS');
$query->condition($group);
}
if ($request->query->get('ecarts') === '1') {
$query->condition('field_ecart', 0, '<>');
}
if ($request->query->get('signale') === '1') {
$query->exists('field_flag');
}
$nids = $query->execute();
$rows = [];
foreach ($storage->loadMultiple($nids) as $node) {
$rows[] = $this->serializeRow($node);
}
return new JsonResponse(['rows' => $rows]);
}
/**
* Resolves taxonomy term names to ids within a given vocabulary --
* shared by the compte/client/flag filters above. Silently drops names
* that don't match anything (the caller falls back to an impossible
* [0] condition rather than treating "no match" as "no filter").
*/
private function termIdsByNames(string $vid, array $names): array {
if (!$names) {
return [];
}
$tids = $this->entityTypeManager()->getStorage('taxonomy_term')->getQuery()
->accessCheck(FALSE)
->condition('vid', $vid)
->condition('name', array_values($names), 'IN')
->execute();
return array_values($tids);
}
/**
* Same row shape as buildRows() in home.js builds client-side from
* JSON:API, so the frontend can treat rows from either source
* identically. `id` is the node's UUID (what JSON:API exposes as
* node.id and every row-matching-by-id in home.js keys on), not the
* integer nid.
*/
private function serializeRow(NodeInterface $node): array {
$parCompte = [];
$somme = 0.0;
foreach ($node->get('field_repartition')->referencedEntities() as $paragraph) {
if (!$paragraph->hasField('field_montant') || $paragraph->get('field_montant')->isEmpty()) {
continue;
}
$montant = (float) $paragraph->get('field_montant')->value;
$compte = $paragraph->get('field_compte')->entity ? $paragraph->get('field_compte')->entity->label() : '(compte inconnu)';
$parCompte[$compte] = ($parCompte[$compte] ?? 0) + $montant;
$somme += $montant;
}
$ecart = $node->hasField('field_ecart') && !$node->get('field_ecart')->isEmpty()
? (float) $node->get('field_ecart')->value : 0.0;
$entreeLieeNodes = $node->hasField('field_entree_liee') ? $node->get('field_entree_liee')->referencedEntities() : [];
$flagTerms = $node->hasField('field_flag') ? $node->get('field_flag')->referencedEntities() : [];
$type = $node->get('field_type_ligne')->value;
return [
'id' => $node->uuid(),
'nid' => (int) $node->id(),
'changed' => date(DATE_ATOM, $node->getChangedTime()),
'date' => $node->get('field_date_ligne')->value,
'type' => $type,
'client' => $node->get('field_client')->entity ? $node->get('field_client')->entity->label() : NULL,
'facture' => $node->get('field_numero_facture')->value ?: NULL,
'libelle' => $node->get('field_notes')->value ?: $node->getTitle(),
'montant_ht' => $node->get('field_montant_ht')->isEmpty() ? NULL : (float) $node->get('field_montant_ht')->value,
'cotisation' => $node->hasField('field_cotisation_urssaf') && !$node->get('field_cotisation_urssaf')->isEmpty() ? (float) $node->get('field_cotisation_urssaf')->value : NULL,
'tva' => $node->hasField('field_tva') && !$node->get('field_tva')->isEmpty() ? (float) $node->get('field_tva')->value : NULL,
'montant_ttc' => $node->hasField('field_montant_ttc') && !$node->get('field_montant_ttc')->isEmpty() ? (float) $node->get('field_montant_ttc')->value : NULL,
'parCompte' => (object) $parCompte,
'somme' => $somme,
'ecart' => $ecart,
'hasError' => abs($ecart) > 0.01,
'linkable' => in_array($type, self::LINKABLE_TYPES, TRUE),
'entreeLieeIds' => array_map(fn ($n) => $n->uuid(), $entreeLieeNodes),
'entreeLieeLabels' => array_map(fn ($n) => $n->getTitle() ?: $n->uuid(), $entreeLieeNodes),
'flags' => array_map(fn ($t) => $t->label(), $flagTerms),
'hasFlag' => count($flagTerms) > 0,
];
}
}