# Module Commerce

Boutique complète : catalogue, variantes, panier, commandes, paiement,
facturation. **Multi-boutique et multilingue.**

## État d'avancement

| Partie | Contenu | État |
|--------|---------|------|
| B1 | Boutiques, catégories, produits, variantes, taxes, traductions | **livré** |
| B2 | Interface de gestion du catalogue | à venir |
| C | Personnalisation |  |
| D | Panier, commande, remises, frais de port |  |
| E | Paiement, facturation |  |
| F | Gestion des commandes, production, expéditions |  |

## Où sont les choses

| Fichier | Rôle |
|---------|------|
| `module.php` | Manifeste : pages, réglages, dépendances |
| `database/migrations/` | Tables, exécutées à l'activation |
| `database/uninstall.sql` | Suppression, requise pour désinstaller |
| `routes/backoffice.php` | `/shop-catalog`, `/shop-orders`, `/shop-settings` |
| `routes/site.php` | Vitrine et panier — à venir |
| `views/pages/` | Vues, trouvées automatiquement par `View::resolve()` |

**Les vues passent par le layout du back-office** — en-tête, menu, styles.
Elles sont cherchées en dernier, après le site et la surcouche : un module
ne peut pas remplacer une page du framework par accident.

Pour remplacer une vue du module sans le modifier :
`backoffice/custom/modules/commerce/pages/shop-catalog.php`.

## Ce que le module fournit

La logique où l'on se trompe : prix gelés à la commande, calcul de TVA,
suivi du stock, numérotation des factures, états de commande.

## Ce qu'il n'impose pas

**La mise en page.** Ses vues passent par le layout du site et sont
remplaçables via `site/custom/modules/commerce/`. Un produit peut s'afficher sur
n'importe quelle page :

```php
$produit = Shop::product('mon-produit');
```

## API

Atteignable sur `/api/commerce.catalog` — convention `module.endpoint`.

Cherchée **après** les API du noyau : un module ne peut pas en remplacer
une. Les noms sont contrôlés, une remontée de chemin est refusée.

| Action | Rôle |
|--------|------|
| `context` | Langues, taxes, types, catégories — chargé une fois |
| `products` / `product` | Liste paginée, fiche complète |
| `save-product` / `delete-product` | Le retrait est un archivage, pas une suppression |
| `save-variant` / `delete-variant` | La dernière variante ne peut être supprimée |
| `adjust-stock` | Ajustement **relatif**, pas absolu |
| `categories` / `category` | Arbre et liste à plat |
| `save-category` / `delete-category` | Les sous-catégories remontent d'un niveau |

## Modèles

| Classe | Rôle |
|--------|------|
| `ShopModel` | Boutiques, langues actives |
| `ProductModel` | Produits, traductions, conversion des montants |
| `VariantModel` | Variantes, stock, alertes |
| `CategoryModel` | Arborescence, détection de boucles |

## Adresses publiques

Format : `/produit/<slug>`

```
/produit/chaise-scandinave
```

**Aucun identifiant dans l'adresse.** L'unicité est garantie à
l'enregistrement : `ProductModel::uniqueSlug()` ajoute un suffixe en cas de
collision — « chaise-scandinave-2 ». Exposer l'identifiant technique
laisserait croire à une adresse mal formée.

Le slug est unique **par boutique et par langue**, pas globalement : deux
boutiques peuvent chacune avoir leur « chaise-scandinave ».

## Images

La boutique suit la **règle de stockage du cœur** (`Media`, table
`media_files` — voir `modules/README-accroches.md`, accroche
`media.owners`) : **une image appartient à une seule chose**, et vit dans
son dossier. La boutique déclare simplement ses propriétaires, dans
`hooks/shared.php` :

```
site/public/uploads/
├── modules/commerce/          placé là par le cœur : la boutique déclare
│   ├── products/42/           seulement « products », avec sa clé
│   │   ├── <uid>.jpg          un produit ET ses variantes
│   │   └── sizes/card, thumb, full    réductions, régénérées à la demande
│   ├── categories/7/
│   └── banners/3/             image, version téléphone, vidéo
└── core/drafts/               commun à tout le site : envoyé, pas encore rattaché

storage/modules/commerce/
└── customer-files/2026/09/    fichiers envoyés par les clients — PRIVÉS
```

