# Catalogue Commerce centralisé

## Décision d'architecture

Chaque installation VitiCore représente exactement un vendeur juridique. Elle
reste la source de vérité de ses produits, prix, stocks, médias, politiques et
URL d'achat.

Le hub CPCREATION lit ces données au travers d'une API canonique commune. Un
webhook signale seulement qu'un changement existe : il ne transporte jamais la
fiche produit complète. Le hub, son administration, les `feed_id` et les
adaptateurs propres à OpenAI restent dans un projet Symfony séparé.

```text
VitiCore -- enrôlement idempotent --------> Hub CPCREATION
    ^                                        |
    +--------- GET catalogue canonique ------+
    |                                        ^
    +--------- notification webhook signée -+
                                             |
                                             +-- adaptateur --> canal cible
```

Cette frontière évite une intégration OpenAI par boutique et empêche le format
d'un canal externe de contaminer le modèle métier VitiCore.

## API de lecture v1

Les opérations sont servies sous `/api` par API Platform et répondent en JSON :

| Méthode | URL | Usage |
|---|---|---|
| `GET` | `/api/commerce/v1/instance` | identité stable de l'installation, vendeur, langues et capacités |
| `GET` | `/api/commerce/v1/products` | snapshot complet des produits boutique actifs |
| `GET` | `/api/commerce/v1/products/{id}` | relecture ciblée après une notification produit |
| `GET` | `/api/commerce/v1/changes?after=0&limit=100` | journal ordonné des changements, limite maximale 250 |

La collection produit inclut volontairement les fiches incomplètes. Une image,
une traduction, un prix ou une variante manquante apparaît dans
`validation.issues`; cette anomalie ne fait pas échouer ou disparaître tout le
snapshot. Le hub doit exclure d'un canal les produits dont
`publishability.status=not_publishable`, tout en les affichant dans son
interface de suivi. `validation` et `compliance` restent les données de
diagnostic ; le hub ne doit pas recalculer la publiabilité générique.

Le cycle de vie et la publiabilité sont deux axes distincts :

```json
{
  "lifecycle": {
    "status": "active"
  },
  "publishability": {
    "status": "not_publishable",
    "reasons": [
      {
        "code": "price_group_restricted",
        "path": null
      }
    ]
  }
}
```

Un produit présent dans ce snapshot est `active` dans Serenity. Il peut rester
`not_publishable`, par exemple si sa catégorie n'autorise pas l'export, si sa
page n'est pas accessible au groupe tarifaire public par défaut ou si une
donnée obligatoire est invalide. Le hub le conserve alors pour le diagnostic,
mais l'exclut des exports Commerce. Le champ historique `status` décrit encore
la visibilité et la commandabilité de la boutique ; il ne doit pas être
interprété comme le cycle de vie central.

Une rupture de stock ou une pause temporaire de commande ne rend pas à elle
seule le produit non publiable : le canal doit recevoir son indisponibilité. À
l'inverse, un prix masqué rend le produit non publiable. Les règles propres à un
canal, notamment l'interdiction de l'alcool chez OpenAI, s'appliquent ensuite et
ne modifient pas la publiabilité Commerce générique.

Les descriptions fournissent une version `plain` et le HTML source administré
dans VitiCore. Ce HTML doit être considéré comme non fiable par le hub : ne pas
le rendre sans assainissement.

### Conformité, alcool et restriction d'âge

La catégorie porte la décision d'activation et la classification communes à
tous ses produits. Les nouvelles catégories et les catégories migrées sont
fermées par défaut : l'administrateur doit explicitement activer leur remontée
vers le catalogue central et choisir leur type réel.

```json
{
  "compliance": {
    "catalog_export_enabled": true,
    "product_type": "alcohol",
    "age_restriction": 18,
    "regulated": true
  }
}
```

`product_type` vaut `unknown`, `general`, `alcohol` ou `service`. Un coffret qui
contient une bouteille doit être classé `alcohol`. `age_restriction` est un
entier positif représentant l'âge minimum d'achat ; le hub pourra le mapper
directement vers le champ homonyme d'un canal qui l'accepte.

Ces champs ont des rôles distincts :

