# ADR-007 : Mouvements de stock typés et immuables, verrouillage de période et stock théorique daté

**Statut :** Accepté
**Date :** 2026-10-12
**Contexte :** Calcul et historique du stock (`Common::recalculateOrderStock`, `stock_adjustments`, documents de commande) — socle du rapport d'écarts

---

## Contexte

Un rapport d'écarts (stock théorique contre stock compté) n'a de valeur que s'il est **opposable**. Un écart constaté à l'inventaire du 30 doit rester le même le 5 du mois suivant. Le modèle actuel ne le garantit pas, pour quatre raisons :

1. **Le stock est recalculé depuis l'origine.** `Common::recalculateOrderStock()` (`app/Classes/Common.php:204-264`) additionne toutes les lignes `order_items` (achats, ventes, retours) et tous les `stock_adjustments` (add/subtract) depuis la création du produit, plus `opening_stock`. Si l'on corrige un achat du mois dernier après un inventaire, le stock courant bouge, alors que l'écart constaté ce jour-là, lui, ne bouge pas.
2. **Tout est modifiable et supprimable physiquement.**
   - Ajustements : `StockAdjustmentController::updating/destroying`.
   - Commandes : `OrderTraits::updating/destroying` (`app/Traits/OrderTraits.php:118-232`) et `OrderProcessingService::processInner` (lignes supprimées ou réécrites).
   - Aucune suppression logique (*soft delete*). `stock_history` trace ces changements, mais n'empêche rien.
3. **Le calcul ignore les dates.** Il ne filtre pas sur `orders.order_date`, et `stock_adjustments` n'a pas de date métier (seulement `created_at`). On ne peut donc pas obtenir « le stock théorique au moment où l'on a compté ». Or, en restauration, les ventes importées arrivent le lendemain et le service continue pendant le comptage.
4. **Un ajustement ne dit pas pourquoi.** `adjustment_type` vaut `add` ou `subtract`, avec une note libre. Une casse, une boisson offerte et un écart d'inventaire sont indiscernables. Le rapport ne peut donc pas séparer la perte connue de la perte inexpliquée, ce qui est le cœur de la note produit.

S'ajoute un défaut de précision. Les quantités sont des `float(8,2)` (FLOAT imprécis, deux décimales, environ 999 999 au maximum), et `opening_stock` comme `stock_quantitiy_alert` sont des entiers. C'est suffisant pour des bouteilles, insuffisant pour 0,125 kg de beurre dans une fiche technique.

## Décision

### 1. `stock_adjustments` devient le journal des mouvements hors facture

Colonnes ajoutées :

| Colonne | Type | Rôle |
|---|---|---|
| `reason` | `varchar(32)`, enum PHP `App\Enums\StockMovementReason` | `manual` (valeur par défaut, et valeur des lignes existantes), `loss_breakage`, `loss_expired`, `loss_offered`, `loss_staff_meal`, `loss_other`, `inventory_variance`, `pos_sale_import`, `recipe_consumption` |
| `movement_date` | `datetime` | Date métier du mouvement. Les lignes existantes la reprennent de `created_at` |
| `unit_cost` | `decimal(14,2)` nullable | Coût unitaire figé au moment du mouvement (ADR-008), pour valoriser sans dépendre du prix actuel |
| `source_type` / `source_id` | nullable | Origine du mouvement : comptage d'inventaire, ligne d'import, ligne de commande consommant une recette |
| `reverses_id` | FK nullable vers `stock_adjustments.id` | Mouvement que cette ligne contre-passe |

Index : `(warehouse_id, product_id, movement_date)`.

Le sens reste porté par `adjustment_type`. **`recalculateOrderStock()` ne change pas** : il additionne déjà tous les add/subtract, quel que soit le motif.

### 2. Immutabilité

- **Mouvements automatiques ou de contrôle** (tout motif autre que `manual`) : ni modification ni suppression, ni par l'API ni par un hook. On corrige par une ligne inverse créée par `StockMovementService::reverse()`. Cette ligne a le même motif et le sens opposé ; son `reverses_id` **et** sa source (`source_type = stock_adjustment`, `source_id` = l'id de la ligne annulée) désignent la ligne qu'elle annule. La contrainte unique du §6 garantit donc par la base qu'une ligne n'est contre-passée qu'une seule fois.
- **Ajustements `manual`** : ils restent modifiables, pour ne pas changer l'usage actuel des boutiques, mais ils sont soumis au verrouillage de période.

Ces règles vivent dans `StockMovementService`, que le contrôleur appelle depuis `updating/destroying`. Elles ne vivent pas dans l'interface.

### 3. Verrouillage de période

