Skip to content

Module — Lignes & Arrêts

Synchronisé avec le code. Mettre à jour à chaque changement structurel.

Rôle

Référentiel logistique des parcours de transport : ligne (parcours global), trajet (sous-segment), arrêt (lieu géographique), horaires de référence.

Emplacement

  • Pages : src/Web/Pages/Lines/, src/Web/Pages/Drives/, src/Web/Pages/Timetables/
  • Services : LineService, DriveService, TimetableService
  • Vue : src/Web/Frontend/Vue/src/drive/

Entités principales

  • Line — parcours global (ex: "Valais → Italie"). Collection de Drives.
  • Drive — sous-trajet d'une Line (ex: "Sion → Martigny via Saxon"). Collection de Stops.
  • Stop — lieu d'arrêt géographique. Champs Locality (ville) + Place (lieu précis : gare, parking, etc.). Flag IsTransfer (utilisé dans LoadingTableEntryStop comme Transfer) = arrêt où les passagers changent de car (passage du petit bus de chargement vers le grand car principal).
  • DriveStop — table de jointure M:N entre Drive et Stop (remplace l'ancien lien 1:N Stop.DriveId). Porte Order, Before, Transfer et surtout OccurrenceId :
    • OccurrenceId == nulllieu générique de la ligne : visible dans Drives + Timetable, horaire porté par TimetableStop.
    • OccurrenceId != nulllieu spécifique à une occurrence donnée : invisible côté ligne (Drives/Timetable), l'horaire est porté directement sur l'occurrence (jamais via TimetableStop).
  • Timetable — modèle d'horaires de référence par ligne (ex: "Ligne Sion-Martigny-Saxon → Sion 5h, Martigny 5h30"). Pas généré pour les nouveaux Drives IsOneShot = true (rattachés à un seul Travel, pas de mutualisation). Les enregistrements pré-migration peuvent encore en avoir une — c'est supporté à la lecture.
  • TimetableStop — un arrêt avec son horaire (Hour string), lié à un Stop. Trois variantes :
    • "ligne" (TimetableId non null, OccurrenceId == null) — Hour rattaché à une Timetable mutualisée. Drive(Timetable).Id résout le Drive cible. DriveId sur le ts reste null.
    • "OneShot" (TimetableId == null + DriveId == drive.Id) — Hour propre à un Drive OneShot, sans Timetable. La FK TimetableStop.DriveId matérialise le lien (autrefois implicite via Stop unique → Drive, perdu après fusion des Stops). Lecture : TravelService.ReverseMap* bloc DrivesOneShot accepte les deux variantes via un OR sur (TimetableId == null && DriveId == td.DriveId) ou Timetable.DriveId == td.DriveId.
    • "occurrence-bound" (TimetableId non null + OccurrenceId == ds.OccurrenceId) — Hour propre à un lieu ajouté manuellement à une occurrence précise via Travel Design. La FK TimetableStop.OccurrenceId matérialise le lien (autrefois implicite via Stop unique → Occurrence). Lecture : TravelService.GetByOccurrence filtre ts.OccurrenceId == ds.OccurrenceId avec fallback sur le premier ts si aucun match (rétrocompatibilité pré-migration).
  • TravelLine — jointure Travel ↔ Line. IsUnloading = aller (false) / retour (true).
  • TravelDrive — jointure Travel ↔ Drive (segments retenus pour ce voyage). Porte un Quota par défaut au niveau du voyage.
  • OneDayTravelDriveOccurrences — jointure M:N Drive ↔ Occurrence = « trajets actifs d'un départ » (courses d'un jour). Porte, pour ce trajet à cette date : Quota (contingent, surcharge le TravelDrive.Quota du voyage) et NavNo (VOYAGE Visual Planning de ce trajet — sert uniquement aux compteurs passagers VP, pas à la facturation, cf. business-rules.md > NavNo par trajet — compteurs passagers Visual Planning). Édité dans travel-design > étape Dates > OneDayTravelDriveOccurrence.vue (colonnes Trajets actifs / Capacité / N° NAV). ⚠ Ces lignes sont recréées de zéro à chaque sauvegarde du voyage (TravelService, objets neufs sans Id) : tout champ ajouté doit être porté par le DriveDto et remappé dans les deux sens, sinon il est perdu silencieusement au premier enregistrement.

