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
dbdedocker-compose.yml, basebackend, 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
bigintfuiterait 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èlesRole/Permissionsous-classés (App\Models\Role,App\Models\Permission) avecHasUuids; migration publiée éditée pour passerid, clés pivots, clé de team (organization_id) et clé de morph (model_uuid) enuuid.laravel/sanctum— modèlePersonalAccessTokensous-classé (App\Models\PersonalAccessToken) avecHasUuidset enregistré viaSanctum::usePersonalAccessTokenModel(); la colonnetokenableest déclarée enuuidMorphs.spatie/laravel-activitylog— modèleActivitysous-classé enApp\Models\AuditLog(nommé ainsi pour laisserActivityà l'entité métier) avecHasUuidset enregistré viaconfig('activitylog.activity_model'); migration publiée éditée pour passeridenuuidet les morphssubject/causerennullableUuidMorphs. Voir Journal d'audit.
Exception : tables d'infrastructure du framework
Les tables purement techniques de Laravel — cache, cache_locks, jobs, job_batches, failed_jobs, sessions — conservent 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
uuidnatif PostgreSQL) contre 8 pour unbigint: surcoût d'index assumé, limité par l'ordonnancement de v7. - Toute nouvelle table ou tout paquet tiers introduisant des
bigintauto- incrémentés doit être adapté. HasUuidsdoit ê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) :
php artisan generate:mermaid-erd --output=file --path=.claude/docs/architecture/base-de-donnees.mdLe 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.
| Colonne | Type | Notes |
|---|---|---|
id | uuid PK | UUIDv7 via HasUuids |
prefix | string, nullable | Identifiant du lot, ex. RESELLER-A — NULL pour les codes hérités |
code | string UNIQUE, nullable | Suffixe alphanumérique généré aléatoirement — NULL pour les codes hérités |
legacy_code | string UNIQUE, nullable | Code importé tel quel de l'ancien système (chaîne arbitraire, sans structure préfixe/suffixe) |
full_code | string STORED UNIQUE | Colonne générée PostgreSQL : COALESCE(legacy_code, prefix || '-' || code) |
batch_id | uuid FK → activation_code_batches, nullable, INDEX | CASCADE 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) |
amount | decimal(10,2) | Montant, défaut 0 |
currency | string(3) | Code ISO 4217, défaut CHF |
commission | decimal(10,2), nullable | Instantané 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_type | string, nullable | percentage | fixed_amount (App\Enums\CommissionType) — un montant fixe s'entend dans la currency du code |
description | text, nullable | Note 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_notes | text, nullable | Note 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_id | uuid FK → organizations | CASCADE DELETE, NOT NULL — retombe sur l'organisation par défaut "Travelise" si omis à la création (voir Identité & accès) |
customer_id | uuid FK → users, nullable | NULL ON DELETE |
tour_id | foreignUuid → tours, nullable | Le tour choisi à l'activation — écrit et effacé atomiquement avec used_at (même UPDATE gardé) ; RESTRICT à la suppression du tour |
traveller_count | unsigned smallint, nullable | Taille 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_at | timestamp, nullable | Date d'achat par le client |
expires_at | timestamp, nullable | Plancher 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) |
duration | unsigned smallint, nullable | Fenêtre d'activation en heures après used_at |
used_at | timestamp, nullable | Dé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_at | timestamp, nullable | Date d'annulation |
reimbursed_at | timestamp, nullable | Date de remboursement |
reimbursement_requested_at | timestamp, nullable | Intention 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_at | timestamp, nullable | Refus 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_reason | text, nullable | Motif 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_at | timestamp, nullable | Intention 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_at | timestamp, nullable | Approbation : 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_at | timestamp, nullable | Refus admin de la demande d'extension — le code retombe dans son statut de base (acheté ou expiré) |
extension_denial_reason | text, nullable | Motif du dernier refus d'extension (obligatoire au refus), écrit pour le demandeur — dernier-écrit-gagne, l'historique vit dans le journal d'audit |
gifted_at | timestamp, nullable | Le 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_at | timestamps |
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é :
prefixetcoderenseignés,batch_idrenseigné (toute génération crée un lot, même pour un seul code),legacy_codeàNULL—full_codevautprefix-code; - hérité :
legacy_coderenseigné (chaîne conservée à l'identique car les clients détiennent encore ces codes),prefix,codeetbatch_idàNULL—full_codevautlegacy_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.
| Colonne | Type | Notes |
|---|---|---|
id | uuid PK | UUIDv7 via HasUuids |
batch_number | bigint UNIQUE, NOT NULL | Numéro de lot lisible tiré de la séquence activation_code_batch_number_seq (voir ci-dessous) |
issuing_organisation_id | uuid FK → organizations | CASCADE DELETE, NOT NULL — même règle que les codes |
internal_notes | text, nullable | Note réservée à l'équipe au niveau du lot (contexte de campagne) — super-admins uniquement, jamais les voyageurs |
expires_at | timestamp, NOT NULL | La date limite du lot, éditable — suivie par tous les codes sans plancher individuel plus tardif |
created_at / updated_at | timestamps |
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éfaut0,stringdéfautpercentage) — l'accord courant, un simple gabarit appliqué aux futurs lots. Modifiable uniquement par un super-admin ; l'historique des renégociations vient gratuitement du traitAuditable(activity_log).activation_codes.commission/commission_type(nullables) — l'instantané contractuel posé sur chaque code parGenerateActivationCodesAction, au même titre queamount/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 unUPDATEexplicite 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/organizationsetPOST /api/import/usersfont du find-or-create (parname, resp. paremail— correspondance exacte, sensible à la casse) et répondent dans l'ordre de la requête avec les uuids et un drapeaucreated, ce qui les rend idempotents et rejouables. Les utilisateurs créés sont des voyageurspendingsans mot de passe.POST /api/import/activation-codesinsère des codes hérités tout-ou-rien (ImportActivationCodesAction) : toute ligne invalide (doublon defull_code, organisation ou client inconnus) fait échouer le lot entier en422avec des erreurs indexées par ligne. La chaîne d'origine arrive dans le champfull_codedu payload et atterrit danslegacy_code; l'état du cycle de vie voyage en timestamps bruts (le statut reste calculé),created_atpeut être antidaté pour préserver l'historique, ladescriptionhé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 brancheexpiredduCASEteste 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), viaeffectiveExpiresAt().
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) :
| # | Statut | Condition |
|---|---|---|
| 1 | reimbursed | reimbursed_at IS NOT NULL |
| 2 | cancelled | cancelled_at IS NOT NULL |
| 3 | pending-reimbursement | reimbursement_requested_at non nul et postdatant reimbursement_denied_at (NULL ignoré) |
| 4 | pre-active | used_at > NOW() (activation planifiée) |
| 5 | active | used_at passé + duration + fenêtre en cours |
| 6 | used | used_at IS NOT NULL |
| 7 | pending-extension | date effective passée et demande d'extension ouverte (has_open_extension_request) |
| 8 | expired | date effective passée |
| 9 | offered | gifted_at IS NOT NULL |
| 10 | purchased | purchased_at IS NOT NULL |
| 11 | in stock | sinon |
Deux subtilités d'ordre :
- la branche
pre-activedoit précéderactive: unused_atfutur donne un temps écoulé négatif qui satisfait la formule de fenêtre — des deux côtés, SQL et PHP (isActive()exclut explicitement lesused_atfuturs) ; - l'approbation d'un remboursement et l'extinction d'
offeredjouent par priorité de cascade (reimbursed/cancelledau-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 : cancelled → cancelled_at, reimbursed → reimbursed_at, used → used_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 :
| Endpoint | Transition | Acteurs | Effet |
|---|---|---|---|
POST /activation-codes/{id}/activate (body tour_id, traveller_count, activation_date?) | acheté → actif / pré-actif | client du code ; super-admin | used_at = date ?? now(), tour_id = tour choisi, traveller_count = taille du groupe — les trois dans le même UPDATE gardé |
DELETE /activation-codes/{id}/activation | pré-actif → acheté | client du code | used_at = NULL, tour_id = NULL, traveller_count = NULL — effacés ensemble |
POST /activation-codes/{id}/reimbursement-request | acheté → attente remboursement | client du code | reimbursement_requested_at = now() |
POST /activation-codes/{id}/reimbursement-request/denial (body reason) | attente remboursement → état naturel | super-admin | reimbursement_denied_at = now() + motif |
POST /activation-codes/{id}/extension-request | acheté ou expiré (sans demande ouverte) → demande ouverte | client du code ; organisation émettrice | extension_requested_at = now() |
POST /activation-codes/{id}/extension-request/denial (body reason) | demande ouverte → statut de base | super-admin | extension_denied_at = now() + motif |
POST /activation-codes/{id}/gift | acheté → offert | client du code | gifted_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 :
| Colonne | Type | Notes |
|---|---|---|
id | uuid PK | UUIDv7 via HasUuids |
name | string, indexée | |
website / booking_email / booking_link / phone | string, nullable | Coordonnées de contact et de réservation |
address, address_more, city, zip(20), country(2) | string, nullable | Même forme d'adresse embarquée que users (ISO 3166-1 alpha-2) |
latitude / longitude | decimal(10,7), nullable | CHECK 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_vector | tsvector généré STORED | Vecteur lexical pondéré name (A) + city (B), index GIN — l'adresse n'alimente pas le vecteur — voir « Recherche plein texte & géolocalisation » |
location | geography(Point, 4326) généré STORED, nullable | Généré depuis latitude/longitude (NULL si l'une manque), index GiST |
standing | string, nullable | Énum App\Enums\Standing (standard, premium) |
status | string, défaut draft, indexée | Énum App\Enums\ApprovalStatus (draft, approved, archived) — hors #[Fillable], assignation directe uniquement |
origin | string | É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_notes | text, nullable | Notes internes de l'équipe (jamais exposées aux voyageurs) |
hubspot_id | string, nullable | Lien CRM repris de l'ancien système |
legacy_pk / legacy_uuid | bigint / uuid, UNIQUE, nullable | Identité 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) :
| Colonne | Type | Notes |
|---|---|---|
id | uuid PK | UUIDv7 via HasUuids |
provider_id | uuid FK → providers | CASCADE DELETE — une activité n'a pas de sens sans son prestataire |
name | string | |
description | text, nullable | Texte orienté voyageur |
internal_notes | text, nullable | |
duration_minutes | integer, nullable | CHECK > 0 ; l'ISO-8601 de l'ancien système (PT2H) se convertit trivialement |
physical_difficulty | string, nullable | Énum App\Enums\PhysicalDifficulty (light, moderate, sustained) |
category | string, nullable, indexée | Énum App\Enums\ActivityCategory (surprise) |
price_min / price_max | decimal(8,2), nullable | Fourchette de prix ; CHECK activities_price_range_check (min <= max) |
currency | string(3), nullable | ISO 4217 |
pick_up_hotel | boolean, défaut false | Le prestataire vient chercher les voyageurs à l'hôtel |
schedule_notes | text, nullable | Disponibilités en texte libre (ancien champ horraire) |
capacity | unsignedInteger, nullable | Capacité 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 |
unlimited | boolean, défaut false | L'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_name | string, nullable | Personne de contact pour cette activité |
contact_phone | string(50), nullable | |
contact_email | string, nullable | |
contact_website | string, nullable | |
status | string, défaut draft, indexée | Énum ApprovalStatus, même mécanique que providers |
image_id | uuid FK → images, nullable | SET 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
providersdetype = overriderattachées au parent parparent_provider_id, pointées paractivities.provider_override_id(FK composite), un champ non-NULL du fantôme l'emportant sur le parent. Le mécanisme a été retiré (migrationremove_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ègleprohibited), 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 suractivities(cf. les colonnescontact_*).
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'énumApp\Enums\Themedirectement 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 (partyajouté au coup par coup ;romantic,teambuilding,wellness,othertoujours manquants) : la migrationreplace_activity_themes_with_activity_taga 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 tsvectorgénérée STORED (nameA +cityB), index GIN. - Activités : vue matérialisée
activity_search_index(activity_id, index UNIQUE ;search_vector, index GIN) combinantname(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 triggersAFTER … FOR EACH STATEMENTsuractivities(INSERT / UPDATE / DELETE / TRUNCATE) etproviders(UPDATE / DELETE) appelant la fonction plpgsqlrefresh_activity_search_index()—REFRESH MATERIALIZED VIEWnon 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 àCONCURRENTLYsi 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&dansto_tsquery('simple', …); le scorets_rankest retourné (rank) et filtrable par le paramètrecutoff(défaut :config('search.default_rank_cutoff')).- Prestataires : colonne
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ürich≠Zurich),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 colonneproviders.location geography(Point, 4326)est générée depuislatitude/longitude(NULL si l'une manque), indexée GiST. Le tri par proximité (latitude/longitudeoptionnels de la recherche) ordonne par l'opérateur KNNlocation <-> point(plus proche d'abord,NULLS LAST) et retournedistance_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_sysest exclue du diagramme (ignore_tables), et l'introspection ERD est bornée au schémapublic(config/mermaid-erd.php) — l'imagepostgis/postgiscrée aussi des schémastiger/topologyqui 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) :
| Colonne | Type | Notes |
|---|---|---|
id | uuid PK | UUIDv7 |
name | string, indexée | |
color | string(7) | Couleur d'affichage frontend ; tours_color_hex_check impose #RRGGBB |
departure_city | string, nullable | Zone de départ approximative (« Fribourg ») révélée en teaser au voyageur la veille du départ |
departure_city_reveal_time | time, nullable | Heure 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 |
status | string, défaut pending, indexée | Énum App\Enums\TourStatus (pending, active, suspended) — hors #[Fillable], affectation directe uniquement |
partner_organization_id | foreignUuid → organizations | RESTRICT à la suppression : on ne fait pas disparaître des tours en silence |
region_organization_id | foreignUuid → organizations, nullable | La 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) :
| Colonne | Type | Notes |
|---|---|---|
id | uuid PK | UUIDv7 |
tour_id | foreignUuid → tours | CASCADE : une étape n'existe pas sans son tour |
name | string | |
position | unsignedInteger | Ordre 1..N contigu dans le tour — seule vérité d'ordre ; hors #[Fillable] |
day_number | unsignedSmallInteger, nullable | Jour 1..N du voyage (steps_day_number_positive_check) |
start_time | time, nullable | Heure de début indicative, sans date |
reveal_time | time, nullable | Heure du jour à laquelle l'étape est révélée au voyageur, sans date |
reveal_days_before | unsignedInteger, défaut 0 | Nombre 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_numberet 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_timeetreveal_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 hookcreatingplace 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 DISTINCTobligatoire (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) :
| Colonne | Type | Notes |
|---|---|---|
id | uuid PK | UUIDv7 |
activation_code_id | foreignUuid → activation_codes | CASCADE : la trace fait partie de l'historique commercial du code |
step_id | foreignUuid → steps | RESTRICT — dénormalisée pour la clé composite |
activity_step_id | uuid, indexée | Le plan suivi ; RESTRICT via la clé composite |
revealed_at | timestamp, nullable | L'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_at | timestamp | created_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 (upsertON 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_atservi 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 desGROUP BYpar (activité, jour) contrecapacity.
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 (
0exact). - 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 tourpendingoususpendedn'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 :
| Colonne | Type | Notes |
|---|---|---|
id | uuid PK | UUIDv7 |
activity_id | foreignUuid → activities | CASCADE : une fermeture n'existe pas sans son activité |
starts_on / ends_on | date | Bornes incluses ; CHECK activity_closures_date_range_check (starts_on <= ends_on) ; index (activity_id, starts_on, ends_on) |
reason | text, nullable | Motif 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, drapeauplaceable;saturationen tête.GET /api/organizations/{id}/capacity?date=Y-m-d— le même tableau au niveau d'une région (organisation de typeregion— sinon 404) : somme des tours actifs, saturation goulot, et par tour capacité / saturation / compteurunplaceable_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.
| Colonne | Type | Notes |
|---|---|---|
id | uuid PK | UUIDv7 — sert aussi de radical de la clé d'objet S3 |
extension | string | jpg | png | webp, re-dérivée du contenu réel (jamais du nom client) |
mime_type | string | Type sniffé à l'upload |
size_bytes | unsignedBigInteger | |
original_name | string | Nom de fichier client, métadonnée d'affichage uniquement |
width / height | unsignedInteger, nullable | Dimensions intrinsèques capturées à l'upload via getimagesize() ; null pour les lignes antérieures à la capture |
pexels_url / pexels_author | string, nullable | Attribution 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_by | foreignUuid → users, nullable | SET 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).

