Skip to content

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) — voir spektrum-buchard-horizon modules travel-catalog et seaside.
  • La forme renvoyée selon ?infoDensity= — voir modules/api.md et domain/glossary.md.

Cette page se concentre sur les champs que better-search lit et indexe.

Forme minimale

json
{
  "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
}

Pour la PK et le lookup

JSONPathColonne généréeNotes
idid (PK)UUID-like string, hérité d'Horizon
namenameTitre, poids A dans la FTS

Pour la recherche plein-texte

Tous concaténés dans search_vector (colonne générée) avec poids :

JSONPathPoids tsvector
nameA
subtitleB
descriptionC

⚠️ descriptionWeb (HTML enrichi) n'est PAS dans le search_vector. La FTS ne voit que les texte brut description. Si une équipe édito ne remplit que descriptionWeb cô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) :

python
'\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 servicesIncluded et highLights. Asymétrie volontaire : la FTS pondère le titre, l'embedding capture le « tout-tout » du voyage.

Pour les filtres

JSONPathColonne généréeFiltre API
country.codedestination?destination=
travelRanges[*].slugtravel_ranges?category=
occurrences[*].startdeparture_dates?dates=
occurrences[*].bookingState ∈ {0, 2}is_valid?hide_invalid=
minPrice.pricePerPersonWithClubSpecialOffers < pricePerPersonWithSpecial…discount_club?discountclub=
isSeasideis_seaside?seaside=

Détails de chaque filtre (sémantique exacte, OR/AND, edge cases) : domain/filter-semantics.md.

Pour le tri

JSONPathExpression SQL?orderBy=
(calculé via vue)next_departuredeparture
commentsAverageRating(raw_data->>'commentsAverageRating')::NUMERICrating
duration(raw_data->>'duration')::NUMERICduration

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:SS sans timezone.
  • Prix : entiers CHF (pas de décimales).
  • commentsAverageRating : float ou null.
  • bookingState : entier. Valeurs vues en pratique : 0, 2 (= bookable), 3 (= non bookable, raisons diverses). Cf. la docstring de tests/test_search.py pour le mapping.

(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.md pour le workflow d'ajout.

Pièges fréquents

  • Le contrat Horizon peut évoluer. Si Horizon renomme country.codecountry.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. Surveiller db_size et n_results_in_search côté Prometheus.
  • bookingState à un autre niveau. Le record top-level a un bookingState (0 dans l'exemple ci-dessus), et chaque occurrences[*] a aussi son bookingState. is_valid regarde celui des occurrences, pas le top-level.
  • isSeaside n'existe pas dans le payload Horizon original. Il est ajouté côté better-search au moment d'ingérer la source /seaside. Si vous testez en mockant l'amont, n'oubliez pas de mocker aussi la source /seaside pour é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.

Contributors

No contributors

Changelog

No recent changes