Supprimer un propriétaire supprime son dossier.

**Sur le disque, le nom est aléatoire** — 32 caractères. Il ne se périme
jamais, même si on renomme le produit, et deux « photo.jpg » ne
s'écrasent pas. **À l'affichage, le nom est lisible** :
`/media/card?f=magnet-rando-3d-<uid>.jpg`. La route retrouve le fichier
par son identifiant : l'arborescence du disque n'est jamais visible depuis
le site. L'ancienne route `/shop/img/<taille>` reste servie, pour les
adresses déjà publiées.

**Le cycle d'une image.** À l'envoi, elle part en brouillon — le produit
n'existe parfois pas encore. Elle rejoint son propriétaire au
**rattachement** (`MediaStore::link`, ou l'enregistrement d'une bannière ou
d'une catégorie). Un brouillon que rien n'utilise est purgé après 24 h ; la
boutique protège par l'accroche `media.referenced` ceux qu'une bannière,
une catégorie ou une liaison désigne encore.

**Pas de partage.** Rattacher une image déjà rangée chez un autre
propriétaire en crée une **copie** : supprimer l'un ne casse jamais l'autre.

**Recadrage.** Le résultat est un *dérivé* (`Media::addDerived`) : un
nouveau fichier à côté de l'original, même propriétaire. L'original n'est
pas touché, et le cadre est retenu (`meta.transform`) pour rouvrir
l'éditeur tel qu'on l'a laissé.

**Ce qui reste propre à la boutique** : les liaisons (`shop_media_links`)
— ordre d'une galerie, image principale, substitutions de variante.

```php
MediaStore::store($_FILES['image'], $shopId);        // → brouillon
MediaStore::link('product', $productId, [12, 8, 3]); // → modules/commerce/products/<id>/
MediaStore::forTarget('product', $productId);
Media::deleteOwner('banner', (string) $bannerId);    // dossier compris
```

### Images par variante

Une variante **hérite** des images du produit et peut en **remplacer**
certaines.

```php
MediaStore::linkOverrides($variantId, [
    12 => 45,   // pour cette variante, montrer l'image 45 à la place de la 12
]);

MediaStore::forVariant($productId, $variantId);
```

**Le remplacement est lié à l'image, pas à sa position.** Réordonner les
photos du produit déplace la substitution avec elle ; une position aurait
laissé le remplacement collé à un rang où se trouve désormais autre chose.

L'ordre reste celui du produit : une variante ne réordonne pas ses images,
elle en remplace. Deux ordres différents rendraient l'image principale
imprévisible.

Une image retirée du produit voit ses substitutions nettoyées — elle ne
peut plus être ni remplacée ni servir de remplacement.

### Références de variante

Dérivées de celle du produit : `CHAISE` avec « couleur: rouge, taille: M »
donne `CHAISE-ROUGE-M`.

Proposée seulement si aucune référence n'est saisie, et modifiable ensuite.
Saisir chaque référence à la main sur vingt variantes est fastidieux et
source d'incohérences.

### Redimensionnement

Une version redimensionnée est un **nouveau fichier**, avec son propre
identifiant unique. L'original n'est jamais écrasé : une erreur de
recadrage ne détruit rien, et `source_media_id` garde le lien.

```php
MediaStore::resize($mediaId, 800, 600);              // proportions libres
MediaStore::resize($mediaId, 800, 800, $box);        // avec recadrage
```

Le recadrage est appliqué **avant** le redimensionnement : l'inverse
appliquerait des coordonnées calculées sur l'original à une image déjà
réduite.

La transparence est préservée pour PNG, WebP et GIF — sans quoi le fond
devient noir.

La **première image de la liste** est l'image principale : c'est l'ordre à
l'écran qui décide, pas une case séparée qu'on oublie de mettre à jour.

## Commandes

### Machine à états

```
draft → pending → paid → processing → shipped → delivered
                    ↓         ↓
                cancelled  refunded / partially_refunded
```

Les transitions sont **déclarées et vérifiées** dans `OrderModel::TRANSITIONS`.
Une commande annulée ne se réactive pas : on en crée une nouvelle. Rouvrir
brouillerait l'historique comptable.

Chaque changement est tracé dans `shop_order_status_history` : qui, quand,
pourquoi.

### Le gel

Une commande **contient** une copie du produit, elle ne le référence pas.
Nom, prix, taux de TVA, personnalisation **et son schéma** sont figés.

Le schéma est gelé avec les valeurs : sans lui, on ne sait plus ce que
`champ_3` désignait.

### État par ligne

`fulfillment_status` vit sur la **ligne**, pas sur la commande. Une commande
peut contenir un article en stock et un article à imprimer : le premier part
tout de suite, le second attend.

C'est ce qui rendra la file de production possible — une simple requête sur
les lignes.

### Stock

Décrémenté **à la commande**, pas au paiement : deux acheteurs ne doivent pas
réserver le dernier exemplaire pendant que l'un paie. Restitué à l'annulation.

### Références

Compteur en base avec verrou, par boutique et par année — `CMD-2026-00042`.
Un `COUNT()` donnerait le même numéro à deux commandes simultanées.

## Calcul des prix

L'ordre est figé dans `PriceCalculator` :

```
1. Sous-total hors taxes
2. Remises sur lignes        réparties au prorata
3. Remises sur commande      réparties au prorata
4. Frais de port             après remise
5. Taxes                     sur les montants remisés, port compris
6. Total
```

**Deux pièges traités.** Les remises se répartissent sur les lignes, elles ne
se soustraient pas du total — sinon la TVA d'une commande à taux mixtes est
fausse. Et la dernière ligne reçoit le reliquat, pour qu'aucun centime ne se
perde dans les arrondis.

Prix affichés TTC, la TVA est **extraite** du montant ; HT, elle s'y
**ajoute**. Confondre les deux produit un écart de l'ordre du taux lui-même.

## Panier

Rattaché à un compte **ou** à un jeton de session : on achète souvent sans
compte.

Un panier anonyme **fusionne** avec celui du compte à la connexion. Sans
cette fusion, ce qu'on a mis au panier avant de se connecter disparaît sans
explication.

Un prix qui change entre l'ajout et le paiement est **signalé**, pas appliqué
en silence.

## Personnalisation

Un produit porte un schéma de champs typés. L'acheteur les remplit ; ses
valeurs vivent sur la ligne de panier, puis sont **gelées** sur la ligne de
commande avec le schéma qui les a produites.

| Type | Règles applicables |
|------|--------------------|
| `text`, `textarea` | longueur minimale et maximale, motif |
| `select` | liste de choix — la valeur reçue doit en faire partie |
| `color` | code hexadécimal |
| `number` | minimum, maximum |
| `file`, `image` | formats acceptés, taille, dimensions |
| `custom` | délégué à un greffon via `renderer` |

**La validation est côté serveur**, à partir du schéma. Un éditeur en
JavaScript ne valide rien : on peut toujours envoyer autre chose.

```php
$result = CustomizationModel::validate($productId, $_POST, $basePrice);
// ['valid' => bool, 'errors' => [], 'values' => [], 'extra_cents' => int]
```

**Effet sur le prix** : aucun, montant fixe en centimes, pourcentage en
centièmes, ou délégué au greffon. Un greffon absent n'ajoute rien plutôt
qu'un montant inventé.

**Variante ou personnalisation ?** Une variante existe en stock, une
personnalisation est produite à la demande. Un tee-shirt rouge en M est une
variante ; le prénom brodé dessus est une personnalisation. Elles se
combinent.

## Langues

Les langues actives se déclarent dans **Boutique › Réglages**. Le formulaire
produit affiche alors un sélecteur discret — et **rien du tout** si une seule
langue est active.

Une boutique monolingue ne voit jamais la mécanique de traduction.

```php
ShopModel::isMultilingual();   // faut-il afficher le sélecteur ?
ShopModel::locales();          // langues actives, défaut inclus
```

La langue par défaut figure toujours dans la liste, même si on l'en retire :
sans elle, le contenu de repli n'existerait pas.

## Montants

Toujours des **centimes entiers**. La saisie accepte les deux notations :

```php
ProductModel::toCents('19,99');   // 1999
ProductModel::toCents('19.99');   // 1999
ProductModel::fromCents(1999);    // « 19,99 »
```

La virgule est traitée comme séparateur décimal : l'ignorer transformerait
19,99 € en 1999 €.

## Conventions

| Règle | Raison |
|-------|--------|
| Montants en **centimes entiers** | `19,99 €` vaut `1999`. Un arrondi flottant sur mille lignes produit des écarts inexplicables |
| Taux en **centièmes de pourcent** | `20 %` vaut `2000` |
| `shop_id` sur toute table | Multi-boutique posé dès la première migration : l'ajouter ensuite est une reprise complète |
| Tables préfixées `shop_` | Aucune collision avec le noyau ou un autre module |
| `deleted_at` plutôt que suppression | Un produit vendu ne se supprime pas, il quitte le catalogue. Le supprimer casserait l'historique |

## Deux principes structurants

**Tout produit a au moins une variante**, même sans déclinaison. Prix et
stock vivent donc toujours au même endroit — deux chemins de calcul
finiraient par diverger.

**Une variante existe en stock, une personnalisation est produite à la
demande.** Un tee-shirt rouge en M est une variante ; le prénom brodé dessus
est une personnalisation. Elles se combinent.

## Multilingue

Une boutique multilingue, pas des boutiques par langue. Le même produit
porte ses traductions dans `shop_product_translations` ; seuls les champs
textuels y vivent. Un contenu absent dans la langue demandée retombe sur la
langue par défaut de la boutique, jamais sur une page vide.

## Désinstallation

`database/uninstall.sql` supprime toutes les tables. **Irréversible** :
commandes, factures et historique disparaissent.

Pour arrêter la boutique sans perdre les données, utiliser **Désactiver**.

## Personnaliser la fiche produit

La page est découpée en partiels, chacun remplaçable **sans toucher au
module**. Déposez un fichier de même nom sous `custom/` :

```
site/custom/modules/commerce/partials/shop/product-gallery.php
site/custom/modules/commerce/partials/shop/product-purchase.php
site/custom/modules/commerce/partials/shop/product-fields.php
site/custom/modules/commerce/partials/shop/product-details.php
site/custom/modules/commerce/partials/shop/product-breadcrumb.php
```

Pour refondre la page entière plutôt qu'un bloc :

```
site/custom/modules/commerce/shop/product.php
```

**Une mise à jour du module n'écrase jamais `custom/`.** C'est le même
principe que `custom/pages/` pour le noyau : le fichier d'origine reste
la référence, la version personnalisée le remplace à l'exécution.

Chaque partiel reçoit les mêmes variables : `$product`, `$t` (traduction
courante), `$variants`, `$media`, `$fields`, `$money` (formateur de
montants), `$displayPrice`, `$hasDiscount`.

## Étendre la vitrine

La fiche produit expose des **emplacements nommés**. Chacun accepte
plusieurs contributions, sans qu'aucune ne connaisse les autres — et
sans toucher à un fichier du module.

### Où déclarer

```
site/custom/modules/commerce/hooks.php    propre à ce site
modules/<votre-module>/shop-hooks.php     apporté par un module
```

`site/custom/` est la **surcouche du framework**, celle que `View`
consulte avant `site/app/Views/`. Une mise à jour remplace le
framework et les modules, jamais ce dossier — c'est le contrat de mise
à jour.

### Emplacements

| Nom | Position |
|-----|----------|
| `product.head` | En-tête HTML — styles, scripts |
| `product.before` | Avant le fil d'Ariane |
| `product.gallery.before` | Avant la galerie |
| `product.gallery.item` | Vignette supplémentaire dans la galerie |
| `product.gallery.after` | Après la galerie |
| `product.title.after` | Sous le titre |
| `product.price.after` | Sous le prix |
| `product.options.before` | Avant les déclinaisons |
| `product.options.after` | Après les déclinaisons |
| `product.form.before` | Avant le bouton d'ajout |
| `product.form.after` | Après le bouton |
| `product.purchase.after` | Après le bloc d'achat |
| `product.tabs.nav` | Bouton d'onglet |
| `product.tabs` | Contenu d'onglet |
| `product.after` | Bas de page |

### Contribuer

```php
ShopHooks::on('product.gallery.after', function (array $ctx) {
    echo '<div id="mon-bloc"></div>';
}, 50);
```

Le contexte contient `$product`, `$t` (traduction), `$variants`,
`$axes`, `$fields`, `$media`, `$money`. La priorité ordonne les
contributions — petit d'abord, 50 par défaut.

Une extension qui lève une exception est **journalisée sans emporter la
page** : le produit reste consultable.

### Réagir côté navigateur

`window.Shop` expose l'état de la fiche :

```js
Shop.on('ready',           s => { /* page prête */ });
Shop.on('variant:change',  v => { /* déclinaison choisie */ });
Shop.on('option:change',   e => { /* champ modifié, e.files pour un fichier */ });
Shop.on('price:change',    p => { /* p.base, p.extra, p.total */ });

Shop.variant();      // déclinaison courante
Shop.selection();    // { Couleur: 'vert', Taille: 'L' }
Shop.options();      // valeurs des champs de personnalisation
Shop.files('trace'); // fichiers d'un champ
Shop.money(1500);    // « 15,00 € »
```

Un abonné fautif est signalé en console sans interrompre les autres.

### Exemple fourni

`site/custom/modules/commerce/hooks.php` contient deux extensions
fonctionnelles :

**Visualiseur GPX** — détecte qu'un produit attend un fichier `.gpx`,
ajoute une vignette dans la galerie et un panneau de carte. Dessine la
trace, calcule distance et dénivelé. Aucune dépendance externe.

**Aperçu coloré** — suit l'axe « Couleur » et affiche une pastille de la
teinte choisie.

Les deux sont à supprimer ou à adapter — elles servent de modèle.

## Extensions

Une extension est un ensemble **autonome** qui apporte deux choses :

- un affichage côté vitrine
- ses propres réglages côté back-office

Le module ne connaît ni l'un ni l'autre : il expose des emplacements,
l'extension s'y branche et **décrit** ce qu'elle a besoin qu'on lui
règle. Le back-office construit le formulaire tout seul.

### Une extension EST un module

Il n'y a pas deux mécanismes. Une extension est un module qui dépose un
fichier `shop-extension.php` :

```
modules/mon-extension/
    module.php              requires: ['commerce']
    shop-extension.php      la déclaration
    assets/                 ses scripts et styles
```

Elle apparaît dans **Technique › Modules**, s'active comme les autres,
et sa dépendance à la boutique est vérifiée au démarrage — pas
constatée à l'affichage.

Désactiver commerce désactive les extensions qui en dépendent.

### Déclarer

```php
ShopExtensions::register('ma-extension', [
    'label'       => 'Nom affiché au back-office',
    'description' => 'Ce qu'elle fait, en une ligne',
    'icon'        => 'bi-puzzle',

    'settings' => [
        'source' => [
            'type'   => 'field',
            'label'  => 'Champ à utiliser',
            'accept' => 'gpx',
        ],
        'slot' => [
            'type'    => 'slot',
            'label'   => 'Où afficher',
            'default' => 'product.gallery.item',
            'options' => [ /* emplacements proposés */ ],
        ],
    ],

    'render' => function (array $ctx, array $settings, string $slot) {
        // Affichage côté vitrine
    },
]);
```

### Types de réglage

| Type | Formulaire généré |
|------|-------------------|
| `text` | Champ texte |
| `number` | Champ numérique, `min` et `max` respectés |
| `bool` | Case à cocher |
| `select` | Liste, d'après `options` |
| `color` | Sélecteur de couleur |
| `field` | Liste des champs de personnalisation du produit, filtrée par `accept` |
| `slot` | Liste des emplacements, d'après `options` |
| `media` | Images du produit |

Un réglage de type `slot` commande **où** l'extension s'affiche : le
module n'appelle son rendu qu'à l'emplacement retenu.

### Réglage par produit

Les valeurs sont propres à chaque produit — le même visualiseur peut
lire le champ « trace » sur l'un et « parcours » sur l'autre. Elles se
saisissent dans l'onglet **Extensions** de la fiche produit.

Une extension désactivée n'est jamais appelée, et ses ressources ne
sont pas chargées.

### Exemple fourni

`modules/gpx-viewer/` est une extension complète et fonctionnelle :

- **Réglages** : quel champ contient la trace, où afficher le
  visualiseur, afficher ou non les statistiques, marquer le départ et
  l'arrivée, couleur du tracé
- **Cinq emplacements** au choix : galerie, sous la galerie, onglet de
  la description, au-dessus du bouton d'ajout, ou dans une fenêtre
- **Rendu** : trace dessinée en SVG, distance et dénivelé calculés,
  sans aucune dépendance externe

Elle sert de modèle : aucun fichier du module n'a été modifié pour
qu'elle fonctionne.
