Skip to content

Base de données & identifiants

PostgreSQL

Le moteur est PostgreSQL 17, en développement comme en test (postgres:17-bookworm). Il a été retenu pour sa robustesse, son support natif du JSON et — surtout — parce qu'il est le seul SGBD à réunir les extensions visées par le produit (PostGIS, pg_vector ; cf. Questions ouvertes du README).

  • Développement : service db de docker-compose.yml, base backend, données persistées dans un volume nommé pgdata.
  • Test : base jetable testing, en RAM (voir Tests).

Connexion en développement (Docker) et artisan serve

Le conteneur app reçoit sa configuration de base de données via le bloc environment: de docker-compose.yml (DB_HOST=db, le nom de service Docker), et non via .env — qui conserve le défaut hôte 127.0.0.1 pour les commandes lancées hors conteneur (ex. la suite de tests). Les deux coexistent volontairement : les variables d'environnement réelles du conteneur priment sur .env pour les commandes en ligne (migrate, tinker).

Piège : php artisan serve (la commande CMD du conteneur) ne transmet à ses workers HTTP qu'une liste blanche restreinte de variables d'environnement (ServeCommand::$passthroughVariables) ; toutes les autres sont relues depuis .env à chaque requête. Sans correctif, les requêtes HTTP retombaient donc sur 127.0.0.1 (échec connection refused, 500) alors que les commandes CLI atteignaient bien db. AppServiceProvider::boot() ajoute les variables DB_* à cette liste blanche pour que l'application servie honore l'environnement du conteneur. Régression couverte par tests/Feature/Database/ServePassthroughTest.php.

Identifiants — UUIDv7 universels

Toutes les clés primaires et toutes les clés étrangères de l'application sont des UUIDv7. Aucun identifiant auto-incrémenté (bigint) n'est utilisé pour les entités du domaine. C'est un invariant à l'échelle du projet.

Pourquoi UUIDv7

  • Non énumérable et non révélateur : sûr à exposer dans les URL, tokens et payloads de l'API REST consommée par les portails web et l'application mobile. Un bigint fuiterait la volumétrie et l'ordre de création, et serait énumérable.
  • Ordonnable lexicographiquement (préfixe temporel) : indexation B-tree quasi aussi efficace qu'un entier séquentiel, et tri temporel implicite sans colonne d'ordre supplémentaire. C'est l'avantage de v7 sur v4.
  • Génération côté application (avant insertion) : utile pour l'offline-first et les insertions en lot, sans aller-retour base pour obtenir l'id.

C'est le défaut du trait HasUuids de Laravel 13 (Str::uuid7()).

