# ADR-006 : Capacités de stock génériques et mode restauration activable

**Statut :** Accepté
**Date :** 2026-10-12
**Contexte :** Évolutions « restauration » (note produit Welely Digital, octobre 2026) — contrôle d'accès aux fonctionnalités, rôles, visibilité du stock

---

## Contexte

La note produit « Évolutions pour le secteur de la restauration » demande d'abord un rapport d'écarts : stock de départ + achats − ventes − pertes déclarées, comparé au stock compté. Pour le produire, il faut des conversions d'unités, l'import des ventes de caisse, un inventaire en aveugle, des pertes expliquées et des fiches techniques. Elle pose une contrainte : ces fonctionnalités doivent former « un mode restauration activable et paramétrable », jamais une branche spécifique à un client.

L'analyse du code (2026-10-12) montre que :

- Les fonctionnalités se contrôlent par des colonnes booléennes `plans.has_*` (`has_pos`, `has_reports`, `has_stock_adjustment`, `has_multi_warehouse`). `AppConfigService::build()` les expose et la SPA les applique (`hasPlanFeature()`), mais **aucun contrôle n'a lieu côté serveur** : `/pos/*` et `stock-adjustments` ne sont protégés que par les permissions Laratrust.
- Il n'existe ni champ « secteur d'activité » ni table d'activation de module par tenant. Les modules nwidart (`Modules/`, vide) s'activent par installation, pas par tenant.
- Deux rôles par tenant (`admin`, `personnel`). Le personnel voit le stock théorique (`current_stock`) à plusieurs endroits : `ProductSearchService` (l.95, 141), `PosController` (l.41, 73), `ProductController::getWarehouseStock` (l.134), `OrderDetailsPresenter` (l.47), `StockAlertService`, `SmartAlertsController`, et le champ `details.current_stock` de la liste produits. Un inventaire « en aveugle » limité à l'interface serait donc contourné en ouvrant un autre écran.

Une bonne part de ce que demande la note n'a rien de propre à la restauration. Une boutique a elle aussi besoin d'inventaires, de motifs de perte, de conditionnements d'achat et d'un stock valorisé. Tout placer derrière un interrupteur « restauration » priverait les autres clients de ces fonctions, ou forcerait plus tard à dupliquer la logique.

## Décision

### 1. Deux niveaux de fonctionnalités

| Niveau | Contenu | Activation |
|---|---|---|
| **Capacités génériques** (feature `inventory`) | Inventaires (aveugles, tournants, par emplacement), pertes à motif, conditionnements d'achat, rapport d'écarts valorisé, verrouillage de période (ADR-007) | Droit accordé par le plan (`plans.has_inventory`) |
| **Mode restauration** (feature `restaurant`) | Articles composés non stockés et fiches techniques, import des ventes de caisse et correspondance des libellés (ADR-009), préréglages du secteur | Droit accordé par le plan (`plans.has_restaurant`) **et** activation par l'admin du tenant |

Le mode restauration suppose `inventory` : l'activer sans ce droit est refusé.

### 2. Mécanique

- **Enum `App\Enums\Feature`** : `Pos`, `Reports`, `StockAdjustment`, `MultiWarehouse`, `Inventory`, `Restaurant`. Il remplace les chaînes passées à `Plan::hasFeature()`.
- **Colonnes `plans.has_inventory` et `plans.has_restaurant`** (booléennes, `false` par défaut), dans le prolongement du schéma existant. Elles s'ajoutent à la liste blanche de `SuperAdminPlanController` et à l'écran d'édition des plans.
- **Table `company_modules`** (`company_id`, `module`, `enabled_at`, `enabled_by`, `settings` JSON, unique `(company_id, module)`), avec le trait `BelongsToCompany` (ADR-003). Elle porte l'activation par le tenant et ses réglages (fin de journée commerciale, colonnes du fichier de caisse, seuil de validation des pertes).
- **Service `FeatureGate`** :
  - `isEnabled(Company $company, Feature $feature): bool` vaut « le plan l'accorde », plus « le tenant l'a activée » pour les features qui exigent une activation ;
  - `settings(Company, Feature): array` renvoie les réglages ;
  - le résultat est mémorisé le temps d'une requête ou d'un job (binding `scoped`), jamais au-delà : un changement de plan ou une activation prend effet à la requête suivante. La config envoyée à la SPA, elle, est mise en cache et invalidée à chaque activation (`Common::forgetAppConfigCache`).
- **Middleware `check.feature:{feature}`** (`App\Http\Middleware\CheckFeature`), qui renvoie un 403 explicite. Il s'applique **dès le premier jour à toutes les routes des nouveaux modules**. Depuis le 2026-10-14, il couvre aussi les modules existants : `pos/*` (`check.feature:pos`), `stock-adjustments` (`check.feature:stock_adjustment`) et les rapports dédiés `reports/*` (`check.feature:reports`). La vérification préalable en production a montré qu'aucun tenant actif n'utilisait un module absent de son plan. Les écrans de rapports qui réutilisent des ressources partagées (produits, commandes, paiements) restent contrôlés par l'interface seulement, ces ressources servant aussi à d'autres écrans.
- **`AppConfigService`** expose `has_inventory`, `has_restaurant` et `restaurant_enabled` dans `plan_features` pour la SPA.

