Module — Réservation (Booking)
Synchronisé avec le code. Mettre à jour à chaque changement structurel.
Rôle
Cycle complet de la réservation : panier, devis, provisoire, validation acompte/total, gestion paiements, annulations partielles ou totales. Couvre la voie back-office (Razor + SPA Vue booking) et la voie web/mobile (API).
Emplacement
- Pages :
src/Web/Pages/Bookings/(CreateUpdate.cshtml,Index.cshtml,Cancel.cshtml, partials, etc.) - Service :
src/Application/Services/Entities/BookingService.cs(~3000 lignes — historiquement gros) - API :
src/Web/Areas/Api/BookingController.cs - Vue :
src/Web/Frontend/Vue/src/booking/(SPA principale) +cancel-booking/ - Templates PDF :
Confirmation.liquid,Balance.liquid,Cancellation.liquid,CancellationInsurance.liquid,Estimate.liquid
Entités principales
Booking— réservation. Champs clés :Number: numéro lisible.Customer: client principal (peut être inclus comme passager viaCustomerIncludeInBooking).TravelOccurrence: départ réservé.Status(cf. enumBookingStatus) — workflow détaillé dansbusiness-rules.PaymentDepositOnly: flag pour paiements web/mobile uniquement.PaymentApproved: paiement validé par le worker.TransactionId: ID transaction Saferpay/CembraPay (pendantPendingWebOrMobile). Peut aussi porter le sentinelleBooking.NoTransactionRequiredMarkerquand il n'y a rien à encaisser (bon couvrant l'acompte ou le total) — cf.billing-payment.md > Worker.OnlineBookingMailSent,OnlineBookingPaymentStatusCheckCount: suivi worker.Amount,FinalAmount(post-annulation),RemainingBalanceAmount(NotMapped).ConfirmationDate,CancellationDate(auto-posées via setterStatus).OfferEndDate: pour lesEstimate.NewInvoiceProcess: flag pour le nouveau workflow facturation (peu d'info, à creuser).ConsumeLoyaltyPoints(bool, entrée) :true= consommer les points de fidélité du client sur la résa (tout-ou-rien, cf.loyalty-points.md). Mappé sur l'entité ; côté API viaBookingDto.General.ConsumeLoyaltyPoints.ConsumedLoyaltyPoints(int, sortie seule) : nombre de points réellement consommés, recalculé dansReverseMap(BookingService.cs:1274) — une valeur envoyée en entrée est ignorée.FinalAmountWithoutLoyalityPoints(NotMapped) : montant avant déduction des points, base de la réduction. ⚠ Débit/crédit des points se font à la création de facture (InvoiceService), pas au POST booking.
BookingPassenger— jointure Booking ↔ Passenger. Porte les données propres à cette réservation :TripType,MealPlan,Cancelled,Price(calculé),BookingDate(date d'ajout du passager, nullable ; pilote l'éligibilité EarlyBooking par passager, fallbackBooking.BookingDatesinull— cf.business-rules.md > EarlyBooking),LoadingStop,UnloadingStop,HeightCm(taille passager en cm, nullable — affiché à la fois dans le formulaire passager du SPA back-officebooking/steps/passengers/Passenger.vueet dans le formulaire de réservation du site web public, conditionné parTravel.EnableFrontendFormPassengerHeight = true. Typiquement pour les voyages avec vélos à réserver), Seats/Insurances/Activities/FlightCodes/ItemPrices. ⚠ Ne porte PAS l'identité :Civility,Firstname,Name,Birthdate,Phonevivent surPassenger(cf. ci-dessous). Le DTOBookingPassengerDtoles aplatit (round-trip via le mapper reflection), ce qui masque cette séparation — d'où la règle « passager repris » dansbusiness-rules.md.Passenger— entité physique et partagée de la personne (peut être attachée à un Customer viaCustomerId). Porte l'identité (Civility/Firstname/Name/Birthdate/Phone). Un mêmePassengerpeut être référencé par plusieursBookingPassengerde bookings différents (repris via la modal « Passagers précédents » deGeneral.vue, ou au chargement d'une résa existante) ⇒ éditer son identité la change dans toutes ses réservations. Garde-fou : règle « passager repris » (business-rules.md).BookingPassengerSeat— siège dans le car (avecVehicleIdxpour Seaside multi-véhicules).BookingPassengerActivity: activités optionnelles.BookingPassengerInsurance: assurances par passager.BookingPassengerFlightCode: codes vol SSR.BookingPassengerItemPrice: ligne de prix supplémentaire par passager.BookingAccommodation+BookingPassengerRoom: assignation chambre.BookingSource: canaux d'acquisition (collection — usage à clarifier).Discount,Supplement,BookingGlobalSupplement: ajustements de prix.Invoice,Payment,Gift: liens vers facturation.
Règles métier spécifiques
- Workflow
BookingStatusdétaillé dansdomain/business-rules.md:- InCreation/Estimate/Draft/WaitingList → Confirmed → Billed → Canceled
- PendingWebOrMobile = chemin parallèle web/mobile
- Acompte vs total :
- Back-office : statut Confirmed = acompte, Billed = total.
- Web/mobile :
PaymentDepositOnlyflag.
- Calcul de prix par passager :
BookingService.GetCostPerPassenger. Logique selonTravelTypeetTripType(cf.business-rules). - Calcul
BookingPassenger.Price: valeur calculée côté front Vue (Web/Frontend/Vue/src/booking/cost.js→getCostPerPassenger) puis envoyée dans le DTO et copiée par Mapper sur l'entité. Contenu =transport (age×tripType) + activitiesCost + insurancesCost + flightCodes + mealPlanCost. Ce champ est donc PAS uniquement le prix de transport — il agrège les suppléments per-pax. Conséquence pour les PDFs : tout endroit qui afficheBookingPassenger.PriceET une ligne dédiée pour un de ces composants (Activities / Insurances / FlightCodes / MealPlan) double-comptera s'il ne soustrait pas. Cas connu corrigé : ligne per-pax seaside-sans-accommodation (TravelType == 3 and RoomTypeCategories.size == 0) dansConfirmation.liquid/Balance.liquid— soustraction depassenger.FlightCodesTotal(computed dansPdfGeneratorService.ComputeFlightCodesTotal, miroir deBookingService.GetCostPerPassenger). Activities/Insurances/MealPlan ne sont pas (encore) soustraits — dans la pratique ils valent souvent 0 dans ce cas, mais le risque de double-comptage subsiste si jamais ils sont > 0. - Annulation partielle :
BookingPassenger.Cancelled = truesur certains passagers ; le booking resteConfirmed/Billed.Booking.StatusTextajoute "- Annulation partielle". ForceCancel(date): helper pour bypass le state machine (réservé aux migrations Globe legacy).- Auto-discount "Rabais groupe" dans Vue store (8-9 pax = 3%, 10+ pax = 5%, sauf OneDay) — cf.
business-rules. - Offres spéciales
IsPerPerson: rabais =Value × nb pax, recompilé sur changement du nombre de passagers et des dates de naissance. Sur un balnéaire sans hébergement, les bébés (< 2 ans au départ) sont exclus du comptage ; date de naissance inconnue ⇒ compté (le rabais s'affiche dès l'étape 1 puis se réduit). Détail :domain/business-rules.md > Offres spéciales « par personne ». - Cycle de vie des suppléments globaux sur une résa (
BookingService) — trois moments distincts :- Création et passage
Confirmed → Billed(+ résas web déjàBilled) →ApplyGlobalSupplementsAsync: recalcul complet, la liste est vidée puis reconstruite avec les montants du catalogue du jour. Piloté parShouldApplyGlobalSupplements. - Édition simple (tout autre
UpdateAsync, horsCanceled) →ReconcileGlobalSupplementsAsync(août 2026) : ajoute les suppléments qui s'appliquent désormais à l'occurrence, retire ceux qui ne s'appliquent plus, et ne retouche jamais le montant des lignes conservées — ce prix a été convenu avec le client et est déjà facturé. Motivation : une résa prise pendant qu'une config temporaire existait gardait indéfiniment un supplément qui ne la concernait pas (cas réel résa 18147, deux suppléments diesel dont un « Croisière septembre » dont l'occurrence avait été retirée de la liste d'inclusion). Le total suit :CreateOrUpdateInvoicesommebooking.GlobalSupplementsaprès la réconciliation. - Annulation → aucune réconciliation, les montants servent au calcul des frais.
- « S'applique » =
GlobalSupplementService.GetActiveSupplements(start, end)puisAppliesToOccurrence(Inclusion= occurrence listée /SeasideInclusion= date + catégorie /Exclusion= plage de dates et occurrence non exclue). Nombre de jours = span de l'occurrence + 1, sauf balnéaire = toujours 3 (GetGlobalSupplementDays). - Tests :
BookingServiceTest.UpdateAsync_ShouldRemoveGlobalSupplement_WhenItNoLongerAppliesToTheOccurrence,…_ShouldReAddMissingGlobalSupplement_WhenStatusRemainsConfirmed,…_ShouldNotRepriceGlobalSupplementsThatStillApply. - Signal UI (
booking/steps/global/Price.vue) : une ligne barrée + badge « Ne s'applique plus à ce départ — sera retiré à l'enregistrement » prévient l'utilisateur avant la sauvegarde. Détection sans appel serveur :catalog.globalSupplements(alimenté parBookings/CreateUpdate.OnGetCatalog) est déjà la liste applicable filtrée parAppliesToOccurrence— donc toutbooking.globalSupplementsabsent de ce jeu est précisément ce que la réconciliation retirera. ⚠ Le total affiché continue de compter la ligne jusqu'à l'enregistrement (cost.jsinchangé) : le montant baisse à la sauvegarde.
- Création et passage
ConfirmationDateauto-posée par le setterStatusquand on passe àConfirmed.- Assurance annulation interdite pour un client
CustomerType.Agency— bloqué à l'endpoint (OnGetInsurances), à la persistance (BookingService.Mapignore lesInsurancesdu DTO) et dans l'UI Vue. L'historique des réservations agence déjà assurées est préservé (écran d'annulation inchangé). Détail :domain/business-rules.md.
Points d'attention / pièges
BookingService>3000 lignes — refactoring graduel possible mais risqué (couvre beaucoup de cas métier).- Validation côté API
[FromBody]: Newtonsoft.Json case-insensitive ⇒civility/Civility/CIVILITYmatchent. Ne pas paniquer si le casing varie. - Le mapper reflection (
Library/Mapper.cs) est utilisé pour le round-tripPassenger ↔ BookingPassengerDto— toute nouvelle propriété ajoutée des deux côtés est mappée automatiquement (utilisé par exemple pourCivilityajouté en mai 2026). BookingPassenger.Cancelled≠Booking.Status = Canceled: le premier indique l'annulation d'un passager seul, le second l'annulation totale du booking.Booking.GetCurrentInvoice(InvoiceType)⇒ tient compte du versioningInvoice.Enabled.Booking.Legacy = true⇒ booking importé depuis Globe, ne pas éditer sans précaution.- Les bookings
PendingWebOrMobilebloquent les places s'ils ont unTransactionIdOU sont encore dans la fenêtre de hold (CreatedAt >= PendingHoldCutoff(), 20 min) — anti-surbooking pour les résas web pas encore payées. Détail + exclusion de la personne courante (PendingHolderKey, clé nom+prénom+email) :domain/business-rules.md > Hold des réservations web. (Avant juin 2026, seuls les pending AVEC txid bloquaient.) - Composition de
BookingPassenger.Price(écrit par le front, seulement lu par le serveur) : prix transport/séjour selon l'âge + part de chambre (prix de la chambre / nb de passagers de la chambre) − remises réparties + activités par personne + codes vol + pension . L'assurance et le supplément global en sont exclus. Calculé parcost.js > getRealCostPerPassenger, qui sert à trois choses : le prix stocké (booking/store.js), l'assiette des frais d'annulation (cancel-booking/App.vue,frais = % × prix) et le palier d'assurance — seulcalculateFeepasseincludeGlobalSupplement = true, le carburant étant refacturé au client via les frais d'annulation et non via le prix (cf.domain/business-rules.md). ⚠ Ne jamais mettre la part du supplément dans le prix ni la soustraire à l'affichage. Ne pas confondre avecgetCostPerPassenger, qui alimentegetSubTotalCostet n'inclut ni la chambre ni le supplément (celui-ci y est ajouté séparément, au niveau réservation). - Colonne « Statut paiement » des paniers abandonnés (visible uniquement sur le filtre
PendingWebOrMobiledeBookings/Index) — calculée dansIndex.cshtml.cs:93, libellés dansIndex.cshtml:286:0« En attente » (le worker va statuer),1« Erreur » (CheckCount == int.MaxValue, worker abandonné → bouton Relancer),2« Aucune validation » (aucune trace de tentative). Depuis août 2026,RekaCheckcompte comme « En attente » : il n'a jamais de transaction en ligne mais est systématiquement validé par le worker (IsPaymentApprovedl.659), l'afficher en « Aucune validation » laissait croire à un panier mort. Idem pour les résas portant le marqueur « rien à encaisser », qui remplissentTransactionIdet basculent donc en0sans code spécifique. - Sélection du client (étape 1 du tunnel Vue) : le bouton « Suivant » est bloqué tant que
/Bookings/CreateUpdate/Customern'a pas répondu. Ce handler ne fait pas que remplir l'écran : dans son.then()il pousse la commission du profil client (description: "Commission"), les rabais des offres du jour, le solde de compte, et commiteSET_CUSTOMER. Passer à l'étape 2 avant la réponse laissait ces effets s'appliquer trop tard (ou pas du tout du point de vue de l'utilisateur) — d'où des commissions manquantes sur des réservations où le client mettait du temps à charger. Le flagcustomerLoadingdeGeneral.vuealimente désormais la propnextDisableddeStepsFooter(juil. 2026). ⚠StepsFooterétant partagé par toutes les étapes, la prop vautfalsepar défaut : seulGeneral.vuela passe. ⚠ Le flag doit être remis àfalsesur tous les chemins (.then()et.catch()), sinon un appel en échec fige l'étape définitivement. - ⚠ Collections d'un passager dans
BookingService.Map:IsNullOrEmpty()empêche toute suppression. Les sous-collections (Insurances,Seats,ItemPrices,FlightCodes) ne sont réaffectées que si la liste du DTO est non vide ⇒ vider la liste côté Vue ne supprime rien, l'ancienne valeur est rechargée telle quelle au reload. Corrigé pourFlightCodes(juil. 2026) : test sur!= nullau lieu deIsNullOrEmpty(), ce qui distingue « liste vide = tout retiré » (on écrase, EF cascade-delete les orphelins — relation requise +DeleteBehavior.Cascade) de « null = champ non transmis, on garde ». Fonctionne parce queGetest tracking et inclutPassengers.FlightCodes: la collection chargée permet à EF de détecter les orphelins.Insurances/Seats/ItemPricesont toujours le bug — même correction applicable, mais à valider cas par cas (les sièges sont aussi édités depuis/PassengerSeats/Assign). - Écran « Réservations quotidiennes » (
/Bookings?daily=true) : résas dont laBookingDateest aujourd'hui,AllStatuses = true(doncCanceledetWaitingListinclus, contrairement à la liste standard) maisPendingWebOrMobileexclu depuis août 2026 — un panier abandonné n'est pas une réservation du jour, il a son propre écran (« Paniers abandonnés »). L'exclusion passe parBookingDataTableSearchModel.ExcludedStatuses, appliqué dansSearchAsyncaprès le filtre de statut. Bookings/CreateUpdate.OnGetTravelappelle Visual Planning (GetVehiclesByOccurrence(occurrence.NavNo)) pour récupérer le véhicule réellement planifié par le dispatcher et écraser, sur tous lesTravelDay, le véhicule théorique du catalogue (VehicleId,Name,Decker). C'est ce plan de véhicule qui sert ensuite à l'attribution des sièges (SeatSelector/DeckPreview) et à la mention « avec le véhicule: X » de l'onglet Voyage. ⚠ L'éditeur de réservation dépend donc d'un service externe à chaque chargement de voyage. Depuis août 2026, VP injoignable ou lent ne bloque plus : timeout 10 s puis collection vide ⇒ le véhicule du catalogue est conservé (détail :resources-visual-planning.md > Timeout et dégradation). Avant, l'appel pouvait pendre 100 s puis lever uneNullReferenceException⇒ l'écran de résa ne chargeait plus le voyage.- Recherche back-office (
SearchAsync,:233) : chaque mot-clé est OR-é sur plusieurs champs. Noms/voyage =Contains(sous-chaîne, collation accent-insensible). Champs numériques (Booking.Number,Invoice.Number,Invoice.NavNo) = égalité exacte depuis mai 2026. Avant, leContainssur les numéros faisait remonter des résas sans rapport (ex. "13033" matchait l'Invoice.Number113033, ou les legacy Globe 130336/130337). Si tu veux à nouveau une recherche par préfixe de numéro, c'est ici qu'il faut l'assouplir — mais ne pas revenir auContainsbrut.
Conventions locales
- Le SPA
Vue/bookingest la source de vérité côté UI back-office. Tout JSON de booking transite viastate.booking. - Sauvegarde via
POST /Bookings/CreateUpdate(FormData avec__RequestVerificationToken+jsonstringifié) — voirVue/booking/store.js > SAVE. - Création via API :
POST /api/Bookingavec body JSON typéBookingDto.
Dépendances
- ←
customer-membership(Customer). - →
loyalty-points(flagConsumeLoyaltyPoints, gain/dépense de points). - ←
occurrence-capacity(TravelOccurrence + Occurrence). - ←
product-catalog(Accommodation, RoomType, Seat, Activity). - →
billing-payment(Invoice + Payment + sync NAV). - →
gifts(Gift utilisable en encaissement). - →
loading-tables(les passagers peuplent les arrêts).
Contributors
No contributors
Changelog
No recent changes