- `catalog_export_enabled` autorise seulement la centralisation par CPCREATION ;
- `regulated` signale qu'une règle d'âge ou de produit réglementé s'applique ;
- `age_restriction` décrit la règle, mais n'autorise jamais à lui seul la
  publication vers un canal ;
- un export activé et encore classé `unknown`, ou un produit `alcohol` sans âge
  minimum, produit une erreur dans `validation.issues`.

Le hub doit ensuite appliquer la politique propre à chaque adaptateur. Tant que
la politique Commerce OpenAI interdit l'alcool, tout produit
`product_type=alcohol` doit être exclu, même si
`catalog_export_enabled=true`, `age_restriction=18` et
`validation.valid=true`. Cette règle couvre également les coffrets contenant
de l'alcool. La présence du champ OpenAI `age_restriction` ne constitue pas une
dérogation à sa liste de produits interdits.

### Identifiants

L'identifiant d'instance est un UUIDv7 généré une seule fois lors de la création
de la configuration Commerce persistée. Il n'est pas éditable dans
l'administration et ne change pas lors d'un déploiement. Les identifiants
globaux dérivés prennent notamment les formes suivantes :

```text
vc_<uuid-instance-compact>_seller
vc_<uuid-instance-compact>_product_<uuid-produit-compact>
vc_<uuid-instance-compact>_variant_<uuid-variante-compact>
vc_<uuid-instance-compact>_option_<uuid-option-compact>
vc_<uuid-instance-compact>_media_<uuid-media-compact>
```

L'identité du vendeur est présente sur le produit et référencée par chaque
variante avec `seller_id`. Les URL d'achat conduisent toujours à la boutique de
ce vendeur, soit sur la fiche produit, soit sur l'ancre du catalogue quand les
fiches séparées sont désactivées.

### Prix, stock et médias

Tous les montants sont des unités mineures entières : `amount_minor=3250`
signifie 32,50 EUR lorsque `minor_unit_exponent=2`. Le hub ne doit jamais
interpréter ces valeurs comme des euros entiers.

`availability.status` décrit le stock réel. `availability.orderable` ajoute les
contraintes de publication, prix visible, pause de commande et disponibilité de
la boutique. Le stock exposé tient compte des ventes et réservations calculées
par VitiCore.

Chaque média publié reçoit deux URL Glide absolues et signées :

- `feed` : JPEG, 1200 x 1200, qualité 85, `contain`, fond blanc ;
- `thumbnail` : JPEG, 400 x 400, qualité 85, `contain`, fond blanc.

Glide génère et met en cache le fichier lors de son premier accès. La clé privée
Glide doit donc être présente sur chaque VitiCore activé.

## Enrôlement automatique et authentification machine-à-machine

Le super-administrateur configure seulement deux valeurs dans
`/admincp/configuration/commerce` : l'activation de VitiCore Commerce et l'URL
publique du hub. L'activation lance ensuite un appairage avec validation humaine
dans le hub :

1. VitiCore génère un `state`, un vérificateur PKCE et son challenge S256, puis
   redirige le navigateur vers le hub ;
2. le hub exige une session administrateur et une confirmation CSRF affichant
   l'identité et le domaine de l'installation ;
3. le hub renvoie au navigateur un code opaque à usage unique, valable cinq
   minutes ;
4. le serveur VitiCore échange ce code avec le vérificateur PKCE, sans faire
   transiter de secret dans le navigateur ;
5. la réponse fournit le JWT d'enrôlement longue durée et les métadonnées de la
   clé publique du hub. VitiCore vérifie immédiatement la signature et les
   claims, puis chiffre ces données en base avec une clé dérivée de
   `APP_SECRET`.

VitiCore génère également lui-même son UUID stable et un secret HMAC webhook.
Ces valeurs, le JWT, la clé publique, les données PKCE temporaires et les
identifiants renvoyés par le hub ne sont jamais éditables ni affichés dans
l'administration.

Une fois l'autorisation obtenue, VitiCore n'envoie encore aucun produit : il
transmet seulement l'identité et les paramètres de connexion de la source par
une requête idempotente :