- **Table `stock_period_locks`** : `company_id`, `warehouse_id`, `locked_until` (datetime), `inventory_count_id`, `locked_by`, `created_at`. Une ligne est ajoutée à chaque inventaire validé ; la ligne la plus récente fait foi.
- **`StockPeriodLockService::assertOpen(int $warehouseId, DateTimeInterface ...$dates)`** lève une `ApiException` 422 (« Période clôturée par l'inventaire du … ») si l'une des dates est antérieure ou égale au verrou. Il est appelé dans :
  - `OrderTraits::storing` (`order_date`), `updating` (ancienne **et** nouvelle `order_date`), `destroying` ;
  - `StockAdjustmentController::storing/updating/destroying` (`movement_date`) ;
  - la validation d'un import de ventes (ADR-009) et celle d'un inventaire (on ne compte pas avant le dernier verrou).
- **Pas de verrou, pas de changement** : un entrepôt qui n'a jamais validé d'inventaire n'est pas verrouillé. Les tenants qui n'utilisent pas l'inventaire ne voient donc aucun changement.
- **Corriger une période verrouillée** se fait par un mouvement daté d'aujourd'hui (contre-passation ou ajustement), jamais en réécrivant le passé.

### 4. Stock théorique daté

`StockLedgerService::theoreticalStockAt(int $warehouseId, int $productId, DateTimeInterface $at): string` (décimal en chaîne) applique la même formule que `recalculateOrderStock()`, filtrée sur `orders.order_date <= $at` et `stock_adjustments.movement_date <= $at`, plus `opening_stock` si `opening_stock_date <= $at`.

Un test d'invariant vérifie que `theoreticalStockAt(now)` est égal à `current_stock` après recalcul. Tant que cet invariant tient, les deux calculs ne peuvent pas diverger.

`theoreticalStocksAt(int $warehouseId, array $productIds, DateTimeInterface $at)` traite plusieurs produits en une seule requête agrégée, pour la validation d'inventaire et le rapport (pas de requêtes en boucle, dites N+1).

### 5. Précision des quantités

Migration en `decimal(14,3)` de :

- `product_details.current_stock`, `opening_stock`, `stock_quantitiy_alert` ;
- `order_items.quantity` ;
- `stock_adjustments.quantity` ;
- `stock_history.quantity` et `old_quantity` ;
- `orders.total_quantity`.

`StockAdjustment` et `ProductDetails` reçoivent un cast `decimal:3`. Les validations (`numeric|min:0.001`) et les composants de saisie de la SPA sont alignés.

La migration modifie des tables volumineuses partagées par tous les tenants. Elle passe par `playbooks/database-migration.md` : vérification préalable du volume, déploiement en heure creuse, sauvegarde testée. Elle peut se dérouler en ligne, car MySQL ne bloque pas les lectures pendant `MODIFY COLUMN`, mais sa durée doit être mesurée en staging.

### 6. Niveau de qualité

Ces changements sont de **niveau Critique** (WES `quality/levels.md`), puisqu'ils modifient l'inventaire :

- chaque écriture multi-étapes se fait en transaction ;
- les opérations rejouables sont idempotentes : une validation d'inventaire ou d'import lancée deux fois ne crée aucun doublon (contrainte unique sur `(source_type, source_id, reason)`. Elle ne s'applique pas aux ajustements `manual`, dont la source est `NULL` : MySQL admet plusieurs `NULL` dans un index unique) ;
- chaque refus pour période verrouillée est journalisé (`Log::warning('stock_period_locked', …)`).

## Alternatives Considérées

### Registre unique `stock_movements` remplaçant le recalcul depuis les documents
Rejeté pour l'instant. Ce serait le modèle le plus juste en théorie : un registre en ajout seul, d'où tout stock est dérivé. Mais il faudrait générer rétroactivement les mouvements de tout l'historique `order_items` de tous les tenants, réécrire `recalculateOrderStock` et ses consommateurs, et maintenir deux sources de vérité pendant la transition. Le pilote boissons n'en tire aucun bénéfice. Réévaluer si l'on a besoin de valoriser en FIFO ou par lot, ou si le recalcul complet devient trop coûteux.

### Interdire toute modification de commande après validation (pour tous les tenants)
Rejeté. Ce serait un changement de comportement brutal pour les boutiques, qui corrigent aujourd'hui leurs factures. Le verrou par période donne la même garantie, mais seulement là où un inventaire l'exige.

### Masquer les boutons modifier et supprimer dans la SPA
Rejeté. L'API resterait ouverte ; ce n'est pas un contrôle (WES `standards/security.md`).

### Calculer l'écart à la validation sans tenir compte des dates
Rejeté. Les ventes de la veille, pas encore importées, et celles faites pendant le comptage produiraient de faux écarts systématiques. C'est ce qui ferait perdre la confiance de la gérante au premier rapport.

## Conséquences

- Un écart d'inventaire validé devient définitif : il ne peut plus changer sans une écriture visible, datée et attribuée.
- Les motifs permettent enfin de séparer la perte expliquée (casse, offert, repas du personnel) de la perte inexpliquée (`inventory_variance`).
- **Coût pour l'utilisateur** : après un inventaire, il ne peut plus corriger une facture antérieure. Il faut un message clair dans l'interface et une procédure documentée de correction par mouvement du jour.
- Les tests `tests/Feature/StockCalculationTest.php` restent valides et doivent passer sans modification. Il faut en ajouter pour : invariant daté, refus en période verrouillée (commande et ajustement), contre-passation unique, idempotence des mouvements générés, isolation des tenants sur `stock_period_locks`.
- **Commandes annulées** : une commande marquée `cancelled` est exclue du recalcul et du stock théorique daté. `cancelled` et `cancelled_by` sont protégés contre l'affectation de masse : aucun parcours d'annulation n'existe encore, et une simple mise à jour ne doit pas pouvoir retirer des quantités du stock sans trace.
- Défaut connu **hors périmètre**, à traiter séparément : le statut d'achat « commandé » compte comme stock reçu (lot 7, réception contrôlée).

## Quand Réviser

- Si l'on a besoin de valoriser en FIFO ou par lot (traçabilité de dates de péremption), ou si le recalcul complet par produit dépasse le budget de performance : passer au registre `stock_movements`.
- Si des clients réclament de corriger une période verrouillée : envisager une réouverture contrôlée (permission dédiée, traçabilité, nouvel inventaire obligatoire) plutôt que d'assouplir le verrou.
