# acportail

ACPORTAIL, logiciel interne : suivi des commandes de portails en aluminium, de
l'accueil du devis jusqu'à la pose. Laravel 12 / PHP 8.2, MySQL en local via
WAMP. L'historique des décisions est dans les messages de commit ; ce fichier
ne garde que les règles et les pièges.

## Messages de commit

- **Aucune mention de Claude** : ni `Co-Authored-By: Claude`, ni `Generated
  with Claude Code`, ni signature. Ce sont les commits de Cyril, à son nom (un
  hook `commit-msg` les retire, il n'a pas à servir).
- Sujet en français, à l'impératif, **sans accent** — « Sortir couleur_id de
  la table commandes ».
- Le corps explique **pourquoi** (contraintes, pièges, ce qu'on abandonne), pas
  quoi.
- Tout commit est suivi d'un `git push` sur `origin/acportail`.

## Les trois sources de données

1. **MySQL local** — la base d'ACPORTAIL, celle des migrations.
2. **HFSQL via ODBC** — l'ancien logiciel (`FABRICATION`, `PROCESS`), en
   service le temps de la migration (`hfsql_gestan`, `hfsql_acportail`).
3. **L'api Gestan** — projet séparé, `C:\wamp64\www\api`, dépôt GitLab
   distinct. Clients, devis, factures, interventions, produits, statistiques.

⚠️ **`APP_ENV` choisit l'api** : `local` vise l'api locale ; toute autre valeur
vise l'api, la base et la messagerie de production. Relire la valeur avant de
conclure. ⚠️ **Les écritures vers Gestan ne se testent que sur l'intervention
8233.**

**Commandes et planning se lisent dans la base locale**
(`App\Services\Acportail\CommandeService`, `PlanningService`), plus par l'api.
Ils rendent leurs lignes dans la forme que l'api rendait, clé pour clé (codes
en texte, libellés en majuscules, client sans copie sous son seul `id`) : les
contrôleurs et les vues n'ont pas changé. Les chantiers d'un client passent
par `PlanningService::byClient()`. Le groupe `/acportail` de l'api et
`/gestan/clients/{id}/chantiers` ne sont plus appelés. ⚠️ L'api compte encore
les chantiers dans `/gestan/clients/{id}` sur notre table `plannings` : une
colonne renommée ici y lève une erreur que personne ne verra.

⚠️ **Filtres d'échéance, vues du tableau de bord, tri par échéance et compteurs
de `CommandeService` sont du SQL de MySQL**, comme `TableauBordService` : les
tests (sqlite) n'y passent pas. Y toucher demande une vérification à la main
sur MySQL.

## Modèle de données

**Colonnes catégorielles en codes numériques** (`statut`, `priorite`,
`finition`, `platine`, `laqueur`, `usine`, `destination`), libellés dans
`config/commandes.php`. La conversion libellé → code se fait en amont, dans les
tables temporaires, avant `CommandeSeeder`.

**`modele` et `couleur` sont des libellés figés**, pas des clés étrangères : une
commande est un document historique. Le catalogue (`modeles`) ne sert pas à
afficher une ancienne commande ; les ids postés par le formulaire ne servent
qu'à résoudre le libellé.

**STANDBY, REÇU, EN STOCK et POSÉ sont cachés à l'usine** : pas encore à
fabriquer, ou déjà arrivée chez nous. Le rôle portugal ne voit ces commandes
ni dans la liste, ni dans la recherche, ni dans les compteurs de l'index
(`etats_caches_a_l_usine`, filtre `sans_etats` posé par le contrôleur), ni
leurs découpes dans la liste des découpes (`CommandeDecoupeController::base`),
et ne pose pas ces états (`etats_hors_usine`). La fiche et le scan ne
filtrent pas.

**Pas d'ATTENTE CHARG. ni au-delà sans les découpes** : refusé tant qu'une
découpe n'est pas REÇU (sans état = non reçue), `Commande::decoupesBloquentLEtat`,
à l'édition (sur ce qui restera après l'envoi), à la sélection et au scan
(`ChangementEtat`, lot jamais refusé en bloc), seulement si l'état change.
Le retour du laquage s'enregistre mais pose IN PROCESS
(`PassageLaquage::retenuesParLesDecoupes`).

**La sélection multiple de l'index est une permission** (`commandes.selection`) :
usine, rôle commande, administrateurs. Sans elle, ni cases ni boutons, et
`fiches` (étiquettes en lot) et `changerEtat` refusent. **Elle ne vit que dans
la page** (commandes et découpes) : changer de page, de filtre ou de recherche
la vide ; les gestes sur place la gardent. « Découpes de la sélection »
ouvre l'écran des découpes filtré (`commandes[]`), toutes les lignes cochées :
`cocher=1` passe en session (`cocher_decoupes`) le temps d'une redirection,
l'adresse reste propre. **Ligne allégée** pour qui a la sélection et
l'édition : ni étiquette ni laquage sur la ligne (la case et la barre font le
même geste) ; l'usine (sans édition) et qui n'a pas la sélection les gardent.
L'état en lot demande
en plus l'édition **ou** `scans.appliquer` (l'usine n'a que le second). ⚠️ La
barre `#cmd-selection` reste dans la page sans ses boutons : `selection.js` y
tient aussi l'icône de laquage d'une ligne.

**REFABRICATION (code 2) ne se pose qu'en refabriquant** : bouton de la fiche
(`CommandeController::refabriquer`) → création pré-remplie ; `store()` pose le
statut et la ligne `refabrications` (fautif et raison obligatoires) dans la
même transaction. L'édition ne propose pas ce statut. 13 commandes importées
l'ont sans ligne `refabrications`.

**« Générer depuis le devis »** (création, pas la refabrication) : propose
une ligne par produit du devis retrouvé au catalogue
(`App\Services\Commandes\LignesDuDevis`, lit « Modèle / Coloris / Finition /
Largeur ou Longueur / Hauteur du cadre ou Hauteur » dans le texte de la
ligne ; maçonnerie si le devis porte une ligne `commandes.devis_maconnerie`).
Le même modèle plusieurs fois au devis : `rang`, collé au libellé
(« CLA-196_1 ») et gardé par `CommandeController::libelleModele` tant qu'il
suit le libellé du catalogue. Rien n'est écrit sans
la case « Vérifiée » de chaque ligne (`required`, côté navigateur seulement).
Les lignes se posent par les gestes de l'écran (`#add-item`, `change` du
type, `window.acChoisir`) : ⚠️ un champ ajouté au bloc d'une commande se
remplit aussi dans `js/commandes/lignes-devis.js`.

**Prix usine à la création** : proposé par `js/commandes/prix-usine.js` dès que
modèle et cotes sont connus, à la main comme depuis le devis. Même calcul que
la calculatrice (`window.acPrixBase`, `js/calculatrice/prix-usine.js`, arrondi
au-dessus), prix au m² de la variation si elle en a un, sinon du modèle. Un prix
tapé à la main ou repris d'une refabrication ne bouge pas (`data-calcule`).
L'autoporté (+600 €) n'existe pas sur une commande : il s'ajoute à la main.
L'édition ne recalcule pas.

