# Extranet Gpharma — côté API

Endpoints dédiés à l'Extranet, ajoutés **sans toucher** aux routes servies à
l'ancien extranet, au mobile ni à PharmaML.

## Ce qui a été ajouté

```
config/extranet.php                      configuration complète (règles métier incluses)
routes/extranet.php                      fichier de routes isolé, préfixe /api/extranet/v1
app/Support/Extranet/                    Jwt, Periode, Filtres, Colonne, ClasseurXlsx
app/Services/Extranet/                   Lecture, Ecriture, Comptes, Jetons, Contexte,
                                         PerimetreLabo, PerimetreClient, Referentiels,
                                         CacheExtranet, TableauBordLabo
app/Services/Extranet/Rapports/          les 7 rapports + socle mutualisé
                                         (Executeur, Export, Ruptures)
app/Http/Middleware/Extranet/            Authentifier, VerifierDroit
app/Http/Controllers/Extranet/           Auth, Referentiel, ControleurRapport,
                                         Labo/*, Client/*
resources/views/extranet/rapport.blade   gabarit d'impression PDF
```

## Fichiers existants modifiés

Deux, tous deux en ajout pur :

- `bootstrap/app.php` : déclaration du fichier de routes (`then:`) et des alias
  de middleware `extranet.auth` / `extranet.droit`.
- `config/cache.php` : ajout d'un store `extranet` (driver fichier).

`ExtranetController.php` et `ExtranetLabo.php` ne sont **ni modifiés, ni
appelés**. Leur logique métier a servi de référence de lecture ; les requêtes
de l'Extranet sont écrites à part et évoluent indépendamment.

## Base de données

Aucune migration, aucune table créée, aucune colonne ajoutée.

Toutes les lectures passent par `App\Services\Extranet\Lecture`, qui exécute
chaque requête dans une transaction PostgreSQL `READ ONLY` avec
`statement_timeout` et `application_name` positionnés. Une écriture par cette
voie est rejetée par le moteur — la garantie est donnée par PostgreSQL, pas par
la discipline du code — et une requête qui dérape est coupée : l'ERP ne peut
pas être ralenti par une consultation.

**Une seule exception**, et elle a son propre point d'entrée :
`App\Services\Extranet\Ecriture`, utilisé par le changement de mot de passe
(`UPDATE UTILISATEUR_EXTRANET SET "PhraseUtilisateur", "ModifieLe"` sur la
ligne du compte connecté). Assouplir `Lecture` aurait ouvert la porte à toutes
les écritures ; un point d'entrée distinct laisse le garde-fou absolu et rend la
liste exhaustive : `grep -rn 'Ecriture::' app/` dit tout ce que l'Extranet
modifie dans la base de l'ERP. Les autres garde-fous — timeout, nom
d'application — y sont conservés.

La connexion utilisée est celle du `.env` (`DB_CONNECTION`), redirigeable par
`DB_CONNECTION_EXTRANET` sans modifier une ligne de code. **`replica_cnx` n'est
pas utilisée** : elle reste réservée à la réplication.

## Variables d'environnement

Toutes optionnelles : les valeurs par défaut conviennent en production.

```dotenv
# Base : par défaut la connexion applicative
DB_CONNECTION_EXTRANET=pgsql

# Cache dédié (pas de Redis en production -> driver fichier)
CACHE_STORE_EXTRANET=file

# Garde-fous de lecture
EXTRANET_TTL_PERIMETRE=60           # périmètre d'une agence laboratoire
EXTRANET_STATEMENT_TIMEOUT_MS=20000
EXTRANET_APPLICATION_NAME=gpharma_extranet

# Authentification (le secret retombe sur APP_KEY s'il n'est pas défini)
EXTRANET_JWT_SECRET=
EXTRANET_TTL_ACCESS=1800
EXTRANET_TTL_REFRESH=43200          # sans « se souvenir de moi » : 12 h
EXTRANET_TTL_REFRESH_LONG=2592000   # avec « se souvenir de moi » : 30 j
EXTRANET_COOKIE_SECURE=true     # à mettre à true derrière HTTPS
EXTRANET_MAX_ECHECS=8
EXTRANET_MIN_MOT_DE_PASSE=8      # longueur minimale d'un mot de passe choisi

# Règles métier
EXTRANET_FACTURE_VALIDEE_SEULEMENT=true
EXTRANET_PEREMPTION_ALERTE_JOURS=180
EXTRANET_EXPORT_MAX_LIGNES=100000
EXTRANET_SOCIETE=GPHARMA
```

