Skip to content

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 via CustomerIncludeInBooking).
    • TravelOccurrence : départ réservé.
    • Status (cf. enum BookingStatus) — workflow détaillé dans business-rules.
    • PaymentDepositOnly : flag pour paiements web/mobile uniquement.
    • PaymentApproved : paiement validé par le worker.
    • TransactionId : ID transaction Saferpay/CembraPay (pendant PendingWebOrMobile). Peut aussi porter le sentinelle Booking.NoTransactionRequiredMarker quand 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 setter Status).
    • OfferEndDate : pour les Estimate.
    • 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 via BookingDto.General.ConsumeLoyaltyPoints.
    • ConsumedLoyaltyPoints (int, sortie seule) : nombre de points réellement consommés, recalculé dans ReverseMap (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, fallback Booking.BookingDate si null — cf. business-rules.md > EarlyBooking), LoadingStop, UnloadingStop, HeightCm (taille passager en cm, nullable — affiché à la fois dans le formulaire passager du SPA back-office booking/steps/passengers/Passenger.vue et dans le formulaire de réservation du site web public, conditionné par Travel.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, Phone vivent sur Passenger (cf. ci-dessous). Le DTO BookingPassengerDto les aplatit (round-trip via le mapper reflection), ce qui masque cette séparation — d'où la règle « passager repris » dans business-rules.md.
  • Passenger — entité physique et partagée de la personne (peut être attachée à un Customer via CustomerId). Porte l'identité (Civility/Firstname/Name/Birthdate/Phone). Un même Passenger peut être référencé par plusieurs BookingPassenger de bookings différents (repris via la modal « Passagers précédents » de General.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 (avec VehicleIdx pour 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 BookingStatus détaillé dans domain/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 : PaymentDepositOnly flag.
  • Calcul de prix par passager : BookingService.GetCostPerPassenger. Logique selon TravelType et TripType (cf. business-rules).
  • Calcul BookingPassenger.Price : valeur calculée côté front Vue (Web/Frontend/Vue/src/booking/cost.jsgetCostPerPassenger) 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 affiche BookingPassenger.Price ET 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) dans Confirmation.liquid / Balance.liquid — soustraction de passenger.FlightCodesTotal (computed dans PdfGeneratorService.ComputeFlightCodesTotal, miroir de BookingService.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 = true sur certains passagers ; le booking reste Confirmed/Billed. Booking.StatusText ajoute "- 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 :
    1. 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é par ShouldApplyGlobalSupplements.
    2. Édition simple (tout autre UpdateAsync, hors Canceled) → 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 : CreateOrUpdateInvoice somme booking.GlobalSupplements après la réconciliation.
    3. Annulation → aucune réconciliation, les montants servent au calcul des frais.
    • « S'applique » = GlobalSupplementService.GetActiveSupplements(start, end) puis AppliesToOccurrence (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é par Bookings/CreateUpdate.OnGetCatalog) est déjà la liste applicable filtrée par AppliesToOccurrence — donc tout booking.globalSupplements absent 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.js inchangé) : le montant baisse à la sauvegarde.
  • ConfirmationDate auto-posée par le setter Status quand on passe à Confirmed.
  • Assurance annulation interdite pour un client CustomerType.Agency — bloqué à l'endpoint (OnGetInsurances), à la persistance (BookingService.Map ignore les Insurances du 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/CIVILITY matchent. Ne pas paniquer si le casing varie.
  • Le mapper reflection (Library/Mapper.cs) est utilisé pour le round-trip Passenger ↔ BookingPassengerDto — toute nouvelle propriété ajoutée des deux côtés est mappée automatiquement (utilisé par exemple pour Civility ajouté en mai 2026).
  • BookingPassenger.CancelledBooking.Status = Canceled : le premier indique l'annulation d'un passager seul, le second l'annulation totale du booking.
  • Booking.GetCurrentInvoice(InvoiceType) ⇒ tient compte du versioning Invoice.Enabled.
  • Booking.Legacy = true ⇒ booking importé depuis Globe, ne pas éditer sans précaution.
  • Les bookings PendingWebOrMobile bloquent les places s'ils ont un TransactionId OU 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é par cost.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 — seul calculateFee passe includeGlobalSupplement = 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 avec getCostPerPassenger, qui alimente getSubTotalCost et 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 PendingWebOrMobile de Bookings/Index) — calculée dans Index.cshtml.cs:93, libellés dans Index.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, RekaCheck compte comme « En attente » : il n'a jamais de transaction en ligne mais est systématiquement validé par le worker (IsPaymentApproved l.659), l'afficher en « Aucune validation » laissait croire à un panier mort. Idem pour les résas portant le marqueur « rien à encaisser », qui remplissent TransactionId et basculent donc en 0 sans code spécifique.
  • Sélection du client (étape 1 du tunnel Vue) : le bouton « Suivant » est bloqué tant que /Bookings/CreateUpdate/Customer n'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 commite SET_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 flag customerLoading de General.vue alimente désormais la prop nextDisabled de StepsFooter (juil. 2026). ⚠ StepsFooter étant partagé par toutes les étapes, la prop vaut false par défaut : seul General.vue la passe. ⚠ Le flag doit être remis à false sur 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é pour FlightCodes (juil. 2026) : test sur != null au lieu de IsNullOrEmpty(), 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 que Get est tracking et inclut Passengers.FlightCodes : la collection chargée permet à EF de détecter les orphelins. Insurances / Seats / ItemPrices ont 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 la BookingDate est aujourd'hui, AllStatuses = true (donc Canceled et WaitingList inclus, contrairement à la liste standard) mais PendingWebOrMobile exclu 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 par BookingDataTableSearchModel.ExcludedStatuses, appliqué dans SearchAsync après le filtre de statut.
  • Bookings/CreateUpdate.OnGetTravel appelle Visual Planning (GetVehiclesByOccurrence(occurrence.NavNo)) pour récupérer le véhicule réellement planifié par le dispatcher et écraser, sur tous les TravelDay, 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 une NullReferenceException ⇒ 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, le Contains sur les numéros faisait remonter des résas sans rapport (ex. "13033" matchait l'Invoice.Number 113033, 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 au Contains brut.

Conventions locales

  • Le SPA Vue/booking est la source de vérité côté UI back-office. Tout JSON de booking transite via state.booking.
  • Sauvegarde via POST /Bookings/CreateUpdate (FormData avec __RequestVerificationToken + json stringifié) — voir Vue/booking/store.js > SAVE.
  • Création via API : POST /api/Booking avec body JSON typé BookingDto.

Dépendances

  • customer-membership (Customer).
  • loyalty-points (flag ConsumeLoyaltyPoints, 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