### Laquage

- **Un passage = une ligne de `laquages`.** `commandes.date_laq_envoi` /
  `date_laq_recep` restent et portent le **dernier** passage. Passage 1 =
  initial ; 2 et suivants = relaquages, **fautif et raison obligatoires**,
  statut RELAQUAGE. Un relaquage est **la même commande qui repart**, jamais
  une commande fille (les volumes doubleraient).
- Tout passe par `App\Services\Commandes\PassageLaquage` : fiche
  (`envoyerAuLaquage`, `retourDuLaquage`), index par ligne ou sélection
  (`laquageLot`), scan. Permission `commandes.laquage`, pas `commandes.edit`
  (l'usine portugaise n'a que celle-là).
- **L'état suit le déroulé** : le premier départ pose AU LAQUAGE, et lui seul
  (`etats_par_laquage`) — ni la sélection ni l'édition ne le proposent ; le scan
  le propose mais demande le laqueur et passe par `PassageLaquage`. Un
  **renvoi** repasse EN ATTENTE. Le **retour** pose ATTENTE CHARG.
  (`PassageLaquage::recevoir`), sauf commande déjà REÇU ou POSÉ. L'édition
  garde l'état de son formulaire.
- **Un relaquage passe uniquement par la fiche** : `laquageLot` et le scan
  laissent de côté une commande déjà laquée. La fenêtre de la liste reprend
  laqueur et envoi d'une commande encore chez le laqueur (`data-laqueur`,
  `data-envoi`) ; la barre de sélection cache Laquage si la pile contient une
  commande AU LAQUAGE (`data-etat`, `selection.js`).
- ⚠️ **Pas d'état > 4 sans retour du laquage** : refusé si `date_laq_envoi` est
  renseignée et `date_laq_recep` vide (`Commande::laquageBloqueLEtat()`), sur
  les trois écrans qui posent un état — édition (erreur sur le champ),
  sélection et scan (via `App\Services\Commandes\ChangementEtat`). Garde-fous
  inverses : une commande **jamais partie** n'est pas concernée ; l'édition ne
  bloque **que si l'état change** (2 927 commandes posées ont un laquage jamais
  clôturé).
- **Un lot n'est jamais refusé en bloc** : les fautives restent, leurs numéros
  sont dans le message, les autres passent.
- La date de retour saisie à l'édition ferme le passage ouvert ; l'édition
  n'ouvre jamais de passage. ⚠️ 3 070 laquages repris n'ont pas de retour :
  seul un *relaquage* ouvert (passage > 1) compte comme passage en cours.
- **Date de retour jamais pré-remplie** (l'envoi vaut aujourd'hui par défaut).
  **Aucune date d'envoi ou de retour après aujourd'hui**
  (`PassageLaquage::DATE_PASSEE`, `max` sur chaque champ) ; à l'édition, la
  règle ne joue que si la date change, et le champ n'a alors pas de `max`.

### Annulation, conflits, présence, historique

- **Une requête est un geste, et un geste se défait.** Les lignes de `logs`
  d'une requête partagent un `lot` (`App\Services\Historique\Lot`, middleware
  `MarquerLeGeste`). Toast « Annuler » du layout, et historique de la fiche
  commande (`App\Services\Historique\Annulation`). Se défont : modifications de
  commande, laquage, découpe, observation, création d'un passage, d'une
  découpe ou d'une observation, suppression d'une observation. ⚠️ Une ligne
  touchée depuis par un geste qui tient fait refuser le tout. ⚠️ Une écriture
  hors Eloquent (`DB::table`, update de masse) ne laisse pas de log : ni
  annulation, ni témoin, ni bulle.
- **Le formulaire de modification d'une commande n'écrit que ce qu'on y
  change.** Champ caché `origine` (l'état à l'ouverture, découpes comprises) ;
  `CommandeController::fusionnerAvecLOuverture` ne garde que ce qui en
  diffère. Un champ changé des deux côtés renvoie le formulaire sans rien
  écrire, avec « Garder la mienne » / « Reprendre la sienne »
  (`choix[colonne]`, session `conflits`). `laquageBloqueLEtat` et la fermeture
  du passage portent sur ce qui s'écrit. Seule la commande a cette fusion.
- **Présence** (« X modifie cette fiche depuis N min ») sur la modification
  d'une commande (`data-presence`) et toute fenêtre ouverte sur un élément.
  Prévenir, jamais bloquer. `js/presence.js` signale toutes les 30 s
  (`PresenceController`, table `presences`), expiration
  `Presence::EXPIRE_APRES_SECONDES`. `Presence` n'a pas `LogsActivity` (le
  témoin bougerait toutes les 30 s) ; le signal fait `reflash`.
- **Chaque fiche montre son historique** : carte `admin._historique-fiche`
  (zone `historique`), lue par `App\Services\Historique\HistoriqueFiche`,
  l'élément seul ; posée aussi par le gabarit de `make:full-crud`. La commande
  a son propre historique (passages, découpes, observations, annulables). La
  fiche employé a « Modifications de sa fiche » et « Ce qu'il a fait ».