## Cache et paramétrage

Le cache est indexé sur le **contenu** du périmètre (liste des laboratoires et
des produits), pas seulement sur l'identifiant de l'agence : modifier
`AGENCELABORATOIRE.ListeLaboratoireProduit` dans l'ERP produit donc une
nouvelle clé, et les référentiels comme le tableau de bord se reconstruisent
immédiatement.

Le périmètre lui-même est la seule entrée qui ne peut pas s'auto-invalider —
elle est forcément indexée par l'agence. Sa durée est donc courte (60 s).

Pour forcer la purge sans attendre :

```bash
php artisan extranet:vider-cache
```

Le fichier `config/extranet.php` est susceptible d'être mis en cache par
`php artisan config:cache`. Après toute modification de configuration — ajout
d'une habilitation, changement d'une règle métier — il faut donc :

```bash
php artisan config:clear   # ou config:cache pour régénérer
```

Sans cela, la nouvelle habilitation reste invisible et la route qui l'exige
renvoie « Habilitation inconnue » (500).

Attention : les jetons de rafraîchissement vivent dans le même store, les
utilisateurs connectés devront donc se reconnecter.

## Création des comptes des agences laboratoire

```bash
php artisan extranet:generer-comptes-labo
```

**N'écrit rien en base.** Produit deux fichiers dans `storage/app/extranet` :

- `comptes_agences_laboratoire.sql` — un `INSERT` par agence non encore dotée,
  mots de passe en bcrypt, chaque instruction protégée par un `NOT EXISTS` sur
  le login : relancer le script ne crée pas de doublon. À relire puis exécuter.
- `comptes_agences_laboratoire.csv` — agence, laboratoire représenté,
  identifiant, mot de passe **en clair**, état du périmètre. C'est le fichier à
  transmettre au client ; les mots de passe n'existent nulle part ailleurs en
  clair et ne sont pas récupérables ensuite. À supprimer du serveur après envoi.

Règle d'identifiant : premier mot significatif du nom de l'agence, les mots
génériques étant écartés (LE, LES, LABORATOIRE, SARL...). En cas de collision le
mot suivant est ajouté, et un nom trop court est complété par `LABO`. Exemples :
`LABORATOIRES INNOTHERA` → `INNOTHERA`, `PHARM UP` → `PHARMUP` face à
`PHARM NATURE CARAIBES` → `PHARM`, `GSK` → `GSKLABO`.

Mots de passe : 12 caractères tirés au sort dans un alphabet sans caractères
ambigus (ni `I`, `l`, `1`, `O`, `0`).

Les comptes reçoivent **toutes** les habilitations de l'Espace Laboratoire.
Ajuster les colonnes `DLabo_*` dans le SQL avant exécution pour un autre
réglage.

### Périmètre des agences

Un compte sans périmètre se connecte mais n'affiche aucune donnée. La fin du
script SQL propose, **en commentaire et donc à activer volontairement**, un
`UPDATE` qui rattache chaque agence au laboratoire portant le même nom, sans
toucher aux périmètres déjà renseignés.

## Endpoints

Base : `/api/extranet/v1`