```http
PUT /api/integrations/serenity/{instance_id}
Authorization: Bearer <jwt-enrolement-longue-duree>
Idempotency-Key: <instance_id>
Content-Type: application/json

{
  "schema_version": "1.0",
  "instance_id": "7ad9e145-910f-4de4-9cec-5f832494adef",
  "name": "VitiCore Champagne Exemple",
  "merchant_name": "Champagne Exemple",
  "base_url": "https://boutique.example",
  "auth_audience": "serenity:catalog",
  "webhook_secret": "secret-aleatoire-distinct-de-32-caracteres-minimum"
}
```

Le JWT d'enrôlement est généré par le hub pour un seul `instance_id`, puis
stocké chiffré dans la configuration Commerce de ce VitiCore. Il peut être de
longue durée parce que son pouvoir est limité à l'enrôlement idempotent de cette
installation (`sub=catalog-enrollment`, `scope=catalog:enroll`). Il n'est jamais
accepté pour lire l'API catalogue. Il reste sensible et doit pouvoir être
renouvelé en cas de fuite. Le hub conserve un seul `jti` actif par
`instance_id`, avec l'URL de base et l'audience approuvées. Une nouvelle
autorisation remplace atomiquement ce grant et révoque immédiatement tous les
anciens JWT de l'installation, même si leur date d'expiration n'est pas
atteinte. Le bouton « Renouveler l'autorisation » de l'administration déclenche
ce parcours sans exposer le jeton.

Après l'enrôlement, le sens d'authentification s'inverse : pour chaque lecture
de `/api/commerce/v1`, le hub émet un JWT RS256 de cinq minutes avec
`sub=catalog-sync`, l'audience et l'`instance_id` attendus. VitiCore vérifie sa
signature avec la clé publique du hub. Le secret HMAC des webhooks constitue un
troisième secret indépendant.

La configuration d'exploitation ne dépend d'aucune variable
`VITICORE_COMMERCE_*`. La base de données est l'unique source runtime. En
développement, l'administrateur saisit donc l'URL ouverte par son navigateur,
par exemple `http://127.0.0.1:8085`. Pour les appels serveur effectués depuis le
conteneur Docker, VitiCore remplace automatiquement l'hôte de boucle locale par
`host.docker.internal`, sans modifier la valeur enregistrée. HTTP n'est accepté
que lorsque `kernel.debug` est actif ; HTTPS est obligatoire ailleurs.

Une configuration désactivée ou incomplète échoue en mode fermé pour l'API et
l'enrôlement, sans jamais empêcher une écriture métier VitiCore.

### Déroulement de l'enrôlement

1. L'activation globale de Commerce ajoute un signal dans
   `commerce_catalog_enrollment_request`, dans la transaction métier. Une
   activation de catégorie ajoute aussi un signal de rattrapage.
2. Toutes les minutes, le scheduler prépare le payload hors du `flush`, puis
   confie l'appel HTTP au transport Messenger `async`.
3. Le hub crée ou met à jour la source par `technical_instance_id`. Une source
   créée manuellement auparavant peut être reprise si son URL est identique.
4. Le hub teste la lecture de l'instance avec un JWT court, puis lance un
   snapshot complet asynchrone.
5. Les modifications suivantes sont signalées par les webhooks HMAC ; le hub
   relit les ressources concernées.

Le scheduler effectue aussi un rattrapage : une configuration autorisée ou une
catégorie déjà activée sera enrôlée dès que les prérequis sont disponibles. Les
appels réseau, les erreurs temporaires et les indisponibilités du hub suivent
les reprises Messenger ; les erreurs et la dernière réponse du hub restent
consultables dans l'outbox.

Les consumers `scheduler_default` et `async` doivent être actifs. Dans le
projet VitiCore de développement, `make messenger` lance le worker prévu par le
`Makefile`. Le hub doit également consommer son transport `async` pour exécuter
le test de connexion et le premier snapshot.

## Outbox transactionnelle et webhook

Le subscriber Doctrine crée une ligne `commerce_catalog_change` dans la même
transaction que la modification métier. Aucun appel réseau, aucune requête
supplémentaire et aucun `flush()` imbriqué ne sont exécutés dans `onFlush`.