Conventions

  • Modèles Eloquent : trait Illuminate\Database\Eloquent\Concerns\HasUuids (qui surcharge déjà le type de clé et le drapeau d'auto-incrément — inutile de définir $keyType / $incrementing).
  • Migrations : $table->uuid('id')->primary(), $table->foreignUuid(...)->constrained(), $table->uuidMorphs(...). Proscrits : id(), foreignId(), morphs().

Paquets tiers adaptés à l'UUID

  • spatie/laravel-permission — modèles Role / Permission sous-classés (App\Models\Role, App\Models\Permission) avec HasUuids ; migration publiée éditée pour passer id, clés pivots, clé de team (organization_id) et clé de morph (model_uuid) en uuid.
  • laravel/sanctum — modèle PersonalAccessToken sous-classé (App\Models\PersonalAccessToken) avec HasUuids et enregistré via Sanctum::usePersonalAccessTokenModel() ; la colonne tokenable est déclarée en uuidMorphs.
  • spatie/laravel-activitylog — modèle Activity sous-classé en App\Models\AuditLog (nommé ainsi pour laisser Activity à l'entité métier) avec HasUuids et enregistré via config('activitylog.activity_model') ; migration publiée éditée pour passer id en uuid et les morphs subject / causer en nullableUuidMorphs. Voir Journal d'audit.

Exception : tables d'infrastructure du framework

Les tables purement techniques de Laravel — cache, cache_locks, jobs, job_batches, failed_jobs, sessionsconservent leur schéma par défaut (y compris les bigint auto-incrémentés). Elles ne portent aucun identifiant de domaine, ne sont jamais exposées par l'API, et les convertir n'apporterait aucun bénéfice. password_reset_tokens utilise l'e-mail comme clé, inchangé.

Points de vigilance

  • Stockage de 16 octets par clé (type uuid natif PostgreSQL) contre 8 pour un bigint : surcoût d'index assumé, limité par l'ordonnancement de v7.
  • Toute nouvelle table ou tout paquet tiers introduisant des bigint auto- incrémentés doit être adapté.
  • HasUuids doit être utilisé systématiquement : un modèle créé sans le trait casserait l'invariant silencieusement.

Schéma relationnel

Le diagramme ci-dessous est généré automatiquement depuis le schéma PostgreSQL réel, par introspection du Schema builder de Laravel (package dev bambamboole/laravel-mermaid-erd). Ne pas l'éditer à la main. Après toute migration qui modifie le schéma, le régénérer (base migrée requise) :

bash
php artisan generate:mermaid-erd --output=file --path=.claude/docs/architecture/base-de-donnees.md

Le test tests/Feature/Documentation/ErdFreshnessTest.php échoue tant que le diagramme committé ne correspond plus au schéma issu des migrations. Les tables d'infrastructure du framework (voir l'exception ci-dessus) sont exclues via ignore_tables dans config/mermaid-erd.php ; les relations polymorphes (Sanctum tokenable, morphs Spatie model_*), invisibles pour l'introspection des clés étrangères, y sont déclarées explicitement.

Entités métier

activation_codes

Codes d'accès remis aux clients (via un revendeur ou une agence) pour activer un road-trip. Chaque code est lié à l'organisation émettrice et, optionnellement, au client qui l'a acquis.

ColonneTypeNotes
iduuid PKUUIDv7 via HasUuids
prefixstring, nullableIdentifiant du lot, ex. RESELLER-ANULL pour les codes hérités
codestring UNIQUE, nullableSuffixe alphanumérique généré aléatoirement — NULL pour les codes hérités
legacy_codestring UNIQUE, nullableCode importé tel quel de l'ancien système (chaîne arbitraire, sans structure préfixe/suffixe)
full_codestring STORED UNIQUEColonne générée PostgreSQL : COALESCE(legacy_code, prefix || '-' || code)
batch_iduuid FK → activation_code_batches, nullable, INDEXCASCADE DELETE — le lot de génération ; NOT NULL de fait pour les codes générés, NULL pour les codes hérités (contrainte CHECK ci-dessous)
amountdecimal(10,2)Montant, défaut 0
currencystring(3)Code ISO 4217, défaut CHF
commissiondecimal(10,2), nullableInstantané de la commission de l'organisation émettrice au moment de la génération (voir Commission revendeur) — NULL = aucun accord appliqué (codes hérités)
commission_typestring, nullablepercentage | fixed_amount (App\Enums\CommissionType) — un montant fixe s'entend dans la currency du code
descriptiontext, nullableNote libre (max 1000 caractères à la validation), orientée voyageur : reprise telle quelle de l'ancien système à l'import, ou valeur partagée par tout le lot à la génération
internal_notestext, nullableNote réservée à l'équipe au niveau du code (consignes ponctuelles) — exposée aux seuls super-admins, jamais aux voyageurs ni dans l'export CSV ; même règle que providers.internal_notes
issuing_organisation_iduuid FK → organizationsCASCADE DELETE, NOT NULL — retombe sur l'organisation par défaut "Travelise" si omis à la création (voir Identité & accès)
customer_iduuid FK → users, nullableNULL ON DELETE
tour_idforeignUuidtours, nullableLe tour choisi à l'activation — écrit et effacé atomiquement avec used_at (même UPDATE gardé) ; RESTRICT à la suppression du tour
traveller_countunsigned smallint, nullableTaille du groupe déclarée à l'activation (obligatoire sur POST /activate) — écrite et effacée atomiquement avec used_at/tour_id ; CHECK activation_codes_traveller_count_positive_check (>= 1) ; NULL (activations héritées, antérieures à la fonctionnalité) compte pour 1 voyageur dans les agrégats d'occupation
purchased_attimestamp, nullableDate d'achat par le client
expires_attimestamp, nullablePlancher individuel de validité — NULL à la génération (le code suit la date du lot) ; renseigné par un renouvellement individuel, ou par les codes hérités qui portent leur propre date (voir Expiration effective)
durationunsigned smallint, nullableFenêtre d'activation en heures après used_at
used_attimestamp, nullableDébut de la fenêtre d'activation — une valeur future est une activation planifiée (statut pre-active), pas une anomalie ; les filtres used_from/used_to et l'export CSV exposent donc des used_at futurs, comportement voulu
cancelled_attimestamp, nullableDate d'annulation
reimbursed_attimestamp, nullableDate de remboursement
reimbursement_requested_attimestamp, nullableIntention voyageur : demande de remboursement (statut pending-reimbursement tant qu'elle postdate reimbursement_denied_at) — jamais nettoyée, l'approbation (reimbursed_at) prime par priorité de cascade
reimbursement_denied_attimestamp, nullableRefus admin de la demande de remboursement — le code retombe dans son état naturel et redevient utilisable ; une nouvelle demande rouvre en postdatant le tampon
reimbursement_denial_reasontext, nullableMotif du dernier refus de remboursement (obligatoire au refus), écrit pour le demandeur — dernier-écrit-gagne, l'historique vit dans le journal d'audit
extension_requested_attimestamp, nullableIntention voyageur/revendeur : demande d'extension d'un code acheté ou expiré — la demande est ouverte tant qu'elle postdate extension_granted_at et extension_denied_at (un tampon NULL est ignoré) ; jamais nettoyée
extension_granted_attimestamp, nullableApprobation : posé par le renouvellement individuel (purchased + new_expiration) et par la prolongation de la date du lot (PATCH batch) sur les demandes ouvertes
extension_denied_attimestamp, nullableRefus admin de la demande d'extension — le code retombe dans son statut de base (acheté ou expiré)
extension_denial_reasontext, nullableMotif du dernier refus d'extension (obligatoire au refus), écrit pour le demandeur — dernier-écrit-gagne, l'historique vit dans le journal d'audit
gifted_attimestamp, nullableLe propriétaire a offert le code (statut offered) — seul timestamp du cycle de vie qui s'efface : le claim du destinataire le remet à NULL
created_at / updated_attimestamps

Index composite sur (issuing_organisation_id, prefix) pour les lookups par lot (ex. « tous les codes du revendeur X portant le préfixe Y »).

Invariant généré XOR hérité : chaque code est exactement de l'une des deux formes, garanti par la contrainte CHECK activation_codes_generated_or_legacy_check :

  • généré : prefix et code renseignés, batch_id renseigné (toute génération crée un lot, même pour un seul code), legacy_code à NULLfull_code vaut prefix-code ;
  • hérité : legacy_code renseigné (chaîne conservée à l'identique car les clients détiennent encore ces codes), prefix, code et batch_id à NULLfull_code vaut legacy_code.

Invariant d'unicité : code (suffixe seul) et legacy_code portent chacun un index UNIQUE, mais depuis l'arrivée des codes hérités l'unicité du full_code n'est plus une conséquence de celle du suffixe (une chaîne héritée peut entrer en collision avec une concaténation prefix-code). L'identité orientée client est donc verrouillée par un index UNIQUE dédié sur full_code. Conséquence assumée : une collision (astronomiquement improbable) entre un suffixe fraîchement généré et un code hérité fait échouer le lot de génération entier avec une QueryException.

Génération par lot : App\Actions\GenerateActivationCodesAction crée la ligne de lot puis génère N codes, le tout en une seule transaction. Elle acquiert un verrou consultatif PostgreSQL (pg_advisory_xact_lock(hashtext($prefix))) pour sérialiser les insertions concurrentes portant le même préfixe, pré-filtre les suffixes déjà existants, puis insère en blocs de 500. La longueur du suffixe est pilotée par la variable d'environnement ACTIVATION_CODE_SUFFIX_LENGTH (défaut : 8, config : activation_codes.suffix_length). La date d'expiration est posée sur le lot (celle fournie dans la requête — obligatoirement future — sinon un an après la génération) ; les codes naissent avec expires_at à NULL et la suivent. Chaque code reçoit un instantané de la commission courante de l'organisation émettrice (voir ci-dessous).

activation_code_batches

Le lot de génération, entité de première classe (fiche métier : Lot de codes d'activation). Tout code généré appartient à un lot ; les codes hérités n'en ont jamais.

ColonneTypeNotes
iduuid PKUUIDv7 via HasUuids
batch_numberbigint UNIQUE, NOT NULLNuméro de lot lisible tiré de la séquence activation_code_batch_number_seq (voir ci-dessous)
issuing_organisation_iduuid FK → organizationsCASCADE DELETE, NOT NULL — même règle que les codes
internal_notestext, nullableNote réservée à l'équipe au niveau du lot (contexte de campagne) — super-admins uniquement, jamais les voyageurs
expires_attimestamp, NOT NULLLa date limite du lot, éditable — suivie par tous les codes sans plancher individuel plus tardif
created_at / updated_attimestamps

Numéro de lot : chaque génération tire un batch_number de la séquence PostgreSQL activation_code_batch_number_seq (explicitement, nextval dans la transaction) ; l'endpoint de génération renvoie {"generated": N, "batch_id": …, "batch_number": X} et l'entrée d'audit porte les deux. C'est un numéro métier (comme un numéro de facture), pensé pour être dicté à voix haute par un admin — pas une clé : l'invariant UUIDv7 ne concerne que les PK/FK et reste intact ; ne jamais joindre dessus dans du code applicatif. Les séquences étant non transactionnelles, la suite est strictement croissante mais pas contiguë (une génération annulée brûle un numéro — assumé). La numérotation rétroactive de la migration du 2026-07-30 (colonne dénormalisée sur les codes) est supersédée par cette table — sans backfill, les environnements étant re-seedés. Sélection : filtre batch_number sur les endpoints de listing des codes (colonne de la vue) et l'export (colonne CSV), champ exposé par ActivationCodeResource ; filtres organisation_id/batch_number sur le listing des lots.

Endpoints de lot : GET /api/activation-codes/batches (liste paginée, super-admin = tout, organisation = ses lots, voyageur = 403), GET /api/activation-codes/batches/{id} et PATCH /api/activation-codes/batches/{id} (internal_notes, expires_at — super-admins uniquement, édition auditée via Auditable). Les compteurs de codes par statut sont agrégés en un seul GROUP BY sur la vue de statut (ActivationCodeBatch::attachStatusCounts(), règle « SQL d'abord »).

Expiration effective — GREATEST

L'invariant « chaque code généré expire » est devenu « chaque lot expire ». La date effective d'un code est GREATEST(ac.expires_at, lot.expires_at), en s'appuyant sur le fait que GREATEST PostgreSQL ignore les NULL :

  • code NULL + lot daté → le code suit son lot, dans les deux sens (un admin qui recule la date du lot fait expirer le stock, qui la ravance le ressuscite) ;
  • code daté + lot daté → la plus tardive gagne : la date du code est un plancher individuel posé par un renouvellement, jamais un plafond ;
  • code hérité (sans lot) → sa propre date ; ni date ni lot → n'expire jamais.

Aucun mécanisme d'expiration ne raccourcit une grâce individuelle ; tuer un code précis est le rôle de cancelled_at, qui prime de toute façon sur l'expiration dans la cascade de statut. Le miroir PHP est ActivationCode::effectiveExpiresAt() (il ignore explicitement les NULL, comme GREATEST) ; isExpired() et l'accessor de statut s'appuient dessus, et la parité avec la vue est couverte par un test dédié.

Commission revendeur — gabarit et instantané

Les revendeurs touchent une commission sur les codes d'activation qu'ils vendent. Le modèle sépare deux natures temporelles :

  • organizations.commission / commission_type (decimal(10,2) NOT NULL défaut 0, string défaut percentage) — l'accord courant, un simple gabarit appliqué aux futurs lots. Modifiable uniquement par un super-admin ; l'historique des renégociations vient gratuitement du trait Auditable (activity_log).
  • activation_codes.commission / commission_type (nullables) — l'instantané contractuel posé sur chaque code par GenerateActivationCodesAction, au même titre que amount/currency. Renégocier l'accord d'une organisation ne réécrit donc jamais la valeur des codes déjà émis ; une application rétroactive à des codes en stock serait un UPDATE explicite et audité, pas un effet de bord. Les codes hérités (import) restent à NULL : les antidater avec le taux actuel serait une fiction.

Un commission_type à fixed_amount s'entend dans la currency du code ; le plafond de 100 ne s'applique qu'aux pourcentages (validation).

Évolution prévue — offres promotionnelles (« deals ») : une promotion du type « +5 % sur les codes commandés et activés entre novembre et janvier » ne peut pas être instantanée à la génération (used_at n'existe pas encore). Elle sera calculée à la lecture, en SQL, sur les timestamps immuables du code — le même motif que la vue activation_codes_with_status : une future table deals (fenêtres de validité sur created_at/used_at, organization_id nullable pour la portée, taux) jointe au moment du reporting. Décision produit actée : un deal remplace le taux de base des codes éligibles (il ne s'y additionne pas). Rien de tout cela n'est construit aujourd'hui ; l'instantané par code garantit que ça se greffera proprement. Questions encore ouvertes pour la future fonctionnalité de paiement : quel événement déclenche la commission (purchased_at vs used_at), et le sort des codes cancelled/reimbursed (récupération ou non).

Import depuis l'ancien système : trois endpoints super-admin sous /api/import alimentent la migration de données (consommés par un workflow KNIME qui nettoie les exports CSV/XLSX) :

  • POST /api/import/organizations et POST /api/import/users font du find-or-create (par name, resp. par email — correspondance exacte, sensible à la casse) et répondent dans l'ordre de la requête avec les uuids et un drapeau created, ce qui les rend idempotents et rejouables. Les utilisateurs créés sont des voyageurs pending sans mot de passe.
  • POST /api/import/activation-codes insère des codes hérités tout-ou-rien (ImportActivationCodesAction) : toute ligne invalide (doublon de full_code, organisation ou client inconnus) fait échouer le lot entier en 422 avec des erreurs indexées par ligne. La chaîne d'origine arrive dans le champ full_code du payload et atterrit dans legacy_code ; l'état du cycle de vie voyage en timestamps bruts (le statut reste calculé), created_at peut être antidaté pour préserver l'historique, la description héritée est conservée telle quelle, et l'organisation émettrice retombe sur "Travelise" si omise.

Vue activation_codes_with_status

Le statut d'un code n'est pas une colonne stockée : il dépend de NOW() (fenêtre d'activation, expiration) et est donc calculé à la lecture. C'est la machine à états du produit tout entière — les transitions temporelles (pré-actif→actif, actif→utilisé, →expiré) n'ont aucun code applicatif : elles émergent de la dérivation. Le statut existe en deux exemplaires qui doivent rester synchrones :

  • la vue PostgreSQL activation_codes_with_status (SELECT ac.*, b.batch_number, GREATEST(ac.expires_at, b.expires_at) AS effective_expires_at, (…) AS has_open_extension_request, CASE … END AS status FROM activation_codes ac LEFT JOIN activation_code_batches b ON b.id = ac.batch_id), utilisée pour tout ce qui filtre ou agrège par statut en SQL — la branche expired du CASE teste la date effective ;
  • l'accessor PHP ActivationCode::getStatusAttribute(), qui reproduit la même cascade pour les instances déjà hydratées (réponses API, CSV), via effectiveExpiresAt().

La vue expose aussi le booléen dérivé has_open_extension_request — l'expression canonique « demande d'extension ouverte » : extension_requested_at non nul et postdatant extension_granted_at et extension_denied_at (un tampon NULL est ignoré). Le drapeau est orthogonal au statut : un code acheté avec une demande ouverte reste purchased, activable, réclamable et offrable — seule une demande ouverte sur code expiré remonte en pending-extension. Miroir PHP : ActivationCode::hasOpenExtensionRequest(), qui privilégie la valeur SQL quand la ligne vient de la vue (même précédent que batchNumber() / effectiveExpiresAt()).

Cascade de statut (priorité décroissante — l'ordre des branches fait partie du contrat) :

#StatutCondition
1reimbursedreimbursed_at IS NOT NULL
2cancelledcancelled_at IS NOT NULL
3pending-reimbursementreimbursement_requested_at non nul et postdatant reimbursement_denied_at (NULL ignoré)
4pre-activeused_at > NOW() (activation planifiée)
5activeused_at passé + duration + fenêtre en cours
6usedused_at IS NOT NULL
7pending-extensiondate effective passée et demande d'extension ouverte (has_open_extension_request)
8expireddate effective passée
9offeredgifted_at IS NOT NULL
10purchasedpurchased_at IS NOT NULL
11in stocksinon

Deux subtilités d'ordre :

  • la branche pre-active doit précéder active : un used_at futur donne un temps écoulé négatif qui satisfait la formule de fenêtre — des deux côtés, SQL et PHP (isActive() exclut explicitement les used_at futurs) ;
  • l'approbation d'un remboursement et l'extinction d'offered jouent par priorité de cascade (reimbursed/cancelled au-dessus de la demande, expiration au-dessus d'offered) — aucun nettoyage de timestamp, nulle part.

Résolutions explicites (branches 3 et 7) : les demandes se résolvent par des tampons, jamais par nettoyage. Une demande ne compte que tant qu'elle postdate ses tampons de résolution : extension_granted_at / extension_denied_at pour l'extension, reimbursement_denied_at pour le remboursement (l'approbation d'un remboursement reste dérivable — reimbursed_at prime par cascade, aucun tampon nécessaire). Une nouvelle demande rouvre en postdatant les tampons ; un nouveau refus remplace le motif (dernier-écrit-gagne, l'historique vit dans le journal d'audit). Ce modèle remplace l'ancienne garde auto-cicatrisante (demande postdatant la date effective), devenue indérivable depuis que l'extension peut se demander sur un code encore valide. Le scénario fantôme reste impossible : code expiré → demande → un super-admin prolonge la date du lot (ce qui tamponne extension_granted_at) → la nouvelle date repasse → le code est expired, pas pending-extension ressuscité. Couvert par des tests dédiés (EffectiveExpiryTest pour la dérivation, UpdateActivationCodeBatchTest pour le chemin d'écriture réel).

L'énum App\Enums\ActivationCodeStatus est la source de vérité PHP des onze valeurs ; ses backed values reflètent le CASE de la vue (les nouvelles en kebab-case ; in stock garde son espace, contrat de fil historique).

Marqueur d'achat : le statut purchased est dérivé de purchased_at (marqueur canonique de l'achat), pas de customer_id, qui n'enregistre que l'assignation du code à un client. Un achat réel renseigne les deux colonnes en même temps ; les gardes de transition (UpdateActivationCodeStatusesAction) s'appuient sur le même marqueur via isPurchased().

Transitions de statut : App\Actions\UpdateActivationCodeStatusesAction (endpoint PUT /activation-codes/status) applique une transition à un groupe de codes : cancelledcancelled_at, reimbursedreimbursed_at, usedused_at, et purchased = renouvellement pur (aucun timestamp de cycle de vie ; ouvert à tout code ni remboursé, ni annulé, ni utilisé — l'achat n'est pas requis, un code en stock reste en stock). C'est le chemin d'écriture du plancher individuel expires_at : le champ optionnel new_expiration renouvelle la date des codes mis à jour (obligatoire pour status=purchased). Un renouvellement pur dont la date ne dépasse pas la date effective courante est renvoyé comme skipped (no-op sous GREATEST) ; une vraie transition accompagnée d'une date ineffective s'applique et stocke la date (plancher inoffensif). Les codes éligibles sont mis à jour en un seul UPDATE … WHERE id IN (…) (règle « SQL d'abord ») ; les autres sont renvoyés comme skipped avec une raison. Le bras used refuse en outre les codes offerts ou en attente de remboursement (l'intention pendante prime).

Arêtes voyageur — endpoints d'intention : le PUT /status garde son vocabulaire en états (opérations admin + status=used voyageur) ; les transitions du diagramme produit qui relèvent d'une intention voyageur ont des endpoints en verbes explicites, mono-code :

EndpointTransitionActeursEffet
POST /activation-codes/{id}/activate (body tour_id, traveller_count, activation_date?)acheté → actif / pré-actifclient du code ; super-adminused_at = date ?? now(), tour_id = tour choisi, traveller_count = taille du groupe — les trois dans le même UPDATE gardé
DELETE /activation-codes/{id}/activationpré-actif → achetéclient du codeused_at = NULL, tour_id = NULL, traveller_count = NULL — effacés ensemble
POST /activation-codes/{id}/reimbursement-requestacheté → attente remboursementclient du codereimbursement_requested_at = now()
POST /activation-codes/{id}/reimbursement-request/denial (body reason)attente remboursement → état naturelsuper-adminreimbursement_denied_at = now() + motif
POST /activation-codes/{id}/extension-requestacheté ou expiré (sans demande ouverte) → demande ouverteclient du code ; organisation émettriceextension_requested_at = now()
POST /activation-codes/{id}/extension-request/denial (body reason)demande ouverte → statut de basesuper-adminextension_denied_at = now() + motif
POST /activation-codes/{id}/giftacheté → offertclient du codegifted_at = now()
POST /activation-codes/claim (body code)en stock → acheté ou offert → achetétout voyageur (le code complet est la référence)customer_id = demandeur, purchased_at = COALESCE(purchased_at, NOW()), gifted_at = NULL

Règles communes : la garde d'état vit dans le WHERE de l'UPDATE (sous-requête sur la vue de statut — le statut dérivé courant doit être celui attendu), rowcount 0 → 422 avec un message produit après coup par les prédicats PHP ; pas de check-then-write (Octane + concurrence, règle « SQL d'abord »). activate refuse une date au-delà de la date effective (la cascade mettrait pre-active au-dessus d'expired et la contournerait) et exige un tour_id et un traveller_count — activer, c'est choisir son voyage et déclarer son groupe : le tour doit exister au sens de Tour::visibleTo() (hors visibilité, un tour « n'existe pas » — un voyageur ne voit que les tours active) et être active au moment de l'écriture, un EXISTS sur le statut du tour vivant dans le même UPDATE gardé (une suspension concurrente ne passe pas). L'annulation d'une planification efface used_at, tour_id et traveller_count ensemble — une réactivation peut choisir un autre tour ou un autre groupe, un code annulé puis offert ne transmet pas un choix périmé. L'audit porte le tour et le groupe choisis (activated) comme effacés (activation_cancelled). Chaque écriture est auditée explicitement (l'UPDATE requêté ne déclenche aucun événement de modèle). Le claim couvre deux arêtes du diagramme avec le même geste — l'import d'un code en stock (le claim vaut achat) et la prise de possession d'un cadeau (date d'achat conservée) ; pas de table d'historique des cadeaux, l'activity log suffit.

Approbations sans endpoint dédié : approuver une extension = renouveler le code (purchased + new_expiration) ou prolonger la date du lot (PATCH batch). Les deux chemins tamponnent extension_granted_at : le renouvellement le pose dans le même UPDATE groupé (y compris sur les codes sans demande — inoffensif, une demande ultérieure rouvre en postdatant le tampon) ; la prolongation du lot le pose en un seul UPDATE ensembliste sur les demandes ouvertes de ses codes, audité extension_granted (raccourcir la date ou n'éditer que les notes ne tamponne rien). Résoudre une demande de remboursement = reimbursed ou cancelled (priorité de cascade), ou le refus explicite ci-dessus. Arête décidée : activer un code ne dépense pas sa demande ouverte — un admin la résout manuellement plus tard.

Divergences assumées : un admin peut rembourser sans demande préalable (souplesse opérationnelle) ; hormis le tamponnage des approbations, le PATCH batch n'a aucune garde nouvelle — raccourcir une date reste un acte super-admin délibéré (une demande encore ouverte remonte alors simplement en pending-extension). Cas limite accepté : un lot raccourci sous une activation planifiée laisse le code devenir actif à l'heure prévue (documenté, pas corrigé).

Les endpoints de listing et l'export CSV lisent la vue (scope ActivationCode::fromStatusView()) afin que les filtres — notamment status in […] — s'exécutent en SQL (règle « SQL d'abord »), via le scope ActivationCode::filter() alimenté par la ListActivationCodesRequest ; les bornes expires_from/expires_to visent effective_expires_at, et le champ expires_at des réponses API et de l'export porte la valeur effective. Deux filtres booléens ciblent les demandes en attente : pending_extension filtre la colonne dérivée has_open_extension_request (orthogonale au statut — il attrape aussi les demandes ouvertes sur des codes encore valides, que status[]=pending-extension ne voit pas), et pending_reimbursement équivaut au statut pending-reimbursement. Les statistiques (ActivationCodeStatsController) agrègent la même vue.

Prestataires & activités — providers, activities, tags, activity_tag

L'ancienne base du client confondait le prestataire (l'entreprise partenaire auprès de laquelle on réserve) et l'activité (l'expérience vendue au voyageur) dans un seul enregistrement — certains enregistrements décrivaient d'ailleurs plusieurs tours distincts dans un même champ texte. Le nouveau modèle les sépare : un prestataire 1—N activités.

providers (App\Models\Provider) — l'identité du partenaire :

ColonneTypeNotes
iduuid PKUUIDv7 via HasUuids
namestring, indexée
website / booking_email / booking_link / phonestring, nullableCoordonnées de contact et de réservation
address, address_more, city, zip(20), country(2)string, nullableMême forme d'adresse embarquée que users (ISO 3166-1 alpha-2)
latitude / longitudedecimal(10,7), nullableCHECK providers_coordinates_check (±90 / ±180). Colonnes d'entrée : le point PostGIS location en est généré (voir « Recherche plein texte & géolocalisation »)
search_vectortsvector généré STOREDVecteur lexical pondéré name (A) + city (B), index GIN — l'adresse n'alimente pas le vecteur — voir « Recherche plein texte & géolocalisation »
locationgeography(Point, 4326) généré STORED, nullableGénéré depuis latitude/longitude (NULL si l'une manque), index GiST
standingstring, nullableÉnum App\Enums\Standing (standard, premium)
statusstring, défaut draft, indexéeÉnum App\Enums\ApprovalStatus (draft, approved, archived) — hors #[Fillable], assignation directe uniquement
originstringÉnum App\Enums\ProviderOrigin (imported, signed_up, on_the_fly) — hors #[Fillable], sans défaut : chaque flux de création la fixe délibérément ; lignes antérieures backfillées imported
internal_notestext, nullableNotes internes de l'équipe (jamais exposées aux voyageurs)
hubspot_idstring, nullableLien CRM repris de l'ancien système
legacy_pk / legacy_uuidbigint / uuid, UNIQUE, nullableIdentité dans l'ancienne base : imports idempotents et reconstruction ultérieure des liens différés (ex. fk_destination)

activities (App\Models\Activity) — l'expérience réservable (à ne pas confondre avec AuditLog, le journal d'audit) :

ColonneTypeNotes
iduuid PKUUIDv7 via HasUuids
provider_iduuid FK → providersCASCADE DELETE — une activité n'a pas de sens sans son prestataire
namestring
descriptiontext, nullableTexte orienté voyageur
internal_notestext, nullable
duration_minutesinteger, nullableCHECK > 0 ; l'ISO-8601 de l'ancien système (PT2H) se convertit trivialement
physical_difficultystring, nullableÉnum App\Enums\PhysicalDifficulty (light, moderate, sustained)
categorystring, nullable, indexéeÉnum App\Enums\ActivityCategory (surprise)
price_min / price_maxdecimal(8,2), nullableFourchette de prix ; CHECK activities_price_range_check (min <= max)
currencystring(3), nullableISO 4217
pick_up_hotelboolean, défaut falseLe prestataire vient chercher les voyageurs à l'hôtel
schedule_notestext, nullableDisponibilités en texte libre (ancien champ horraire)
capacityunsignedInteger, nullableCapacité indicative en voyageurs par jour de départ — signal d'affluence, jamais une contrainte ; NULL = inconnue ; CHECK activities_capacity_positive_check (>= 1) — voir Capacité & saturation
unlimitedboolean, défaut falseL'activité ne sature jamais ; exclut une capacity chiffrée (CHECK activities_unlimited_excludes_capacity_check, miroir dans la validation — y compris sur l'état résultant d'un update partiel)
contact_namestring, nullablePersonne de contact pour cette activité
contact_phonestring(50), nullable
contact_emailstring, nullable
contact_websitestring, nullable
statusstring, défaut draft, indexéeÉnum ApprovalStatus, même mécanique que providers
image_iduuid FK → images, nullableSET NULL à la suppression de l'image — l'image d'illustration, voir Stockage d'images

L'activité est agnostique de l'horaire : réutilisée d'un tour à l'autre à des moments différents de la journée, elle ne porte ni heure de début ni paramètres de révélation — c'est l'étape qui les porte.

Le vecteur de recherche des activités ne vit pas sur la table mais dans la vue matérialisée activity_search_index — voir « Recherche plein texte & géolocalisation ».

Les champs publics du prestataire (identité / contact / localisation — la constante Provider::PUBLIC_ATTRIBUTES) sont ce que ActivityResource expose comme effective_provider et ce qu'un prestataire peut déclarer sur lui-même au signup (SignupProviderRequest) ; les champs de workflow et internes (status, standing, origin, notes, clés CRM / héritage) n'en font jamais partie.

Historique : une activité pouvait surcharger le visage public de son prestataire via des « prestataires fantômes » — des lignes providers de type = override rattachées au parent par parent_provider_id, pointées par activities.provider_override_id (FK composite), un champ non-NULL du fantôme l'emportant sur le parent. Le mécanisme a été retiré (migration remove_provider_override_mechanism) : le produit présente prestataire et activité comme une fiche unique, la surcharge était donc inexerçable, sa validation renvoie désormais 422 (provider_override → règle prohibited), et la FK composite transformait toute déduplication/fusion future de prestataires en danse de contraintes. Si une vraie divergence par activité devient un besoin, la voie sanctionnée est la colonne nullable ciblée sur activities (cf. les colonnes contact_*).

tags (App\Models\Tag) — le registre de tags : les vocabulaires multi-valués et non ordonnés du catalogue, gérés par les super-admins via le CRUD /api/tags. Un discriminant type (énum App\Enums\TagType + CHECK tags_type_check) sépare les vocabulaires : theme (classement éditorial), restriction (contre-indications voyageur : claustrophobie, vertige…), thing_to_bring (affaires à prendre : coupe-vent, maillot de bain…), suitability (garanties affirmatives : adapté aux enfants, chiens bienvenus, accessible en fauteuil roulant…). La polarité est une propriété du vocabulaire : une restriction est un avertissement (présence = exclusion, absence = rien à signaler), une suitability une garantie (présence = inclusion, absence = inconnu — jamais « supporté par défaut »). slug est l'identifiant machine stable — contrat de filtrage et clé de fusion de l'import — immuable après création (règle prohibited du CRUD, garde-fou saving du modèle en filet) ; UNIQUE (type, slug), un même slug pouvant exister dans deux vocabulaires. Libellés trilingues : name_fr NOT NULL (langue canonique), name_en / name_de et les trois description_* nullables — les clients retombent sur le français quand une traduction manque. C'est le premier contenu localisé côté serveur de l'application. icon (nullable) porte un slug d'icône Lucide que les clients affichent à côté du libellé — le format est validé, l'appartenance au jeu Lucide reste l'affaire du frontend. Les valeurs sont des données (CRUD super-admin), les vocabulaires sont du code : un nouveau type = un cas d'énum + une migration du CHECK — la recherche, la validation et les clients doivent le connaître. Les propriétés exclusives ou ordinales (physical_difficulty, category, status, standing) restent des colonnes à énum : un pivot leur ferait perdre l'ordre et la garantie mono-valeur.

activity_tag (App\Models\ActivityTag, pivot) — l'étiquetage d'une activité : activity_id (CASCADE DELETE) × tag_id (RESTRICT DELETE — la base refuse de supprimer un tag encore porté ; le endpoint répond 409 en amont), UNIQUE (activity_id, tag_id), sans timestamps. Le modèle pivot existe pour qu'attach()/sync() génèrent la PK UUIDv7 (même raison qu'ActivityStep) ; il est Auditable.

Historique : les thèmes vivaient dans activity_themes, la valeur de l'énum App\Enums\Theme directement dans la ligne enfant, et élargir le vocabulaire était une décision de code. La décision a été inversée quand le vocabulaire hérité a débordé l'énum (party ajouté au coup par coup ; romantic, teambuilding, wellness, other toujours manquants) : la migration replace_activity_themes_with_activity_tag a converti les lignes vers le registre et supprimé la table comme l'énum.

Recherche plein texte & géolocalisation

Les endpoints GET /api/tour/provider/search et GET /api/tour/activity/search reposent sur deux mécanismes SQL, sans paquet Eloquent dédié (règle « SQL d'abord » — aucun type spatial n'est jamais hydraté côté PHP) :

  • Vecteurs lexicaux : la recherche porte sur le nom et le lieu uniquement — la description des activités et l'adresse des prestataires ne sont pas recherchables, et les champs internes (internal_notes, clés CRM / héritage) n'alimentent jamais les vecteurs.

    • Prestataires : colonne search_vector tsvector générée STORED (name A + city B), index GIN.
    • Activités : vue matérialisée activity_search_index (activity_id, index UNIQUE ; search_vector, index GIN) combinant name (A) et la ville du prestataire (B) — une colonne générée ne pouvant pas lire une autre table. Elle est rafraîchie par des triggers AFTER … FOR EACH STATEMENT sur activities (INSERT / UPDATE / DELETE / TRUNCATE) et providers (UPDATE / DELETE) appelant la fonction plpgsql refresh_activity_search_index()REFRESH MATERIALIZED VIEW non CONCURRENTLY, donc utilisable dans une transaction (tests) ; le catalogue est petit, le rebuild complet est bon marché, et l'index UNIQUE garde la porte ouverte à CONCURRENTLY si le volume grandit.

    La requête utilisateur est réduite en lexèmes sûrs (tout caractère hors lettre/chiffre est un séparateur), chaque terme reçoit :* (préfixe) et le tout est joint par & dans to_tsquery('simple', …) ; le score ts_rank est retourné (rank) et filtrable par le paramètre cutoff (défaut : config('search.default_rank_cutoff')).

  • Configuration simple (choix délibéré) : catalogue multilingue (fr/de/en) + correspondance par préfixe — un dictionnaire à racinisation les combattrait. Limite assumée : sensibilité aux accents (ZürichZurich), unaccent() n'étant pas IMMUTABLE (inutilisable dans une colonne générée).

  • PostGIS : l'extension est créée par migration (CREATE EXTENSION IF NOT EXISTS postgis, down() no-op — l'extension peut préexister). La colonne providers.location geography(Point, 4326) est générée depuis latitude/longitude (NULL si l'une manque), indexée GiST. Le tri par proximité (latitude/longitude optionnels de la recherche) ordonne par l'opérateur KNN location <-> point (plus proche d'abord, NULLS LAST) et retourne distance_meters (ST_Distance, sphéroïde). Une activité n'a pas de coordonnées propres : sa position est celle de son prestataire (providers.location).

  • Effets de bord de l'extension : la table système spatial_ref_sys est exclue du diagramme (ignore_tables), et l'introspection ERD est bornée au schéma public (config/mermaid-erd.php) — l'image postgis/postgis crée aussi des schémas tiger / topology qui n'appartiennent pas au domaine.

Road-trips — tours, steps, activity_step, step_routings

Un tour est un road-trip planifié, vendu à travers une organisation partenaire : une séquence ordonnée d'étapes (steps), chacune proposant plusieurs activités en alternatives priorisées (plan A, plan B…) pour dérouter les voyageurs quand une activité est fermée ou complète.

tours (App\Models\Tour) :

ColonneTypeNotes
iduuid PKUUIDv7
namestring, indexée
colorstring(7)Couleur d'affichage frontend ; tours_color_hex_check impose #RRGGBB
departure_citystring, nullableZone de départ approximative (« Fribourg ») révélée en teaser au voyageur la veille du départ
departure_city_reveal_timetime, nullableHeure du jour, la veille du départ, à laquelle la ville de départ est révélée — sans date : le tour est un gabarit, l'instant réel se dérive du used_at du code d'activation
statusstring, défaut pending, indexéeÉnum App\Enums\TourStatus (pending, active, suspended) — hors #[Fillable], affectation directe uniquement
partner_organization_idforeignUuidorganizationsRESTRICT à la suppression : on ne fait pas disparaître des tours en silence
region_organization_idforeignUuidorganizations, nullableLa région d'appartenance (organisation de type region, règle portée par la validation) ; SET NULL à la suppression

La visibilité d'un tour dépend du contexte de visualisation (voir identité & accès) : le scope Tour::visibleTo() est la source de vérité unique — un super-admin voit tout, les organisations et les voyageurs ne voient que les tours active.

Un tour choisi par des codes d'activation (activation_codes.tour_id) n'est pas supprimable : refus applicatif en 409 (même motif que les tags portés par des activités), adossé à la FK RESTRICT — le tour porte de l'histoire de voyage, qui meurt avec le code et jamais avec le tour ; la voie de retrait est la suspension.

steps (App\Models\Step) :

ColonneTypeNotes
iduuid PKUUIDv7
tour_idforeignUuidtoursCASCADE : une étape n'existe pas sans son tour
namestring
positionunsignedIntegerOrdre 1..N contigu dans le tour — seule vérité d'ordre ; hors #[Fillable]
day_numberunsignedSmallInteger, nullableJour 1..N du voyage (steps_day_number_positive_check)
start_timetime, nullableHeure de début indicative, sans date
reveal_timetime, nullableHeure du jour à laquelle l'étape est révélée au voyageur, sans date
reveal_days_beforeunsignedInteger, défaut 0Nombre de jours avant le jour de l'étape où la révélation a lieu (0 = le jour même) ; CHECK steps_reveal_days_before_positive_check (>= 0)
  • Invariant de position : UNIQUE (tour_id, position) DEFERRABLE INITIALLY DEFERRED (DDL brut — le schema builder ne sait pas l'exprimer). Différé au commit pour que la réécriture en un seul UPDATE du réordonnancement (une permutation) et la décrémentation après suppression ne trébuchent pas sur des collisions transitoires, tout en refusant un état réellement dupliqué. day_number et les heures sont informatifs : rien ne force l'ordre des positions à respecter l'ordre des jours.
  • Le squelette horaire appartient aux créneaux, pas aux étapes : au réordonnancement, le créneau p (en ordre de positions) conserve son day_number, start_time, reveal_time et reveal_days_before, et l'étape placée en position p les reprend — les étapes permutent à travers un squelette d'itinéraire invariant. Le tout reste un seul UPDATE ensembliste (la sous-requête de créneaux lit le snapshot de l'instruction, jointe par rang) ; comme la réécriture de positions, il contourne Eloquent et donc le journal d'audit.
  • Écrivains sérialisés : toute écriture de positions (création, suppression, réordonnancement) verrouille la ligne du tour (lockForUpdate) — le tour est le point de sérialisation. En secours, un hook creating place une étape sans position explicite en fin de tour (factories, seeders).

activity_step (App\Models\ActivityStep, pivot sans timestamps) — les alternatives d'une étape : step_id + activity_id (tous deux CASCADE), priority (1 = plan A, nullable, activity_step_priority_positive_check) et retired_at (nullable). Contraintes :

  • UNIQUE (step_id, activity_id) — une activité au plus une fois par étape, lignes retirées comprises : le ré-ajout ressuscite la ligne retirée au lieu d'en créer une seconde (une ligne par couple, pour toujours).
  • UNIQUE (step_id, priority) DEFERRABLE INITIALLY DEFERRED (DDL brut, même motif que les positions des étapes) : l'édition par différence permute des priorités en place, le différé absorbe les collisions transitoires. NULLS DISTINCT obligatoire (défaut PostgreSQL — ne jamais passer à NULLS NOT DISTINCT) : le retrait annule la priorité, et plusieurs lignes retirées par étape doivent coexister.
  • CHECK ((retired_at IS NULL) = (priority IS NOT NULL)) — vivante et classée, ou retirée et sans rang, jamais entre les deux.
  • UNIQUE (id, step_id) — cible de la clé composite des routages (ci-dessous) : un routage ne peut référencer que le plan de sa propre étape.

Les alternatives ne sont plus réécrites en bloc : l'écriture est un diff sur activity_id (les lignes survivantes gardent leur UUID — les routages en dépendent), la suppression physique n'est tentée que pour les lignes sans routage (RESTRICT arbitre), les autres sont retirées (retired_at = now(), priority = NULL d'un même UPDATE). Le modèle pivot dédié existe pour que attach()/sync() génèrent la clé UUIDv7 de la ligne (la colonne n'a pas de défaut côté base).

step_routings (App\Models\StepRouting) — le parcours individuel d'un voyage, une ligne par étape routée (révélée par le voyageur ou pré-routée par un super-admin) :

ColonneTypeNotes
iduuid PKUUIDv7
activation_code_idforeignUuidactivation_codesCASCADE : la trace fait partie de l'historique commercial du code
step_idforeignUuidstepsRESTRICT — dénormalisée pour la clé composite
activity_step_iduuid, indexéeLe plan suivi ; RESTRICT via la clé composite
revealed_attimestamp, nullableL'instant du premier affichage au voyageur — c'est lui qui veut dire « révélée », jamais l'existence de la ligne. NULL = pré-routée par un admin, pas encore vue
created_at / updated_attimestampcreated_at est la naissance de la ligne (révélation ou pré-routage — plus un synonyme de « révélée ») ; les déroutages mettent à jour en place (historique au journal d'audit)
  • UNIQUE (activation_code_id, step_id) — un routage par étape et par voyage ; c'est aussi la cible d'écriture idempotente de la révélation (upsert ON CONFLICT … DO UPDATE SET revealed_at = COALESCE(revealed_at, now()), qui conserve le plan pré-routé sauf s'il a été retiré — la politique par défaut re-résout alors).
  • FOREIGN KEY (activity_step_id, step_id) REFERENCES activity_step (id, step_id) (DDL brut — clé composite) — l'intégrité « le plan appartient à l'étape » est portée par la base, pas par l'application.
  • L'instant revealable_at servi au voyageur est calculé côté serveur (App\Services\StepRevealSchedule, Europe/Zurich) et imposé à l'endpoint de révélation — voir Routage d'étape.
  • Aucune colonne de date de passage : la date d'une étape est dérivée (used_at + day_number), les projections d'affluence sont des GROUP BY par (activité, jour) contre capacity.

Capacité & saturation

Répond à « combien de voyageurs cette région peut-elle absorber par jour, et à quel point est-elle pleine ? ». La capacité vit sur l'activité (activities.capacity / unlimited) ; tout ce qui est au-dessus est dérivé à la lecture, jamais stocké. Elle est consultative en amont, décisive seulement à la révélation (lot futur — routage) : elle ne bloque jamais la génération de codes, l'activation ni le choix d'un tour.

Sémantique à trois valeurs : une capacity chiffrée (>= 1), NULL = inconnue, ou unlimited = ne sature jamais — les deux dernières mutuellement exclusives avec un chiffre (CHECK + validation). Pour le futur routage, inconnue ≡ illimitée (ne sature jamais) ; elles ne diffèrent que dans le reporting. L'unité est le voyageur par jour de départ.

Algèbre de roll-up (implémentée par App\Services\Capacity, requêtes groupées dans App\Services\CapacityReport — règle « SQL d'abord » : une seule requête groupée par rapport, l'algèbre ne travaille que sur les lignes agrégées) ; chaque niveau expose un struct {value, exactness} avec exactness ∈ exact | partial | unknown | unlimited (App\Enums\CapacityExactness) — jamais un entier nu, pour qu'un agrégat partiel ne passe jamais pour un fait :

  • étape = SOMME de ses plans : un plan illimité → illimitée ; tous les plans inconnus → inconnue ; un plan inconnu parmi d'autres → somme connue marquée partial ; sinon exact. Une étape sans plan absorbe personne (0 exact).
  • tour = MIN de ses étapes (l'étape la plus étroite est le goulot) : les étapes illimitées ne bornent pas ; une étape inconnue teinte le minimum en partial ; un tour sans étape bornante → illimité.
  • région = SOMME de ses tours active (un tour pending ou suspended n'absorbe personne), même propagation.

Approximations acceptées (documentées, non corrigées) : deux tours partageant une activité la comptent deux fois dans la somme régionale ; les clients sans road-trip (walk-ins) des prestataires sont invisibles.

activity_closures (App\Models\ActivityClosure, Auditable) — les fermetures datées d'une activité, seule pièce stockée du tableau :

ColonneTypeNotes
iduuid PKUUIDv7
activity_idforeignUuidactivitiesCASCADE : une fermeture n'existe pas sans son activité
starts_on / ends_ondateBornes incluses ; CHECK activity_closures_date_range_check (starts_on <= ends_on) ; index (activity_id, starts_on, ends_on)
reasontext, nullableMotif libre (max 1000 à la validation)

Aux dates couvertes, la contribution de l'activité à la capacité effective vaut zéro ; les chevauchements sont légaux (deux raisons indépendantes peuvent fermer les mêmes jours) et une fermeture n'annule jamais de voyage. Des « fermetures partielles » (capacité réduite plutôt que nulle) seraient un ajout additif si le produit en a besoin. CRUD super-admin sous /api/tour/activity/{id}/closures (liste / création / édition / suppression).

Occupation (numérateur) : les voyageurs présents à l'étape (tour, day_number = N) à la date D sont la SUM(COALESCE(traveller_count, 1)) des codes de ce tour dont le jour de départ Europe/Zurich (used_at converti d'UTC) vaut D − (N − 1), filtrés par status NOT IN (cancelled, reimbursed) via la vue activation_codes_with_status — la fenêtre d'activation (duration) est délibérément ignorée, et les activations planifiées (pre-active) comptent. Les dates du produit se lisent en Europe/Zurich (paramètre date des endpoints, défaut aujourd'hui à Zurich).

Granularité de la plénitude : le couple (étape, date) — il n'existe pas de plénitude par plan dans ce lot (aucun routage encore). Par étape datée : capacité effective au jour demandé (fermetures appliquées), occupancy, et fullness = occupation / capacité (NULL quand le ratio n'a pas de sens : capacité illimitée, inconnue ou nulle — une capacité 0 avec une occupation > 0 se lit surbooké). Les étapes sans day_number sont légales : elles bornent la capacité (MIN, hors dates) mais sont remontées comme unplaceable dans le reporting — jamais silencieusement ignorées. La saturation est la vue goulot : MAX des fullness des étapes datées d'un tour, puis MAX des tours pour la région.

Endpoints (super-admin ; consommés par le tableau de bord régions) :

  • GET /api/tour/{id}/capacity?date=Y-m-d — struct de capacité structurelle du tour (hors fermetures) + lignes (étape, date) : capacité effective, occupation, plénitude, drapeau placeable ; saturation en tête.
  • GET /api/organizations/{id}/capacity?date=Y-m-d — le même tableau au niveau d'une région (organisation de type region — sinon 404) : somme des tours actifs, saturation goulot, et par tour capacité / saturation / compteur unplaceable_steps.

images

Une ligne par image téléversée via POST /api/images (voir Stockage d'images). La table ne porte que des métadonnées : les octets vivent sur le bucket S3 sous la clé plate {id}.{extension}, dérivée par l'accessor object_key du modèle (App\Models\Image) — jamais stockée, elle ne peut pas dériver de l'id.

ColonneTypeNotes
iduuid PKUUIDv7 — sert aussi de radical de la clé d'objet S3
extensionstringjpg | png | webp, re-dérivée du contenu réel (jamais du nom client)
mime_typestringType sniffé à l'upload
size_bytesunsignedBigInteger
original_namestringNom de fichier client, métadonnée d'affichage uniquement
width / heightunsignedInteger, nullableDimensions intrinsèques capturées à l'upload via getimagesize() ; null pour les lignes antérieures à la capture
pexels_url / pexels_authorstring, nullableAttribution Pexels (URL de la page photo + nom du photographe), exigée par leurs guidelines ; fournie par paire à l'upload quand l'image vient de la recherche Pexels du frontend, null pour les uploads utilisateur
uploaded_byforeignUuidusers, nullableSET NULL à la suppression : l'image survit à son uploadeur

Liaisons en place : users.profile_picture_id (photo de profil — voir Identité et accès) et activities.image_id (image d'illustration de l'activité), toutes deux foreignUuid nullable avec SET NULL à la suppression de l'image. Les prestataires restent à lier. Le modèle est Auditable, la provenance est donc aussi tracée par le journal d'audit (causer).

Différés volontairement (périmètre de la première itération) : l'entité « destinations » (l'ancien fk_destination reste reconstructible via legacy_pk), la liaison des images aux prestataires (la photo de profil et l'image d'activité consomment la table images aujourd'hui) et l'endpoint d'import /api/import/providers. Champs hérités abandonnés : marqueurs de carte, colonnes *_export, compteurs dérivés (nbr_voyage), auteurs/dates manuels (couverts par le journal d'audit).

Contributors

No contributors

Changelog

No recent changes