# ADR-009 : Import des ventes depuis une caisse externe

**Statut :** Accepté (2026-10-15, §6 révisé, §8 ajouté pour ne plus dépendre du format d'une caisse précise)
**Date :** 2026-10-12
**Contexte :** Mode restauration (ADR-006) — alimentation des sorties de stock à partir du logiciel de caisse du client

---

## Contexte

La note produit est claire : « il ne faut jamais demander à l'équipe de ressaisir les ventes, l'outil serait abandonné en deux semaines ». Elle propose de commencer par un import CSV ou Excel quotidien, accompagné d'une table de correspondance entre les libellés de la caisse et les articles.

Le logiciel de caisse du restaurant pilote n'est **pas encore connu** (arbitrage du 2026-10-12). GestionStock possède une caisse intégrée (`PosController`, `PosOrderService`). Elle est cependant orientée boutique (pas de tables, de tickets cuisine ni d'additions), et l'on ne peut pas supposer que le client en changera.

Ce que montre le code :

- **Brique réutilisable** : une chaîne d'import produits complète.
  - `ProductImportParser` (CSV avec détection du séparateur, plus XLSX) et `XlsxParser`, qui fonctionne sans dépendance.
  - `ProductRowValidator` pour la validation ligne par ligne.
  - Le job `ProcessProductImport`, avec suivi de statut en cache sous `import_status_{uuid}`, et `ImportModal.vue` pour l'interface.
- **Piège** : une vente enregistrée comme commande `sales` déclenche `CustomerBalanceService`, `WarehouseHistoryService` et la balance âgée des créances. Des ventes importées sans paiement apparaîtraient comme des impayés clients et fausseraient les tableaux de bord financiers. Or l'argent est géré par la caisse du client, pas par GestionStock.
- **Contrainte de file d'attente** : `QUEUE_CONNECTION=sync` par défaut, donc les jobs s'exécutent dans la requête.

## Décision

### 1. Les ventes importées sont des mouvements de stock, jamais des factures

Chaque import validé produit **une sortie par produit et par journée** : des lignes `stock_adjustments` avec `adjustment_type = subtract`, `reason = pos_sale_import` et `source` = la ligne agrégée de l'import (ADR-007). Il n'y a ni commande, ni paiement, ni mouvement de solde client.

Agréger par produit plutôt que par ticket donne environ 100 mouvements par jour au lieu de plusieurs milliers. C'est suffisant, puisque l'écart se lit par produit et par période.

### 2. Modèle

| Table | Colonnes clés | Rôle |
|---|---|---|
| `pos_sales_imports` | `company_id`, `warehouse_id`, `sales_date` (date), `file_hash` (sha256), `original_filename`, `status`, `rows_count`, `imported_by`, `validated_by`, `validated_at`, `active` (tinyint nullable) | Un lot = un fichier = une journée commerciale d'un point de vente |
| `pos_sales_import_lines` | `pos_sales_import_id`, `external_label`, `normalized_label`, `quantity` decimal(14,3), `amount` nullable, `product_id` nullable, `line_status` (`mapped`, `unmapped`, `ignored`) | Contenu brut du fichier, conservé pour audit |
| `pos_label_mappings` | `company_id`, `normalized_label` (unique par tenant), `action` (`product`, `ignore` ; plus tard `composite` pour les fiches techniques), `product_id` nullable, `created_by` | Correspondance apprise une fois, réutilisée à chaque import |

S'y ajoute `pos_sales_import_items` (lot, produit stocké, quantité) : la sortie agrégée par produit écrite à la validation, source de chaque mouvement (§8). Une « journée sans vente » est un lot validé sans fichier ni ligne (`no_sales`), qui trace la décision.

Les statuts d'un lot sont `parsed`, `needs_mapping`, `ready`, `validated`, `reverted` et `failed`.

Toutes ces tables utilisent `BelongsToCompany` (ADR-003).

### 3. Idempotence garantie par la base (sans `exists()` préalable, cf. ADR-017 WES)

Deux contraintes uniques :

- **`(company_id, file_hash)`** : renvoyer le même fichier est refusé dès la réception.
- **`(company_id, warehouse_id, sales_date, active)`** : `active` vaut `1` quand le lot est validé et `NULL` sinon. MySQL accepte plusieurs `NULL` dans un index unique, donc **une seule journée validée par point de vente** est possible, et aucun double import n'est concevable même avec deux validations concurrentes.

La validation se fait dans une transaction unique : verrou de période (ADR-007), écriture des mouvements, passage de `active` à 1, recalcul des produits concernés.

### 4. Correspondance des libellés et quarantaine

- **Normalisation** : minuscules, sans accents, espaces fusionnés, ponctuation de bord retirée.
- **À l'import** : chaque libellé est cherché dans `pos_label_mappings`. Un libellé inconnu met le lot en `needs_mapping`.
- **Blocage** : un lot comportant des libellés non associés **ne peut pas être validé**. On ne les ignore jamais en silence, car une vente perdue fabrique un faux écart. L'écran de correspondance permet d'associer un libellé à un produit ou de le marquer « ignoré » (un service, un supplément sans stock). Le choix est mémorisé pour les imports suivants.

### 5. Journée commerciale