| Méthode | Route | Habilitation |
|---|---|---|
| POST | `/auth/connexion` | — (corps : `login`, `motDePasse`, `seSouvenir`) |
| POST | `/auth/rafraichir` | cookie httpOnly |
| POST | `/auth/deconnexion` | — |
| GET | `/auth/moi` | jeton |
| POST | `/auth/mot-de-passe` | jeton (corps : `motDePasseActuel`, `nouveauMotDePasse`, `confirmation`) |
| GET | `/referentiel/{laboratoires\|produits\|agences\|secteurs}` | jeton |
| GET | `/labo/accueil` | `DLabo_AccesTableauBord` |
| GET | `/labo/stock/lots` | `DLabo_EtatStockParLot` |
| GET | `/labo/stock/a-terme` | `DLabo_EtatStockATerme` |
| GET | `/labo/achat/commandes` | `DLabo_SuiviCommandeFournisseur` |
| GET | `/labo/achat/statistiques` | `DLabo_StatAchat` |
| GET | `/labo/vente/stat-produit` | `DLabo_StatVenteParProduit` |
| GET | `/labo/vente/stat-client` | `DLabo_StatVenteParClient` |
| GET | `/labo/vente/stat-secteur` | `DLabo_StatVenteParSecteur` |
| GET | `/labo/vente/stat-ville` | `DLabo_StatVenteParVille` |
| GET | `/labo/vente/detail` | `DLabo_StatVenteParClient` |
| GET | `/client/commande/liste` | `D_PasserCommande` |
| GET | `/client/commande/detail` | `D_PasserCommande` |

Paramètres communs aux rapports (query string) :
`dateDebut`, `dateFin`, `laboratoire[]`, `produit[]`, `agence[]`, `recherche`,
`tri`, `sens`, `page`, `parPage`, `inclureStockNul`, `inverse`,
`format=json|csv|xlsx|pdf`.

Deux paramètres servent les renvois depuis les cartes du tableau de bord :

| Paramètre | Rapport | Effet |
|---|---|---|
| `statut=perime,proche` | `/labo/stock/lots` | ne garde que les statuts listés parmi `valide`, `proche`, `perime`, `suspendu`, `inconnu` |
| `rupture=1` | `/labo/stock/a-terme` | ne garde que les références dont le stock réel cumulé est nul ou négatif |

Un troisième ne concerne que les exports :

| Paramètre | Effet |
|---|---|
| `vue=synthese` | sur un rapport à rupture, exporte **une ligne par groupe** au lieu du détail |

`statut` est une **liste blanche** : toute autre valeur est écartée en silence,
un statut inconnu ne doit pas vider le rapport sans explication. Le statut
affiché étant calculé par un `CASE` dans le `SELECT`, son alias n'est pas
utilisable en `WHERE` : les prédicats sont rejoués dans le même ordre que le
`CASE` — un lot suspendu reste « suspendu » quelle que soit sa date. Les cinq
statuts partitionnent donc exactement le rapport, ce qui est vérifiable en
sommant leurs totaux.

`rupture` s'applique en `HAVING` sur la somme par produit, pas en `WHERE` sur la
ligne de stock : un produit à zéro dans un seul dépôt mais disponible ailleurs
n'est pas en rupture. C'est la définition du compteur d'accueil, et les deux
nombres se correspondent.

### Blocs du tableau de bord

`/labo/accueil` renvoie `{ perimetreVide, blocs }`. Chaque bloc est conditionné
par une habilitation : un compte sans droit ne le reçoit pas, pas même masqué.

| Bloc | Habilitation |
|---|---|
| `stock` | `stock_lot` **ou** `stock_terme` |
| `peremption` | `stock_lot` |
| `achats` | `achat_commande` **ou** `achat_stat` |
| `ventes`, `meilleursProduits` | `vente_produit`, `vente_client` **ou** `vente_secteur` |
| `meilleursClients` | `vente_client` |

`meilleursClients` a sa propre condition, plus étroite que celle des autres
blocs de vente : connaître ses meilleurs produits ne donne pas le droit de
savoir qui achète. Il porte la même forme que `meilleursProduits`
(`code`, `designation`, `quantite`), ce qui permet à la carte d'accueil de
basculer d'un classement à l'autre sans rien changer à son rendu.

### Ce que contiennent les exports

CSV, Excel et PDF partent des mêmes colonnes et des mêmes lignes que l'écran.
La **rupture** en fait partie : un rapport ventilé ne s'exporte pas en liste
plate. `Ruptures::decouper()` calcule le découpage une fois, les trois formats
le rendent à l'identique.