- ⚠️ **Un pivot n'a pas d'évènement Eloquent** : appeler
  `GlobalActivityObserver::liste()` après chaque `sync` à journaliser
  (permissions d'un rôle, rôle et responsables d'un employé ;
  `PlanningController::synchroniser()` pour employés, devis et interventions
  d'un planning). Les rôles des écrans sont ceux de Spatie, observés depuis
  `AppServiceProvider` (`App\Models\Role` ne sert pas).

### Interventions, produits, employés, équipes

- **Une fiche d'intervention se modifie tant que l'intervention est À FAIRE
  ou EN COURS**, signée ou non. Création, modification et suppression
  suivent `FicheIntervention::seSaisitSur` (boutons, et état relu dans Gestan
  côté serveur) ; terminée, elle ne bouge plus. Aucun PDF
  rangé : « PDF » le refait à chaque ouverture. La signature (`signee_le`,
  `signature_document_id`) ne verrouille rien ; une nouvelle la remplace,
  fichier compris. Client, libellés et prix recopiés à la saisie. Le prix d'une pièce du catalogue vient de Gestan
  côté serveur (`FicheFacturation::lignes`) ; seule une ligne hors catalogue
  garde le prix saisi. Permissions `fiche_interventions.create` / `.edit`.
- **Retour de chantier (fiches d'octobre 2026)** : questions de chaque métier
  dans `rapport_chantiers.champs_par_type`, articles « en plus / en moins »
  dans `rapport_chantiers.articles` — une ligne de `rapport_chantier_articles`
  par article renseigné (quantité, sens, à facturer / offert), rien pour une
  ligne vide. Ajouter un article = une entrée de config. Une précision
  (« fait le ») n'est gardée que pour les réponses de son `quand`. La fiche
  ne prend aucun fichier : **les photos vont au chantier**.
- **Photos d'un chantier envoyées une à une** (bloc `plannings/photos/_bloc`
  des deux vues du détail, `PhotoChantierController`,
  `js/plannings/photos-chantier.js`) : chacun de ceux qui sont sur place
  envoie les siennes, une requête par photo, une file pour la page ; une
  ratée reste en rouge et se renvoie d'un geste. Ajouter :
  `rapport_chantiers.create` (vérifiée dans le contrôleur, pas en middleware :
  sa redirection passerait pour une réussite). Pastilles, légende, retrait :
  l'auteur ou un administrateur. Lot `planning-<id>` : une carte par chantier
  dans la photothèque. ⚠️ **Jamais les photos de digiKam** (`source`), même
  rattachées au planning : `Planning::photos()` les écarte et
  `IndexPhoto::indexer` les rend telles quelles — un `photos:indexer` qui les
  prenait a réécrit album, jour et produits de 18 837 photos le 2026-10-28.
  Vidée de la corbeille, une photo de chantier s'efface entière (plus aucune
  fiche ne la tient). Tableau de bord, « Photos des chantiers »
  (`App\Services\Plannings\PhotosDesChantiers`) : chantiers de pose et de
  maçonnerie passés sans photo envoyée par ACPORTAIL, depuis
  `rapport_chantiers.photos_depuis` (⚠️ à avancer au jour de la mise en
  production, sinon tout l'avant compte), 30 jours au plus.
- **Pièces proposées au technicien** : cochées par IDPRODUIT dans `produits_sav`
  (écran Produits SAV, `produits.proposer`), jamais par libellé. Décocher
  retire de la recherche, pas du prix. On coche **en local** : la synchro
  écraserait une case cochée en prod.
- **`users.actif` ≠ `users.deleted_at`.** `deleted_at` : plus salarié, sort des
  listes d'affectation. `actif` : peut se connecter, **fermé par défaut**. Ne
  jamais supprimer un compte pour couper l'accès. On ouvre dans
  `UserSeeder::AUTORISES`. ⚠️ **La synchronisation vers la prod recopie la
  table `users` du local** : une ouverture, une case ou un chemin de photo
  posés seulement en prod sont perdus. Middleware `CompteActif` pour les
  sessions ouvertes.
- **« absence » et « réparation véhicules » ne sont pas des équipes** : des
  colonnes du planning rangées dans `equipes`, trouvées par type
  (`Equipe::TYPES_DU_PLANNING`). La dernière de chaque type ne se supprime ni
  ne change de type (`Equipe::estIndispensableAuPlanning()`). Les congés
  viennent de l'api Évaluations, sans ligne dans `plannings`.
- **Planifiée avec un employé, l'intervention passe EN COURS dans Gestan** ;
  retirée à tous, elle redevient « À FAIRE », sauf rapport saisi
  (`PriseEnCharge::accorderAuPlanning`, après la réponse, depuis
  `storeIntervention`, `updateIntervention`, `destroy`). ⚠️ Gestan injoignable :
  rien ne rejoue, seulement le journal (« Etat Gestan non accorde au
  planning »).

### Notices des fournisseurs

- **ACPORTAIL tient le classement, le dossier n'apporte que du neuf.**
  `notice_dossiers` (fournisseur, parent, nom, `uuid`) : de vrais dossiers,
  vides compris, sans fusion, créés, renommés, glissés et supprimés (vides)
  à l'écran ; `notices.notice_dossier_id` vide = racine du fournisseur.
  `notices:importer` et `NoticesSeeder` (`ImportNotices`) rejouent d'abord
  le classement noté hors base (`ClassementNotices`,
  `storage/app/private/notices/classement.json` : une photo entière,
  dossiers par uuid, fournisseurs par nom, notices par `source`, sources
  retirées), puis rangent par leur chemin les seuls PDF de `NOTICES_RACINE`
  qu'il ne connaît pas (dossiers retrouvés par nom ou créés). Sans photo
  (rien fait à l'écran), tout suit le disque. Le dossier source n'est jamais
  écrit. Droits : `notices.create` / `.edit` / `.delete` (administrateurs,
  accueil).
- ⚠️ La photo se réécrit dans les évènements de `Notice` et `NoticeDossier`,
  pas dans le contrôleur : le toast « Annuler » passe par Eloquent et doit
  se noter aussi. L'import la coupe (`ClassementNotices::pendantLImport`).
  Une écriture hors Eloquent sur ces tables ne serait pas notée.
- Une ligne par PDF retrouvée par `source` (chemin dans le dossier, ou
  `ajout:<uuid>`) ; copie sur le disque public sous
  `notices/<empreinte>.pdf` (deux noms d'un même fichier, une copie). Une
  copie ne s'efface qu'à l'import, quand plus rien ne la tient : retrait et
  remplacement gardent l'ancienne pour « Annuler ». `fournisseur_id` d'une
  notice suit son dossier : glisser un dossier vers un autre fournisseur
  réécrit sous-dossiers et notices (`deplacerDossier`). ⚠️ En prod, pas de
  dossier source : les copies partent par `storage:archiver`.

### Copie locale de Gestan

`client_id`, `devis_id` restent des ids Gestan (`client_id` →
`CONTACT.IDCONTACT`, `devis_id` → `DEVIS.IDFACTURE`), avec une copie en
lecture : `gestan_clients`, `gestan_devis`, `gestan_interventions` (modèles
`Client`, `Devis` ; relations `client()` / `devis()` sur `Commande`, `client()`
sur `Planning`). **Tous les clients** (≈ 22 000, `CopieGestan::tousLesClients`,
la nuit) ; des devis et interventions, seulement ce que les commandes et
plannings référencent. Gestan reste le seul endroit où ils se modifient.

- `App\Services\Gestan\CopieGestan` est le seul à recopier :
  `CopieGestanSeeder`, `gestan:copier` (la nuit ; `--manquants` après un
  import), `CopieGestan::avantReponse()` à l'enregistrement d'une commande ou
  d'un planning pour un id jamais vu. ⚠️ **Avant la réponse**, sinon l'écran
  suivant et le log de création perdent le nom. Api muette ou coupée par le
  Disjoncteur : repli sur `apresReponse()`, puis la nuit. `PriseEnCharge` reste
  sur `apresReponse()` (elle tourne sous verrou).
- **Client de la veille** sur commandes et planning ; les écrans Clients,
  Devis, Factures lisent l'api en direct.
- **Recherche d'un client** (fenêtre de la commande et du chantier) : la copie
  locale d'abord (quelques ms), puis Gestan en fond (`source=gestan`) pour les
  clients créés depuis la nuit, ajoutés en tête. ⚠️ En local, `php -S` ne
  traite qu'une requête à la fois : une recherche tapée pendant l'appel à
  Gestan l'attend. En production, non.
- **Pas de clé étrangère, la copie peut manquer** : toujours prévoir le repli
  sur l'id (« Client 4512 »). `inconnu` : id que Gestan ne connaît pas.
- ⚠️ Une requête Gestan coûte une dizaine de secondes en local (VPN, une
  connexion par requête) — d'où les lots de 1000. Ne jamais conclure à une
  lenteur depuis le local. Coupée dans les tests (`GESTAN_COPIE_LOCALE=false`).
- D'une intervention, seulement libellé, secteur et lieu quand ce n'est pas
  chez le client. **Jamais l'état, le rapport ni la facturation** :
  `InterventionService::findFresh()` avant chaque écriture ; les écrans des
  interventions lisent l'api.

### Lieu d'un chantier

- **L'adresse du chantier se saisit à la commande, une par devis**
  (`adresses_chantier`, modèle `AdresseChantier`) : toutes les commandes et
  tous les plannings du devis la lisent. Le planning n'a plus d'adresse à lui.
  Absente, le chantier suit l'adresse du devis ; remplie, elle passe devant
  **entière** (on ne complète jamais par la ville du devis). **Sans devis**
  (≈ 1 % des commandes), elle se range sur la commande (`commande_id`) et ne
  vaut que pour elle : un planning sans devis n'a pas de lien vers une
  commande, il garde l'adresse du client. `AdresseChantier::pourCommande`
  choisit ; `NettoyageCommande` retire celle d'une commande supprimée.
- `PlanningService::lieu` est le seul à trancher (`source` : `chantier`,
  `devis`, `client` ou rien). Carte, détail (deux vues), accordéons des
  fiches client, employé, équipe et export `/api/plannings` lisent `lieu`.
  **Ne jamais relire `devis[0]` pour une adresse.** Une intervention : adresse
  du chantier, puis `gestan_inter_adresse`, puis devis et client
  (`villeDeCarte`, export).
- Bloc `commandes/_adresse_chantier` + `js/commandes/adresse-chantier.js`,
  caché sans devis ni client (`commande:devis`, `commande:client`), rempli de l'adresse déjà saisie pour ce devis
  (`/ajax/devis` rend `adresse_chantier_saisie`). ⚠️ Le serveur n'écrit que si
  `adresse_chantier_touchee` vaut 1 : une commande partie avant la fin du
  chargement du devis ne vide pas l'adresse d'une autre. Propositions, jamais
  écrites d'office : référence du devis (`ReferenceChantier`, qui découpe
  mal), adresses du client (`ClientService::adresses()`), dédoublées par
  `App\Services\Gestan\AdressePostale` (même règle en JS).
- Reprise : `PlanningSeeder` range l'adresse du plus ancien rendez-vous sur
  son devis, `ElaguerAdressesChantierSeeder` (après `CopieGestanSeeder`) retire
  celles qui redisent le devis.
- Formulaire du planning : `chantiers/_form`, partagé par création et
  modification. ⚠️ Un employé coché n'est jamais replié (`employes-repliables`).
- **Nature des travaux** : même règle que l'adresse. `plannings.nature` vide,
  le planning dit la nature de son premier devis ; remplie, elle passe
  devant. `PlanningService::travaux` (`libelle`, `source`, `devis`) est le
  seul à trancher : carte de la grille, détail (deux vues,
  `_nature_chantier`), accordéons des fiches (`_nature_ligne`). Formulaire :
  `chantiers/_nature` + `js/plannings/nature-chantier.js`, qui propose les
  lignes du devis déposées par `js/api/devis.js` (`data-nature`,
  `data-lignes`) sans rappeler Gestan.
- **Planifier des interventions** (`js/interventions/file.js`) : un bloc
  `.intervention-block` par intervention cliquée, un seul envoi. ⚠️ Les scripts
  travaillent **par bloc** ; formulaire `novalidate`, `file.js` vérifie.

### Contrat public du catalogue

⚠️ `categories` / `sous_categories` (ex-`styles` / `sous_styles`, comme
`modeles.categorie_id` / `sous_categorie_id`) : les clés émises par
`app/Services/Wordpress/ModeleService.php` sont un **contrat public**, lu par
trois projets déployés séparément — `C:\wamp64\www\modeles-phpdur\api.php`
(`modeles.acportail.fr`), les plugins WordPress `formulaire-devis` (v4, 5, 6),
`php-catalogue` et `php-catalogue-v2`. Désalignés, le catalogue en ligne
s'affiche vide. (`michel-app` est une autre application.)

- Le type « cloture aluminium » reste `cloture` sur le site
  (`ModeleService::NOMS_PUBLICS`), à l'aller comme au retour : renommer un type
  casse le catalogue sans cette entrée. `IdTypeSeeder` garde « clôture » de
  l'ancien logiciel, et les décisions de `rattachements-modeles.json` sont
  clées par le nom du type.
- Laravel singularise `categories` en `category` : `Route::resources` nommerait
  `{category}` là où les méthodes attendent `$categorie` — d'où les
  `->parameters()` de `routes/web.php`.
- L'en-tête de `database/seeders/modeles/csv/produits.csv` suit le nom des
  colonnes (`NewModeleSeeder` mappe par en-tête).
- **Promotions du site vitrine** : une fiche `Promotion` (du, au inclus,
  taux) et ses pivots `modele_promotion` / `promotion_variation`, jamais une
  colonne sur `modeles`. L'api rend `promo` et `promo_taux` du jour
  (`Promotion::enCours`), plus `promotions` (nom, taux, du, au) au niveau du
  type pour le bandeau du catalogue v3, recalculés à la lecture : rien ne
  tourne pour qu'une promo commence ou finisse. Deux promos ne couvrent jamais un même
  portail sur des dates qui se chevauchent (refus nommé,
  `PromotionController`). Modèle et variation se cochent séparément.

### InnoDB et clés étrangères

- InnoDB forcé par `config/database.php` (`DB_ENGINE`), pas par le my.ini (WAMP
  est en MyISAM, sans transactions). La connexion `prod` garde `null`.
- **Clés étrangères en RESTRICT.** Tout nettoyage de pivot ou de ligne liée se
  fait **explicitement dans le code**, avant le parent : on détache, puis on
  supprime. Commande : `App\Services\Commandes\NettoyageCommande` (refuse une
  commande refabriquée).
- Exceptions : `notifications`, `push_subscriptions`, `presences` en cascade
  sur `users` ; `logs.user_id` sans clé ; pas de clé sur les ids Gestan, les
  colonnes polymorphes ni les tables `temp_`.
- Pièges : migrations triées par nom de fichier **globalement** (une clé vers
  une table créée plus tard → `errno 1824`, d'où les dates avancées de
  `types`, `categories`, `sous_categories`, `equipes`, `fournisseurs`) ;
  toujours passer la table à `constrained('commande_decoupe')` ; un seeder
  d'une ligne liée tourne après celui de sa cible ; `PlanningSeeder` écarte
  les employés absents de `users` ; `seed:refresh` coupe les contrôles pour
  `TRUNCATE`.

## Délais prévisionnels : une urgence, pas une promesse

`config/commandes.php` (`delais_previsionnels`) dit à l'usine **dans quel ordre
fabriquer**. L'échéance part de `created_at`, jamais de `date_accueil` —
sauf commande sortie du STANDBY : elle part alors de `lancee_le`, posée par
`Commande::booted` à chaque sortie (`CommandeHelper::depart` / `sqlDepart`).
**En STANDBY, pas d'échéance** et pas attendue (`etats_en_suspens`).

- **42 jours** : le socle.
- **+30 jours** si `cintrage` : un supplément, il s'ajoute.
- **120 jours** si `maconnerie` : il **remplace** le socle. La maçonnerie est
  faite par ACPORTAIL ; le barème long veut dire « ne nous l'envoie pas
  encore ».

⚠️ **Un écart négatif avec maçonnerie n'est pas une bonne performance** : c'est
un portail livré avant le terrassement, qui encombre l'entrepôt. Ne jamais lire
les deux familles avec la même règle.

Le calcul doit rester d'accord entre `CommandeHelper::datePrevisionnelle()`
(une ligne) et `CommandeHelper::sqlDatePrevue()` / `sqlAttendue()` (SQL, pour
`TableauBordService` et le tri des découpes). `CommandeService::sqlPrevue()` /
`sqlAttendue()` appellent `CommandeHelper`.

- **Relaquage en cours** : l'échéance part de son envoi +
  `delais_previsionnels.relaquage` (30 j) et la commande redevient attendue
  (`CommandeHelper::datePrevue`, `sqlDatePrevue($table, $aliasRelaquage)`,
  `sqlRelaquageEnvoi()` en sous-requête). ⚠️ Seuls les compteurs du moment la
  suivent, pas l'écart à l'arrivée.
- ⚠️ **Chiffre du tableau de bord = liste filtrée** : chaque chiffre mène à
  l'index (`?vue=code`, combinable, plus `laqueur`, `fautif`, `modele`,
  `couleur`) ; le compte vient de `TableauBordService`, la liste de
  `CommandeService::VUES` / `sqlVue()`, avec le même SQL.
- « Attendue » = pas de `date_entrepot`, ni fabriquée ni en STANDBY
  (`CommandeHelper::etatsSansEcheance`). ⚠️ Un `etat` NULL compte comme
  attendu : `whereNotIn` l'écarte.
- Les facettes d'échéance de l'index sont **cumulatives**, les parts du donut
  **disjointes**.

## Les gestes ne rechargent pas la page

`public/js/sur-place.js` (layout) envoie les formulaires POST en arrière-plan
et remplace seulement les **zones** (`data-zone="nom"`) par celles de la page
reçue. Ni route ni contrôleur à changer ; sans zone, rien ne change. Le toast
« Annuler » suit le même chemin (JSON : `geste_toast` si l'en-tête
`X-Geste-Toast` est présent).

Ce qu'une vue doit respecter, sinon elle casse après le premier geste :

- **Tout écouteur sur un élément de zone se pose par délégation.**
- Un état gardé dans une zone se note à `sur-place:avant` et se remet à
  `sur-place:maj` ; un état hors zone lit `detail.doc`.
- La fenêtre qui a envoyé se referme, sauf `data-rouvrir` sur la page reçue ou
  `data-rester`. `data-recharger` fait partir le formulaire à l'ancienne.
- ⚠️ Tout envoi sans rechargement appelle `window.acBrouillonEnvoye(form,
  reussi)`, sinon `js/form-draft.js` ressort la saisie précédente.
- ⚠️ `update()` revient d'où l'on vient
  (`RevientALaListe::retourALaFicheOuALaListe`) : `sur-place.js` ne remplace
  les zones que si l'adresse d'arrivée est celle du départ.
- **Messages flottants** en haut à droite, à l'arrivée comme après un geste :
  succès qui s'efface, refus qui reste. Une page sans zone met un bloc
  `data-messages` (⚠️ pas `data-zone` : une seule zone fait partir tous les POST
  de la page en arrière-plan). `GarderLesMessages` remet les messages en
  attente après toute lecture de fond (GET ajax ou JSON).

### Fenêtres de saisie

- Un formulaire léger s'ouvre dans une fenêtre, sur la liste **et** sur la
  fiche (`public/js/modale-saisie.js`). Partiels `_modale` et `_modale_ouvre`
  (attributs posés dans la balise du bouton) ; ⚠️ un champ ajouté s'ajoute
  dans les deux. Plus de vues `create` / `edit` : `create` ramène à la liste,
  `edit` à la fiche avec `?modifier=<id>`. `show()` fournit les mêmes listes
  qu'`index()`.
- **Restent en page** : rôles (118 permissions), commandes, formulaires du
  planning et du terrain. Fournisseurs et profils sont en développement : ne
  pas y toucher.
- **Bouton d'envoi éteint tant que rien n'a changé**
  (`enregistrer-si-modifie.js`, relevé à `shown.bs.modal` ; `data-onglet`
  n'est pas une saisie ; exception `data-sans-modification`). ⚠️ Classe
  `disabled`, jamais la propriété (`reduire-image.js` touche les mêmes
  boutons).
- ⚠️ Un refus qui doit laisser la fenêtre ouverte passe par `withErrors`, pas
  `with('error')`.
- Listes de cases (`data-champ-liste="roles"`, `data-roles="1,3"` sur le
  bouton) : ⚠️ l'enregistrement retire le décoché — une case mal pré-cochée
  vide la ligne.
- Fenêtre employé : `UserController::fenetreDeSaisie` (responsable `group:1` /
  `user:12`, `data-pas-pour="<id>"`).
- **Nouvel écran de gestion : `php artisan make:full-crud Nom`** (gabarits
  `resources/stubs/crud/default`). ⚠️ Nom de modèle anglais pour une table
  nouvelle au pluriel irrégulier (`ResolvesModelNames`) ; lit le schéma MySQL.

### Petits composants

- Booléen d'une liste = interrupteur (`admin/_interrupteur`,
  `interrupteurs.js`, `BasculeUnBooleen`) ; ⚠️ Vitrine publie, son retrait
  confirme (`confirmerRetrait`).
- Image choisie : `data-apercu` (`apercu-image.js`, `data-apercu-actuel`).
- **Toute image affichée s'ouvre en grand** : un lien `a[data-visionneuse="groupe"]`
  vers le fichier entier (`js/visionneuse.js`, chargé par le layout), `data-legende`,
  `data-fond="clair"` pour un PNG détouré ou une signature,
  `data-visionneuse-bloc` pour borner le défilement. Pas d'autre fenêtre d'image.
  Hors règle : logos, avatar du menu, cartes de l'organigramme (lien vers la
  fiche).
