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:
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user