| Élément | À l'écran | Dans les exports |
|---|---|---|
| Colonnes de rupture | en bandeau de bloc, retirées des lignes | en ligne d'en-tête de bloc, retirées des lignes |
| Sous-total du bloc | à droite du bandeau | ligne « Total » sous le bloc |
| Total général | badges au-dessus du tableau | ligne « Total général » en fin |

Un rapport sans `groupePar` reste une liste plate, là aussi comme à l'écran ;
il gagne seulement sa ligne de total général.

### La vue suit la bascule de l'écran

Le tableau d'un rapport à rupture propose « Détail » ou « Synthèse ». Le front
transmet ce choix par `vue=synthese`, et l'export produit alors le même
découpage : une ligne par groupe, colonnes `identité du groupe | Lignes |
cumuls`, exactement celles de la vue de synthèse. Sans ce paramètre, on
exportait le détail alors que l'écran montrait la synthèse.

La synthèse est construite depuis les blocs déjà découpés par `Ruptures`, et non
depuis `meta.resumeGroupes` : ce résumé est plafonné à 2 000 groupes pour la
réponse JSON, alors que l'export porte l'intégralité du résultat.

Le nom du fichier porte alors `-synthese`, sans quoi les deux vues du même
rapport se distingueraient à la seconde près. Le PDF, lui, annonce
« Synthèse · N client(s) » plutôt que « N ligne(s) » : en synthèse, une ligne
du document est un groupe.

Deux points à connaître :

- les sous-totaux sont **recalculés depuis les lignes exportées**, pas lus dans
  `meta.resumeGroupes` : ce résumé est plafonné à 2 000 groupes pour ne pas
  alourdir la réponse JSON, et n'existe que pour un rapport. L'export dispose
  au contraire de l'intégralité du résultat ;
- chaque sous-total monétaire étant arrondi au centime, leur somme peut
  s'écarter d'un centime du total général, lui arrondi une seule fois sur
  l'ensemble. L'écran a exactement la même propriété.

Le CSV et l'Excel gardent la **précision brute** des lignes de détail — ce sont
des fichiers de données, un tableur les met en forme lui-même. Le PDF, lui, est
un document : il applique la mise en forme de l'écran (deux décimales, dates en
jj/mm/aaaa).

### Identification du destinataire

Un état imprimé ou exporté circule hors de l'application : il doit dire de
lui-même pour qui il a été édité.

- Le **PDF** porte le nom de l'agence laboratoire en tête, à droite du titre.
  Le bandeau est un tableau à deux cellules et non des flottants : DomPDF place
  les cellules de façon fiable, ce qu'il ne fait pas de `float`.
- Le **nom des fichiers** téléchargés suit la forme
  `<rapport>-<agence>-<horodatage>.<ext>`, par exemple
  `detail-des-ventes-par-client-arko-medica-20260905-101643.csv`. Un
  laboratoire qui archive ses états les reçoit tous sous le même titre ;
  l'agence suffit à les distinguer sans les ouvrir.

`Str::slug` assure seul la sûreté du nom : il translittère les accents et ne
laisse passer que lettres, chiffres et tirets — les barres obliques,
apostrophes, deux-points et autres caractères qu'un système de fichiers refuse
disparaissent. Les barres sont converties en espaces **avant** le nettoyage,
sans quoi « CARAIBES/MAYOLY » ressortirait en `caraibesmayoly`, les deux mots
soudés. Chaque partie est bornée en longueur.

Le nom de l'agence vient de `Rapport::nomAgence()`, seul accès public au
périmètre : il est vide pour un compte sans agence, et l'appelant l'omet alors
plutôt que d'afficher un intitulé creux.

`inverse=1` ne concerne que les statistiques ventilées (client, secteur, ville) :
il échange la colonne de rupture et les lignes. Par défaut la rupture porte sur
le produit — pour un produit, on lit ses secteurs ; avec `inverse=1` elle porte
sur l'axe — pour un secteur, on lit ses produits. Le calcul est identique, seuls
`ORDER BY` et `meta.groupePar` changent. La réponse annonce `meta.inversable` et
`meta.inverse` pour que l'interface propose la bascule sans rien coder en dur.

