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 deDrives.Drive— sous-trajet d'une Line (ex: "Sion → Martigny via Saxon"). Collection deStops.Stop— lieu d'arrêt géographique. ChampsLocality(ville) +Place(lieu précis : gare, parking, etc.). FlagIsTransfer(utilisé dans LoadingTableEntryStop commeTransfer) = 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 entreDriveetStop(remplace l'ancien lien 1:NStop.DriveId). PorteOrder,Before,Transferet surtoutOccurrenceId:OccurrenceId == null→ lieu générique de la ligne : visible dans Drives + Timetable, horaire porté parTimetableStop.OccurrenceId != null→ lieu spécifique à une occurrence donnée : invisible côté ligne (Drives/Timetable), l'horaire est porté directement sur l'occurrence (jamais viaTimetableStop).
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 DrivesIsOneShot = 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 (Hourstring), lié à unStop. Trois variantes :- "ligne" (
TimetableIdnon null,OccurrenceId == null) —Hourrattaché à uneTimetablemutualisée.Drive(Timetable).Idrésout le Drive cible.DriveIdsur le ts reste null. - "OneShot" (
TimetableId == null+DriveId == drive.Id) —Hourpropre à un Drive OneShot, sans Timetable. La FKTimetableStop.DriveIdmatérialise le lien (autrefois implicite via Stop unique → Drive, perdu après fusion des Stops). Lecture :TravelService.ReverseMap*blocDrivesOneShotaccepte les deux variantes via un OR sur(TimetableId == null && DriveId == td.DriveId)ouTimetable.DriveId == td.DriveId. - "occurrence-bound" (
TimetableIdnon null +OccurrenceId == ds.OccurrenceId) —Hourpropre à un lieu ajouté manuellement à une occurrence précise via Travel Design. La FKTimetableStop.OccurrenceIdmatérialise le lien (autrefois implicite via Stop unique → Occurrence). Lecture :TravelService.GetByOccurrencefiltrets.OccurrenceId == ds.OccurrenceIdavec fallback sur le premier ts si aucun match (rétrocompatibilité pré-migration).
- "ligne" (
TravelLine— jointure Travel ↔ Line.IsUnloading= aller (false) / retour (true).TravelDrive— jointure Travel ↔ Drive (segments retenus pour ce voyage). Porte unQuotapar 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 leTravelDrive.Quotadu voyage) etNavNo(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 leDriveDtoet 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). UneLinepeut contenir plusieursDrive, chaqueDriveplusieursStop. Travel.LoadingLine(singulier) = DÉPRÉCIÉ. Plus utilisé. Utiliser la collectionTravel.TravelLinesfiltrée parIsUnloading = false(aller) ouIsUnloading = true(retour).- Surcharge horaires : un
Timetableest un référentiel ; les horaires effectifs sont surchargés au niveau d'unLoadingTableviaLoadingTableTimetableStop. - Filtrage des
TimetableStopà l'affichage (TimetableService.Get) : unTimetableStopn'est rendu visible que si leStopcible possède unDriveStopgénérique (OccurrenceId == null) sur le mêmeDriveque leTimetablecourant. 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 flagTransferindique que c'est ce stop précis qui sert de transfert pour cette entry.- Recherche fuzzy de stops :
LoadingTableServiceutiliseLevenshteinDistancepour 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.TimetableStopsni recréer lesDriveStopsd'occurrence sur un graphe tracké. Bug historique (juil. 2026, « horaires des lignes 2027 disparaissent ») : le bloc « stops » d'occurrence deTravelService.Mapfaisaitstop.TimetableStops = <sous-liste>→ le fixup EF mettaitStopId = nullsur 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 lesDriveStopd'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 surstop.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 unDriveStopoccurrence-bound (lieu particulier d'un départ d'un autre voyage, Quercy-Périgord 06.10.25,Order1) et leDriveStopgénérique (Order2). Le DTO les sort tous les deux, ordonnés parOrder; le premier gagnait le dédoublonnage, puis le filtre finaloccurrenceId === occurrence.idle 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 chaquepush, et le filtre final a disparu. Côté serveur,OnGetTraveltermine parTravelStopScope.KeepOnlyStopsOfOccurrence(travel, occurrenceId)— appelé en dernier, donc après la reconstruction des lignes par l'overrideOccurrenceLines— qui purge les lieux des autres départs deGeneral.LoadingStops/UnloadingStops,Drives,DrivesOneShotetLoadingLinesOnly/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 profilCreateMap<Drive, DriveModel>().ForMember(d => d.Stops, …).ReverseMap()n'est pas réversible sur les arrêts (l'entité exposeDriveStops, le modèleStops) :_mapper.Map<Drive>(Input)rend unDriveavecDriveStopsà null, etCreateAsyncnull-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:NDriveStops, la pageDrives/Createn'avait pas été adaptée alors queDrives/Updatepassait déjà parMap. Test de régression :Pages.Drives.CreateTest.OnPost_ShouldCreateDriveWithItsStops.TimetableService.Getexclut les ts occurrence-bound (ts.OccurrenceId == nulldans 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 sansOccurrenceIdmatérialisé peuvent encore fuir (rétrocompat assumée).Modale Timetable Update : tri par ordre du trajet, pas par heure (
Update.OnGet).TimetableService.Gettrie parHour, et les arrêts du drive absents de la timetable sont auto-ajoutés en fin de liste sans heure — or la validationAreTimetableStopInOrderexige 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.OnGetretrie parDriveStop.Order(génériques,Min(Order)si doublons) et la validation redevient « les heures croissent le long du parcours ».Create.OnGetStopsuivait 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 surEnabledau point de perdre sonHour— seulHourvide 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 etUpdate.OnGetla 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 viats.TimetableId == null && ts.DriveId == driveStop.DriveId(fallback Timetable pour le pré-migration). Le match surts.DriveIdest 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)(sinonTimetableStopsest null et l'heure disparaît) — cf.OccurrenceController,TravelController.Heures côté API
TravelDto.Drives/DrivesOneShot(/api/travels/{slug}) :TravelDriveStopDto(alimenteApiTravelDto.Drives/DrivesOneShotviaTravelDrives) doit désambiguïser leTimetableStoppardriveStop.DriveId(variante ligneTimetable.DriveId == DriveIdOU OneShotTimetableId == null && DriveId == DriveId), exactement commeStopDto. Bug historique : un simpleTimetableStops.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.Mappré-remplitLoadingTimetableStopIdà la création/màj du booking. Il doit scoper leTimetableStoppar le drive de l'occurrence qui dessert l'arrêt du passager (driveIds viaOneDayTravelDriveOccurrences, puists.DriveId ∈ driveIdspour les OneShot orphelins outs.Timetable.DriveId ∈ driveIdspour les lignes), même désambiguïsation queStopDto/TravelDriveStopDtoci-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 » deTransportAndAccommodation.liquid, gardée parBookingStatus > 4(facturé) et non par l'existence d'un plan de chargement. ⚠ Le champ étant figé à la sauvegarde, les réservations existantes gardent l'ancienLoadingTimetableStopIdtant qu'elles ne sont pas resauvegardées (backfill éventuel pour les dossiers déjà émis).StopDto.DriveIdn'est plus rempli depuis l'entitéStop: la migration M:NDriveStopsa suppriméStop.DriveId, et le mapping AutoMapperStop → StopDtol'ignore explicitement (Profiles.cs). Il n'est renseigné que quand le DTO est construit depuis unDriveStop(ds.DriveId, ex.TravelService.ReverseMap). Ne jamais filtrer/matcher surloadingStop.driveId/unloadingStop.driveIdd'un passager — toujours passer par l'appartenance du Stop auxDriveStopsdu 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(overridesOccurrenceLines, sinonTravelLines, fallback aller si pas de lignes retour) +occurrence.Drives. Utiliser cette méthode (légère, sans appels NAV) plutôt que le catalogueTravelDtoquand 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 estlines.Drives.DriveStopsfiltré surOccurrenceId == 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 surLocality+Place) sert uniquement à choisir quels arrêts garder — il ne réduit pas la liste renvoyée. Il faut unDistinctBy(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étiqueLocalitypuisPlace(et non plusDriveStop.Order, qui n'a plus de sens une fois la liste dédoublonnée toutes lignes confondues). Cache : policytravels(tagtravels, 1 h,NoCacheen 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 pasGetAll().LineService.GetAllchargeLines → Drives → DriveStops → Stop → TimetableStops → LoadingTableTimetableStopsen tracké : indispensable pour son autre appelant,Bookings/CreateUpdate.OnGetTravel(branche « occurrence avecOccurrenceLines»), qui mappeLine → Dtos.Travel.LineDtodont lesDriveDto.StopsembarquentTimetableStops+LoadingTableTimetableStops(Profiles.cs,CreateMap<Drive, DriveDto>) — retirer cesIncludeviderait les horaires du sélecteur d'arrêts de l'éditeur de réservation. L'API, elle, n'a besoin que du lieu :GetGenericLineDriveStopsattaque directement_db.DriveStopsenAsNoTracking, filtréOccurrenceId == null && Drive.LineId != null(leLineId != nullest obligatoire : les drives OneShot des courses d'un jour sont rattachés viaTravelDrives, pas à une ligne, et ne doivent pas apparaître dans les lieux de départ), avec le seulInclude(ds => ds.Stop).Naming
Place:Stop.Placeest 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/UnloadingStoppeuvent pointer dessus).OccurrenceLineoverride lesTravelLinespour une occurrence donnée (cas particulier où un départ change de ligne).OneDay travels ont leurs lignes via
TravelDrivesdirectement (pattern différent — voirLoadingTableService.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ériquesOccurrenceId == null, join sur_db.Drivesfiltré soft-delete, distinct par DriveId). Au survol du badge, menu CSS listantTrajet (Ligne)avec liens deeplink/Drives?id=et/Lines?id=(nouvel onglet). Tri par nom par défaut. - Pickers de lieux (éditeur Razor
Drives/FormviaDrives.js, éditeur VueAddOrUpdateOneShotDrive.vue, Travel Design) : libellé enrichi du compteur d'utilisation viaStopService.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/longen 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 unDriveavec sesStopsordonné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).

