Magasins et espaces
Magasins et espaces
Le module catalog (app/mypromo/server/modules/catalog/) gère le référentiel des points de vente, le catalogue de meubles promotionnels et leurs espaces publicitaires. Un distributeur ne crée plus un espace « à partir de zéro » : il consulte le catalogue de meubles opéré par l'admin, envoie une demande, puis finalise (placement, disponibilités, tarif, photos) l'espace créé en brouillon après approbation. Le module est utilisé par les distributeurs pour publier leur offre, par l'admin pour opérer le catalogue et approuver les demandes, et par les industriels pour rechercher les espaces publiés sur la carte.
Hiérarchie du domaine
Organisation distributeur
└── Store (magasin)
└── AdSpace (espace publicitaire)
└── PromoFurniture (instance de meuble, n° de série)
└── PromoFurnitureType (configuration catalogue)
Store (magasin)
Un magasin est identifié par un storeCode normalisé (majuscules, tirets). Il est rattaché à l'organisation Better Auth du distributeur et exige un SIRET valide (14 chiffres) dont les 9 premiers (SIREN) doivent correspondre au SIREN de l'organisation.
Champs requis à la création : code, name, siret, addressFormatted.
AdSpace (espace publicitaire)
Un espace appartient à un seul magasin. Il n'est plus créé directement par le distributeur : il est créé en statut draft par l'admin, à l'unité, lors de l'approbation d'une FurnitureRequest (voir plus bas). Chaque espace expose :
| Champ | Description |
|---|---|
supportType | Type de meuble promotionnel, dérivé du PromoFurnitureType à la création (lecture seule) |
widthMm / heightMm | Dimensions physiques en millimètres, dérivées du PromoFurnitureType à la création (lecture seule) |
promoFurnitureTypeId | Référence vers le PromoFurnitureType d'origine (identité figée) |
sourceFurnitureRequestId | Référence vers la FurnitureRequest ayant produit cet espace |
placementUniverseId | Univers du magasin choisi dans le référentiel administrable — obligatoire pour publier |
placementRayonId | Zone de rayon choisie dans le référentiel, doit appartenir à l'univers sélectionné — obligatoire pour publier |
area (sérialisé) | Libellé du rayon, lu par jointure sur placementRayonId au moment de la sérialisation — non stocké, jamais saisi par le client |
placementAisle | Allée précise (optionnel, texte libre) |
campaignEligibilityScopes | Niveaux de campagne auxquels l'espace peut participer (String[], multi-choix : magasin, local, departemental, regional, national, enseigne) — au moins un requis pour publier |
availabilityWindows | Plages d'indisponibilité (max 120 fenêtres, tri automatique, sans chevauchement) — sans fenêtre, l'espace est disponible en continu |
minimumReservationDays | Durée minimale de location (1 à 365 j, défaut 1) |
pricing | Tarification (voir ci-dessous) |
photos | Jusqu'à 12 URLs photos (HTTPS ou chemin relatif app) |
status | Cycle de vie de l'espace |
L'identité du support (supportType, widthMm/heightMm, promoFurnitureTypeId) est figée par le meuble d'origine et non modifiable par le distributeur : seuls le placement, les disponibilités, la tarification et les photos sont éditables lors de la finalisation.
Placement via le référentiel univers / rayons
Le placement d'un espace ne se saisit pas en texte libre : le distributeur choisit un univers puis une zone de rayon dans le référentiel administré par l'admin (/admin/app-settings, tables catalog_store_universes et catalog_store_rayons).
Les emplacements physiques qui ne relèvent pas d'une famille produit (entrée magasin, allée centrale, zone caisses…) sont modélisés comme un univers transversal (isTransversal) dédié — par ex. « Emplacements stratégiques » — dont les rayons sont des localisations. Le distributeur le sélectionne comme n'importe quel univers ; aucune règle serveur ne distingue un univers transversal d'un univers produit.
Les deux colonnes placementUniverseId / placementRayonId sont nullables en base : un espace naît en draft sans placement, à l'approbation de la demande. L'obligation est portée par le formulaire (validation par étape) et surtout par le gating serveur au passage en published.
resolveAdSpacePlacement (catalog/application/ad-space-placement.ts) valide à chaque écriture que le rayon existe, qu'il appartient bien à l'univers transmis, et que ni l'univers ni le rayon ne sont archivés. Le libellé du rayon n'est pas dupliqué sur l'AdSpace : il est lu par jointure sur placementRayonId au moment de la lecture (sérialisation, détail carte, recherche). Renommer un rayon dans le référentiel se reflète donc immédiatement partout, sans copie à propager.
Cycle de vie d'un espace
(demande approuvée) → draft → in_review → published
└→ suspended
- Un espace n'existe qu'après approbation d'une
FurnitureRequestpar l'admin : il est créé directement endraft, lié à une instance de meuble (PromoFurniture) et à la demande d'origine. draft: brouillon, à finaliser par le distributeur (placement, disponibilités, tarif, photos) ; non visible en recherche industrielle.in_review: soumis à validation interne.published: visible et réservable par les industriels. Le passage àpublishedest soumis à un gating (voir Règles métier).suspended: retiré temporairement de la recherche.
Un espace non publié (draft, in_review, suspended) n'apparaît pas dans les résultats de recherche industrielle.
Catalogue mobilier promotionnel et demande de meuble
PromoFurnitureType — configuration catalogue
Une configuration de meuble (PromoFurnitureType) est créée et administrée par l'admin (/admin/promo-furniture-types). Elle porte les dimensions, volumes, surfaces publicitaires et deux familles de fichiers :
| Relation | Modèle | Usage |
|---|---|---|
placementImages | PromoFurnitureTypePlacementImage | Images de placement des zones — support du tracé des quads (positionnement des faces), usage admin |
catalogVisuals | PromoFurnitureTypeCatalogVisual | Visuels du meuble (catalogue) — montrés au distributeur dans le catalogue |
documents | PromoFurnitureTypeDocument | Documents techniques associés au type |
Les vrais fichiers d'impression vierges (gabarits téléchargeables) ne vivent plus sur le type : ils vivent sur les faces de la bibliothèque de faces (PrintableZone → PrintableZoneGabarit), voir ci-dessous.
Tarif locatif — PromoFurnitureTypeRentalPrice
Un type porte un tarif locatif mensuel borné dans le temps (date-à-date), saisi par l'admin à la création ou à l'édition du type. Chaque entrée est une ligne de la table enfant PromoFurnitureTypeRentalPrice :
| Champ | Type | Rôle |
|---|---|---|
validFrom | DateTime | Début de validité (obligatoire) |
validTo | DateTime | Fin de validité (obligatoire, strictement postérieure à validFrom) |
monthlyPriceCents | Int | Tarif mensuel en centimes d'euro |
position | Int | Ordre de saisie |
Invariant de domaine (catalog/domain/furniture-rental-price.ts) : les périodes d'un même type ne se chevauchent pas (validateRentalPricePeriods). La normalisation et le remplacement complet des lignes sont gérés par catalog/application/furniture-rental-price.ts ; la création/mise à jour du type délègue à ce module.
Le catalogue distributeur expose un champ dérivé activeMonthlyPriceCents (CatalogTypeSummary / CatalogTypeDetail) calculé par pickActiveRentalPrice(entries, atDate) : tarif actif à la date du jour, à défaut le prochain à venir, sinon null. Le distributeur voit alors « À partir de X € / mois ». Le rendu diffère selon la surface quand activeMonthlyPriceCents vaut null :
| Surface | activeMonthlyPriceCents renseigné | activeMonthlyPriceCents à null |
|---|---|---|
Carte du catalogue (FurnitureCatalogCard) | « À partir de X € / mois » | ligne masquée |
Détail du meuble (furniture-catalog/[id]) | « À partir de X € / mois » | encart « Tarif locatif » toujours affiché, avec un message d'absence |
- une API vers Boost Your Shop (application qui gérera la location effective des meubles réfrigérés) ;
- une synchronisation ERP / CRM de la société ;
- une tarification évolutive par période (le modèle enfant par période accueille de nouvelles lignes sans migration structurelle) ;
- une reprise commerciale sans ressaisie côté BMP (chaque ligne a un
idcuid stable, montants en centimes entiers — pas de flottant).
externalRef, currency, sourceSystem.Faces — relation plusieurs-à-plusieurs avec les types
Une face (zone imprimable, PrintableZone) porte son titre, ses dimensions et ses gabarits téléchargeables (PrintableZoneGabarit). Elle est associée à un ou plusieurs PromoFurnitureType via la table de jointure PromoFurnitureTypeFace :
PromoFurnitureType ←── PromoFurnitureTypeFace ──→ PrintableZone
Une même face peut donc être réutilisée sur plusieurs meubles (avant ce changement, une face n'appartenait qu'à un seul type). Le nombre de faces d'un type (facesCount) et la liste de ses faces se dérivent en interrogeant PromoFurnitureTypeFace par promoFurnitureTypeId, jamais par un champ « propriétaire » sur PrintableZone.
Quantité de faces — la seule source de vérité
PromoFurnitureTypeFace.quantity (entier, 1 par défaut, borné à 99) déclare combien de panneaux de ce type de face le meuble porte réellement. C'est la seule valeur qui commande la fabrication.
qualifier) aux faces visibles. Un parallélépipède à 4 faces dont la photo n'en montre que 2 n'a que 2 quads — et une quantity de 4. Dériver un nombre de panneaux d'un quads.length ou d'un qualifiers.length est le défaut que ce champ corrige : l'imprimeur recevait « 1 panneau par type » au lieu de 2.Conséquences dans le code :
- L'API d'association est
PATCH /api/admin/catalog/promo-furniture-types/{typeId}/facesavec{ faces: [{ printableZoneId, quantity }] }(elle remplaceprintable-zone-ids), servie parcatalog/application/promo-furniture-type-faces.ts. - Dessiner un quad sur une zone non associée crée toujours la face manquante, avec la quantité par défaut 1 — l'admin corrige ensuite la valeur réelle dans la section « Faces » de la configuration.
- Le détail admin d'un type expose
faces: [{ printableZoneId, quantity }](et non plusprintableZoneIds). - Deux quads sur la même zone doivent toujours porter des
qualifierdistincts : c'est une contrainte d'unicité d'étiquette, plus un comptage.
FurnitureRequest — demande de meuble (distributeur → admin)
Le distributeur ne crée plus d'espace directement : il consulte le catalogue (types en lecture seule), ajoute à sa demande de meuble (par magasin) un ou plusieurs types de meuble avec une quantité, puis envoie une FurnitureRequest. Côté code le widget reste le panier client (useFurnitureRequestCart, FurnitureCartSlideover) ; seule la terminologie visible côté distributeur a été renommée « panier » → « demande de meuble » (clés i18n distributorCatalog.*).
model FurnitureRequest {
distributorOrganizationId String
distributorStoreId String
status String // pending | approved | rejected
note String?
adminNote String?
items FurnitureRequestItem[]
}
model FurnitureRequestItem {
promoFurnitureTypeId String
quantity Int
}
Domaine pur (catalog/domain/furniture-request.ts) :
canApproveFurnitureRequest(status)/canRejectFurnitureRequest(status)— uniquement surpending.totalRequestedUnits(items)— somme des quantités demandées.validateSerialNumbers(serialNumbers, expectedCount)— vérifie le compte (count_mismatch), l'absence de doublon (duplicate) et l'absence de valeur vide (empty).
Approbation — création des triplettes meuble + espace
L'admin approuve une demande pending (/admin/furniture-requests) en saisissant un numéro de série par unité demandée (regroupés par ligne/type). L'approbation exécute une transaction unique qui, pour chaque unité :
- crée une instance
PromoFurniture(serialNumberunique,promoFurnitureTypeId, organisation/magasin distributeur) ; - crée un
AdSpaceendraftdont l'identité (supportType, dimensions,promoFurnitureTypeId) est dérivée du type, avecsourceFurnitureRequestIdrenseigné et uncodegénéré (ESP-<storeCode>-<n>) ; - lie
PromoFurniture.distributorAdSpaceIdà l'espace créé.
La demande passe ensuite à approved (handledByUserId/handledAt renseignés). Si validateSerialNumbers échoue, l'approbation est rejetée sans rien créer.
Le rejet (canRejectFurnitureRequest) exige un adminNote (motif) et passe la demande à rejected — le motif reste consultable par le distributeur.
Annulation d'une approbation
canCancelFurnitureRequestApproval(status) autorise l'annulation uniquement depuis approved. cancelFurnitureRequestApproval (catalog/application/furniture-request-approval.ts) vérifie ensuite que tous les AdSpace créés par l'approbation sont encore draft et qu'aucun n'a de réservation active (Reservation.status parmi requested/pending_validation/approved — les réservations rejected/cancelled/completed ne bloquent pas l'annulation). Si la condition est remplie, une transaction supprime les PromoFurniture et AdSpace créés et repasse la demande à pending (handledByUserId/handledAt/adminNote réinitialisés) ; sinon l'appel échoue en 409 (« Impossible d'annuler : un espace est déjà publié ou réservé. ») sans rien supprimer.
Notifications
Les événements furniture_request.created (→ admin), furniture_request.approved et furniture_request.rejected (→ distributeur) sont émis via le module notifications (voir app/docs/content/developpeur/2.modules/5.notifications.md).
Tarification
La tarification est définie à la finalisation d'un espace et peut être mise à jour. Elle combine :
standardDailyRateCents— tarif journalier HT de base, en centimes EUR.periodicRules— règles de modulation périodique (hebdomadaire ou mensuelle au Nième jour de la semaine). Chaque règle a unrateCentset unepriorityunique (0–1000).eventRules— règles tarifaires ponctuelles (ex. Noël, Pâques) avec plagestartsAt/endsAt,rateCentsetpriorityunique.
La priorité détermine quelle règle s'applique en cas de chevauchement (priorité la plus haute gagne). Les priorités doivent être uniques sur l'ensemble des règles périodiques et événementielles.
Normalisation avant calcul — source unique. Toute lecture de tarification passe par normalizeAdSpacePricingRules (catalog/domain/ad-space-pricing-normalization.ts, exporté par le baril catalog), qui écarte les règles inexploitables avant qu'un prix ne soit calculé : standardDailyRateCents doit être strictement positif (sinon l'espace n'est pas tarifable du tout), une règle périodique doit porter un weekday entier compris entre 0 et 6, un type connu et un rateCents strictement positif, une règle événementielle doit avoir deux dates analysables et un rateCents strictement positif. Les montants et priorités retenus sont tronqués à l'entier.
Cette fonction est la seule implémentation de cette normalisation : la recherche carte (catalog/application/map-search.ts) et l'assistant de réservation de masse (campaigns/application/mass-reservation/pricing-helpers.ts) y délèguent tous les deux. Elles ont un temps porté deux copies divergentes, la seconde ayant perdu les contrôles de bornes — un distributeur avec une règle malformée voyait alors le même emplacement tarifé différemment sur la carte et dans l'assistant. Ajouter un nouveau consommateur de tarification signifie appeler cette fonction, jamais réécrire le filtrage.
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 magasins du distributeur connecté
Nécessite le rôle distributor avec la permission catalog.manage.own.
La liste est paginée côté serveur. Paramètres de requête (tous optionnels) :
| Paramètre | Valeurs | Défaut |
|---|---|---|
page | entier ≥ 1 | 1 |
pageSize | entier ≥ 1 (plafonné à 100) | 20 |
search | texte (nom, code, enseigne, SIRET, ville, code postal) | — |
georefStatus | resolved | unresolved | — |
city | ville exacte | — |
sortBy | name | code | enseigne | city | createdAt | updatedAt | updatedAt |
sortOrder | asc | desc | desc |
GET /api/distributor/stores?page=1&pageSize=20&search=lille
Cookie: better-auth.session_token=<jeton-de-session>
Réponse :
{
"ok": true,
"data": {
"organization": {
"organizationId": "distributor:418166098",
"organizationName": "Leclerc Distribution"
},
"stores": [
{
"code": "MAG-LILLE-001",
"name": "Leclerc Lille Nord",
"siret": "41816609800069",
"enseigne": "E.Leclerc",
"address": { "formatted": "1 avenue de la Paix, 59000 Lille", "city": "Lille" },
"adSpacesCount": 3
}
],
"warnings": [],
"pagination": {
"page": 1,
"pageSize": 20,
"totalItems": 137,
"totalPages": 7,
"hasPreviousPage": false,
"hasNextPage": true
}
}
}
Détail d'un magasin
Nécessite le rôle distributor avec la permission catalog.manage.own. Renvoie un seul magasin par son storeCode, avec l'adresse, la géolocalisation, l'audience, le nombre d'emplacements et de signaux d'intérêt. C'est la source de la page de détail magasin (/distributor/stores/{code}).
GET /api/distributor/stores/MAG-LILLE-001
Cookie: better-auth.session_token=<jeton-de-session>
{
"ok": true,
"data": {
"store": {
"id": "cmr…",
"code": "MAG-LILLE-001",
"name": "Leclerc Lille Nord",
"siret": "41816609800069",
"enseigne": "E.Leclerc",
"address": { "formatted": "1 avenue de la Paix, 59000 Lille", "city": "Lille" },
"location": { "latitude": 50.63, "longitude": 3.07 },
"adSpacesCount": 3,
"audience": { "customerFlowPerDay": 1200, "catchmentArea": "Centre-ville" },
"interestSignalsCount": 4
}
}
}
L'ancien endpoint
GET /api/distributor/stores/{code}/identity(identité minimale) a été retiré : ses appelants consomment désormais ce détail.
Référentiel univers / rayons (distributeur)
Nécessite la permission catalog.manage.own. Renvoie les univers actifs avec leurs rayons actifs, triés par sortOrder puis libellé. Les univers dont tous les rayons sont archivés sont omis. Alimente les deux sélecteurs de l'étape « Identité » du formulaire d'emplacement.
GET /api/distributor/store-zones
Cookie: better-auth.session_token=<jeton-de-session>
{
"ok": true,
"data": {
"universes": [
{
"id": "cmr…",
"label": "Produits frais",
"isTransversal": false,
"rayons": [{ "id": "cmr…", "label": "Boucherie" }]
}
]
}
}
Lister et finaliser les espaces (distributeur)
Deux listes alimentent l'interface distributeur, toutes deux derrière catalog.manage.own :
GET /api/distributor/ad-spaces— liste paginée de tous les espaces du distributeur (filtressearchetstatus), source de la page/distributor/spaces.GET /api/distributor/stores/{storeCode}/ad-spaces— espaces d'un seul magasin, source de la page de détail magasin.
La finalisation et toutes les modifications passent par un unique PATCH (corps partiel : seuls les champs présents sont mis à jour). L'identité du support (supportType, dimensions) reste figée même si transmise. Le placement se donne par placementUniverseId + placementRayonId ; le libellé (area) n'est jamais transmis, il est lu par jointure sur le référentiel.
PATCH /api/distributor/stores/MAG-LILLE-001/ad-spaces/ESP-001
Content-Type: application/json
Cookie: better-auth.session_token=<jeton-de-session>
{
"placementUniverseId": "cmr…",
"placementRayonId": "cmr…",
"placementAisle": "A12",
"minimumReservationDays": 7,
"availabilityWindows": [],
"pricing": { "standardDailyRateCents": 2500, "currency": "EUR", "periodicRules": [], "eventRules": [] }
}
Le même endpoint gère les transitions de statut, en n'envoyant que status (draft / in_review / published / suspended) — le passage à published applique le gating (univers + rayon + au moins un niveau de campagne éligible + au moins une règle de tarif). DELETE /api/distributor/stores/{storeCode}/ad-spaces/{adSpaceCode} supprime l'espace.
PATCH /api/distributor/ad-spaces/eligibility met à jour en masse les niveaux de campagne éligibles d'un lot d'espaces ({ adSpaceIds: string[], campaignEligibilityScopes: string[] }, ≥ 1 niveau requis) ; l'opération est scopée à l'organisation appelante (les ids d'autres organisations sont ignorés).
Marqueurs des magasins (carte et sélecteur)
Endpoint léger renvoyant tous les magasins du distributeur (projection minimale,
sans pagination) — alimente la carte de la page magasins et le sélecteur de
magasin de la page espaces, sans charger les lignes complètes. Accepte les mêmes
filtres que la liste (sans pagination ni tri) pour rester cohérent avec elle :
search (texte libre), georefStatus (resolved | unresolved) et city
(ville exacte).
GET /api/distributor/stores/markers?search=lille&georefStatus=resolved&city=Lille
Cookie: better-auth.session_token=<jeton-de-session>
{
"ok": true,
"data": {
"markers": [
{
"code": "MAG-LILLE-001",
"name": "Leclerc Lille Nord",
"enseigne": "E.Leclerc",
"city": "Lille",
"postalCode": "59000",
"adSpacesCount": 3,
"latitude": 50.63,
"longitude": 3.07
}
]
}
}
Créer un magasin
Le KYB de l'organisation distributeur doit être approuvé (catalog.manage capability).
POST /api/distributor/stores
Content-Type: application/json
Cookie: better-auth.session_token=<jeton-de-session>
{
"code": "MAG-LILLE-001",
"name": "Leclerc Lille Nord",
"siret": "41816609800069",
"addressFormatted": "1 avenue de la Paix, 59000 Lille",
"enseigne": "E.Leclerc"
}
Le champ storeCode dans l'URL est le code normalisé du magasin (ex. MAG-LILLE-001).
Consulter le catalogue de meubles (distributeur, lecture seule)
GET /api/distributor/furniture-types
Cookie: better-auth.session_token=<jeton-de-session>
Retourne les résumés (id, code, name, dimensions, thumbnailUrl dérivé du premier visuel catalogue, facesCount).
GET /api/distributor/furniture-types/{id}
Cookie: better-auth.session_token=<jeton-de-session>
Retourne le détail (dimensions, volumes, surfaces, catalogVisuals[], documents[], faces[] avec title/widthMm/heightMm).
Envoyer une demande de meuble
POST /api/distributor/furniture-requests
Content-Type: application/json
Cookie: better-auth.session_token=<jeton-de-session>
{
"distributorStoreId": "cst_123",
"note": "Pour la mise en avant rentrée scolaire",
"items": [
{ "promoFurnitureTypeId": "cft_abc", "quantity": 2 }
]
}
Le magasin doit appartenir à l'organisation du distributeur ; chaque quantity doit être un entier ≥ 1. La demande est créée au statut pending.
GET /api/distributor/furniture-requests
Cookie: better-auth.session_token=<jeton-de-session>
Liste les demandes du distributeur connecté avec leur statut, leurs lignes et le motif de refus éventuel (adminNote).
File d'attente et approbation (admin)
Requiert la permission catalog.manage.all.
GET /api/admin/furniture-requests?status=pending
GET /api/admin/furniture-requests/{id}
POST /api/admin/furniture-requests/{id}/approve
Content-Type: application/json
Cookie: better-auth.session_token=<jeton-de-session>
{ "serialNumbers": ["SN-0001", "SN-0002"] }
Le nombre de numéros de série doit correspondre exactement au total des quantités demandées (totalRequestedUnits), sans doublon ni valeur vide. Crée en une transaction les instances PromoFurniture et les AdSpace draft correspondants.
POST /api/admin/furniture-requests/{id}/reject
Content-Type: application/json
Cookie: better-auth.session_token=<jeton-de-session>
{ "adminNote": "Quantité incompatible avec la surface du magasin." }
POST /api/admin/furniture-requests/{id}/cancel-approval
Cookie: better-auth.session_token=<jeton-de-session>
Annule une approbation (voir « Annulation d'une approbation » plus haut). Répond 409 si un espace créé est déjà publié ou a une réservation active ; sinon supprime les PromoFurniture/AdSpace créés et repasse la demande à pending.
Emplacements promo (admin)
GET /api/admin/catalog/promo-furnitures?storeId={distributorStoreId}
DELETE /api/admin/catalog/promo-furnitures/{furnitureId}
Le paramètre storeId filtre la liste sur un magasin (utilisé par le lien « Voir les emplacements » depuis une demande approuvée). La suppression est refusée en 409 si le PromoFurniture a un distributorAdSpaceId (c'est-à-dire s'il est rattaché à un espace) — un emplacement lié ne peut être supprimé qu'en passant par l'annulation d'approbation de sa demande d'origine.
Recherche carte (industriels)
Accessible sans authentification (session optionnelle pour personnalisation).
GET /api/catalog/map/search?lat=50.63&lng=3.07&radiusKm=30&availableFrom=2026-09-01&availableTo=2026-09-30
GET /api/catalog/map/search?universeIds=u1&universeIds=u2&rayonIds=r1&eligibilityScopes=national
Les filtres géographiques (lat/lng/radiusKm ou bounding box north/south/east/west) et calendaires (availableFrom/availableTo) sont combinables. Les espaces non publiés ou indisponibles sur la période sont exclus des résultats.
Fenêtre de carte (bounding box) et coût des requêtes
La bounding box est appliquée en SQL (latitude/longitude, cas de l'antiméridien traité), adossée à l'index @@index([latitude, longitude]) sur Store. C'est le principal garde-fou de coût de la recherche carte : sans elle, la requête charge tous les magasins correspondant aux autres filtres.
Le client décide quelles bornes envoyer via resolveViewportBoundsForQuery (app/utils/map-search-query.ts), une fonction pure qui arbitre trois situations :
| Situation | Bornes envoyées | Pourquoi |
|---|---|---|
| Navigation normale | la fenêtre courante | cas nominal |
| Clic sur un magasin dans la liste (fenêtre de suppression) | les dernières bornes appliquées | la carte s'anime vers le magasin ; sans gel, le moveend relancerait la recherche et la liste se réécrirait sous le curseur, faisant disparaître la ligne cliquée |
| Changement de filtre ville / département / région | aucune | la fenêtre courante décrit encore l'ancienne zone ; la requête est de toute façon bornée par les motifs postaux du filtre, elle n'est donc pas globale |
Le point important est le deuxième : la suppression gèle les bornes, elle ne les supprime pas. Les retirer rendrait la requête globale — cliquer une ligne de liste déclencherait alors la requête la plus coûteuse possible, exactement l'inverse de l'intention. useMapStoreSelection mémorise pour cela les dernières bornes réellement appliquées et ne les écrase jamais avec une absence de bornes.
Limite connue : au tout premier rendu la carte n'est pas encore montée, donc aucune borne n'est disponible et la première requête n'est pas fenêtrée. À volume réel il faudra soit différer la recherche jusqu'à ce que la carte annonce ses bornes, soit basculer sur le mode « territoire » décrit ci-dessous.
Filtrage « disponibles seulement » à faible zoom. Quand des bornes sont fournies et que le zoom est <= 7, la recherche calcule la disponibilité de tous les magasins de la fenêtre avant de paginer, afin de ne tracer que des marqueurs réellement réservables (shouldFilterAvailableOnlyBeforePagination, catalog/application/map-search.ts). C'est un comportement voulu, mais c'est aussi le chemin le plus coûteux — et il se déclenche précisément quand la fenêtre est la plus large. Déplacer la tarification après la pagination supprimerait la garantie ; la vraie réponse est de ne plus lister ni tarifer les magasins un à un à cette échelle, mais de renvoyer des compteurs par zone (mode « territoire »), ce qui ferait disparaître le cas d'usage plutôt que de le rendre moins cher. Non implémenté à ce jour.
Trois filtres d'inventaire complètent la recherche, tous multi-valués (paramètre répété) et cumulables en ET :
| Paramètre | Champ AdSpace ciblé | Correspondance |
|---|---|---|
universeIds | placementUniverseId | l'espace appartient à l'un des univers demandés |
rayonIds | placementRayonId | l'espace appartient à l'une des zones de rayon demandées |
eligibilityScopes | campaignEligibilityScopes | intersection non vide avec les niveaux demandés |
eligibilityScopes n'accepte que les niveaux du vocabulaire partagé (magasin, local, departemental, regional, national, enseigne) — toute autre valeur renvoie une erreur de validation. universeIds/rayonIds sont des identifiants opaques : un identifiant inconnu ne remonte simplement aucun résultat.
Ces trois filtres s'appliquent en mémoire, espace par espace (et non dans la requête SQL), afin de préserver la distinction d'état côté carte : un magasin dont les espaces existent mais ne correspondent pas aux filtres reste unavailable (visible, non réservable), au lieu de basculer en no_inventory (magasin sans aucun équipement, masqué de la carte). Un magasin reste available dès qu'au moins un de ses espaces satisfait tous les filtres.
Côté industriel, GET /api/industrial/store-zones expose le référentiel univers/rayons actif pour alimenter ces filtres (même charge utile que son équivalent distributeur, réservée au rôle industrial).
Règles métier clés
- Un espace publicitaire ne se crée plus directement : il naît toujours de l'approbation d'une
FurnitureRequest, à l'unité, avec identité (support/dimensions/type) figée. - Pour passer un espace en
published, l'univers et la zone de rayon doivent être renseignés (placementUniverseId+placementRayonId), au moins un niveau de campagne éligible doit être sélectionné (campaignEligibilityScopes) et au moins une règle de tarification doit exister (standardDailyRateCents > 0, ou une règle périodique, ou une règle événementielle). - Les niveaux de campagne (
magasin,local,departemental,regional,national,enseigne) forment un vocabulaire partagé entre les campagnes (Campaign.scope, un seul niveau) et l'éligibilité des espaces (campaignEligibilityScopes, plusieurs niveaux) — défini une seule fois dansshared/domain/campaign-scope.ts. « Toutes campagnes » côté espace = tous les niveaux sélectionnés (pas de valeur sentinelle). - Une approbation exige exactement un numéro de série unique et non vide par unité demandée ; en cas d'écart, rien n'est créé.
- Une approbation ne peut être annulée que si tous les espaces créés sont encore
draftet sans réservation active ; unPromoFurniturerattaché à un espace (distributorAdSpaceIdnon nul) ne peut pas être supprimé directement. - Une face (
PrintableZone) peut être associée à plusieursPromoFurnitureType(relation plusieurs-à-plusieurs viaPromoFurnitureTypeFace) — les fichiers d'impression vierges vivent sur la face, pas sur le type. - Un distributeur ne peut gérer que les magasins, demandes et espaces rattachés à ses propres organisations (isolation par organization).
- Le SIRET du magasin doit commencer par le SIREN de l'organisation (vérification d'appartenance légale).
- Les plages d'indisponibilité sont triées et validées sans chevauchement à chaque création/mise à jour. Dans la recherche industrielle, une fenêtre qui chevauche la période demandée exclut l'espace des résultats (
hasDistributorBlock,map-search.ts).