Axes non renseignés : les libellés de repli sont explicites —
`SECTEUR NON DEFINI`, `VILLE NON DEFINI`, `CLIENT NON DEFINI` — plutôt que des
lignes masquées ou une cellule vide.

Réponse : `{ rapport, colonnes[], lignes[], meta{ pagination, filtres, mois[], totaux, groupePar, resumeGroupes } }`.
Le front construit son tableau à partir de `colonnes` et `meta.mois` : ajouter un
rapport côté serveur ne demande aucune modification du front.

`meta.resumeGroupes` n'apparaît que pour les rapports à rupture qui exposent des
sous-totaux (`Rapport::resumeGroupes`). Il est indexé par la valeur `cleGroupe`
portée par chaque ligne, et calculé sur **l'intégralité du résultat filtré** :
un groupe à cheval sur deux pages reste juste, ce qu'un cumul côté navigateur ne
saurait garantir. Chaque entrée porte aussi ses libellés, ce qui permet à
l'interface d'afficher la synthèse complète sans attendre le chargement des
lignes de détail.

### Détail des ventes par client

`/labo/vente/detail` reproduit l'état que le grossiste envoie mensuellement aux
laboratoires (fichier `StatVentes_<LABO>_<ANNÉE>.xlsx`) : une ligne par ligne de
facture — code client, EAN, libellé produit, quantité, numéro et date de
facture, valeur — groupée par client, avec sous-total par client et total
général. Le numéro et la date de facture proviennent de FACTURECLIENT. Le découpage
mensuel du fichier Excel est couvert par le filtre de période.

L'habilitation réutilisée est `DLabo_StatVenteParClient` : même périmètre de
données que les statistiques par client, donc aucune colonne ajoutée en base.

## Sécurité

- Mots de passe : bcrypt déjà en base, `Hash::check` direct. `Hash::make` écrit
  dans le même format : un mot de passe changé depuis l'Extranet reste lisible
  par l'ERP, sans reprise de données.
- Jeton d'accès JWT HS256 (30 min), signé sans dépendance Composer
  (`app/Support/Extranet/Jwt.php`, algorithme épinglé, comparaison à temps constant).
- Jeton de rafraîchissement opaque, stocké **haché** côté serveur, tourné à
  chaque usage avec un sursis de 30 s pour absorber les appels concurrents
  (plusieurs onglets).
- « Se souvenir de moi » n'agit que sur la durée de vie du cookie : cochée, il
  persiste 30 jours ; décochée, c'est un cookie de session effacé à la
  fermeture du navigateur. Le choix est mémorisé avec le jeton et survit aux
  rotations. Aucun mot de passe n'est jamais stocké côté navigateur.
- Le périmètre laboratoire est **toujours recalculé côté serveur** depuis
  `AGENCELABORATOIRE`. Il n'est jamais transmis par le client : un paramètre
  d'URL ne peut pas l'élargir. Un périmètre vide ne renvoie rien.
- Les droits sont relus en base à chaque requête (mémorisés 60 s) : retirer une
  habilitation dans l'ERP prend effet immédiatement.
- Changement de mot de passe : le mot de passe actuel est redemandé, et les
  échecs alimentent le même compteur que la connexion (même clé, même fenêtre)
  — un jeton valide ne donne donc pas un oracle pour deviner le mot de passe en
  place. L'empreinte est relue en base au moment de la vérification, jamais
  transportée par le contexte ni mémorisée dans le cache. En cas de succès, le
  jeton de rafraîchissement présenté est révoqué et remplacé ; l'entrée de cache
  du compte est oubliée. Les sessions ouvertes sur d'autres appareils restent
  valides jusqu'à leur expiration : les refresh étant indexés par condensat, il
  n'existe pas d'index par compte pour les révoquer en masse. Le front l'annonce
  à l'utilisateur plutôt que de le laisser croire l'inverse.
- Longueur minimale du nouveau mot de passe : `EXTRANET_MIN_MOT_DE_PASSE`
  (8 par défaut). Ne s'applique qu'aux changements faits depuis l'Extranet ;
  les mots de passe déjà en base ne sont pas remis en cause.