Types de changements :

| Type | Ressource | Action attendue du hub |
|---|---|---|
| `product.changed` | produit ciblé | relire l'URL produit |
| `product.removed` | produit ciblé | retirer le produit du snapshot central |
| `seller.changed` | vendeur | relire l'instance puis les produits concernés |
| `reconciliation.required` | catalogue | relire le snapshot complet |

Les dépendances partagées, par exemple catégorie, type, groupe de prix, locale,
configuration boutique ou média sans relation inverse fiable, demandent une
réconciliation complète. Seules les transitions de panier ou de commande qui
peuvent modifier une vente ou une réservation déclenchent le recalcul des
stocks ; les mises à jour techniques ordinaires d'un panier sont ignorées.
Une réconciliation horaire couvre aussi les changements purement temporels,
notamment la libération d'une réservation quand un panier expire sans nouvelle
écriture en base.

Chaque produit expose également un objet `reviews` complet :

- `enabled` indique si les avis sont activés sur l’installation ;
- `review_count` et `average_rating` décrivent uniquement les avis publics
  encore dans leur durée de publication ;
- `items` contient leurs données publiques d’affichage, sans email, jeton
  d’invitation ni autre donnée client privée.

Lorsque les avis sont désactivés, VitiCore renvoie obligatoirement
`{"enabled": false, "review_count": 0, "average_rating": null, "items": []}`.
Le hub doit traiter cet objet comme un état de remplacement complet : supprimer
les avis déjà indexés pour ce produit et masquer le composant d’avis. Une
publication, un retrait, une réponse commerçant ou une suppression d’avis
produit un événement `product.changed`. L’activation ou la désactivation
globale produit `reconciliation.required` afin que le hub relise tous les
produits.

Toutes les minutes, le scheduler revendique les lignes à notifier puis place un
message sur le transport `async`. Le webhook contient seulement l'événement :

```json
{
  "schema_version": "1.0",
  "cursor": 42,
  "event_id": "018f6f62-5279-7ae0-a17f-b0fdd21b6880",
  "instance_id": "7ad9e145-910f-4de4-9cec-5f832494adef",
  "type": "product.changed",
  "resource_type": "product",
  "resource_id": "vc_7ad9e145910f4de49cec5f832494adef_product_de239cbda2bd4431a1d632b73f9c35d1",
  "occurred_at": "2026-07-22T09:58:00+00:00"
}
```

En-têtes importants :

```text
Idempotency-Key: <event_id>
X-Serenity-Timestamp: <timestamp Unix>
X-Serenity-Instance: <instance_id>
X-Serenity-Event-Id: <event_id>
X-Serenity-Signature: v1=<hex>
```

La signature porte sur les octets exacts du corps :

```text
hex(HMAC-SHA256(secret, timestamp + "." + body))
```

Le hub doit vérifier la signature avant de décoder le JSON, appliquer une
fenêtre anti-rejeu sur le timestamp, comparer en temps constant et dédupliquer
par `event_id`. Les redirections HTTP ne sont pas suivies. Les statuts 2xx
valident la livraison ; 408, 425, 429, 5xx et erreurs réseau suivent les cinq
reprises Messenger ; les autres 4xx sont définitifs.

## Algorithme recommandé pour le hub

1. Recevoir l'enrôlement idempotent et enregistrer l'URL de base, le secret
   webhook, l'audience et l'`instance_id` attendu.
2. Lire `instance`, puis le snapshot `products`; indexer par identifiant global,
   mémoriser `snapshot_hash` et conserver aussi les produits non exportables
   pour les afficher dans l'administration du hub.
3. À réception d'un webhook valide, relire `changes` depuis une petite fenêtre
   avant le dernier curseur connu, puis dédupliquer par `event_id`.
4. Pour un produit changé, relire l'item. Une réponse 404 équivaut à un retrait.
   Pour un vendeur ou une réconciliation, relire le snapshot complet.
5. Construire chaque flux uniquement avec les produits
   `publishability.status=publishable`, puis appliquer la politique propre au
   canal ; OpenAI doit actuellement exclure tout `product_type=alcohol`.