- Ajout sur place depuis une liste (`ajout-sur-place.js`, `_ajout-ligne` dans
  une fenêtre) ; ⚠️ deux formulaires ne s'imbriquent pas.
- Longue liste : `data-cherchable` (`liste-cherchable.js`).

### Images

Tout champ image dit sous lui s'il est réduit, et à combien.

- **Photo d'identité** (`App\Services\Employes\PhotoIdentite`) : rangée
  `PHOTOS_IDENTITE_EMPLOYES_ACPORTAIL/NOM PRENOM.jpg`, nom qui la rattache à
  la reconstruction (`ComblerPhotoUsersSeeder`). Réduite par le navigateur
  (`reduire-image.js`, `data-reduire="800"`), puis par le serveur, qui fait
  foi : `PhotoHelper::reduire` (redressée selon l'EXIF, JPEG) et `miniature`.
  ⚠️ Fichier hors dépôt : la synchro vers la prod recopie le chemin, pas le
  fichier.
- **Photos de terrain** : `App\Services\Documents\PieceJointe` (1600 points,
  JPEG, miniature, original non gardé). **Toujours `PieceJointe::ranger` /
  `supprimer`**, jamais `store()` / `delete()`. Vignette :
  `$document->vignetteUrl()`. `data-reduire="1600"` sur le champ, sauf
  `photos-chantier.js` (`data-cote` + `window.acReduireImages`, photo par
  photo).
- **Ce que montre une photo de chantier** : pastilles sous chaque photo
  (`App\Services\Photos\PastillesChantier`, route
  `plannings.photos.pastilles` lue en fond) — commandes du chantier, puis
  lignes de ses devis (api, `CD_PRODUIT`), la ligne qui redit une commande en
  moins, prestations à part (`rapport_chantiers.prestations`). Rien de
  touché : toutes les commandes. Touché : `photos.produits_choisis`, la photo
  ne tient que ça, et la ré-indexation le garde. Chaque pastille touchée
  s'enregistre aussitôt, avec toutes celles cochées (`pastilles=1`) ; une
  ligne choisie que Gestan muet ne rend plus reste affichée, cochée, pour ne
  pas s'effacer. Photothèque : filtre `article[]`.
- **Client de digiKam resté en texte** (`photos.client_nom`, pas de
  `client_id`) : `photos:importer-digikam --clients` refait le rapprochement
  de l'import ; le panneau de la photo le rattache à la main, toutes les
  photos du même nom avec (`photos.edit`). Rattaché à la main = `client_id`
  **et** `client_nom` (le nom reste). ⚠️ `Photo` n'a pas de journal : le
  toast « Annuler » ne le défait pas, c'est « Ce n'est pas ce client ».
  Chaque rattachement est aussi noté **hors base**
  (`RattachementsClients`, `storage/app/private/phototheque/`) et rejoué
  au seed. Bouton « Clients à rattacher » : filtre `a_rattacher`, le plus récent d'abord.
- **Produit de digiKam resté en libellé** (`photo_produits.modele` sans
  `modele_id`, ni commande ni devis) : écran « Produits à rattacher »
  (`photos.produits`, `photos.edit`), une décision par **type + libellé**,
  toutes les lignes suivent (`RattachementsModeles`) : modèle ou variation
  (le libellé reste, le type devient celui du modèle), étiquette ou « le type
  suffit » (le libellé quitte la ligne). Notée hors base par **nom**
  (`rattachements-modeles.json`), rejouée par `PhotothequeSeeder` ; `lignes`
  et `photos` y sont les ids de **cette** base, pour « Défaire ». Une catégorie
  aussi (« sectionnelle » → Portes sectionnelles, `photo_produits.categorie_id`,
  cible `c:<id>`) : le filtre Produit la montre sous son type (`c:<type>:<catégorie>`)
  et y ajoute les modèles rangés dedans ; seules les catégories choisies y sont.
- **Étiquettes de la photothèque** : fenêtre « Gérer » des filtres
  (`admin/photos/_etiquettes`, `GestionEtiquettes`, `photos.edit`) — ajouter,
  renommer, supprimer. Un nom pris réunit les deux, seulement avec `fusionner`
  (demandé par `js/phototheque-etiquettes.js`). Supprimer garde les photos.
  ⚠️ L'import recrée les noms de digiKam : chaque geste est noté hors base, dans
  l'ordre et par nom (`etiquettes.json`), rejoué après `PhotothequeSeeder` et
  `photos:importer-digikam`. Renommer met aussi à jour les décisions de
  `RattachementsModeles`. Pas de journal sur `Etiquette` : pas de toast
  « Annuler ».
- **PERSO dans un nom de digiKam = étiquette « Personnalisé »**
  (`ImportDigikam::PERSONNALISE`, `estPerso`) : le mot quitte le libellé
  (B-98-PERSO → b-98, B-PERSO → le type suffit) et le modèle reste. C'est ce
  qui se croise avec le type dans les filtres (Battant + Personnalisé).
- **AUTOPORTE / ANTAGONISTE en fin de nom = une seconde ligne, le modèle
  du système** (`ImportDigikam::SYSTEMES`, `systemeDe`, catégorie Systèmes
  spéciaux) : le mot quitte le libellé, le modèle reste (C-200 + autoporté).
  Sans code, la ligne du système seule. Pas une étiquette : la page
  Autoporté du site est un modèle, « Site vitrine » ne propose que les
  modèles de la photo.
- **Ville d'une photo : une seule orthographe** (`VillePhoto`, posée par le
  modèle `Photo`) — majuscules sans accent ni tiret, `ST` / `STE`, sans
  CEDEX. Les fautes de Gestan et de digiKam se corrigent dans
  `phototheque.villes`, puis `photos:villes` pour les photos déjà en base ;
  la source garde la sienne. ⚠️ Une mise à jour en masse ne passe pas par
  le modèle : appeler `VillePhoto::corriger` (`ImportDigikam::lieu`).