Règles métier spécifiques

  • Hiérarchie : Line (global) → Drive (segment) → Stop (arrêt). Une Line peut contenir plusieurs Drive, chaque Drive plusieurs Stop.
  • Travel.LoadingLine (singulier) = DÉPRÉCIÉ. Plus utilisé. Utiliser la collection Travel.TravelLines filtrée par IsUnloading = false (aller) ou IsUnloading = true (retour).
  • Surcharge horaires : un Timetable est un référentiel ; les horaires effectifs sont surchargés au niveau d'un LoadingTable via LoadingTableTimetableStop.
  • Filtrage des TimetableStop à l'affichage (TimetableService.Get) : un TimetableStop n'est rendu visible que si le Stop cible possède un DriveStop générique (OccurrenceId == null) sur le même Drive que le Timetable courant. Sans cette double condition, un stop occurrence-specific pourrait apparaître à tort dans la page d'édition Timetable, alors que son horaire vit sur l'occurrence.
  • Stop.IsTransfer : marque les arrêts de transfert. Sur les LoadingTableEntryStop, le flag Transfer indique que c'est ce stop précis qui sert de transfert pour cette entry.
  • Recherche fuzzy de stops : LoadingTableService utilise LevenshteinDistance pour matcher des arrêts par nom approximatif quand l'ID est manquant (FindTimetableStop, FindStop).

