Module — Occurrences & Capacité
Synchronisé avec le code. Mettre à jour à chaque changement structurel.
Rôle
Une Occurrence est un départ daté d'un Travel : prix par tier, capacités, statut, rattachement comptable BC (NavNo). Le service TravelOccupancyAndBookingStateService calcule l'occupation et l'état "bookable" d'une occurrence depuis les bookings actifs.
Emplacement
- Pages :
src/Web/Pages/Occurrences/ - Service :
src/Application/Services/Entities/OccurrenceService.cs,src/Application/Services/TravelOccupancyAndBookingStateService.cs - Vue :
src/Web/Frontend/Vue/src/occurrence-assign-resources/ - Export Excel Helvetic :
src/Web/Pages/Occurrences/HelveticListPassengers.cshtml.cs
Entités principales
Occurrence— instance datée. Champs clés :Start,End: dates du voyage.Status:Published/Cancelled.SellingPrice,SellingPriceJunior,SellingPriceChild(round-trip) + variantesOneWay.Capacity,CapacityMax: limites globales.BreakEvenPoint: seuil de rentabilité (indicatif).ExpectedPassengers: prévisionnel ([À CONFIRMER] usage exact).NavNo: lien NAV/BC, et source duNAV_TravelIDfacturé pour tous les types de voyage (seul le balnéaire retombe surSeasideDate.NavNoaller si l'occurrence n'en a pas). Cf.domain/business-rules.md > NavNo — source du NAV_TravelID facturé.Note,FileOrder,FileActivityCertificate,FileReturnPlan: documents officiels uploadés.MinutesForADayOfWork: minutes de travail standard pour calcul coût chauffeur.EffectiveNumberOfDays(NotMapped) : nb jours effectifs.BookingState(NotMapped) :Bookable/Done/Full/TooLate/Cancelledcalculé.
TravelOccurrence: jointure N-N avec Travel (cf.travel-catalog).OccurrenceCost+OccurrenceCostVariousCost: compta analytique par départ (charges réelles). À titre indicatif uniquement, données pas publiées plus loin (module en pause) — ⚠ saufOccurrenceCost.VatType, qui n'est pas indicatif du tout : c'est le régime TVA appliqué à la facturation du départ (BookingService, groupe TVA + comptes BC). Cf.domain/business-rules.md, section « TVA non renseignée ».OccurrencePrice: prix custom par chambre/option pour le voyage (Catalog).OccurrenceRoomPrice: prix custom par chambre/option pour Seaside.OccurrenceRoomTypeQuota: contingent par type de chambre (cf.product-catalog).OccurrenceRoomTypeCabin: contingent cabines pour les croisières.OccurrenceVehicle: véhicules assignés à l'occurrence.OccurrenceResource+OccurrenceResourceSeat+OccurrenceResourceAccommodation: assignations chauffeur/hôtesse/musicien à l'occurrence (avec siège dans le car et chambre à l'hôtel).OccurrenceLine: surcharge des lignes de chargement/dépose pour cette occurrence (override desTravelLines).OccurrenceBlockedSeat: sièges bloqués (réservés à l'équipe ou inutilisables).OccurrenceCancellationTask+OccurrenceCancellationTaskValue: checklist d'annulation prévue mais pas utilisée.OccurrenceDocument: documents attachés.OneDayTravelDriveOccurrences: pour les courses d'un jour, lien Drive ↔ Occurrence avec quota par drive.
Règles métier spécifiques
OccurrenceStatus: binaire,PublishedouCancelled. Pas d'état "Draft" ou "Archived".- Calcul d'occupation (cf.
business-rules.md > Calcul d'occupation) : par défaut les occurrences passées sont skip-pées. Pour les inclure (page archive, stats) →CalculateOccupancyAndSetBookingState(..., includeAllOccurrences: true). BookingStateest posé pour toutes les occurrences listées :CancelledsiStatus = CancelledDonesiStart < now- sinon calculé via remplissage :
Bookable,TooLate,Full
Travel.BookingState(NotMapped) est agrégé depuis les occurrences parDetermineTravelBookingState(cf.TravelOccupancyAndBookingStateService), dans cet ordre :Bookablesi au moins une occurrence estBookable- sinon
TooLatesi au moins une estTooLate - sinon
Cancelledsi toutes les occurrences sont annulées OU si toutes les occurrences encore à venir (End >= now) sont annulées (plus aucun départ futur vendable) - sinon
Full - ⚠ Ne produit jamais
Done: un voyage 100% passé non annulé resteFull. La conditionCancelleda été ajoutée en mai 2026 (avant, un voyage entièrement annulé tombait à tort dansFull).
MainTravelOccurrencesurOccurrence: helper qui retourneTravels.FirstOrDefault(p => p.IsPriority)ou à défaut le premierTravelOccurrence.SettingGeneral: porté par chaque occurrence pour overrider des paramètres généraux (à confirmer).Occurrence.Billed: booléen indiquant que toutes les factures du départ sont émises.- Baseline whitelist
Published || DraftInternalBookabledansOccurrenceService.SearchAsync: appliqué sur le Main Travel dès la construction initiale duIQueryable, sans override. Tout nouveauTravelStatusreste non-listé tant qu'il n'est pas explicitement ajouté à ce filtre. Détail dansdomain/business-rules.md > Travel Draft vs DraftInternalBookable vs Published.
Points d'attention / pièges
OccurrenceCostindicatif, saufVatType: ne pas baser de calcul de prix client sur les autres champs. Le prix vendu =Occurrence.SellingPrice/Junior/Child(+ variantes OneWay) + ajustements activities/insurance/supplements viaBookingService.VatTypefait exception : il pilote la facturation réelle, un départ vendable ne peut pas le laisser à « - » (cf.domain/business-rules.md).- Helvetic salutation codes : si on modifie
GetSalutationCodedansHelveticListPassengers.cshtml.cs, ça impacte aussi les exports Seaside viaBuildHelveticWorksheet. Ne pas dupliquer la logique ailleurs. o.NumberOfPassengersest[NotMapped]sur Occurrence et est rempli parTravelOccupancyAndBookingStateService. Ne pas espérer le voir directement depuis_db.Occurrences.HasImpactOnOccurrenceOccupancyExpr: utiliser cette expression (pas laIsActivecollection) dans lesIQueryable.Where(...)pour que EF traduise correctement le SQL. Inclut désormais lesPendingWebOrMobileen hold (txid OUCreatedAt >= PendingHoldCutoff(), 20 min) — cf.domain/business-rules.md > Hold des réservations web. Les filtres inline équivalents (... || (b.Status == PendingWebOrMobile && (b.TransactionId != null || b.CreatedAt >= ValidatingBookingStatus.PendingHoldCutoff())))) doivent rester alignés sur cette règle.- La capacité opposable à une réservation n'est pas
Occurrence.CapacityMax: ce champ ne pilote que l'occupation affichée et leBookingState(Full).BookingService.Validatene le lit jamais — il contrôle les sièges (voyages avec plan de car), les quotas de types de chambre, et pour les courses d'un jour les quotas de lignes de chargement (OneDayTravelDriveOccurrences.Quota). Un dépassement deCapacityMaxreste donc possible si les quotas de lignes totalisent plus que la capacité. Cf.domain/business-rules.md > Quota de ligne de chargement (OneDay)(surbooking Europa-Park du 22.08.2026). - Compteurs de passagers divergents entre écrans (Plan vs ListPassengers vs Index Occurrences vs tableau de chargement) : voir le tableau récap dans
domain/business-rules.md > Compteurs de passagers. Plan filtre par sens (TripType), ListPassengers + Index ne filtrent pas, le tableau de chargement filtre aussi par sens. - Passagers sans
LoadingStopId: depuis le fix mai 2026,TravelListService.PopulateDriveWithPassengersles rattache au drive virtuel "Trajet indéterminé" sous un stop "Lieu non assigné" (au lieu de les éjecter silencieusement).LoadingTableServicene fait pas encore ce fallback — un même booking mal saisi peut donner des compteurs différents entre Plan et tableau de chargement. Occurrence.ResourceAccommodations= tous les hôtels du départ : la collection n'est pas scopée à un hébergement, et elle conserve les chambres des hôtels retirés du voyage depuis (orphelines invisibles dansResources/Assign). Tout écran par hébergement doit filtrer surra.Room.RoomType.AccommodationId. Détail dansresources-visual-planning.md > Points d'attention.GetTravelVehicleSeatOccupancy: ne jamais laisserbookingsenIQueryable(correctif 11.08.2026, mesuré 5 872 ms → 141 ms). La boucle par siège cherchait leBookingPassengeroccupant viabookings.SelectMany(b => b.Passengers).FirstOrDefault(...)alors quebookingsétait une requête non matérialisée portant quatreIncludede collections : la requête était rejouée pour chaque siège réservé (90 passagers ⇒ 90 exécutions). La liste est désormais matérialisée une fois et les passagers indexés sur la même clé(vehicleIdx, numéro de siège)que le dictionnairebookedSeatsconstruit juste au-dessus, avec la même règle « le premier gagne » — résultat identique. ⚠ Méthode partagée : plan de véhicule, attribution des sièges, chambres balnéaires, dossier chauffeur. Trouvé en instrumentant l'export d'un dossier chauffeur, où elle pesait 35 % du temps total.- Occupation par drive (OneDay) & stops partagés :
CalculateDriveOccupancyindexe les drives parStopId. Depuis le passage deDriveStopen many-to-many, un même stop peut appartenir à plusieurs drives. LeBookingPassengerne porte que sonLoadingStop.Id(pas deDriveId), donc on ne peut pas savoir ici sur quel drive il est. Règle : le passager est rattaché à UN seul drive (le premier, déterministe viaGroupBy(StopId).First()) → l'occupation totale reste égale au nombre de passagers. ⚠ Compter sur tous les drives desservant le stop sur-gonflerait l'occupation. Avant ce fix, leToDictionary(StopId → Drive)crashait (ArgumentException: same key) dès qu'un stop était partagé (ex. pageReports/Management).
Conventions locales
- L'export Excel Helvetic charge seulement les
BookingPassengernon annulés des bookings actifs (statuts dansHasImpactOnOccurrenceOccupancy+PendingWebOrMobileavec txid ou en holdCreatedAt >= PendingHoldCutoff()). OccurrenceFilterModeenum guide certaines requêtes (à explorer si besoin).
Dépendances
- ←
travel-catalog(Travel parent). - →
booking(consomme Occurrence + capacité). - →
seaside(Seaside-spécifique pour les rotations multiples). - →
loading-tables(un tableau peut grouper plusieurs occurrences). - →
resources-visual-planning(assignment chauffeurs).
Contributors
No contributors
Changelog
No recent changes