- **Vidéos de digiKam** (`photos.video`) : reprises par l'import, fichier
  tel quel (≈ 865 Mo), miniature tirée par ffmpeg (`phototheque.ffmpeg`,
  sur le poste du seed). **En lecture seule** : aucun écran n'en accepte,
  ni retouche, ni site vitrine, ni jumelles. Lues dans le panneau et
  dans la visionneuse (`data-video` sur le lien). Une vidéo n'est pas une
  photo à l'écran : comptes par `Phototheque::compte` / `compteDe`. ⚠️ Pour les reprendre sur une base vivante :
  `photos:importer-digikam --videos` — un import complet ferait revenir
  les photos effacées depuis (corbeille, jumelles).
- **Catalogue** : `ImageCatalogue`, plafonné à 2000 points **dans son
  format** (un PNG détouré reste un PNG).
- **Photos du site vitrine = photos publiées de la photothèque**
  (`photos.publiee_le`, pivots `modele_photo` / `photo_variation`,
  `App\Services\Photos\PublicationsSite`). On publie depuis le panneau
  d'une photo (« Site vitrine », permission `photos.publier`), sous des
  modèles et variations **choisis à la main** : ceux que la photo porte
  sont proposés, jamais posés. C'est la photo **affichée** qui part (la
  retouche s'il y en a une) ; à la corbeille, elle quitte le site. L'api
  rend `photos` sur modèles et variations, mêmes clés qu'avant ; miniature
  de 600 points dans `_site/` à côté de la photo, faite en publiant.
  - Le dossier `photos chantiers/<famille>/<type>/<modèle>/<variation>/`
    entre dans la photothèque au seed (`PhotosSiteSeeder`, après
    `PhotothequeSeeder`), publié sous ce que nomme le **nom du dossier**,
    `source` = `site:<empreinte>`. Réduit dans `photos-chantier/` : ⚠️ c'est
    **ce dossier** (avec `_site/` et `_thumbs/`) qui part en prod, pas
    l'original (1,1 Go). Toute photo publiée doit avoir son fichier sur le
    serveur de prod, sinon l'api l'écarte. Les fichiers partent en prod par
    `php artisan storage:archiver` (`--depuis=` pour une mise à jour), qui
    écarte l'original : pas à pas dans DEPLOYMENT.md § 4.6.
  - Chaque publication d'une photo reprise (digiKam, dossier) est **notée
    hors base** (`storage/app/private/phototheque/publications-site.json`,
    par `source` et nom des modèles, `false` = retirée) et rejouée par
    `PhotosSiteSeeder`. Une photo envoyée d'un chantier n'a pas de source :
    sa publication ne survit pas à un `migrate:fresh`.
  - **Jumelles de digiKam** (`App\Services\Photos\DoublonsSite`,
    `photos:doublons-site --simulation`) : la même prise, envoyée par
    WhatsApp avant d'être retouchée pour le site. Reconnue à l'oeil (dHash
    de la miniature, ≤ 7 bits d'écart, mesuré : 9 et plus = autre prise) ;
    la photo du site prend client, chantier (`lot`), jour, étiquettes,
    produits, et la jumelle est effacée. Fusions notées hors base
    (`doublons-site.json`), rejouées par `PhotosSiteSeeder` avant les
    publications. À relancer après un ajout au dossier du site.
  - ⚠️ Lancer `PhotosSiteSeeder` par `db:seed --class`, pas `seed:refresh`
    (qui vide les tables).

### Ce qu'un autre poste change apparaît tout seul

Liste et fiche des commandes, liste des découpes. Pas de temps réel (Reverb ne
tourne pas sur un mutualisé OVH, Pusher est un tiers) : `sur-place.js` repose
les zones en fond.

- **Témoin** (`App\Services\Historique\Temoin`) : dernier numéro de `logs`,
  retenu **avant** de lire les données (`$temoin->retenir()` en tête d'`index`
  et `show`), interrogé toutes les 20 s (route `actualite`). Une page s'y met
  en appelant `retenir()` et en posant `data-ligne` sur ses lignes.
- **Rien ne bouge pendant un geste** (fenêtre, menu, envoi, saisie,
  sélection). ⚠️ Un champ rempli par script à l'ouverture d'une fenêtre revient
  à sa valeur à la fermeture (`hidden.bs.modal` → `form.reset()`), sinon la
  page ne se relit plus. Une relecture ne repose pas les messages
  (`detail.fond = true`).
- Surlignage `ac-maj`, comparé sur le HTML du serveur. ⚠️ Un script qui
  modifie les lignes avant `sur-place.js` voit ses ajouts retirés dans
  `brut()`.
- **Bulle « qui a changé quoi »** (`ChangementsRecents`, route
  `actualite.changements`, `window.acChangements`), demandée par
  `retenir(['portee' => 'commandes'])`, plus `'commande' => id` sur une fiche.

### Le planning ne passe pas par les zones

Ses scripts (`public/js/plannings/`) se branchent carte par carte au
chargement : une grille remplacée ne les connaîtrait plus.

- Supprimer retire la seule carte (`suppression.js`, JSON de
  `PlanningController::destroy`).
- Changer de période est une vraie navigation (`navigation.js`, liens
  `data-nav`, fondu fait main `admin/plannings/_fondu`).
- Un autre poste qui change la grille la recharge en fondu, hors geste en
  cours : empreinte `App\Services\Plannings\TemoinPlanning` (route
  `actualite.planning`, période de `PlanningService::bornes()`). ⚠️ Pas le
  témoin des commandes (il suit tout `logs` et ne voit pas les `sync()`). La
  bulle part de `data-logs`.
- Le détail d'un planning existe en deux vues jumelles (page et fenêtre) :
  toujours toucher les deux.

### Découpes

Ajouter une découpe ou nommer une ligne sans nom
(`CommandeController::ajouterDecoupes`, `definirDecoupe`) répond en JSON avec le
bloc lu par `decoupesServies()`. ⚠️ Ce tableau copie la forme de
`CommandeService::decoupes()` : une clé ajoutée là-bas s'ajoute ici.
Permission `commandes.decoupes`, pas `commandes.edit`.

**Retirer une ligne** (`retirerDecoupe`, poubelle de `_decoupe-gestes` et de
l'écran des découpes, avec confirmation nommée) : seulement une ligne vierge,
la règle de l'édition (`CommandeDecoupe::suiviCommence`), relue sous verrou. Par Eloquent : le toast la remet avec son numéro (`Annulation`,
`deleted` de `CommandeDecoupe`). La dernière partie décoche `decoupe`.

**Suivi et pièces jointes d'une ligne** : une seule fenêtre de chaque par
page, partagée par l'écran des découpes, le détail sous une commande de la
liste et l'onglet de la fiche (`commande_decoupes/_modale-suivi`,
`_modale-joindre`, boutons `_decoupe-gestes`, `js/commandes/decoupe-suivi.js`
par délégation : sur la fiche, elles sont dans la zone). Retirer un fichier
reste sur l'écran des découpes. `CommandeDecoupeController::retour` n'accepte
que ces pages.

