Module — Ressources humaines & Visual Planning
Synchronisé avec le code. Mettre à jour à chaque changement structurel.
Rôle
Référentiel des chauffeurs / hôtesses-stewards / musiciens / véhicules physiques utilisés par Buchard, et synchronisation avec Visual Planning (VP) — système externe propriétaire (Stilog) où les dispatchers organisent qui conduit quoi quand.
Emplacement
- Pages :
src/Web/Pages/Resources/ - Service :
src/Application/Services/Entities/ResourceService.cs - Sync VP :
src/Application/Services/VisualPlanningService.cs,src/Application/VisualPlanning/ApiClient.cs,Entity.cs
Entités principales
Resource— entité Horizon liant une personne ou un véhicule à sonVisualPlanningId.Type∈ResourceType:Driver(chauffeur),Host(hôtesse/steward),Musician,Vehicle(véhicule physique).Name,VisualPlanningId,Vehicle(lien vers entitéVehicledétaillée si Type=Vehicle), métadonnées (description, image musicien…).
OccurrenceResource: assignment d'uneResourceà uneOccurrence.OccurrenceResourceSeat: siège dans le car alloué à la ressource.OccurrenceResourceAccommodation: chambre allouée à la ressource sur place.Vehicle(catalogue produit) — distinct deResourceavecType=Vehicle. Voirproduct-catalog.md.
Synchronisation Visual Planning
Lecture (massive)
Le service VisualPlanningService interroge l'API HTTP de VP pour récupérer les assignments :
GetTravelDriversAndHostsByOccurrence(navNo, orderByDate)— chauffeurs/hôtesses du voyage.GetTransferDriversAndHostsByOccurrence— transferts (retour balnéaire).GetReturnVehiclesByOccurrence,GetReturnTravelDriversAndHostsByOccurrence— retour.GetLoadingDriversByOccurrence,GetUnloadingDriversByOccurrence— chauffeurs des petits cars de chargement (transfert vers le grand car).GetVehiclesByOccurrence,GetVehicle(id)— véhicules.SyncHostsAndDrivers()— sync daily du référentiel des collaborateurs actifs (mappingVisualPlanningId↔Resource). Filtré parCOLLABORATEUR-Fonction: seuls les collaborateurs dont la Fonction VP figure dans le dictionnairecodescodé en dur (VisualPlanningService.cs, ~6 FonctionsDriver+ 2Host) sont synchronisés. Un collaborateur dont la Fonction n'y est pas (ex: un nouveau type de sous-traitant) n'aura jamais deResource→ sonVisualPlanningIdne matchera nulle part. Ajouter son code Fonction au dictionnaire pour le synchroniser.
Timeout et dégradation (VP injoignable)
ApiClient applique un timeout de 10 s par appel HTTP (VisualPlanning:TimeoutSeconds dans les appsettings, défaut 10 si absent ou invalide). Avant août 2026 aucun timeout n'était fixé : on héritait du défaut HttpClient (100 s) et, comme les TaskCanceledException de timeout n'étaient pas rattrapées (seules HttpRequestException / JsonException l'étaient), un VP lent bloquait puis faisait planter la page appelante — notamment l'éditeur de réservation (cf. booking.md, OnGetTravel).
Comportement en cas d'échec (timeout, VP down, JSON invalide) :
ApiClient.CallAsync/GetResourcesAsyncrenvoientnull,UpdateResourceAsyncrenvoiefalse. L'échec est silencieux côté appelant (aucune interface ne remonte l'erreur — choix assumé pour ne pas propager unServiceResultdans toute la chaîne) mais il est capturé dans Sentry avec le tagservice=VisualPlanninget l'URL appelée.VisualPlanningServicerenvoie alors une collection vide (NoEntities) et ne met rien en cache : l'écran s'affiche sans les données VP (véhicule planifié, chauffeurs) au lieu de tomber en erreur 500.- Exception :
SyncHostsAndDriversabandonne toute la sync si un appel échoue — une réponse partielle désactiverait (Enabled = false) toutes les ressources absentes de la réponse. Resources/CreateetResources/Updaterefusent explicitement l'enregistrement d'une ressourceVehiclesi VP est injoignable (leInternalNumbervient de VP).
La contrepartie de ce silence est la page Système > État des services et l'endpoint /api/health/services, qui sondent VP en continu (modules/monitoring.md).
Temps de réponse observés sur testvisualplanning (août 2026) : 0.07 à 0.6 s en régime normal, mais ~11 s sur le tout premier appel après une longue inactivité (démarrage à froid côté VP). Un timeout à 10 s peut donc sacrifier ce premier appel ; monter TimeoutSeconds à 15 si ce cas se manifeste en prod.
Chaque résultat est un IEnumerable<IEnumerable<Entity>> (entité VP avec GetValue("COLLABORATEUR"), GetValue("VEHICULE"), GetValue("Evénement-Date de début"), etc.).
Charge VP — incident des 17-18.08.2026
Diagnostic ARC Logiciels (fournisseur VP) : la base VP se verrouille, toutes les requêtes se bloquent et l'impatience des utilisateurs ajoute des requêtes — effet boule de neige. Deux causes cumulées : le volume d'appels venant d'Horizon, et un moteur de requêtes VP v8 dont certains filtres scannent l'intégralité du planning (optimisé seulement en v9). ARC a ajouté des index et des limites de son côté.
Coût d'un affichage de tableau de chargement, par NavNo distinct (le cache de VisualPlanningService dédoublonne à l'intérieur d'une requête, pas entre requêtes) :
| Cas | develop | feature/loading-table-by-vehicule |
|---|---|---|
| Catalogue (ni retour ni 1 jour) | 6 GET /events | 3 |
| Course d'un jour | 6 | 2 |
| Balnéaire retour | 6–7 | 4–5 |
Ce que la branche corrige, et qu'il faut ne pas réintroduire :
- ⚠
GetVehiclesByOccurrenceétait appelé pour rien dansLoadingTableService(develop:940) : la variablevisualPlanningOneWayVehiclesn'est jamais relue. UnGET /eventspar NavNo et par affichage, jeté. - Les deux directions étaient lues pour n'en garder qu'une (
GetLoadingDriversByOccurrenceetGetUnloadingDriversByOccurrence, puis choix selonloadingTable.Returns) : la branche ne demande que celle qui est consommée. GetVoyageByNavNo/GetCourseByVoyagemis en cache (_idCache) :UpdateCoursePassengersByVoyagecoûte 3 appels (GET VOYAGE, GET COURSE, PUT), un NavNo déjà résolu dans la même requête ne coûte plus que le PUT.- Le prefetch est borné à 3 appels simultanés (
LoadingTableService.VisualPlanningPrefetchConcurrency).PrefetchVisualPlanningAsynclance les lectures en parallèle pour la latence de la page, mais unTask.WhenAllnon borné envoyait jusqu'à ~30 requêtes lourdes d'un coup — exactement la rafale qui verrouille VP. Ne pas retirer ce sémaphore : sans lui on échange une longue séquence contre un pic.
Pistes de réduction identifiées, pas encore faites (à réévaluer après le merge de la branche) :
- Cache inter-requêtes (
IMemoryCache, 30-60 s, clé navNo + méthode +byDate) : aujourd'hui le cache meurt avec la requête, trois personnes sur le même départ = trois fois les mêmes appels. - Coupe-circuit : après N échecs VP consécutifs, cesser d'appeler VP pendant 1-2 min. Le timeout de 10 s protège Horizon mais pas VP — la requête abandonnée continue de s'exécuter côté VP.
- Demander à ARC si l'API v2 accepte plusieurs valeurs sur
VOYAGE-Voyage(opérateurIN) : un tableau de chargement passerait de N requêtes à 1.
⚠ /Tools/Misc/UpdateAllFutureOccurrencesPassengers parcourt toutes les occurrences futures par lots de 50 et fait 3 appels VP par occurrence (+3 par trajet ayant son propre NavNo) : plusieurs milliers d'appels en une seule requête HTTP. C'est le rattrapage des compteurs quand ils ont dérivé, mais à ne jamais lancer pendant un incident de performance VP.
⚠ Dérive assumée des compteurs : pendant un gel VP, un PUT de compteur passagers dépasse les 10 s de timeout (relevé ARC : jusqu'à 445 s) et est abandonné en silence. Les compteurs VP dérivent alors jusqu'au prochain enregistrement de la réservation ou au rattrapage manuel ci-dessus. Arbitrage Buchard du 18.08.2026 : acceptable, ne pas bloquer l'utilisateur pour ça.
Écriture (limitée)
Horizon écrit dans VP uniquement le nombre de passagers (aller / retour) sur les COURSE. Aucun assignment chauffeur/véhicule n'est poussé — le dispatcher fait ce travail dans VP directement.
UpdatePassengersTotalByOccurrence(occurrenceId)— appelée à chaque sauvegarde de résa / tableau de chargement : pousse le compteur du départ (passagers comptés parOccurrenceId, aller = retour) puis un compteur par trajet ayant son propreNavNo, avec aller et retour distincts (Journées Buchard). Détail du rattachement passager → trajet et de ses limites :domain/business-rules.md > NavNo par trajet — compteurs passagers Visual Planning.UpdatePassengersTotalByNavNo(navNo)— balnéaire uniquement (LoadingTableService, brancheSeaside) : la rotation n'est identifiée que par sonSeasideDate.NavNo, les passagers sont donc comptés viaOccurrence.NavNo == navNo. Partout ailleurs on passe par l'id.UpdateCoursePassengersByVoyage(navNo, aller, retour)— primitive bas niveau (résout VOYAGE → COURSE → écrit) : 3 appels HTTP, sans cache.
⚠ Les boucles qui sauvegardent plusieurs résas d'un même départ (pages Billing, /Occurrences/Cancel) appellent BookingService.UpdateAsync(booking, syncVisualPlanning: false) et poussent une fois par occurrence après la boucle. Cf. domain/business-rules.md.
Règles métier spécifiques
Resource.Type=Vehicle≠Vehicleentité : le premier est l'instance physique sync VP (ex: "MAN 238 acheté 2020-01-08"), le second est le modèle/configuration (ex: "car 44 places").- OrderResourcesByDate (sur LoadingTable) : si activé, les Entities VP retournées doivent être filtrées par date exacte (
Evénement-Date de débutparsé endd/MM/yyyy HH:mminvariant culture). - Tableau de chargement : la lecture VP alimente automatiquement les
DriverNamedes lignes éditables et de la ligneIsMainTravel(cf.loading-tables.md). - Daily worker :
VisualPlanningService.SyncHostsAndDriversrafraîchit la liste desResourceactives.CustomerService.CleanGuestCustomers(parallèle) purge les comptes guest.
Écran « Resources par dates » (/Resources/Occurrences)
Liste des départs à planifier (bouton Assigner → /Resources/Assign). Réutilise OccurrenceService.SearchAsync (whitelist statuts Published / DraftInternalBookable).
- Sous-menu En cours / Archivées (dans le menu latéral, comme les Dates) : liens
?archive=false/?archive=true.OnGet(bool? archive)alimente le champ cachéDataTableSearchModelInput.Archive(false =Occurrence.End >= now, true =<= now), sérialisé dans la requête DataTable via#advanced-search-form. - Recherche : via le champ global DataTable (
search[value]→GetKeyWords). Les mots-clés doivent être joints par espace (String.Join(" ", …)) — un join par virgule laissait des virgules parasites qui cassaient la recherche multi-mots (leSearchModel.Keywordsre-split sur l'espace). - Recherche restreinte (identity-only) : ce seul écran met
KeywordsIdentityOnly = true→ApplyKeywordFilterne matche queName/ReferenceNumber/Category.Name/Country.Name, pas le corps éditorial (Description,Highlights,TravelDays.Description, …). Évite qu'un mot comme « croisière », présent dans le programme jour-par-jour d'un voyage terrestre (Oberland), ne le fasse remonter. La page Dates principale (/Occurrences) garde la recherche large. - Tri des colonnes : les clés
filterdu DataTable doivent matcher exactement (casse comprise) le nom de propriété de l'entitéOccurrence.LinqExtensions.OrderBy(string)fait unp.Name == namesensible à la casse et retourne la liste non triée sans erreur si rien ne matche. D'oùfilter: "Start"/"End"(et nonstart/end) pour trier par date de départ/retour.
Points d'attention / pièges
- VP HTTP peut être lent ou indisponible — toujours wrap dans try/catch côté
LoadingTableServicequi tolère les VP vides. VisualPlanningIdest l'ID externe ; ne pas le considérer comme stable (un collaborateur peut être recréé dans VP). La sync daily traite ces cas.- Mapping entity field names : les noms VP sont en français accentué (
Evénement-Date de début,VEHICULE-Véhicule). Ne pas modifier sans mettre à jour côté VP. Reference.csdu Connected ServiceServices.Nav.Resources≠ Visual Planning. NAV "Resources" = côté ERP, c'est différent du référentiel chauffeurs Horizon (peut être utilisé pour rémunération via NAV).- Collaborateur VP non synchronisé → effet de bord sur le tableau de chargement 1-jour : un COLLABORATEUR sans
Resourcecorrespondante est ignoré dansLoadingTableService(lookupVisualPlanningId→null). Sur une course 1-jour multi-lignes (Ligne A+B), la boucle d'affectation itère une fois par ligne voyage en réutilisant unresourceCountglobal ; si un groupe véhicule est manquant, le groupe survivant est recopié sur la ligne suivante (chauffeur ET véhicule dupliqués) au lieu de laisser la case vide. Symptôme observé : même chauffeur sur deux lignes. Cause = le collaborateur de la 2ᵉ ligne (souvent un sous-traitant) n'était pas dans les Fonctions synchronisées. Cf.loading-tables.md. OccurrenceResourceAccommodationorphelines après changement d'hôtel :Resources/Assign(Vueoccurrence-assign-resources) ne rend que les lignes dont l'accommodationIdfigure encore dans lesTravelDays.Accommodations(Accommodations.vue, filtre duv-for), maissave()repost la totalité deresourceAssignment.accommodationsetAssign.OnPostréécrit tout tel quel. Conséquence : si l'hôtel du voyage change après la planification des ressources, les chambres accompagnateurs de l'ancien hôtel deviennent invisibles dans l'écran de saisie mais persistent en base indéfiniment. Observé juillet 2026 sur 4 départs (Gand, Menton/Nice, croisières MS Rhône Princess/MS Van Gogh), toujours 2 chambres fantômes sans ressource assignée.- Toujours filtrer
Occurrence.ResourceAccommodationspar hébergement avant de compter : la collection couvre tous les hôtels du départ (y compris les orphelins ci-dessus). Filtre :ra.Room.RoomType.AccommodationId == accommodation.Id.Accommodations/Occupancy.cshtml.csl'a oublié jusqu'en juillet 2026 → la ligne « Accompagnateurs » comptait 4 chambres single au lieu de 2, avec 2 lignes vides sans nom dans la liste sous le tableau (les côtés « Clients » filtraient déjà, eux). - Nom affiché = nom du COLLABORATEUR VP, pas le contenu des remarques. Si le vrai chauffeur est saisi en texte libre dans la remarque mais que le collaborateur de l'étape est un placeholder générique (« Mo sous traitant »), c'est le placeholder qui s'affiche. Pour afficher le vrai nom, il doit être le collaborateur de l'étape.
Conventions locales
- L'API VP est appelée en HTTP (pas SOAP). Voir
Application/VisualPlanning/ApiClient.cs. - Aucune authentification utilisateur (auth système). Token/credentials dans
appsettings.json.
Dépendances
- ←
loading-tables(consomme les sync VP). - ←
occurrence-capacity(assignment viaOccurrenceResource). - → daily Worker (sync planifiée).