6. Effectuer malgré tout une réconciliation complète quotidienne.

Le curseur PostgreSQL `BIGSERIAL` est monotone lors de son allocation, mais deux
transactions concurrentes peuvent être validées dans un ordre différent. La
fenêtre de recouvrement et la déduplication sont donc obligatoires. La livraison
du webhook est « au moins une fois », jamais « exactement une fois ».

Les événements terminaux, livrés ou définitivement échoués, sont conservés 90
jours puis purgés quotidiennement. Les événements `pending` et `queued` ne sont
jamais supprimés par cette purge : une indisponibilité prolongée du hub ne doit
pas faire perdre silencieusement une notification. Après une interruption
longue, le hub doit néanmoins relire l'instance et le snapshot complet.

## Monitoring central des erreurs HTTP

Le super-administrateur peut activer séparément le monitoring des erreurs
serveur depuis `/admincp/configuration/commerce`. Le catalogue et le monitoring
partagent l’identité technique et l’appairage sécurisé du hub, mais leurs
interrupteurs sont indépendants : une installation peut remonter ses erreurs
sans exposer ni synchroniser son catalogue.

Après l’enrôlement, toute exception de la requête HTTP principale qui produit un
statut `5xx` crée un message Messenger `async`. La réponse d’erreur n’attend donc
pas le hub. Le worker transmet ensuite un rapport signé avec le même secret HMAC
que les webhooks Commerce vers :

```text
POST /api/integrations/serenity/{instance_id}/errors
```

Le rapport contient l’identifiant d’événement, une empreinte stable, la classe
et le message de l’exception, la route, la méthode, l’environnement, le fichier,
la ligne et une trace sans arguments. Le corps et les champs de formulaire, la
query string, les en-têtes, les cookies et les paramètres de route ne sont
jamais collectés. Les en-têtes `Idempotency-Key`, `X-Serenity-Timestamp`,
`X-Serenity-Instance`, `X-Serenity-Event-Id` et `X-Serenity-Signature`
protègent l’identité, l’anti-rejeu et les reprises.

## Mise en exploitation d'un VitiCore

Après déploiement et configuration :

```bash
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console messenger:consume scheduler_default async
```

Après une évolution des DTO du contrat Commerce, vider aussi les caches de
métadonnées avant de redémarrer PHP. Dans le projet hôte, `make cc` vide le
cache global ; une séquence ciblée équivalente est :

```bash
php bin/console cache:pool:clear cache.property_info
php bin/console cache:pool:clear api_platform.cache.metadata.property
php bin/console cache:pool:clear cache.serializer
```

Un simple `cache:clear` peut conserver l'ancienne liste des propriétés dans le
pool PropertyInfo et produire une réponse HTTP incomplète malgré un schéma
OpenAPI à jour.

Les deux receivers sont nécessaires : `scheduler_default` déclenche la lecture
des outbox de changements et d'enrôlement, et `async` effectue les appels HTTP.
En production, ils peuvent être exploités par deux workers distincts et
supervisés séparément. Le hub doit lui aussi faire tourner son worker Messenger
pour exécuter le test de connexion et le snapshot initial.

## Adaptateur OpenAI

VitiCore n'embarque volontairement ni clé OpenAI, ni `feed_id`, ni client HTTP
OpenAI. Le hub transforme le contrat canonique vers le schéma du canal cible et
conserve le mapping de flux par vendeur.

Au 22 juillet 2026, la politique Commerce OpenAI exclut toujours l'alcool. Les
catalogues de Champagne peuvent donc être centralisés et contrôlés avec cette
architecture, mais ne doivent pas être publiés vers OpenAI tant qu'un programme
autorisé avec contrôle d'âge et restrictions géographiques n'a pas été confirmé.

Références officielles :

- https://developers.openai.com/commerce/guides/get-started#prohibited-products-policy
- https://developers.openai.com/commerce/specs/file-upload/products#compliance
- https://developers.openai.com/commerce/specs/api/overview
- https://developers.openai.com/commerce/specs/api/feeds
- https://developers.openai.com/commerce/specs/api/products