**Note sur une découpe** (`noterDecoupe`, `commande_decoupe_observation`,
type `Observation::TYPE_DECOUPE`) : liste, fiche et fenêtre du suivi des
découpes, avec `commandes.decoupes` **ou** `commande_decoupes.edit`. Ajout
seul à ces endroits, signé (`_decoupe-notes`).

### Observations d'une commande

- **Un seul fil, sur la fiche** (onglet Observations,
  `_observations-fil`, `App\Services\Commandes\NotesCommande`) : notes de la
  commande et de ses découpes, ajout en tête (commande et dates avec
  `commandes.edit`, découpe avec le droit des découpes). **Le formulaire de
  modification n'en porte plus** ; la création garde ses boutons.
- **L'auteur seul** corrige ou supprime une note signée
  (`CommandeObservationController`, `NotesCommande::estAuteur`) ; une note
  reprise sans auteur ne se touche pas.
- Tout se défait par le toast. ⚠️ Une observation créée dans le même geste
  qu'une commande ne se défait pas seule. ⚠️ Le pivot ne laisse pas de log :
  `NotesCommande` écrit le rattachement (`commande_id` ou
  `commande_decoupe_id`) dans la ligne `created` / `deleted` du journal ; c'est
  ce qui permet de recréer une note supprimée au même endroit, et à
  l'historique de la fiche de la retrouver. Une note saisie à la main passe
  par `NotesCommande` ; seules les notes posées par un geste (incomplet, plan,
  création) gardent `CommandeController::ajouterObservation`.