## Logos et polices des documents imprimés

Ni les logos ni les polices ne vivent dans cette application : **Gpharma Web
les téléverse**, et la base ne stocke qu'un chemin *relatif à son « public »*.
L'API lit donc le fichier sur disque et l'embarque en `data:` dans le PDF —
sans quoi DomPDF chercherait un chemin relatif dans son propre dossier public
et imprimerait un document sans logo.

La chaîne compte quatre maillons :

| # | Maillon | Où |
|---|---|---|
| 1 | `INFO_SOCIETE.UrlLogoSociete` — et `UrlLogoDocumentCMU`, qui le remplace sur un document CMU d'un parc en **mode 3** | base |
| 2 | `DOCUMENTS_PUBLIC_PATH` — défaut : `<parent de l'API>/erp_gpharma_web/public` | `.env` |
| 3 | Le fichier présent et lisible par l'utilisateur du serveur web | disque |
| 4 | `styleLogo()` met à l'échelle pour **tenir** dans la boîte, DomPDF n'ayant pas `object-fit` | code |

Les polices suivent la même règle, par `DOCUMENTS_FONT_PATH`.

**Toute la chaîne échoue en silence** : chemin mal réglé, fichier absent,
droits insuffisants — le document s'imprime *sans* logo plutôt que de ne pas
s'imprimer. C'est le bon arbitrage en production, mais la panne n'y laisse
aucune trace. D'où :

```bash
php artisan extranet:verifier-documents
```

qui contrôle les quatre maillons et nomme celui qui manque.

Deux pièges se voient surtout au passage d'une instance à l'autre : le nom du
fichier est **horodaté** (`documentTeleverse/logo_societe/1789414746.png`), donc
propre à chaque base ; et le dossier `documentTeleverse/` est du **contenu
téléversé**, qu'un déploiement de code ne transporte pas.

## Espace Client

### Le programme de couverture maladie et son complément

Le programme ne porte pas le même nom d'un parc à l'autre, et **son complément
non plus**. `config/extranet.php` → `commande_client` porte les deux, choisis
sur `PARAMETRE.ModeGpharma` :

| Mode | `marqueur_cmu` | `marqueur_hors_cmu` |
|---|---|---|
| 3 | `TPOM` | `WINOULA` |
| défaut | `CMU` | `Hors CMU` |

Les deux partent dans la réponse du tableau de bord (`marqueur`,
`marqueurHors`) et intitulent les deux boutons du filtre. C'est délibérément
l'API qui nomme : là où les deux côtés portent une marque commerciale, un
« Hors TPOM » fabriqué par l'interface dirait la mauvaise chose. Seul « Tout »
reste un mot du front, puisqu'il ne désigne aucun programme.

Le profil d'un compte client porte, en plus des champs communs :

```json
{
  "espace": "client",
  "client": { "id": 144, "code": "100", "nom": "WINOULA TEST",
              "ville": "LE ROBERT", "actif": true, "perimetreVide": false },
  "droits": { "catalogue": false, "commande": true, "reclamation": true,
              "document_bl": true, "document_facture": true, "tableau_bord": true }
}
```

`client` vient de `PerimetreClient`, pendant de `PerimetreLabo` : identité du
client rattaché, recalculée côté serveur depuis le compte connecté et jamais
transmise par le navigateur. `actif` reflète `CLIENT.EtatCompteClient` — un
compte inactif ne doit pas pouvoir commander, et l'interface l'annonce.

Les habilitations viennent de `config/extranet.php` → `droits_client`, sur le
même principe que `droits_labo` : clé exposée au front, colonne réelle en
configuration. **Aucune colonne n'est ajoutée** — celles de l'ancien extranet
sont reprises telles quelles, et le rattachement suit son menu :

| Clé | Colonne | Écran de l'ancien extranet |
|---|---|---|
| `catalogue` | `D_InfoProduit` | info produit |
| `commande` | `D_PasserCommande` | passer commande |
| `reclamation` | `D_Reclamation` | réclamation |
| `document_bl` | `D_Doc_BL` | « B.L.V par quinzaine » |
| `document_facture` | `D_Doc_Releve` | « Facture de quinzaine » |
| `tableau_bord` | `D_TableauBord` | tableau de bord |

