fix: étendre la recherche aux identifications
This commit is contained in:
parent
361bef8561
commit
a932db0ace
8 changed files with 66 additions and 12 deletions
|
|
@ -2,6 +2,15 @@
|
||||||
|
|
||||||
Toutes les modifications importantes du projet sont consignées ici. Le format s’inspire de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/).
|
Toutes les modifications importantes du projet sont consignées ici. Le format s’inspire de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/).
|
||||||
|
|
||||||
|
## [1.0.0-rc.27] — 2026-09-15
|
||||||
|
|
||||||
|
### Recherche globale des animaux
|
||||||
|
|
||||||
|
- ajout des numéros de puce et de tatouage actuels à la recherche, avec maintien du champ historique ;
|
||||||
|
- recherche insensible aux majuscules, minuscules et accents dans tous les groupes de résultats ;
|
||||||
|
- affichage du numéro d’identification dans le résultat animal correspondant ;
|
||||||
|
- ajout de tests couvrant notamment `Bréhat`, `Brehat`, `BREHAT`, `bReHaT` et un numéro de puce.
|
||||||
|
|
||||||
## [1.0.0-rc.26] — 2026-09-15
|
## [1.0.0-rc.26] — 2026-09-15
|
||||||
|
|
||||||
### Données animales et suivi vétérinaire
|
### Données animales et suivi vétérinaire
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ L’import depuis Animal Shelter Manager a été conçu comme un parcours pruden
|
||||||
|
|
||||||
Avant la version 1.0, le projet a fait l’objet d’un nettoyage structurel et d’un découpage de ses fichiers les plus volumineux. La recherche universelle, les salles configurables, les traductions française et anglaise et une suite de contrôles isolés ont complété cette phase de stabilisation.
|
Avant la version 1.0, le projet a fait l’objet d’un nettoyage structurel et d’un découpage de ses fichiers les plus volumineux. La recherche universelle, les salles configurables, les traductions française et anglaise et une suite de contrôles isolés ont complété cette phase de stabilisation.
|
||||||
|
|
||||||
|
## `1.0.0-rc.27` — Recherche tolérante
|
||||||
|
|
||||||
|
La recherche globale retrouve les animaux par leur numéro de puce ou de tatouage et rapproche les termes indépendamment de leur casse et de leurs accents.
|
||||||
|
|
||||||
## `1.0.0-rc.26` — Salles réellement configurables
|
## `1.0.0-rc.26` — Salles réellement configurables
|
||||||
|
|
||||||
Retrait de la dernière contrainte héritée qui limitait encore les fiches animales aux deux premières salles historiques. La migration conserve les animaux et toutes leurs relations tout en autorisant les salles définies dans la configuration.
|
Retrait de la dernière contrainte héritée qui limitait encore les fiches animales aux deux premières salles historiques. La migration conserve les animaux et toutes leurs relations tout en autorisant les salles définies dans la configuration.
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,7 @@ L’option `--apply` ne doit être utilisée qu’après lecture des rapports. L
|
||||||
|
|
||||||
## État du projet
|
## État du projet
|
||||||
|
|
||||||
La candidate actuelle est `1.0.0-rc.26`. La branche 1.0 est gelée fonctionnellement : seuls les défauts bloquants découverts pendant les validations terrain sont corrigés avant `1.0.0`.
|
La candidate actuelle est `1.0.0-rc.27`. La branche 1.0 est gelée fonctionnellement : seuls les défauts bloquants découverts pendant les validations terrain sont corrigés avant `1.0.0`.
|
||||||
|
|
||||||
Le contrôle de stabilisation est disponible dans [docs/AUDIT-1.0.md](docs/AUDIT-1.0.md).
|
Le contrôle de stabilisation est disponible dans [docs/AUDIT-1.0.md](docs/AUDIT-1.0.md).
|
||||||
|
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
1.0.0-rc.26
|
1.0.0-rc.27
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,17 @@ final class DB
|
||||||
$pdo->exec('PRAGMA busy_timeout = 3000;');
|
$pdo->exec('PRAGMA busy_timeout = 3000;');
|
||||||
@chmod($dbPath, 0600);
|
@chmod($dbPath, 0600);
|
||||||
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||||
|
$pdo->sqliteCreateFunction(
|
||||||
|
'search_fold',
|
||||||
|
static function (mixed $value): string {
|
||||||
|
$text = mb_strtolower(trim((string) $value), 'UTF-8');
|
||||||
|
$text = strtr($text, ['œ' => 'oe', 'æ' => 'ae', 'ø' => 'o', 'ß' => 'ss']);
|
||||||
|
$ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $text);
|
||||||
|
return $ascii === false ? $text : strtolower($ascii);
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
PDO::SQLITE_DETERMINISTIC,
|
||||||
|
);
|
||||||
|
|
||||||
self::$pdo = $pdo;
|
self::$pdo = $pdo;
|
||||||
return $pdo;
|
return $pdo;
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,21 @@ final class GlobalSearchService
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$db = DB::pdo();
|
$db = DB::pdo();
|
||||||
$like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $query) . '%';
|
$foldedQuery = self::fold($query);
|
||||||
|
$like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $foldedQuery) . '%';
|
||||||
$out = [];
|
$out = [];
|
||||||
if (PermissionService::can('animals')) {
|
if (PermissionService::can('animals')) {
|
||||||
$out['animals'] = self::rows(
|
$out['animals'] = self::rows(
|
||||||
$db,
|
$db,
|
||||||
"SELECT id,name,internal_code,status,species FROM animals WHERE deleted_at IS NULL AND (name LIKE :q ESCAPE '\\' OR internal_code LIKE :q ESCAPE '\\' OR COALESCE(chip,'') LIKE :q ESCAPE '\\') ORDER BY name COLLATE NOCASE LIMIT :lim",
|
"SELECT id,name,internal_code,status,species,COALESCE(NULLIF(chip_id,''),NULLIF(chip,'')) identification_number FROM animals WHERE deleted_at IS NULL AND (search_fold(name) LIKE :q ESCAPE '\\' OR search_fold(internal_code) LIKE :q ESCAPE '\\' OR search_fold(COALESCE(chip_id,'')) LIKE :q ESCAPE '\\' OR search_fold(COALESCE(chip,'')) LIKE :q ESCAPE '\\') ORDER BY name COLLATE NOCASE LIMIT :lim",
|
||||||
$like,
|
$like,
|
||||||
$limit,
|
$limit,
|
||||||
static fn($r) => [
|
static fn($r) => [
|
||||||
'title' => $r['name'],
|
'title' => $r['name'],
|
||||||
'detail' => implode(' · ', array_filter([$r['internal_code'], $r['species'], $r['status']])),
|
'detail' => implode(
|
||||||
|
' · ',
|
||||||
|
array_filter([$r['internal_code'], $r['identification_number'], $r['species'], $r['status']]),
|
||||||
|
),
|
||||||
'url' => '/animal?id=' . (int) $r['id'],
|
'url' => '/animal?id=' . (int) $r['id'],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
@ -29,7 +33,7 @@ final class GlobalSearchService
|
||||||
if (PermissionService::can('directory')) {
|
if (PermissionService::can('directory')) {
|
||||||
$out['directory'] = self::rows(
|
$out['directory'] = self::rows(
|
||||||
$db,
|
$db,
|
||||||
"SELECT id,name,kind,phone,email,city FROM directory_contacts WHERE deleted_at IS NULL AND (name LIKE :q ESCAPE '\\' OR COALESCE(phone,'') LIKE :q ESCAPE '\\' OR COALESCE(email,'') LIKE :q ESCAPE '\\' OR COALESCE(city,'') LIKE :q ESCAPE '\\') ORDER BY name COLLATE NOCASE LIMIT :lim",
|
"SELECT id,name,kind,phone,email,city FROM directory_contacts WHERE deleted_at IS NULL AND (search_fold(name) LIKE :q ESCAPE '\\' OR search_fold(COALESCE(phone,'')) LIKE :q ESCAPE '\\' OR search_fold(COALESCE(email,'')) LIKE :q ESCAPE '\\' OR search_fold(COALESCE(city,'')) LIKE :q ESCAPE '\\') ORDER BY name COLLATE NOCASE LIMIT :lim",
|
||||||
$like,
|
$like,
|
||||||
$limit,
|
$limit,
|
||||||
static fn($r) => [
|
static fn($r) => [
|
||||||
|
|
@ -42,7 +46,7 @@ final class GlobalSearchService
|
||||||
if (PermissionService::can('agenda')) {
|
if (PermissionService::can('agenda')) {
|
||||||
$out['agenda'] = self::rows(
|
$out['agenda'] = self::rows(
|
||||||
$db,
|
$db,
|
||||||
"SELECT id,title,starts_at,location,status FROM agenda_items WHERE title LIKE :q ESCAPE '\\' OR COALESCE(description,'') LIKE :q ESCAPE '\\' OR COALESCE(location,'') LIKE :q ESCAPE '\\' ORDER BY starts_at DESC LIMIT :lim",
|
"SELECT id,title,starts_at,location,status FROM agenda_items WHERE search_fold(title) LIKE :q ESCAPE '\\' OR search_fold(COALESCE(description,'')) LIKE :q ESCAPE '\\' OR search_fold(COALESCE(location,'')) LIKE :q ESCAPE '\\' ORDER BY starts_at DESC LIMIT :lim",
|
||||||
$like,
|
$like,
|
||||||
$limit,
|
$limit,
|
||||||
static fn($r) => [
|
static fn($r) => [
|
||||||
|
|
@ -58,7 +62,7 @@ final class GlobalSearchService
|
||||||
if (PermissionService::can('accounting')) {
|
if (PermissionService::can('accounting')) {
|
||||||
$out['accounting'] = self::rows(
|
$out['accounting'] = self::rows(
|
||||||
$db,
|
$db,
|
||||||
"SELECT ai.id,ai.reference,ai.invoice_date,ai.status,dc.name vendor FROM accounting_invoices ai LEFT JOIN directory_contacts dc ON dc.id=ai.vendor_contact_id WHERE ai.deleted_at IS NULL AND (ai.reference LIKE :q ESCAPE '\\' OR COALESCE(ai.notes,'') LIKE :q ESCAPE '\\' OR COALESCE(dc.name,'') LIKE :q ESCAPE '\\') ORDER BY ai.invoice_date DESC LIMIT :lim",
|
"SELECT ai.id,ai.reference,ai.invoice_date,ai.status,dc.name vendor FROM accounting_invoices ai LEFT JOIN directory_contacts dc ON dc.id=ai.vendor_contact_id WHERE ai.deleted_at IS NULL AND (search_fold(ai.reference) LIKE :q ESCAPE '\\' OR search_fold(COALESCE(ai.notes,'')) LIKE :q ESCAPE '\\' OR search_fold(COALESCE(dc.name,'')) LIKE :q ESCAPE '\\') ORDER BY ai.invoice_date DESC LIMIT :lim",
|
||||||
$like,
|
$like,
|
||||||
$limit,
|
$limit,
|
||||||
static fn($r) => [
|
static fn($r) => [
|
||||||
|
|
@ -71,6 +75,14 @@ final class GlobalSearchService
|
||||||
return array_filter($out);
|
return array_filter($out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function fold(string $value): string
|
||||||
|
{
|
||||||
|
$value = mb_strtolower(trim($value), 'UTF-8');
|
||||||
|
$value = strtr($value, ['œ' => 'oe', 'æ' => 'ae', 'ø' => 'o', 'ß' => 'ss']);
|
||||||
|
$ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
|
||||||
|
return $ascii === false ? $value : strtolower($ascii);
|
||||||
|
}
|
||||||
|
|
||||||
private static function rows(PDO $db, string $sql, string $like, int $limit, callable $map): array
|
private static function rows(PDO $db, string $sql, string $like, int $limit, callable $map): array
|
||||||
{
|
{
|
||||||
$s = $db->prepare($sql);
|
$s = $db->prepare($sql);
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,10 @@
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
<section class="card h-100">
|
<section class="card h-100">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<span class="badge bg-blue-lt mb-2">1.0.0-rc.26</span>
|
<span class="badge bg-blue-lt mb-2">1.0.0-rc.27</span>
|
||||||
<h3>Données animales fiabilisées</h3>
|
<h3>Recherche tolérante</h3>
|
||||||
<p class="text-muted">Les salles configurables, les états I-CAD et le suivi vaccinal restent
|
<p class="text-muted">Les fiches sont retrouvées par nom, code ou identification sans que les
|
||||||
cohérents sur les installations neuves comme sur les bases historiques.</p>
|
accents ni les différences de casse empêchent le résultat.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -481,6 +481,24 @@ try {
|
||||||
$agenda = GlobalSearchService::search('Mission bénévole');
|
$agenda = GlobalSearchService::search('Mission bénévole');
|
||||||
$assert(!empty($agenda['agenda']), 'Événement absent de la recherche');
|
$assert(!empty($agenda['agenda']), 'Événement absent de la recherche');
|
||||||
});
|
});
|
||||||
|
$test('La recherche ignore la casse et les accents et retrouve les identifiants', function () use ($db, $assert): void {
|
||||||
|
$code = 'TEST-SEARCH-' . bin2hex(random_bytes(3));
|
||||||
|
$chip = '250269610987654';
|
||||||
|
$db->prepare("INSERT INTO animals(name,internal_code,chip_id,identification_type) VALUES('Bréhat',:code,:chip,'microchip')")
|
||||||
|
->execute([':code' => $code, ':chip' => $chip]);
|
||||||
|
$id = (int) $db->lastInsertId();
|
||||||
|
foreach (['Bréhat', 'Brehat', 'BREHAT', 'bReHaT', $chip] as $query) {
|
||||||
|
$results = GlobalSearchService::search($query);
|
||||||
|
$ids = array_map(
|
||||||
|
static fn(array $row): int => (int) filter_var($row['url'], FILTER_SANITIZE_NUMBER_INT),
|
||||||
|
$results['animals'] ?? [],
|
||||||
|
);
|
||||||
|
$assert(in_array($id, $ids, true), 'Recherche infructueuse : ' . $query);
|
||||||
|
}
|
||||||
|
$result = GlobalSearchService::search($chip)['animals'][0] ?? [];
|
||||||
|
$assert(str_contains((string) ($result['detail'] ?? ''), $chip), 'Numéro absent du résultat');
|
||||||
|
$db->prepare('DELETE FROM animals WHERE id=:id')->execute([':id' => $id]);
|
||||||
|
});
|
||||||
$test('Une sauvegarde ZIP contient une base et un manifeste', function () use (&$createdBackup, $assert): void {
|
$test('Une sauvegarde ZIP contient une base et un manifeste', function () use (&$createdBackup, $assert): void {
|
||||||
if (!class_exists('ZipArchive')) {
|
if (!class_exists('ZipArchive')) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue