Campagnes et réservations

Module campaigns — cycle de vie des campagnes, des réservations et de leur impression, tarification et commissions.

Campagnes et réservations

Le module campaigns (app/mypromo/server/modules/campaigns/) gère la création de campagnes marketing par les industriels, la réservation d'espaces publicitaires auprès des distributeurs, et la tarification appliquée à chaque réservation.

Modèle de domaine

Campagne

Une campagne est créée par un industriel (rôle industrial). Elle regroupe un ou plusieurs espaces réservés sur une plage de dates commune.

Champs invariants :

ChampValeurs autorisées
namechaîne non vide
objectiveawareness · traffic · sales
scopemagasin · local · departemental · regional · national · enseigne
startsAt / endsAtdates valides, startsAt < endsAt
budgetAmountnombre positif (optionnel)

scope est un choix unique (single-select) parmi les six niveaux du vocabulaire partagé défini dans shared/domain/campaign-scope.ts — le même vocabulaire que celui utilisé côté espace pour campaignEligibilityScopes (voir « Magasins et espaces »). Dans l'assistant de création (/industrial/campaign/new), la première étape regroupe quatre cartes : Identité (nom, marque, produits, objectif), Périmètre (sélection du scope, complétée d'une sous-section Zone géographique pour les champs de ciblage regions/departments/cities), Ciblage (cascade univers → rayon puis enseignes, pour universeIds/rayonIds/enseigneCodes) et Calendrier (dates et budget cible). La campagne est créée d'abord ; si au moins un champ de ciblage a été renseigné, un appel PATCH /api/campaigns/{campaignId}/targeting suit immédiatement la création pour l'enregistrer (voir « Ciblage » ci-dessous).

Cycle de vie d'une campagne