## Migrations et seeders

Le projet **n'est pas déployé** : il se reconstruit par
`php artisan migrate:fresh --seed`.

- **Pas de migration additive** : on édite la migration qui crée la colonne.
- L'ordre de `CommandesDatabaseSeeder` compte (chaque seeder prépare une
  colonne de `temp_commandes` ; les commentaires disent les dépendances).
  `php artisan seed:refresh <Seeder>` rejoue un seeder isolé.
- **La photothèque se reconstruit au seed** (`PhotothequeSeeder`, après
  `CopieGestanSeeder`) : import digiKam (`DIGIKAM_BASE`, `DIGIKAM_RACINE`
  du `.env` ; absents, il passe), rattrapage des clients, rattachements
  notés. Avant l'import, la copie du poste est remise à jour depuis
  digiKam sur le réseau (`DIGIKAM_SOURCE_BASE`, `DIGIKAM_SOURCE_RACINE`,
  `App\Services\Photos\CopieDigikam`, seule : `photos:copier-digikam`) :
  la base par `VACUUM INTO` (la précédente en `.precedente`), puis les
  photos qu'elle connaît et qui manquent au poste — rien d'effacé ni de
  remplacé. Réseau coupé : il prévient et importe la copie telle quelle.
  ⚠️ La base de digiKam a un index abîmé : `REINDEX` sur la copie, jamais
  sur l'originale. Les fichiers déjà réduits sont réutilisés, rien n'est réencodé ;
  ≈ 8 min pour 29 000 photos. ⚠️ Étiquettes posées, retouches, corbeille et
  « traitée » faits dans ACPORTAIL ne vivent qu'en base : perdus au
  `migrate:fresh`, gardés par `db:fresh-import`.
