Domain — Forme d'un raw_data voyage
Le service stocke et renvoie les records voyage tels que reçus de Horizon. Cette page documente la structure attendue, à ne pas confondre avec :
- La définition canonique côté Horizon (
Domain/Entities/Travel.cs) — voirspektrum-buchard-horizonmodulestravel-catalogetseaside. - La forme renvoyée selon
?infoDensity=— voirmodules/api.mdetdomain/glossary.md.
Cette page se concentre sur les champs que better-search lit et indexe.
Forme minimale
{
"id": "b56c9cbf-ee9b-4f6c-e6dc-08dc7b26cd9b",
"name": "Coupe du monde de ski à Kitzbühel",
"slug": "coupe-du-monde-de-ski-a-kitzbuhel",
"subtitle": "Pleins feux sur la plus prestigieuse étape …",
"description": "…",
"descriptionWeb": "<p>La descente …</p>",
"servicesIncluded": "<p>Voyage en car 4* …</p>",
"highLights": "- Un accès direct …",
"duration": 4,
"depositPercentage": 35,
"commentsAverageRating": null,
"country": { "id": "…", "name": "Autriche", "code": "AT" },
"bookingState": 0,
"travelRanges": [
{ "id": "…", "name": "Autocar 4*", "slug": "autocar-4" },
{ "id": "…", "name": "Ski", "slug": "ski" }
],
"occurrences": [
{
"id": "…",
"start": "2026-01-23T00:00:00",
"end": "2026-01-26T00:00:00",
"bookingState": 0,
"occupancyRate": 63,
"minPrice": { "pricePerPerson": 795, "…": "…" },
"…": "…"
}
],
"minPrice": {
"price": 1590,
"pricePerPerson": 795,
"priceWithSpecialOffers": 1590,
"pricePerPersonWithSpecialOffers": 795,
"priceWithClubSpecialOffers": 1590,
"pricePerPersonWithClubSpecialOffers": 795
},
"isSeaside": true // OPTIONNEL — injecté par le reindex pour les records venant de /seaside
}Champs lus par better-search
Pour la PK et le lookup
| JSONPath | Colonne générée | Notes |
|---|---|---|
id | id (PK) | UUID-like string, hérité d'Horizon |
name | name | Titre, poids A dans la FTS |
Pour la recherche plein-texte
Tous concaténés dans search_vector (colonne générée) avec poids :
| JSONPath | Poids tsvector |
|---|---|
name | A |
subtitle | B |
description | C |
⚠️
descriptionWeb(HTML enrichi) n'est PAS dans lesearch_vector. La FTS ne voit que les texte brutdescription. Si une équipe édito ne remplit quedescriptionWebcôté Horizon, la recherche FTS ne trouvera pas ce contenu.
Pour les embeddings
src/_utils.get_embedding_text(travel) concatène (avec nettoyage HTML via BeautifulSoup) :
'\n'.join([
prettify_html(travel.get('name', '')),
prettify_html(travel.get('subtitle', '')),
prettify_html(travel.get('description', '')),
prettify_html(travel.get('servicesIncluded', '')),
prettify_html(travel.get('highLights', ''))
])Donc l'embedding voit plus que la FTS — il inclut aussi
servicesIncludedethighLights. Asymétrie volontaire : la FTS pondère le titre, l'embedding capture le « tout-tout » du voyage.
Pour les filtres
| JSONPath | Colonne générée | Filtre API |
|---|---|---|
country.code | destination | ?destination= |
travelRanges[*].slug | travel_ranges | ?category= |
occurrences[*].start | departure_dates | ?dates= |
occurrences[*].bookingState ∈ {0, 2} | is_valid | ?hide_invalid= |
minPrice.pricePerPersonWithClubSpecialOffers < pricePerPersonWithSpecial… | discount_club | ?discountclub= |
isSeaside | is_seaside | ?seaside= |
Détails de chaque filtre (sémantique exacte, OR/AND, edge cases) : domain/filter-semantics.md.
Pour le tri
| JSONPath | Expression SQL | ?orderBy= |
|---|---|---|
| (calculé via vue) | next_departure | departure |
commentsAverageRating | (raw_data->>'commentsAverageRating')::NUMERIC | rating |
duration | (raw_data->>'duration')::NUMERIC | duration |
Tri n'est appliqué qu'en branche filtre-seul (pas de
?search=). En branche hybride, le tri est forcé sur_relevance_score DESC.
Conventions de typage
- IDs : tous string. Format UUID Horizon (avec ou sans tirets, sensible à la casse — passe-passe directement).
country.code: ISO-2 alpha (CH,FR,IT,AT,ES, …).- Dates : ISO 8601, format
YYYY-MM-DDTHH:MM:SSsans timezone. - Prix : entiers CHF (pas de décimales).
commentsAverageRating: float ounull.bookingState: entier. Valeurs vues en pratique :0,2(= bookable),3(= non bookable, raisons diverses). Cf. la docstring detests/test_search.pypour le mapping.
Champs ignorés par better-search
(non utilisés, mais préservés dans raw_data et donc renvoyés en infoDensity=full)
metaTitle,metaDescription— SEO, pour le site web.capacityTotal,proximityToTheSea,proximityToTheCityCenter— méta-données affichées en front.travelPictures[],activities[]— pour l'UI de fiche voyage.accommodation— info hôtel principal.descriptionWeb(HTML) — utilisé par le site, pas indexé.
Si un nouveau besoin de filtre/tri apparaît, voir
modules/migrations.mdpour le workflow d'ajout.
Pièges fréquents
- Le contrat Horizon peut évoluer. Si Horizon renomme
country.code→country.iso2, toutes les colonnes générées qui pointent dessus deviennent NULL silencieusement. La PK ne casse pas (l'UPSERT fonctionne) mais les filtres ne renvoient plus rien. Surveillerdb_sizeetn_results_in_searchcôté Prometheus. bookingStateà un autre niveau. Le record top-level a unbookingState(0dans l'exemple ci-dessus), et chaqueoccurrences[*]a aussi sonbookingState.is_validregarde celui desoccurrences, pas le top-level.isSeasiden'existe pas dans le payload Horizon original. Il est ajouté côtébetter-searchau moment d'ingérer la source/seaside. Si vous testez en mockant l'amont, n'oubliez pas de mocker aussi la source/seasidepour éviter de planter le reindex (cf.tests/conftest.py).- Les dates sans timezone sont traitées comme heure locale du conteneur. Si vous redéployez dans un autre fuseau, les filtres par date pourraient bouger d'une heure aux extrémités. Acceptable car la granularité du filtre est journalière.