draft ──→ active ──→ cancelled
             └──→ completed (automatique à l'échéance)
StatutDéclencheurModification possible
draftcréationname, brandId, productIds, budgetAmount, objective, scope, startsAt, endsAt
activeactivation manuellebrief uniquement
completedendsAt dépasséaucune
cancelledannulation manuelle depuis activeaucune

La transition draft → active est la seule transition manuelle hors annulation. Le passage en completed est calculé automatiquement à la lecture si endsAt < now. Une campagne en draft peut être supprimée ; ce n'est plus possible à partir de active.

En draft, PATCH /api/campaigns/{campaignId} accepte tous les champs invariants (name, objective, scope, startsAt, endsAt) en plus des métadonnées commerciales (brandId, productIds, budgetAmount). Chaque champ omis conserve sa valeur courante ; l'ensemble fusionné (valeurs actuelles + champs fournis) est revalidé via assertCampaignInvariants, la même fonction utilisée à la création. Une campagne hors draft (active, completed, cancelled) refuse toute modification de ces champs avec 409 CONFLICT.

Ciblage (régions, départements, villes, univers, rayons, enseignes)

Une campagne porte également six champs de ciblage, tous String[] avec pour défaut [] :

ChampContenu
regionsslugs de régions françaises (ex. ile_de_france), vocabulaire fermé CAMPAIGN_TARGETING_REGION_OPTIONS (catalog/domain/map-search.ts)
departmentscode de département français (75, 2A/2B, 971…) ou code postal à 5 chiffres (75001…) — les deux formats sont acceptés et normalisés vers le département par la fonction pure partagée normalizeDepartmentInput (catalog/domain/map-search.ts) : Corse 20xxx2A en dessous de 20200, 2B à partir de 20200, DOM 97xxx → les 3 premiers chiffres, sinon les 2 premiers chiffres. Les codes 98xxx (COM : Nouvelle-Calédonie, Polynésie…) ne correspondent à aucun code de département reconnu et sont donc rejetés. Cette même fonction normalise le filtre department de la recherche carte (validateMapSearchInput) ; la granularité de filtrage reste le département, pas le code postal exact
citiestexte libre, aucun vocabulaire fermé
universeIdsidentifiants d'univers du référentiel CatalogStoreUniverse — le même référentiel que celui utilisé pour le placement des espaces (voir « Magasins et espaces »)
rayonIdsidentifiants de zones de rayon du référentiel CatalogStoreRayon, rattachées à un univers
enseigneCodescodes de CatalogEnseigne (ex. E.Leclerc) — à ne pas confondre avec le niveau enseigne du vocabulaire scope : ce dernier est un niveau d'éligibilité abstrait, enseigneCodes désigne des enseignes concrètes du référentiel

Ces six champs restent des préférences non contraignantes (« soft ») pour le reste de la plateforme : ils ne bloquent et ne filtrent aucune réservation créée via le panier (/industrial/reservations/new) — aucun calcul de disponibilité ou d'éligibilité n'en tient compte à cet endroit. Ils ont en revanche un premier consommateur concret : l'assistant de réservation de masse (voir « Réservation de masse » ci-dessous) les reprend comme critères de recherche pré-remplis à l'ouverture — regions/departments/cities/universeIds/rayonIds y sont figés (chips non modifiables), enseigneCodes reste ajustable pour la recherche. scope reste conceptuellement autre chose : le niveau d'éligibilité abstrait de la campagne (toujours non appliqué en dur en dehors de la réservation de masse, mais un choix unique dans un vocabulaire fermé, quand le ciblage est une liste ouverte de préférences concrètes).

La validation est portée par des fonctions pures dans campaigns/domain/campaign-targeting.ts : normalizeCampaignRegions et normalizeCampaignDepartments rejettent toute valeur hors du vocabulaire fermé (région inconnue, code département mal formé) ; normalizeCampaignCities et sanitizeCampaignTargetingIds sont permissives (trim + déduplication, aucune valeur rejetée). universeIds, rayonIds et enseigneCodes sont en plus vérifiés à la couche application (updateCampaignTargeting, campaigns/application/campaign-lifecycle.ts) contre le référentiel catalogue réel (univers/rayons actifs, annuaire des enseignes). Cette vérification est tolérante : seuls les identifiants nouvellement ajoutés par rapport à la valeur déjà enregistrée sont contrôlés — un identifiant inconnu ajouté déclenche 400 VALIDATION_ERROR, mais un identifiant déjà présent sur la campagne reste accepté même s'il a été archivé ou supprimé côté catalogue depuis (grandfathering). Cela évite qu'une action admin (archivage/suppression d'un univers/rayon) bloque la sauvegarde d'une campagne qui le référençait ; l'industriel peut retirer l'identifiant orphelin quand il le souhaite. Un consommateur de ces champs (p. ex. le futur wizard de réservation de masse) doit donc gérer des identifiants qui ne résolvent plus vers le catalogue actif.

Éditabilité : le ciblage suit une règle d'édition distincte du reste de la campagne. canEditCampaignTargeting(status) (campaigns/domain/campaign-status.ts) autorise la modification en draft et en active, alors que resolveCampaignEditScope(status) — la même fonction qui gouverne objective/scope/startsAt/endsAt via PATCH /api/campaigns/{campaignId} (comparée à 'metadata', donc draft-only) — gouverne aussi l'édition du brief, mais comparée cette fois à !== 'none' (donc draft et active). En completed ou cancelled, le ciblage est figé et toute tentative de modification renvoie 409 CONFLICT.

Côté interface, la page de détail (/industrial/campaign/{campaignId}) affiche dans son résumé de campagne le ciblage complet des six champs (régions, départements/codes postaux, villes, univers, rayons, enseignes), via le composant CampaignTargetingSummary (app/components/campaign/targeting/TargetingSummary.vue, groupes construits par la fonction pure buildTargetingSummaryGroups, app/utils/campaign-targeting-summary.ts) ; un groupe vide n'est pas affiché. Sa modale d'édition expose une section Ciblage (mêmes composants que l'assistant de création) visible et modifiable dès que canEditTargeting (statut draft ou active) est vrai — indépendamment de la section objectif/périmètre/dates, restée réservée au draft.

Réservation

Une réservation associe une campagne à un espace publicitaire précis pour une période donnée. La période de la réservation doit être contenue dans la période de la campagne.

Cycle de vie d'une réservation

Statuts définis dans CampaignReservationStatus (app/mypromo/server/modules/campaigns/application/campaigns.ts) et valeur par défaut Prisma "requested" :

requested ──→ pending_validation ──→ approved ──→ completed
    │                └──→ rejected
    └──→ approved
    └──→ rejected
    └──→ cancelled
StatutDéclencheurEffet sur l'inventaire
requestedcréation depuis le panieraucun blocage ferme
pending_validationcontre-proposition distributeurespace en attente
approvedvalidation distributeurespace bloqué définitivement
rejectedrefus distributeurespace libéré
cancelledannulation industrielespace libéré
completeddépassement de endsAt
  • requested : statut initial créé par createReservationsFromCart ; seul statut supprimable par l'industriel (DELETE /api/reservations/{id}, voir « Supprimer une demande »).
  • pending_validation : le distributeur a soumis une contre-proposition ; l'espace reste réservé provisoirement.
  • approved : bloque définitivement la disponibilité de l'espace.
  • cancelled / rejected : libère l'espace.

Impression

Le cycle d'impression d'une réservation est séparé de son cycle commercial (statut de réservation ci-dessus). Il est porté par deux champs sur Reservation :

  • impressionStatus (ImpressionStatus | null) — l'état courant du dossier d'impression.
  • printDepositDueAt (Date | null) — l'échéance de dépôt des visuels, posée uniquement pendant l'état to_deposit.
locked ──→ to_deposit ──→ in_review ──→ validated
              └──→ in_stock (mode reuse_all)
StatutSignification
lockedRéservation pas encore approved — l'impression n'est pas encore ouverte
to_depositApprouvée, en attente de dépôt des visuels (échéance printDepositDueAt)
in_reviewVisuels déposés et confirmés, PrintOrder créé en draft, en contrôle qualité
validatedPrintOrder transmis à l'imprimeur (draft → pending)
in_stockMode reuse_all : supports déjà en stock, aucune impression requise
nullRéservation rejected ou cancelled — pas de dossier d'impression

impressionStatus est calculé par la fonction pure resolveImpressionState (campaigns/domain/impression-status.ts) à partir du statut de réservation, du mode de support d'impression (print | reuse_all | hybrid) et du statut du PrintOrder associé. Elle a un unique point d'écriture, writeImpressionStatus (campaigns/application/reservations/impression-status-writer.ts), appelé à chaque transition qui peut affecter l'impression :

  • Création de la réservation → locked.
  • Approbation par le distributeur, ou acceptation d'une contre-proposition → to_deposit (avec pose de printDepositDueAt à 5 jours ouvrés via addBusinessDays, shared/domain/business-days.ts) ou in_stock si le mode est reuse_all.
  • Refus ou annulation → null.
  • Confirmation du dépôt (POST /api/reservations/{id}/upload/confirm, création du PrintOrder en draft) → in_review.
  • Envoi à l'imprimeur (POST /api/reservations/{id}/print-order/submit, PrintOrder draft → pending) → validated.

Le dépôt et l'envoi à l'imprimeur sont deux actions distinctes : confirmer les visuels ne les transmet pas automatiquement à l'imprimeur, il faut une action explicite supplémentaire. Un changement de mode de support ne réinitialise pas une échéance de dépôt déjà posée ; un PrintOrder annulé (cancelled) fait retomber la réservation en to_deposit (redépôt requis).

Voir aussi la page « Impression » (workspace /printer) pour le cycle de vie du PrintOrder lui-même (draft → pending → sent → in_production → produced → delivered).

Dépôt groupé des visuels (lot)

Depuis la fiche de campagne, /industrial/campaign/{campaignId}/impressions ouvre un parcours dédié au dépôt en lot : déposer les visuels une seule fois pour toutes les réservations qui partagent la même campagne et la même configuration de meuble (promoFurnitureTypeId), plutôt qu'emplacement par emplacement. La clé de regroupement se réduit à ces deux champs : deux réservations sur la même configuration portent par construction le même jeu de faces imprimables. Le parcours et l'API (PrintOrderBatch, routes /api/print-batches/*) sont documentés dans la page « Impression » (section « Lot de dépôt »), et la note de passation docs/handover/modules/4.impression.md couvre les décisions et l'état d'avancement.

Panier (cart)

Avant de créer des réservations, l'industriel constitue un panier d'espaces présélectionnés sur des périodes précises. Les items du panier sont éphémères : ils sont stockés dans campaign_cart_items avec une durée de vie (TTL) et expirent automatiquement s'ils ne sont pas confirmés. Un item de panier n'est pas une réservation — aucune reservation n'est créée tant que la demande n'est pas envoyée au distributeur depuis le récapitulatif.

Un item de panier représente une période réservée sur un espace donné ; un même espace peut porter plusieurs périodes (plusieurs items).

RouteEffet
GET /api/campaigns/cartListe les items actifs (espace, magasin, ville, période, montant estimé).
POST /api/campaigns/cart/itemsAjoute une période (adSpaceCode, startsAt, endsAt) ; upsert si identique.
PATCH /api/campaigns/cart/items/{itemId}Modifie en place les dates d'une période existante.
DELETE /api/campaigns/cart/items/{itemId}Retire une période du panier.
POST /api/campaigns/cart/refreshRéactive ou purge les items dont le TTL a expiré.

Convention de dates : endsAt est transmis inclusif (dernier jour sélectionné) puis normalisé en borne exclusive côté stockage. Validations communes à l'ajout et à la modification : durée minimale de l'espace, disponibilité publiée par le distributeur, absence de chevauchement avec une réservation existante ou un autre item du panier (l'item en cours de modification est exclu de ce contrôle).

Sur la carte, l'industriel gère les périodes d'un espace sous forme de cartes (« boxes ») éditables dans le panneau de sélection : ajout, modification des dates et suppression, chaque période correspondant à un item de panier. Les périodes déjà au panier pour l'espace apparaissent en orange dans le calendrier et ne sont pas re-sélectionnables (pas de chevauchement).

La conversion finale en réservations fermes se fait via POST /api/reservations depuis le récapitulatif (/industrial/reservations/new), qui rattache les items sélectionnés à une campagne. Cet écran est présenté comme « Confirmer l'espace — Étape 1 sur 2 » : il ne réserve que le créneau, l'impression étant un parcours séparé qui ne s'ouvre qu'après approbation par le distributeur.

Séparation espace / impression (UI)

Côté industriel, l'espace réservé et son impression sont deux parcours distincts :

  • Détail de réservation (/industrial/reservations/{id}) — espace uniquement : emplacement, période, prix, statut, contre-proposition, incident. Il ne contient plus de dépôt de fichiers ; une carte compacte « Impression » y résume le statut d'impression courant et renvoie vers le détail d'impression (sauf en locked, où seule une indication « après approbation » est affichée).
  • Impressions (/industrial/impressions) — nouvelle entrée de la navigation industrielle, indépendante du statut commercial des réservations. Liste paginée côté serveur, organisée en onglets sur les statuts to_deposit (« À déposer »), in_review (« En contrôle »), validated (« Validés ») et in_stock (« En stock »). locked n'est jamais un onglet : il apparaît uniquement comme une puce (« Verrouillée · après approbation ») sur les lignes de la liste des réservations.
  • Détail d'impression (/industrial/impressions/{reservationId}) — vue consolidée : téléchargement des gabarits, choix du mode (print | reuse_all | hybrid), dépôt + preflight + validation du bon-à-tirer par face, puis une action séparée « Envoyer à l'imprimeur ». Une fois le PrintOrder transmis, la vue affiche son récapitulatif (PrintOrderRecap).

L'ancienne page reservations/{id}/upload.vue a été supprimée ; son contenu (dépôt, preflight, BAT) a été déplacé vers le détail d'impression sans être réécrit.

Réservation de masse

Un parcours guidé permet à l'industriel de réserver un grand nombre d'emplacements en une seule opération, depuis la fiche de campagne (/industrial/campaign/{campaignId}/reserver). L'entrée du parcours (industrialMassReservation.entry.button sur la page de détail) n'est activable que lorsque la campagne est active ; le garde-fou de domaine est assertCampaignCanReceiveReservations (campaigns/application/reservations/utils.ts), qui rejette toute campagne non active avec 409 CONFLICT — c'est le même garde-fou que celui utilisé par le parcours panier.

Endpoints

  • GET /api/campaigns/{campaignId}/mass-reservation/search — rôle industrial. Résout d'abord la campagne appartenant à l'appelant (resolveOwnedCampaign, 404 sinon). Accepte des filtres tableau (regions, departments, cities, enseigneCodes, universeIds, rayonIds, eligibilityScopes, promoFurnitureTypeIds) plus startsAt/endsAt. Retourne les magasins groupés avec leurs emplacements disponibles correspondants, ainsi que des agrégats : totalStoreCount, totalEmplacementCount, totalGrossAmount, allAdSpaceIds, truncated. allAdSpaceIds est la liste complète des identifiants d'espace correspondants — c'est elle qui alimente directement le bouton « Tout sélectionner » côté interface ; il n'existe pas d'endpoint de résolution séparé.
    • Identité des groupes : chaque groupe porte storeId et storeCode. storeCode n'est unique que par distributeur (@@unique([organizationId, code]) sur Store) alors que la recherche de masse balaie tous les distributeurs : deux magasins de distributeurs différents peuvent partager le même code. storeId est donc la seule identité fiable — c'est lui qui sert de clé de liste, d'état de dépliage et de cible de la case « tout le magasin » côté interface ; storeCode est purement de l'affichage.
    • Plafond de balayage : la requête magasins est plafonnée à MASS_RESERVATION_STORE_SCAN_LIMIT (2000 par défaut, surchargeable par la variable d'environnement du même nom via resolveMassReservationStoreScanLimit, dans campaigns/application/mass-reservation/candidate-resolution.ts et ré-exporté par search-service.ts pour compatibilité), triée par id. Sans critère de zone/ville/enseigne la clause WHERE serait vide et chargerait l'intégralité des magasins puis tous leurs espaces avec leurs règles tarifaires.
      • Le ciblage géographique est poussé dans le WHERE SQL, donc appliqué avant le plafond. Chaque département cible fournit un préfixe postal (buildDepartmentPostalPatterns(...).postalPrefix) et la clause retient les magasins dont legalAddressPostalCode commence par ce préfixe ou dont legalAddressFormatted le contient. Cette clause SQL est volontairement un sur-ensemble du filtre exact : les expressions régulières continuent de tourner en mémoire juste après pour trancher les cas limites (adresse formatée, code postal absent). L'ordre importe : filtrer après le plafond tronquerait l'ensemble non filtré trié par id, si bien qu'une campagne ciblant le 75 ne verrait que les magasins parisiens ayant survécu à une coupe portant sur tous les distributeurs confondus — les autres disparaîtraient sans aucun signal exploitable, la bannière truncated invitant à affiner un critère hérité que l'assistant ne laisse pas modifier.
      • La requête prend limite + 1 lignes puis retranche la dernière : truncated vaut donc true uniquement quand il existe strictement plus de magasins que le plafond, et non lorsque leur nombre l'atteint exactement.
      • Quand le plafond est réellement atteint, la réponse porte truncated: true : tous les agrégats (totalStoreCount, totalEmplacementCount, totalGrossAmount) et allAdSpaceIds ne décrivent alors que le sous-ensemble chargé, pas l'ensemble réel des correspondances. L'interface affiche un avertissement non bloquant (industrialMassReservation.results.truncated) invitant à affiner les critères. La résolution des emplacements candidats (magasins → espaces filtrés) vit dans candidate-resolution.ts et est partagée entre search et availability-timeline (voir ci-dessous) : elle ne dépend pas de la période demandée.
  • POST /api/campaigns/{campaignId}/mass-reservation — rôle industrial + capacité KYB campaigns.create. Corps { adSpaceIds, startsAt, endsAt }. Retourne un rapport best-effort { created, createdIds, skipped[] } (createMassReservation, campaigns/application/mass-reservation/reservation-service.ts).

Validation des dates (les deux points d'entrée). parseRequiredDateParam et assertPeriodOrder (shared/api/query-params.ts, exportés par le baril shared) remplacent les copies locales que portaient les deux handlers. Le message d'erreur nomme le champ fautif (startsAt is required., endsAt is not a valid date.) au lieu d'accuser les deux dates, et distingue une valeur absente d'une valeur illisible — l'assistant remonte ce message tel quel à l'utilisateur via resolveApiErrorMessage. assertPeriodOrder rejette en 400 une période inversée ou de durée nulle : sans elle, un intervalle à l'envers traversait toute la chaîne pour ressortir en created: 0 avec chaque emplacement écarté en min_days, un symptôme qui ne désignait pas sa cause.

  • GET /api/industrial/furniture-types — rôle industrial. Référentiel utilisé par la galerie de l'assistant : { id, code, name, thumbnailUrl, facesCount, dimensions } (listFurnitureTypeOptions, catalog/application/furniture-type-options.ts). thumbnailUrl est le premier visuel catalogue du type de meuble (position croissant) ; facesCount est le nombre de faces imprimables du meuble.
  • GET /api/campaigns/{campaignId}/mass-reservation/availability-timeline — rôle industrial. Accepte les mêmes filtres que search (regions, departments, cities, enseigneCodes, universeIds, rayonIds, eligibilityScopes, promoFurnitureTypeIds) à l'exception de startsAt/endsAt : l'horizon découpé est toujours la période de la campagne (campaign.startsAt/endsAt), pas une période choisie par l'appelant. Ajoute granularity (week | month, week par défaut). Retourne des tranches { startsAt, endsAt, days, availableCount } (computeMassReservationAvailabilityTimeline, campaigns/application/mass-reservation/availability-timeline-service.ts) : les bornes [startsAt, endsAt) de chaque tranche sont exclusives en fin, comme le reste du modèle de dates de la plateforme. Un emplacement n'est compté disponible pour une tranche que s'il est libre sur toute sa durée — une seule fenêtre de conflit chevauchante suffit à l'exclure — et si la durée de la tranche couvre sa minimumReservationDays ; les tranches les plus grossières (month) donnent donc systématiquement des compteurs inférieurs ou égaux à celles plus fines (week). La résolution des emplacements candidats (zone/enseigne/univers/rayon/type de meuble/éligibilité/tarification) est partagée avec search via resolveMassReservationCandidates (candidate-resolution.ts) ; seules les fenêtres de conflit (réservations existantes + items panier d'autres industriels) sont chargées séparément, en une seule requête par source sur tout l'horizon — jamais une requête par tranche.

Règle de filtrage (contrainte d'architecture importante)

Univers, rayon, éligibilité et type de meuble sont filtrés en mémoire, par AdSpace, via matchesAdSpaceFilters (catalog/domain/ad-space-filter-match.ts) — jamais dans la clause WHERE SQL au niveau Store. La recherche charge d'abord tous les Store correspondant aux critères géographiques (+ villes/enseignes en SQL), puis tous leurs AdSpace published/active, puis applique matchesAdSpaceFilters en mémoire sur chaque espace. Filtrer ces critères au niveau Store en SQL ferait disparaître des magasins entiers des résultats dès qu'un seul de leurs espaces ne correspond pas — c'est le même écueil que documenté pour la carte (map-search). Un magasin dont aucun emplacement ne survit au filtrage est exclu des résultats.

Géographie : les régions sont d'abord résolues en liste de départements (resolveTargetDepartments), puis chaque département devient un motif d'expression régulière sur le code postal (buildDepartmentPostalPatterns), appliqué lui aussi en mémoire sur les magasins déjà chargés. Les villes, elles, sont un filtre SQL contains (insensible à la casse) sur legalAddressCity/legalAddressFormatted.

resolveTargetDepartments fait passer chaque valeur — celles envoyées explicitement dans departments comme celles dérivées des régions — par normalizeDepartmentInput (catalog/domain/map-search.ts, la même normalisation que la carte) et écarte toute valeur qui n'en ressort pas : un code postal complet est ramené à son département (7500175, 201902A, 97400974), et une valeur hors vocabulaire (abc, 98800, ou un fragment d'expression régulière comme [) est simplement ignorée. C'est une exigence de sécurité et pas seulement de propreté : ces valeurs alimentent new RegExp(...) dans buildDepartmentPostalPatterns, donc une entrée non validée permettrait à un utilisateur authentifié de provoquer une SyntaxError (500) ou un motif à retour arrière catastrophique évalué sur chaque magasin chargé.

Sémantique de création

createMassReservation procède dans cet ordre :

  1. Sanitize des identifiants d'espace (sanitizeSelectedCartItemIds), 400 si la liste est vide.
  2. Résolution de la campagne possédée par l'appelant + assertCampaignCanReceiveReservations.
  3. Chargement des AdSpace demandés (statut published/active).
  4. Revalidation par lot : une requête de conflits de réservation groupée, une requête de verrous panier (les items d'un autre industriel encore actifs au sens du TTL et chevauchant la période — la même exclusion que le parcours de recherche, voir ci-dessous), les fenêtres d'indisponibilité posées par le distributeur, la durée minimale de réservation, puis le calcul du tarif — chaque échec alimente skipped[] avec une raison (unavailable | min_days | not_found | no_pricing). Un emplacement retenu dans le panier d'un autre industriel est reporté en unavailable, comme un emplacement déjà réservé.
  5. Vérification des invariants de réservation sur chaque document candidat (assertReservationInvariants), convertie en 400 VALIDATION_ERROR. Elle a lieu avant l'ouverture de la transaction, pour que celle-ci reste courte.
  6. Une seule transaction (prisma.$transaction) contenant, dans cet ordre :
    1. un verrou de ligne sur la campagne (SELECT id FROM campaigns WHERE id = … FOR UPDATE) ;
    2. la relecture de budgetAmount/budgetReservedAmount sous ce verrou ;
    3. le calcul budgétaire non bloquant : summarizeCampaignBudget (shared/utils/campaign-budget.ts) compare le total de la sélection valide au reste (budgetAmount - budgetReservedAmount, tolérance 0.005 pour l'arrondi) et produit un CampaignBudgetSummary. Un dépassement n'interrompt rien : il est renvoyé dans budget avec la réponse, et c'est l'interface qui en avertit l'utilisateur avant l'envoi ;
    4. la création des réservations en une seule requête (createManyAndReturn, status: 'requested', impressionStatus: 'locked') ;
    5. syncCampaignReservedBudget(campaignId, tx) à l'intérieur de la même transaction.

Pourquoi le verrou et la transaction. Le verrou garantit que le chiffre annoncé est le chiffre réellement appliqué : sans FOR UPDATE, deux lots concurrents (deux onglets, une requête rejouée) liraient le même budgetReservedAmount et rapporteraient chacun un engagement qui ignore l'autre. Le verrou sérialise les lots concurrents sur une même campagne : le second attend la validation du premier et calcule son résumé sur le budget réellement consommé. La transaction, elle, empêche l'écriture partielle : sans elle, une interruption au milieu d'une création séquentielle laisserait des réservations bien réelles avec un budgetReservedAmount d'avant le lot, dérive que le lot suivant amplifierait. C'est pour cette raison qu'il n'existe aucun plafond sur la taille du lot : le risque porte sur l'écriture partielle, pas sur le volume.

Si tous les emplacements demandés sont skippés (aucun document valide), l'endpoint retourne created: 0 avec le rapport de skip complet ; la transaction n'est pas ouverte et le résumé budgétaire est calculé par une lecture simple, avec une sélection nulle. Une campagne sans budget renseigné (budgetAmount à 0/null) ne déclenche aucun avertissement : summarizeCampaignBudget renvoie hasBudget: false, et l'interface affiche « aucun budget emplacements défini » au lieu d'un compteur qui vaudrait zéro.

Le budget de campagne couvre les emplacements, pas l'impression

Le compteur, l'avertissement et la case à cocher parlent tous de budget emplacements, jamais de « budget de la campagne ». PrintOrder et PrintOrderLine ne portent aucun prix et la plateforme n'expose aucun tarif imprimeur : le coût d'impression n'entre donc pas dans ce calcul.

Le dépassement avertit, il ne bloque pas

La règle est identique sur les deux parcours de réservation, unitaire et groupé :

  • le montant de la sélection est confronté au budget emplacements restant, et le résultat est affiché (budget, déjà engagé, cette sélection, restant après) ;
  • un dépassement affiche une alerte rouge et demande une case à cocher de confirmation — le bouton d'envoi reste désactivé tant qu'elle n'est pas cochée ;
  • cette confirmation n'est persistée nulle part : aucune colonne, aucune table. Elle conditionne le bouton d'envoi et rien d'autre.

L'acceptation est liée au dépassement qu'elle accepte, et pas à un simple booléen : côté écran elle mémorise le montant du dépassement au moment où elle est cochée et redevient fausse dès que ce montant change (modification de la sélection, changement de campagne). Une case cochée ne peut donc jamais confirmer un dépassement différent de celui qui a été lu.

summarizeCampaignBudget est une fonction pure partagée entre le serveur et les deux écrans (shared/utils/campaign-budget.ts, importée via #shared/utils/campaign-budget), pour que le montant affiché et le montant appliqué ne puissent pas être calculés différemment. L'alerte et les tuiles sont elles aussi des composants partagés (app/components/campaign/BudgetOverrunAlert.vue, BudgetTiles.vue).

syncCampaignReservedBudget (campaigns/application/reservations/reservation-service.ts) accepte un second argument optionnel : le client Prisma à utiliser (prisma par défaut). C'est ce qui lui permet de s'exécuter dans la transaction du parcours de masse sans changer les appels du parcours panier.

Dates

Comme pour le reste du modèle de campagne, endsAt est exclusif : l'interface affiche le dernier jour inclusif sélectionné et transmet jour + 1 à l'API. La conversion est portée par les deux fonctions inverses toInclusiveEndDateInputValue / toExclusiveEnd (app/utils/campaign-date-input.ts), partagées avec l'édition de campagne.

Les deux champs de date de l'assistant sont bornés (min/max) par la période inclusive de la campagne, et se bornent mutuellement (début ≤ fin). Côté serveur, une période hors campagne reste rejetée par assertReservationInvariants, dont l'erreur brute est convertie en 400 VALIDATION_ERROR (même traitement que le parcours panier) plutôt que de remonter en 500.

Cohérence entre les prix affichés et la période soumise. useMassReservation mémorise dans searchedPeriod la période pour laquelle la réponse de recherche courante a été calculée. Les champs de date, eux, alimentent filters de façon synchrone (flush: 'sync'), si bien qu'entre un changement de date et l'arrivée de la nouvelle réponse — la fenêtre d'anti-rebond de 300 ms plus la durée de la requête — l'écran affiche encore les montants de la période précédente. canSubmit exige donc !pending et pricesMatchRequestedPeriod (searchedPeriod identique à filters), et submit() envoie searchedPeriod, jamais filters. Sans cette garde, un utilisateur changeant la date de fin puis confirmant aussitôt validait un montant à l'écran et obtenait des réservations tarifées sur la nouvelle période, potentiellement plusieurs fois supérieures. pending est également armé dès scheduleSearch() (et non seulement au départ de la requête), sans quoi l'anti-rebond laissait une fenêtre où la liste était déjà périmée sans que rien ne l'indique. L'interface explique le blocage plutôt que de se contenter d'un bouton inerte (industrialMassReservation.bar.periodChanged).

Une erreur de chargement de la frise de disponibilité est affichée par la page (UAlert alimentée par timeline.error) : sans cela le composant retombait sur son état vide et l'industriel lisait « aucune disponibilité » là où la requête avait simplement échoué.

Un seul type de meuble par réservation de masse

La galerie de types de meuble de l'assistant est un choix unique et obligatoire (FurnitureTypeGallery.vue, modèle string | null), pas une sélection multiple. Tant qu'aucun type n'est choisi, useMassReservation ne lance aucune recherche (requiresFurnitureType) : les résultats et la frise de disponibilité sont remplacés par un message qui invite à choisir un meuble, et la sélection courante est vidée. Réserver plusieurs configurations sur une même campagne se fait en relançant l'assistant, une fois par configuration.

La raison n'est pas ergonomique mais structurelle : le dépôt groupé des visuels regroupe par (campagne, promoFurnitureTypeId) (voir « Dépôt groupé des visuels » plus haut). Une réservation de masse mêlant deux configurations produit donc mécaniquement deux groupes de dépôt, et l'industriel qui avait fait « une » réservation doit faire « deux » dépôts sans que rien ne le lui ait annoncé. Un lot de réservation aligné sur un type de meuble aligne les deux parcours.

Le filtre reste un tableau (promoFurnitureTypeIds) côté API et côté résolution des candidats : le contrat n'a pas bougé, c'est l'écran qui n'y met plus qu'une valeur. Un client qui en enverrait deux serait toujours servi.

Pourquoi pas le panier

Le parcours de masse ne passe volontairement pas par le panier (campaign_cart_items) : le panier est plafonné à 500 items, porte des verrous TTL par item et vérifie les conflits un par un — un mauvais candidat pour des sélections potentiellement massives. L'étape de récapitulatif/confirmation vit donc directement dans l'assistant de réservation de masse plutôt que de réutiliser l'écran de récapitulatif basé sur le panier (/industrial/reservations/new).

Fiche magasin (panneau détail)

Dans l'écran de résultats, cliquer un marqueur de magasin sur la carte (ResultsMap.vue, événement focusStore) ouvre sa fiche détaillée dans un tiroir (UDrawer, industrial/campaign/mass-reservation/StorePanel.vue) — le même composant et le même comportement que le panneau du flux carte unique (/industrial/map) ; la page (reserver.vue) fait aussi défiler la ligne correspondante dans la liste de résultats jusqu'à la vue. Une version en overlay confiné à la carte (liste et carte restant visibles pendant que le tiroir est ouvert) a été essayée puis écartée au profit de cette cohérence avec le flux unitaire.

Trois détails de ce tiroir sont volontaires et ont chacun coûté un aller-retour :

  • :overlay="false" — comme le flux carte unique sur desktop (map.vue passe :overlay="!isDesktopSelectionSheet"). Sans cette prop, UDrawer assombrit toute la page par défaut, ce qui masque la carte et la liste que le panneau est justement censé compléter.
  • Montage et ouverture séparés d'un tickreserver.vue monte le panneau via v-if="panelStoreId" puis passe :open="panelOpen", panelOpen étant armé dans un nextTick. Ouvrir dans le même tick que le montage fait démarrer l'animation d'entrée depuis un élément pas encore rendu, d'où un à-coup visible.
  • Pas de bindPopup sur les marqueurs — le marqueur n'ouvre que le tiroir. Lier une infobulle Leaflet et émettre focusStore sur le même clic faisait apparaître les deux en même temps, l'infobulle n'apportant rien que le tiroir n'affiche déjà.

Le cadrage de la carte ne suit pas les réponses de recherche mais l'identité des magasins tracés (plottedStoresSignature, la liste triée des storeId) : searchResult est réassigné à chaque recherche, si bien qu'un recadrage sur ce signal annulait le zoom de l'utilisateur à chaque changement de période — précisément le geste que la frise de disponibilité encourage.

Aucun composant dupliqué. Le panneau réutilise tel quel la chaîne de composants du flux carte unique (/industrial/map) : CarteSelectionCard (carte/SelectionCard.vue) → CarteSiteSelectionHero (carte/SiteSelectionHero.vue) + AdSpaceDetailPanel (ad-space/DetailPanel.vue). Les deux flux ne se référencent jamais l'un l'autre ; ils divergent uniquement par le contenu fourni à trois slots nommés par ligne d'emplacement, exposés par DetailPanel.vue :

  • #emplacement-pricing — la zone prix de la carte compacte. Contenu par défaut (repris tel quel par le flux carte unique) : « À partir de X/jour HT ». Le panneau de masse le remplace par le montant calculé pour la période (voir ci-dessous).
  • #emplacement-action — le contrôle compact de la barre d'action. Le flux carte unique y place AdSpaceCartActionButton (ajout au panier) ; le panneau de masse y place une case à cocher de sélection.

Un troisième slot, #emplacement-action-expanded, couvre le contenu affiché sous le calendrier déplié d'une ligne. Le flux carte unique y place AdSpaceCartExpandedPanel (gestion des périodes de panier) ; le panneau de masse y place la mention rappelant que le calendrier est en lecture seule (industrialMassReservation.panel.readOnlyCalendar).

Résolution par storeId. Le panneau charge la fiche via GET /api/catalog/map/space-detail?storeId=... plutôt que storeCode. storeCode n'est unique que par distributeur (@@unique([organizationId, code]) sur Store), alors que la recherche de masse balaie tous les distributeurs — deux magasins de distributeurs différents peuvent partager le même code. validateMapSpaceDetailInput (catalog/domain/map-space-detail.ts) accepte désormais les deux paramètres ; getMapSpaceDetail (catalog/application/map-space-detail.ts) donne la priorité à storeId quand les deux sont fournis. Le flux carte unique continue d'utiliser storeCode sans changement.

Le lien « Fiche complète » propage cet identifiant. SelectionCard.vue construit sa destination avec buildStoreFullSheetPath(code, storeId?) (app/utils/map-search-query.ts), qui produit /industrial/spaces/{code}?storeId={id} quand l'identifiant est connu — c'est le cas depuis le panneau de masse, qui le tient déjà. La page spaces/[storeCode].vue lit route.query.storeId et le transmet à buildSpaceDetailQueryString, le serveur faisant le reste. Sans ce relais, le panneau résolvait bien le bon magasin mais son lien de sortie repartait du seul code : sur une surface qui balaie plusieurs distributeurs, la page d'arrivée pouvait afficher les emplacements, prix et photos d'un autre distributeur sous le bon intitulé. Le code reste dans l'URL comme segment lisible ; c'est l'identifiant qui fait foi.

Source du prix. Le montant affiché dans #emplacement-pricing ne vient jamais de requestedPeriodTotalCents, le total calculé par space-detail lui-même : il vient du montant déjà calculé par la recherche de masse, transmis au panneau via la prop grossAmountByAdSpaceId (Map<string, number>), alimentée par unitById (composables/useMassReservation.ts), lui-même construit à partir du champ unitGrossAmount par emplacement dans la réponse de GET .../mass-reservation/search. Les deux montants peuvent différer — l'un est un tarif de base, l'autre inclut la commission plateforme — utiliser le mauvais afficherait un total différent dans le panneau et dans la liste de résultats pour le même emplacement.

Garde-fou d'identité. Le panneau associe les emplacements du magasin chargé à grossAmountByAdSpaceId par leur identifiant réel en base (adSpace.id), jamais par leur code — même exigence d'isolation inter-distributeurs que le choix de storeId ci-dessus (buildPanelEmplacements, utils/mass-reservation-panel-view.ts). Un emplacement dont l'id ne correspond à aucune clé de la map est marqué non sélectionnable (case à cocher désactivée) ; si aucun emplacement du magasin chargé ne correspond à la sélection courante, le panneau affiche un message explicite plutôt que de risquer d'afficher les données d'un autre magasin ou d'un magasin obsolète.

Calendrier en lecture seule. Le panneau force calendar-selectable="false" (prop de SelectionCard.vue/DetailPanel.vue, true par défaut pour le flux carte unique) : le calendrier déplié met en évidence la période choisie dans l'assistant sans permettre de la modifier depuis le panneau.

Tarification et commissions

La plateforme opère en mode vendeur principal (modèle §5 de regles-business-mvp.md).

Pour chaque réservation, le prix total industriel est :

prix HT industriel = tarif HT magasin + commission plateforme
commission plateforme = 20 % du tarif HT magasin (baseline)

La validation de cohérence est assurée par assertReservationInvariants :

grossAmount == commissionAmount + netDistributorAmount

Tous les montants doivent être positifs ou nuls.

Circuit de facturation :

  • L'industriel règle 100 % à la réservation.
  • Il reçoit une facture unique plateforme → industriel.
  • La plateforme émet une auto-facture fournisseur (self-billing) magasin → plateforme pour la part distributeur.

API — exemples représentatifs

Authentification — la plateforme utilise des sessions par cookie (Better Auth). La session est établie à la connexion et transmise automatiquement via le cookie better-auth.session_token. Dans les exemples curl, utilisez -b 'better-auth.session_token=<jeton-de-session>'.

Lister les campagnes de l'industriel connecté

Nécessite le rôle industrial avec un profil organisation actif.

GET /api/campaigns?page=1&pageSize=20&sortBy=createdAt&sortOrder=desc
Cookie: better-auth.session_token=<jeton-de-session>

Paramètres de filtre optionnels : search (texte libre), sortBy, sortOrder, page, pageSize.

Créer une campagne

Le KYB de l'organisation industrielle doit être approuvé (campaigns.create capability).

POST /api/campaigns
Content-Type: application/json
Cookie: better-auth.session_token=<jeton-de-session>

{
  "name": "Rentrée 2026 — Biscuits Dupont",
  "brandId": "brand_abc123",
  "objective": "traffic",
  "scope": "regional",
  "startsAt": "2026-09-01T00:00:00Z",
  "endsAt": "2026-09-30T23:59:59Z",
  "budgetAmount": 15000
}

Consulter le détail d'une campagne

GET /api/campaigns/{campaignId}
Cookie: better-auth.session_token=<jeton-de-session>

Retourne les métadonnées de la campagne, son statut calculé et la liste des réservations associées.

Modifier une campagne en brouillon

Réservé au statut draft ; renvoie 409 CONFLICT sinon.

PATCH /api/campaigns/{campaignId}
Content-Type: application/json
Cookie: better-auth.session_token=<jeton-de-session>

{
  "name": "Rentrée 2026 — Biscuits Dupont",
  "objective": "sales",
  "scope": "national",
  "startsAt": "2026-09-01T00:00:00Z",
  "endsAt": "2026-10-15T23:59:59Z",
  "brandId": "brand_abc123",
  "productIds": ["product_001"],
  "budgetAmount": 18000
}

Tous les champs sont optionnels ; seuls ceux fournis sont modifiés, le reste conserve la valeur courante de la campagne.

Mettre à jour le ciblage d'une campagne

Autorisé en draft et en active (canEditCampaignTargeting) ; renvoie 409 CONFLICT en completed ou cancelled. Nécessite le KYB approuvé (campaigns.create capability), comme la création.

PATCH /api/campaigns/{campaignId}/targeting
Content-Type: application/json
Cookie: better-auth.session_token=<jeton-de-session>

{
  "regions": ["ile_de_france"],
  "departments": ["75", "92"],
  "cities": ["Paris"],
  "universeIds": ["cmu_abc123"],
  "rayonIds": ["cmr_def456"],
  "enseigneCodes": ["E.Leclerc"]
}

Les six champs sont optionnels et indépendants les uns des autres : un champ omis conserve sa valeur courante en base, un champ fourni — y compris [] — remplace intégralement la liste existante.

Lister les réservations de l'industriel

L'industriel consulte ses propres réservations via une liste paginée et résolue côté serveur (jointures espace/magasin/campagne en SQL brut, LIMIT/OFFSET et COUNT dédiés — aucune pagination en mémoire). Chaque item porte le nom, la ville et le SIRET du magasin (storeName, storeCity, storeSiret) résolus via la jointure sur l'espace. Paramètres optionnels : page, pageSize (plafonné à 100, défaut 20), search (code espace, statut, nom de campagne, nom et ville du magasin), campaignId (filtre sur une campagne), status (filtre sur un statut de réservation), sortBy (createdAt | adSpaceCode | status | startsAt | totalAmount), sortOrder.

L'interface de cette liste s'appuie sur le composant <AkDataList> (@aidalinfo/nuxt-ui-kit) en mode="server" : colonnes déclaratives, recherche, tri, et deux filtres déroulants (campagne, statut) câblés sur les paramètres campaignId/status.

Dans l'interface, le nom du magasin de chaque réservation est un lien vers la carte (/industrial/map?focusStore=<siret>) : la carte applique alors le filtre SIRET, recadre sur le magasin ciblé et ouvre directement sa fiche de sélection. Le paramètre focusStore est retiré de l'URL une fois la sélection effectuée.

GET /api/reservations?page=1&pageSize=20&sortBy=startsAt&sortOrder=asc
Cookie: better-auth.session_token=<jeton-de-session>

Le tri sur adSpaceCode est résolu au niveau base via la jointure sur l'espace. La réponse porte items, pagination et counts : le nombre de réservations par statut (buildReservationStatusCounts, shared/utils/reservation-status.ts), qui alimente les onglets de /industrial/reservations.

Ces compteurs sont calculés sur la même clause WHERE que la liste, moins la condition de statut : la recherche et le filtre campagne les font varier, le statut non. C'est ce qui rend la somme des onglets égale au nombre de lignes réellement listables, et permet à chaque onglet d'annoncer exactement ce qu'il affichera. Les filtres campaignId et status ignorent la valeur all que le composant de liste émet lorsqu'on efface un filtre (resolveDataListFilterValue, app/utils/data-list-filters.ts) : sans cela all serait interprété comme un identifiant et ne remonterait aucune ligne.

Vue calendrier de l'industriel

GET /api/industrial/calendar alimente la page calendrier (vue mois). La réponse agrège trois blocs :

  • campaigns — les campagnes de l'industriel (métadonnées, statut effectif, compteurs de réservations) pour la barre latérale et les barres de campagne. Les campagnes annulées (cancelled) sont exclues du calendrier : ni carte latérale, ni barre, ni rappel.
  • reservations — les réservations dont la période chevauche la fenêtre demandée, résolues via jointure espace/magasin : { id, campaignId, adSpaceCode, storeCity, status, startsAt, endsAt }. Les réservations avec et sans campagne sont incluses.
  • alerts — alertes marketing d'activation (mêmes codes que la liste des campagnes).

La fenêtre est contrôlée par les paramètres optionnels from/to (ISO). Par défaut, le serveur couvre le mois courant et le mois suivant. La page envoie la fenêtre des deux mois affichés et recharge à la navigation mensuelle.

Côté interface, chaque campagne est une barre épaisse colorée (palette stable par campagne, jamais orange). Les réservations sont des barres fines : posées sur le bord inférieur de la barre de leur campagne quand elles y sont rattachées, ou sur une voie dédiée sinon. Leur couleur encode le statut (approuvée = teal, demandée/en attente = ambre, refusée/annulée = rouge). Le survol d'une réservation affiche emplacement, statut et ville.

Créer des réservations depuis le panier

Convertit les items sélectionnés du panier en réservations et les rattache à la campagne désignée. Le KYB doit être approuvé.

POST /api/reservations
Content-Type: application/json
Cookie: better-auth.session_token=<jeton-de-session>

{
  "campaignId": "cmpgn_xyz789",
  "selectedItemIds": ["item_001", "item_002"]
}

Chaque item du panier sélectionné donne lieu à une réservation indépendante au statut requested. Si campaignId est omis, les réservations sont créées sans campagne. PATCH /api/reservations/{reservationId} permet de les rattacher ensuite, mais aucun écran n'expose ce rattachement : la campagne d'une réservation se choisit à la création (validation du panier ou assistant groupé), et la colonne « Campagne » de /industrial/reservations est en lecture seule.

Supprimer une demande

Tant qu'une réservation est encore au statut requested (« Demandée »), l'industriel qui l'a émise peut la supprimer — pour corriger une demande faite par erreur avant tout arbitrage du distributeur.

DELETE /api/reservations/{reservationId}
Cookie: better-auth.session_token=<jeton-de-session>
  • Rôle industrial + capacité KYB campaigns.create, strictement isolée par organisation : un industriel ne peut supprimer que ses propres réservations (sinon 404).
  • Autorisée uniquement si le statut est encore requested (règle de domaine canDeleteRequestedReservation) ; sinon 409 CONFLICT.
  • Suppression ferme (deleteRequestedReservation) : la réservation et ses messages sont retirés dans une transaction, l'emplacement redevient disponible et le budget réservé de la campagne éventuellement rattachée est resynchronisé.
  • Côté interface, l'action « Supprimer » n'apparaît dans le menu de la liste que pour les lignes requested.

Lister ses impressions

Alimente /industrial/impressions. Nécessite le rôle industrial avec un profil organisation actif.

GET /api/impressions?tab=to_deposit&page=1&pageSize=20
Cookie: better-auth.session_token=<jeton-de-session>

Le paramètre tab est obligatoire, l'une des valeurs to_deposit | in_review | validated | in_stock (sinon 400 VALIDATION_ERROR) ; locked n'est pas un onglet listable. page/pageSize suivent la même convention de pagination que les autres listes serveur (défaut 20, plafond 100). La réponse porte les items de l'onglet demandé, les compteurs de chaque onglet (counts) et la pagination :

campaignId est un filtre facultatif : un identifiant de campagne, ou la sentinelle none (NO_CAMPAIGN_FILTER, shared/utils/reservation-status.ts, partagée avec l'interface) pour ne retenir que les réservations rattachées à aucune campagne. Toute autre valeur vide ou absente laisse la liste complète (resolveImpressionCampaignFilter). Le filtre s'applique aussi aux counts, pas seulement aux lignes listées : un compteur d'onglet égale toujours le nombre de lignes que ce même onglet affiche sous le filtre courant, et totalItems en découle — un compteur qui promettrait des lignes qu'il ne montre pas est un défaut que ce module a déjà payé ailleurs.

{
  "ok": true,
  "data": {
    "items": [
      {
        "reservationId": "rsv_abc123",
        "storeName": "Leclerc Lille Nord",
        "adSpaceCode": "PODIUM-ENTREE",
        "furnitureTypeLabel": "Meuble podium 3 faces",
        "printableZoneCount": 3,
        "impressionStatus": "to_deposit",
        "printDepositDueAt": "2026-09-08T00:00:00.000Z",
        "period": { "startsAt": "2026-09-01T00:00:00.000Z", "endsAt": "2026-09-15T00:00:00.000Z" }
      }
    ],
    "counts": { "to_deposit": 4, "in_review": 1, "validated": 2, "in_stock": 0 },
    "pagination": {
      "page": 1,
      "pageSize": 20,
      "totalItems": 4,
      "totalPages": 1,
      "hasPreviousPage": false,
      "hasNextPage": false
    }
  }
}

Consulter le détail d'une impression

Alimente /industrial/impressions/{reservationId}.

GET /api/impressions/{reservationId}
Cookie: better-auth.session_token=<jeton-de-session>

Isolé par organisation industrielle (404 si la réservation n'appartient pas à l'appelant). Retourne le statut d'impression, l'échéance de dépôt, le mode de support, le libellé du meuble et le nombre de zones imprimables, ainsi que le PrintOrder associé s'il existe déjà :

{
  "ok": true,
  "data": {
    "impressionStatus": "to_deposit",
    "printDepositDueAt": "2026-09-08T00:00:00.000Z",
    "printSupportMode": "print",
    "furnitureTypeLabel": "Meuble podium 3 faces",
    "printableZoneCount": 3,
    "printOrder": null,
    "gabaritsAvailable": true
  }
}

Le dépôt des visuels (POST /api/reservations/{reservationId}/upload/confirm) et l'envoi à l'imprimeur (POST /api/reservations/{reservationId}/print-order/submit) restent servis par les routes existantes sous /api/reservations/{id}/... ; elles ne sont pas dupliquées sous /api/impressions.

Côté distributeur

Les routes ci-dessus concernent l'industriel qui émet les demandes. Le distributeur dispose de son propre flux pour consulter et arbitrer les réservations reçues sur ses espaces. Elles nécessitent le rôle distributor avec la permission campaigns.review.own et sont strictement isolées par organisation (un distributeur ne voit que les réservations portant sur ses propres espaces).

Lister les réservations reçues

La liste est paginée et résolue côté serveur (jointures espace/magasin/industriel). Paramètres de requête (tous optionnels) :

ParamètreValeursDéfaut
pageentier ≥ 11
pageSizeentier ≥ 1 (plafonné à 100)20
searchtexte (code espace, statut, nom de campagne, code/nom de magasin, identifiant/nom de l'industriel)
sortBycreatedAt | adSpaceCode | status | startsAt | totalAmount | storeCode | industrialNamecreatedAt
sortOrderasc | descdesc
GET /api/distributor/reservations?page=1&pageSize=20&search=danone&sortBy=startsAt&sortOrder=asc
Cookie: better-auth.session_token=<jeton-de-session>

Réponse :

{
  "ok": true,
  "data": {
    "items": [
      {
        "id": "rsv_abc123",
        "adSpaceCode": "PODIUM-ENTREE",
        "status": "requested",
        "startsAt": "2026-09-01T00:00:00.000Z",
        "endsAt": "2026-09-15T00:00:00.000Z",
        "totalAmount": 1500,
        "currency": "EUR",
        "storeCode": "MAG-LILLE-001",
        "storeName": "Leclerc Lille Nord",
        "industrialOrganizationId": "industrial:552032534",
        "industrialName": "Danone France",
        "canReview": true,
        "canCounterPropose": true,
        "counterProposal": null,
        "campaign": { "id": "cmpgn_xyz789", "name": "Rentrée 2026", "status": "active" }
      }
    ],
    "pagination": {
      "page": 1,
      "pageSize": 20,
      "totalItems": 42,
      "totalPages": 3,
      "hasPreviousPage": false,
      "hasNextPage": true
    }
  }
}

Les indicateurs canReview (vrai tant que la demande est requested) et canCounterPropose (vrai en requested ou pending_validation) renseignent l'interface sur les actions encore possibles. counterProposal est null tant qu'aucune contre-proposition n'a été émise.

Arbitrer une réservation

Le distributeur accepte, refuse, ou émet une contre-proposition sur une réservation reçue.

PATCH /api/distributor/reservations/{reservationId}
Content-Type: application/json
Cookie: better-auth.session_token=<jeton-de-session>

{
  "status": "pending_validation",
  "counterProposal": {
    "startsAt": "2026-09-03T00:00:00Z",
    "endsAt": "2026-09-17T00:00:00Z",
    "totalAmount": 1700,
    "currency": "EUR",
    "note": "Dates décalées et tarif ajusté."
  }
}
  • status à approved ou rejected valide ou refuse la demande directement.
  • status à pending_validation accompagné de counterProposal renvoie une contre-proposition à l'industriel (dates et/ou montant révisés), qui doit alors la valider à son tour.

Règles métier clés

  • Aucune réservation n'est possible sans KYB approuvé (industriel côté création, distributeur côté espace).
  • Les données sont strictement isolées par organisation : un industriel ne voit que ses propres campagnes et réservations.
  • Une demande en requested ou pending_validation fige temporairement l'inventaire ciblé ; un refus ou une annulation le libère.
  • Une réservation approuvée (approved) bloque définitivement la disponibilité de l'espace pour la période.
  • La période de réservation doit être strictement comprise dans la période de la campagne parente.
  • L'impression est un parcours séparé de l'approbation de l'espace : le dépôt des visuels (to_deposit) ne s'ouvre qu'après l'approbation de la réservation (voir « Impression » ci-dessus), jamais avant.