Un restaurant vend après minuit, et la caisse rattache ces ventes à la journée de la veille. Le réglage `business_day_ends_at` du mode restauration (`company_modules.settings`, ADR-006, `04:00` par défaut) fixe la `movement_date` des mouvements à `sales_date + 1 jour + business_day_ends_at - 1 seconde`.

Un inventaire dont `count_at` est postérieur à cette heure inclut la journée. La validation d'un inventaire est **refusée** tant qu'une journée commerciale entre le dernier verrou et `count_at` n'a pas son import validé. Le gérant peut lever ce refus explicitement (« journée sans vente »), et ce choix est tracé.

### 6. Format du fichier : colonnes choisies à l'import

La caisse du pilote n'étant pas connue, l'import ne suppose **aucun format** :
- le fichier (CSV, quel que soit le séparateur, ou XLSX) est d'abord lu en aperçu : en-têtes et premières lignes ;
- l'utilisateur désigne la colonne du **libellé** et celle de la **quantité** (obligatoires), et celle du **montant** (facultative) ;
- le choix est **mémorisé par tenant** (`company_modules.settings.pos_import_columns`, par nom d'en-tête) et proposé d'office aux imports suivants ; à défaut, des en-têtes courants sont reconnus (« Libellé », « Article », « Désignation », « Qté », « Quantité »…) ;
- un fichier correspond à une journée commerciale, choisie à l'import ; plusieurs lignes d'un même libellé (un ticket par ligne) sont additionnées ;
- plafond de 5 000 lignes par fichier, exécutable en mode `sync`. Si les volumes l'exigent, le traitement passe sur une vraie file avec le patron `ProcessProductImport`.

### 7. Annulation

Un lot validé peut être annulé tant que sa période n'est pas verrouillée. L'annulation crée les mouvements inverses (`reverses_id`, ADR-007), passe `active` à `NULL` et le statut à `reverted`. La journée peut ensuite être réimportée avec un fichier corrigé.

### 8. Plats composés

Un libellé peut être associé à un plat composé (ADR-013). À la validation, sa quantité vendue est convertie en sorties de ses **ingrédients**, au brut (net × 100 / rendement), selon la fiche en vigueur. Les sorties sont agrégées par produit stocké : une ligne `pos_sales_import_items` par produit, source de son mouvement `pos_sale_import`. La contrainte `(source_type, source_id, reason)` reste respectée même quand plusieurs plats consomment le même ingrédient.

### 9. Niveau de qualité

Niveau **Critique** : le module modifie l'inventaire et doit être idempotent. Il faut des tests de double envoi du même fichier, de deux validations concurrentes de la même journée, de lot bloqué par un libellé inconnu, de journée commerciale à cheval sur minuit, d'annulation puis réimport, de refus en période verrouillée et d'isolation des tenants sur les correspondances.

## Alternatives Considérées

### Créer des commandes de vente (`sales`) à partir de l'import
Rejeté. Cela fausserait les soldes clients, la balance âgée et le chiffre d'affaires, qui sont gérés par la caisse du client. Il faudrait ensuite exclure ces commandes de tous les rapports financiers, un filtre fragile à maintenir partout.

### Connecteur API vers la caisse dès la v1
Différé, comme le propose la note : on attendra que la caisse le permette et que plusieurs clients utilisent la même. Le modèle de lot et de correspondance de cet ADR est indépendant du transport : un connecteur créera des lots comme le ferait un fichier.

### Imposer la caisse GestionStock au restaurant
Rejeté comme prérequis. Changer de caisse est un projet en soi pour un restaurant, et la caisse actuelle n'est pas faite pour la restauration. Si le client choisit de l'adopter, ce module devient simplement inutile pour lui : ses ventes alimentent le stock nativement.

### Ignorer les libellés inconnus et continuer
Rejeté. Chaque vente perdue gonfle le stock théorique et produit un faux écart attribué à l'équipe. Le rapport accuserait à tort, exactement ce que la note veut éviter.

## Conséquences

- La caisse du client reste la source de vérité des ventes. GestionStock n'en tire que les sorties de stock.
- Le travail de correspondance pèse sur les premiers jours, puis tend vers zéro. Une nouvelle référence à la carte demande une association, une seule fois.
- Les fiches techniques (ADR-013) se branchent sur la même table de correspondance : un libellé associé à un plat composé sort ses ingrédients (§8).
- Le contrôle des journées manquantes (§5) ne s'applique qu'aux entrepôts qui utilisent l'import (au moins un lot) : un établissement qui encaisse dans GestionStock n'est pas bloqué.
- Reste à vérifier avec le premier export réel de la caisse du client : qu'il contient bien une quantité par article (et pas seulement des montants). Le choix des colonnes à l'import (§6) dispense de fixer son format à l'avance.

## Quand Réviser

- Si plusieurs clients utilisent la même caisse et qu'elle expose une API : ajouter un connecteur qui produit des lots.
- Si un client a besoin de suivre les ventes à l'heure (bar ouvert pendant l'inventaire tournant de jour) : passer d'un lot par journée à un lot par service.
- Si les fichiers dépassent régulièrement 5 000 lignes : passer le traitement sur une vraie file d'attente.