### 3. Activation du mode restauration

L'activation se fait en une transaction : création de la ligne `company_modules`, puis création des préréglages **s'ils n'existent pas encore** (idempotent). Ces préréglages sont les emplacements de comptage « Bar », « Chambre froide », « Réserve » et les motifs de perte « Offert », « Repas du personnel ». Le tout est tracé par `TenantActivityLogService`.

La désactivation conserve toutes les données : elle masque les écrans et bloque les écritures des routes restauration. Elle ne supprime rien.

### 4. Rôles et visibilité du stock théorique

- **Nouvelles permissions** :

  | Permission | Objet |
  |---|---|
  | `stock_quantities_view` | Voir le stock théorique |
  | `inventory_counts_view` / `_create` | Consulter et saisir les comptages |
  | `inventory_counts_validate` | Valider un inventaire |
  | `stock_losses_create` / `_approve` | Déclarer et valider les pertes |
  | `pos_sales_imports_*` | Importer les ventes de caisse |
  | `purchases_price_edit` | Saisir le prix d'achat réel (ADR-008) |

- **Attribution** : une migration les attache au rôle `admin` de chaque tenant, comme `2026_05_24_000001_seed_tenant_roles_permissions`, et les ajoute à `TenantProvisioningService`. Le rôle `personnel` reçoit seulement `inventory_counts_create` et `stock_losses_create`.
- **Masquage côté serveur** : quand le tenant utilise l'inventaire (`Feature::Inventory`), un utilisateur sans `stock_quantities_view` ne reçoit plus la quantité en stock dans les **réponses API**, et pas seulement à l'écran. Les points d'exposition listés dans le Contexte passent tous par un service unique, `StockVisibility`. Pour le tableau de bord et les alertes, mis en cache par entrepôt, le masquage s'applique après lecture du cache. Sans inventaire activé, rien ne change : un tenant qui ne compte pas n'a pas d'aveugle à protéger.
- **SPA** : un stock masqué arrive à `null`. La caisse et les formulaires de vente ne plafonnent alors plus la quantité saisie, car le plafond trahirait le théorique. Les écrans affichent « — ».
- **Exception de la caisse** : elle garde le filtre « en stock > 0 » côté serveur, mais ne renvoie plus la quantité exacte au personnel.

### 5. Placement du code

Le code suit la structure plate de `standards/laravel.md` : `app/Services`, `app/Enums`, `app/DTOs`. Il n'y a ni module nwidart ni dossier `Restaurant/` à part. Une capacité générique ne doit jamais tester le secteur du client : seul `FeatureGate` connaît la notion de mode.

## Alternatives Considérées

### Tout placer derrière le mode restauration
Rejeté. Les boutiques, qui sont la clientèle actuelle, n'auraient pas accès à l'inventaire ni aux pertes. Le jour où elles le demanderaient, il faudrait soit leur ouvrir le « mode restauration », ce qui n'a pas de sens, soit dupliquer la logique. Ce serait précisément la dérive « branche par client » que la note veut éviter.

### Laravel Pennant
Rejeté pour l'instant. Pennant résout la valeur d'une feature par portée (*scope*) et la stocke, mais il faudrait le brancher sur les droits du plan **et** sur l'activation du tenant. Un service de 50 lignes sur le schéma existant suffit, sans nouvelle dépendance. À réévaluer si l'on a besoin de déploiements progressifs ou d'expérimentations A/B.

### Modules nwidart (`Modules/Restaurant`)
Rejeté. Ces modules s'activent par installation (`modules_statuses.json`), pas par tenant. Ils introduiraient en plus une seconde convention de placement du code dans un projet qui suit la structure plate.

### Table générique `plan_features` à la place des colonnes `has_*`
Différé. Elle serait plus souple au-delà d'une dizaine de features, mais elle demanderait de migrer les quatre colonnes existantes et leurs écrans superadmin. Deux colonnes de plus restent cohérentes avec le schéma actuel.

## Conséquences

- Le contrôle d'accès par plan devient réel côté serveur pour tous les nouveaux modules. C'est aussi le patron à suivre pour corriger plus tard les modules existants, protégés aujourd'hui uniquement par l'interface.
- Toute nouvelle route d'inventaire ou de restauration porte **à la fois** `check.feature:*` et une permission Laratrust. Un test de route le vérifie.
- **Changement visible pour un tenant dès qu'il active l'inventaire** : son personnel ne voit plus les quantités en stock, et la caisse ne plafonne plus les quantités. Cela doit figurer dans l'accompagnement de l'activation. Les tenants qui n'utilisent pas l'inventaire ne voient aucun changement.
- `FeatureGate` devient la seule réponse à la question « ce tenant a-t-il accès à X ? ». Une vérification directe de `$plan->has_*` hors de ce service est un défaut de revue.

## Quand Réviser

- Au-delà d'environ dix features contrôlées, ou s'il faut activer une feature pour un seul tenant (pilote, essai) : envisager `plan_features` ou Pennant.
- Si un second secteur (pharmacie, quincaillerie) demande son propre mode : vérifier que la frontière « capacité générique ou propre au secteur » tient toujours.
- Si un client a besoin que le personnel voie le stock pour travailler : remplacer la permission unique par une visibilité réglable par entrepôt.