Un compte garde donc exactement ce qu'il voyait avant. Deux écrans de l'ancien
menu restent hors périmètre pour l'instant : **Escomptes** (`D_Doc_Escompte`) et
**Ristournes** (`D_Doc_Ristourne`).

### Commandes du client

`/client/commande/liste` sert une ligne par commande — date, numéro, type,
mode, statut, Total HT, remise, TVA, net à payer, nombre de lignes et de
boîtes — et `/client/commande/detail?commande=<numéro>` les lignes de l'une
d'elles. Les deux passent par le socle des rapports : tri, pagination, totaux
et exports CSV / Excel / PDF sont donc ceux du laboratoire.

Points à connaître :

- **Restriction.** Les deux rapports filtrent sur `IDCLIENT` lu dans le
  contexte. Vérifié : nommer explicitement la commande d'un autre client
  renvoie zéro ligne, sur la liste comme sur le détail.
- **Sans numéro, le détail ne renvoie rien** (`WHERE 1 = 0`) plutôt que toutes
  les lignes de toutes les commandes : un paramètre oublié ne doit pas se
  traduire par un export massif.
- **Filtres** : `type[]`, `mode[]`, `etape[]` (entiers de l'ERP) et la période.
  Les libellés et la liste des valeurs voyagent dans `meta.options`, ce qui
  permet au front de peupler ses trois sélecteurs sans coder une seule valeur.
- **Traduction des énumérations** : `config/extranet.php` → `commande_client`
  porte les tables `types`, `modes`, `etapes`, reprises de l'ancien extranet.
  Elle est appliquée **en SQL**, par un `CASE` à paramètres liés : trier sur
  « Statut » classe donc comme l'affichage, et non sur un entier invisible.
  Une valeur absente des tables ressort en clair — « TYPE 7 », qu'on trouve
  effectivement en base — plutôt que rangée en silence sous une étiquette
  fausse.
- **Nb boîte** vient d'une sous-requête pré-agrégée par commande : sommer les
  quantités après jointure multiplierait les totaux de l'entête par le nombre
  de lignes.
- L'identité `Total HT - Remise + TVA = Net à payer` a été vérifiée sur les
  commandes réelles ; `TotalTTC` est donc exposé tel quel plutôt que recalculé.

`Contexte::aDroit()` consulte le jeu de l'espace du compte, jamais celui de
l'autre : la clé `tableau_bord` existe des deux côtés, et c'est le type de
compte qui tranche. `Comptes` charge en une requête l'union des colonnes des
deux espaces — le type n'est connu qu'après la lecture, et quelques booléens
coûtent moins qu'une seconde requête.

## Règles métier appliquées

Regroupées dans `config/extranet.php` → `regles`.

| Sujet | Règle |
|---|---|
| Prix d'achat | `PRODUIT.PrixAchatFHT` |
| VM | `STOCKAGENCE.Vm_Produit` |
| Nb mois de stock | `Stock / VM`, vide si VM = 0 |
| CR | `STOCKAGENCE.QuantiteCoursRoute` |
| Ventes | `SUM(QteFacturee)` sur `TypeFactureClient IN (1,2)` — les avoirs portent déjà des quantités négatives, la somme les déduit |
| Factures retenues | `FactureValidee IS TRUE` et `FactureSupprimee IS NOT TRUE` |
| Qté reçue (achat) | `SUM(COMMANDE_SUIVI.QteSuiviCommande)` sur les suivis validés |
| Reliquat | `QuantiteCommandee - Qté reçue`, plancher à 0 |
| Commande client en cours | ligne validée, non supprimée, sans ligne de facture associée |
| Stock à terme | `Stock réel + Qté en achat - Commande en cours` |

Attention : `FactureSupprimee`, `FactureValidee`, `CompteBloque`,
`LotProduitSuspendu` valent **NULL** et non `false` en base. Toutes les
comparaisons utilisent `IS TRUE` / `IS NOT TRUE`.