- **Un seed ne journalise pas tout** (`GlobalActivityObserver::SANS_LOG_AU_SEED`,
  reconnu à la commande lancée) : référentiels du catalogue, observations,
  chantiers, découpes d'une commande, employés, équipes — l'ancien logiciel
  n'en connaît pas l'auteur.
- Commandes et refabrications : un log « créé » seulement si
  `FABRICATION.Createur` le nomme. ⚠️ Ne pas remettre `TempCommandeUserSeeder`
  ni aucun auteur « par défaut » : une note reprise n'a pas d'auteur
  (`observations.user_id` nul).
- Une ligne « créé » écrite pendant un seed prend le `created_at` de l'élément :
  un seeder qui veut une date juste le pose (`CommandeSeeder` : `D_Commande`).
- **Schéma `draw_acportail.drawio` à jour à chaque table ou colonne touchée** :
  `composer schema:drawio` (`outils/drawio/drawio.py`, lit la base locale après
  migration). Il remplit les tables, pose les nouvelles, retire les disparues et
  retrace tous les liens sans qu'ils se superposent. Une référence sans clé
  étrangère (id Gestan, polymorphe) se déclare dans `RELATIONS_SANS_CLE`.
  Déplacer une table : `--placer table=x,y`, pas à la main (ses liens ne
  suivraient pas). Vérifier l'aperçu (`--png`).

## Page Aide : à tenir à jour

⚠️ **Tout écran ajouté, tout geste qui change ou toute règle visible par
l'utilisateur se reporte dans l'aide, dans le même commit.** Thèmes et sujets
dans `config/aide.php` (`peut` = permissions qui montrent le sujet, une
suffit ; `ajoute_le` = date d'ajout ou de refonte), texte dans
`admin/support/sujets/<clé>.blade.php`, phrases courtes sous `__()`, avec
`@can` pour le détail réservé. Un sujet que l'usine voit se traduit dans
`lang/pt.json`. Changer `ajoute_le` allume « Nouveau » et la pastille du menu
pour qui l'a lue avant : seulement quand le sujet dit quelque chose de neuf.

- Ex-page Support : route `supports.index` (`/aide`, `/support` y mène),
  permission `supports.view` (lecture commune), `SupportController` gardés.
- **Première connexion → l'aide** (`AuthenticatedSessionController::store`,
  `users.aide_vue_le` vide). La visite se note hors Eloquent
  (`Aide::marquerVue`, sans log ni toast). La synchro vers la prod garde la
  valeur de la prod (`SyncProdDatabaseSeeder::COLONNES_DE_LA_PROD`). La
  fabrique pose `aide_vue_le` (état `jamaisVuLAide()`).
- N'y écrire que ce qui est vérifié dans le code (libellés des boutons
  compris) : une aide fausse est pire que pas d'aide.

## Langues

Interface en français et en portugais (`lang/fr.json`, `lang/pt.json`) — l'usine
est au Portugal. Toute chaîne affichée passe par `__()` ; les libellés venus de
la base servent de clé telle quelle.

## Tests

```bash
composer test
```

**JavaScript dans un vrai Chrome** (Laravel Dusk) : `composer test:navigateur`,
à part, avec MySQL. `tests/Browser/DeuxPostesTest.php` ouvre deux navigateurs
sous deux comptes.

- ⚠️ **Jamais `php artisan dusk`** : il passe les valeurs du `.env` en vraies
  variables d'environnement, qui l'emportent sur `phpunit.dusk.xml` — il a déjà
  recréé puis vidé la base de travail `acportail`. `composer test:navigateur`
  appelle phpunit directement.
- ⚠️ `tests/DuskTestCase.php` arrête tout avant la moindre écriture si la base
  n'est pas `acportail_dusk` sur le port **8001** — **ne pas retirer ce
  garde-fou**. Il lance son propre serveur sur 8001 ; celui du port 8000 n'est
  pas touché. Base vidée avant chaque test (`DatabaseTruncation`) ; le compte
  `laravel` doit y avoir tous les droits.
- ⚠️ **Pas de `.env.dusk.local`** : le `.env` serait échangé et le serveur du
  port 8000 lirait la base de test. L'environnement est dans
  `phpunit.dusk.xml`.
- ⚠️ **`APP_ENV` y reste `local`** : toute autre valeur viserait l'api,
  l'adresse et la messagerie de production.
- Intervalles à une seconde (`ACTUALISATION_MS`, `PRESENCE_MS`). ChromeDriver
  suit Chrome : `php artisan dusk:chrome-driver --detect`. Captures d'échec
  dans `tests/Browser/screenshots`.