Points d'attention / pièges

  • Stops partagés : ne JAMAIS réassigner stop.TimetableStops ni recréer les DriveStops d'occurrence sur un graphe tracké. Bug historique (juil. 2026, « horaires des lignes 2027 disparaissent ») : le bloc « stops » d'occurrence de TravelService.Map faisait stop.TimetableStops = <sous-liste> → le fixup EF mettait StopId = null sur tous les ts de ligne du stop partagé (heures disparues de toutes les lignes passant par le lieu), créait un ts occurrence-bound neuf à chaque save (accumulation, ~500 ts sur un stop), et recréait les DriveStop d'occurrence (les anciens détachés → OccurrenceId = null → le lieu particulier fuyait comme lieu générique du trajet, en doublon à chaque save). Depuis le fix : update-or-create du ts occurrence-bound sans toucher à la collection du stop (ajout via _db.TimetableStops.Add), réutilisation des DriveStops d'occurrence existants, obsolètes soft-deleted (jamais retirés de la collection). Test de régression : TravelServiceTest.Map_OccurrenceStops_ShouldNotOrphanSharedStopData_AndShouldReuseTsAndDriveStops.

  • Lieux du sélecteur passager : filtrer par occurrence AVANT le dédoublonnage par id. Passenger.vue (loadingStops / unloadingStops) empile les stops de tous les drives puis dédoublonne sur stop.id, premier arrivé premier gardé. Bug historique (août 2026, « Châtel-St-Denis n'est même pas proposé » sur Douceur provençale 05.10.26) : le drive Romont - Châtel-St-Denis portait pour ce lieu un DriveStop occurrence-bound (lieu particulier d'un départ d'un autre voyage, Quercy-Périgord 06.10.25, Order 1) et le DriveStop générique (Order 2). Le DTO les sort tous les deux, ordonnés par Order ; le premier gagnait le dédoublonnage, puis le filtre final occurrenceId === occurrence.id le jetait — et le générique, déjà écarté, ne revenait jamais. Le lieu disparaissait du menu déroulant pour toutes les autres occurrences du drive (13 couples (trajet, lieu) concernés en prod : Genève-Aéroport, Sion Gare CFF, Martigny Gare CFF, Lavaux, Bâle Badischer Bahnhof…). Depuis le fix, isStopSelectable(stop) garde chaque push, et le filtre final a disparu. Côté serveur, OnGetTravel termine par TravelStopScope.KeepOnlyStopsOfOccurrence(travel, occurrenceId) — appelé en dernier, donc après la reconstruction des lignes par l'override OccurrenceLines — qui purge les lieux des autres départs de General.LoadingStops/UnloadingStops, Drives, DrivesOneShot et LoadingLinesOnly/UnloadingLinesOnly. Les deux gardes sont volontairement redondantes. ⚠ Ne pas appliquer ce filtre à TravelController (/api/travels/{slug}) : cet endpoint n'est pas scopé à une occurrence et le site a besoin de tous les lieux. Tests : TravelStopScopeTest.

  • Création d'un trajet : passer par DriveService.Map, jamais par AutoMapper seul. Le profil CreateMap<Drive, DriveModel>().ForMember(d => d.Stops, …).ReverseMap() n'est pas réversible sur les arrêts (l'entité expose DriveStops, le modèle Stops) : _mapper.Map<Drive>(Input) rend un Drive avec DriveStops à null, et CreateAsync null-guard → trajet enregistré sans aucun arrêt, sans erreur. Bug historique (août 2026, « en créant une nouvelle ligne les arrêts ne s'enregistrent plus », ligne 2027 Kitzbühel) : régression silencieuse depuis la migration M:N DriveStops, la page Drives/Create n'avait pas été adaptée alors que Drives/Update passait déjà par Map. Test de régression : Pages.Drives.CreateTest.OnPost_ShouldCreateDriveWithItsStops.

  • TimetableService.Get exclut les ts occurrence-bound (ts.OccurrenceId == null dans le filtre d'include) : l'heure propre à une occurrence ne doit pas s'afficher comme heure de référence de la ligne dans la modale Timetable. Les ts occurrence-bound pré-migration sans OccurrenceId matérialisé peuvent encore fuir (rétrocompat assumée).

  • Modale Timetable Update : tri par ordre du trajet, pas par heure (Update.OnGet). TimetableService.Get trie par Hour, et les arrêts du drive absents de la timetable sont auto-ajoutés en fin de liste sans heure — or la validation AreTimetableStopInOrder exige des heures croissantes dans l'ordre affiché : un arrêt auto-ajouté en bas rendait toute saisie de son heure invalide (« Veuillez saisir les heures dans l'ordre ») → réparation manuelle impossible. Depuis le fix, Update.OnGet retrie par DriveStop.Order (génériques, Min(Order) si doublons) et la validation redevient « les heures croissent le long du parcours ». Create.OnGetStop suivait déjà l'ordre du trajet.

  • TimetableStop.Enabled ≠ « pas de données » : une case décochée (Enabled = false) porte quand même une vraie heure de référence. Les scripts de dédoublonnage/merge (MiscController.MergeStopDuplicates, MergeDuplicateTimetableStops) ne doivent jamais filtrer ni déprioriser un ts sur Enabled au point de perdre son Hour — seul Hour vide signale un résidu inerte. Bug historique : un ts décoché-avec-heure n'était pas repointé lors d'une fusion de Stops, son heure devenait orpheline sur le Stop soft-deleted et Update.OnGet la remplaçait par un ts auto-ajouté coché-sans-heure.

  • Heures côté API courses d'un jour : StopDto(driveStop, oneDayTravel: true) lit l'heure OneShot via ts.TimetableId == null && ts.DriveId == driveStop.DriveId (fallback Timetable pour le pré-migration). Le match sur ts.DriveId est obligatoire : un même Stop est partagé entre plusieurs drives OneShot, chacun avec son heure. Côté requête, la chaîne d'include doit descendre jusqu'à …OneDayTravelDriveOccurrences.Drive.DriveStops.Stop.TimetableStops(.Timetable) (sinon TimetableStops est null et l'heure disparaît) — cf. OccurrenceController, TravelController.

  • Heures côté API TravelDto.Drives / DrivesOneShot (/api/travels/{slug}) : TravelDriveStopDto (alimente ApiTravelDto.Drives/DrivesOneShot via TravelDrives) doit désambiguïser le TimetableStop par driveStop.DriveId (variante ligne Timetable.DriveId == DriveId OU OneShot TimetableId == null && DriveId == DriveId), exactement comme StopDto. Bug historique : un simple TimetableStops.FirstOrDefault() renvoyait l'heure du premier ts chargé sur le Stop partagé — donc l'heure d'une autre ligne passant par le même lieu (heures aberrantes type 08:00/20:35). Test de régression : TravelServiceTest.TravelDto_DriveStopHour_ShouldMatchOwnDrive_WhenStopSharedAcrossDrives.

  • Heure d'embarquement sur le PDF de réservation (BookingPassenger.LoadingTimetableStop) : sur les courses d'un jour sans plan de chargement, BookingService.Map pré-remplit LoadingTimetableStopId à la création/màj du booking. Il doit scoper le TimetableStop par le drive de l'occurrence qui dessert l'arrêt du passager (driveIds via OneDayTravelDriveOccurrences, puis ts.DriveId ∈ driveIds pour les OneShot orphelins ou ts.Timetable.DriveId ∈ driveIds pour les lignes), même désambiguïsation que StopDto/TravelDriveStopDto ci-dessus. Bug historique (corrigé juil. 2026) : stop.TimetableStops.FirstOrDefault() prenait un ts au hasard parmi tous ceux partageant l'arrêt physique → heure d'une autre ligne (ex. dossier 17724, arrêt Chailly affiché à 20:35 au lieu de 07:30). Cette heure s'affiche dans la section « Rendez-vous » de TransportAndAccommodation.liquid, gardée par BookingStatus > 4 (facturé) et non par l'existence d'un plan de chargement. ⚠ Le champ étant figé à la sauvegarde, les réservations existantes gardent l'ancien LoadingTimetableStopId tant qu'elles ne sont pas resauvegardées (backfill éventuel pour les dossiers déjà émis).

  • StopDto.DriveId n'est plus rempli depuis l'entité Stop : la migration M:N DriveStops a supprimé Stop.DriveId, et le mapping AutoMapper Stop → StopDto l'ignore explicitement (Profiles.cs). Il n'est renseigné que quand le DTO est construit depuis un DriveStop (ds.DriveId, ex. TravelService.ReverseMap). Ne jamais filtrer/matcher sur loadingStop.driveId/unloadingStop.driveId d'un passager — toujours passer par l'appartenance du Stop aux DriveStops du drive. Bug historique : le filtre par trajet de PassengerSeats/Assign comparait ce champ (toujours null → liste vide), régression silencieuse côté JS que le compilateur n'a pas attrapée.

  • Trajets effectifs d'une occurrence : TravelListService.GetOccurrenceEffectiveDrives(occurrenceId, returns) = Occurrence.GetEffectiveDrives (overrides OccurrenceLines, sinon TravelLines, fallback aller si pas de lignes retour) + occurrence.Drives. Utiliser cette méthode (légère, sans appels NAV) plutôt que le catalogue TravelDto quand on a besoin de la liste des trajets réellement applicables à un départ.

  • GET /api/lines/stops — dédoublonnage en deux temps, les deux sont obligatoires (alimente la page « Lieux de départ » de buchard.ch). Le point de départ est lines.Drives.DriveStops filtré sur OccurrenceId == null, soit une ligne par (arrêt × drive qui le dessert) : 2157 entrées pour 91 arrêts réels en prod (août 2026), certains lieux répétés jusqu'à 139 fois. Le premier dédoublonnage (tempStops, sur lat/long arrondis à 3 décimales puis sur Locality+Place) sert uniquement à choisir quels arrêts garder — il ne réduit pas la liste renvoyée. Il faut un DistinctBy(ds => ds.StopId) après le filtre pour ne renvoyer qu'une entrée par lieu. Bug historique (corrigé 10.08.2026) : ce second distinct manquait, le site affichait chaque ville des dizaines de fois. Tri : alphabétique Locality puis Place (et non plus DriveStop.Order, qui n'a plus de sens une fois la liste dédoublonnée toutes lignes confondues). Cache : policy travels (tag travels, 1 h, NoCache en Development/Integration) — mutualisée avec les endpoints Travels/Occurrences, donc l'éviction existante s'applique. Tests de régression : LineControllerTest.Stops_* (dédoublonnage par drive, dédoublonnage par coordonnées arrondies, tri).

  • Requête de /api/lines/stops : ILineService.GetGenericLineDriveStops(), surtout pas GetAll(). LineService.GetAll charge Lines → Drives → DriveStops → Stop → TimetableStops → LoadingTableTimetableStops en tracké : indispensable pour son autre appelant, Bookings/CreateUpdate.OnGetTravel (branche « occurrence avec OccurrenceLines »), qui mappe Line → Dtos.Travel.LineDto dont les DriveDto.Stops embarquent TimetableStops + LoadingTableTimetableStops (Profiles.cs, CreateMap<Drive, DriveDto>) — retirer ces Include viderait les horaires du sélecteur d'arrêts de l'éditeur de réservation. L'API, elle, n'a besoin que du lieu : GetGenericLineDriveStops attaque directement _db.DriveStops en AsNoTracking, filtré OccurrenceId == null && Drive.LineId != null (le LineId != null est obligatoire : les drives OneShot des courses d'un jour sont rattachés via TravelDrives, pas à une ligne, et ne doivent pas apparaître dans les lieux de départ), avec le seul Include(ds => ds.Stop).

  • Naming Place : Stop.Place est libre — souvent un nom de lieu (gare, parking, parking église…). Ne pas y mettre l'adresse complète.

  • Stops "soft-deleted" : si un stop est désactivé, vérifier qu'il n'est pas référencé par des bookings actifs (les BookingPassenger.LoadingStop / UnloadingStop peuvent pointer dessus).

  • OccurrenceLine override les TravelLines pour une occurrence donnée (cas particulier où un départ change de ligne).

  • OneDay travels ont leurs lignes via TravelDrives directement (pattern différent — voir LoadingTableService.GetLines).

Outillage de nettoyage des doublons de Stops

La migration vers le catalogue centralisé laisse des variantes orthographiques d'un même lieu (« 65-67 » vs « 65/67 », accents, espaces) que seul le client peut fusionner. Outils fournis :

  • Liste des lieux (Pages/Stops/Index) : colonne « Trajets » = StopService.GetDriveUsage (drives génériques OccurrenceId == null, join sur _db.Drives filtré soft-delete, distinct par DriveId). Au survol du badge, menu CSS listant Trajet (Ligne) avec liens deeplink /Drives?id= et /Lines?id= (nouvel onglet). Tri par nom par défaut.
  • Pickers de lieux (éditeur Razor Drives/Form via Drives.js, éditeur Vue AddOrUpdateOneShotDrive.vue, Travel Design) : libellé enrichi du compteur d'utilisation via StopService.GetDriveUsageCounts (Dictionary<Guid,int>), trié par nom.
  • Remplacement d'un lieu : bouton « ⇄ » sur chaque ligne de stop dans les deux éditeurs → la sélection suivante dans le picker remplace id/place/locality/state/lat/long en conservant heure, transfert et position. Permet de basculer une variante vers le lieu de référence sans perdre l'ordre ni les horaires.

Conventions locales

  • Le Vue drive/ permet d'éditer un Drive avec ses Stops ordonnés (drag-and-drop).

Dépendances

  • travel-catalog (Travel.TravelLines, TravelDrives).
  • loading-tables (consomme Lines/Drives/Stops/Timetables).
  • booking (Stop référencé par BookingPassenger.LoadingStop / UnloadingStop).

Contributors

No contributors

Changelog

No recent changes