# Décisions techniques

Ce document répond aux questions ouvertes du cahier des charges (§10) et
justifie les choix retenus dans la V1.

---

## 1. Quelle API de géocodage et d'itinéraire ?

**Retenu : API Adresse (BAN) pour le géocodage, OSRM pour les temps de trajet,
optimisation calculée par l'application.**

### Géocodage — API Adresse (adresse.data.gouv.fr)

| | API Adresse | Google Geocoding | Mapbox |
|---|---|---|---|
| Coût | gratuit | ~4 €/1000 requêtes | ~0,45 €/1000 |
| Clé / contrat | aucune | compte + carte bancaire | compte |
| Couverture | France uniquement | mondiale | mondiale |
| Hébergement | France (DINUM) | États-Unis | États-Unis |

La collecte se fait sur le territoire du Grand Calais : la couverture mondiale
n'apporte rien. La BAN est la base de référence des adresses françaises, elle
est plus précise que les concurrents sur les voies rurales et les lieux-dits du
Pas-de-Calais. Surtout, elle évite d'envoyer les adresses des demandeurs à un
sous-traitant hors UE, ce qui allège nettement le dossier RGPD.

Un plafond d'usage existe (50 requêtes/seconde/IP) ; une tournée de 40 arrêts en
consomme 40 au moment de la validation. On en est très loin.

### Itinéraire — OSRM auto-hébergé

Le besoin se décompose en deux :

1. **Une matrice des temps de trajet** entre les arrêts → OSRM (`/table`).
2. **L'ordre de passage optimal** → calculé dans l'application.

Les API d'optimisation payantes (Google Route Optimization, Mapbox Optimization)
factureraient chaque recalcul d'une tournée. Or l'ordonnancement d'une vingtaine
de points est un problème que l'on résout très bien localement : plus proche
voisin suivi d'un 2-opt donne un trajet à quelques pourcents de l'optimum en
quelques millisecondes. Le code tient dans `app/Services/RouteOptimizer.php`.

OSRM tourne en conteneur sur le VPS (voir
[deploiement.md §6](deploiement.md#6-itinéraire--passer-osrm-en-auto-hébergé)) :
coût marginal nul, aucune dépendance à un tiers, et les adresses ne sortent pas
du serveur. **Tant que l'instance auto-hébergée n'est pas en place, la démo
publique OSRM est utilisée — elle ne convient pas à l'exploitation.**

Si OSRM est injoignable, l'application ordonne les arrêts à vol d'oiseau plutôt
que d'échouer : la tournée reste exploitable, seule la finesse de l'ordre baisse.

### Ce que cela coûte

Géocodage et itinéraire : **0 €/mois**, hors ressources du VPS.

---

## 2. Le VPS actuel suffit-il ?

**Oui, à une réserve près : l'extraction passe nécessairement par une API
externe.**

### Ce que le VPS héberge

Application PHP, MySQL, file d'attente, et OSRM. Pour la charge décrite
(quelques tournées par jour, une poignée d'utilisateurs simultanés), la
consommation est dominée par OSRM :

| Brique | RAM | Remarque |
|---|---|---|
| PHP-FPM + MySQL | ~500 Mo | charge très faible |
| Worker de file d'attente | ~80 Mo | un seul process suffit |
| OSRM (Nord-Pas-de-Calais) | ~1 Go à l'exécution, ~2 Go à la préparation | préparation une fois, à refaire à chaque mise à jour des cartes |

**Un VPS de 4 Go de RAM est confortable ; 2 Go passent si OSRM est préparé sur
une autre machine** et que seuls les fichiers `.osrm` sont copiés sur le VPS.

### La brique qui ne peut pas être interne

L'extraction depuis une photo demande un modèle de vision. Aucun modèle
auto-hébergeable ne tiendrait sur ce VPS, et un OCR classique (Tesseract) ne
répond pas au besoin : les feuilles sont manuscrites, photographiées de biais et
inégalement éclairées, et le gabarit peut changer. C'est précisément le cas
d'usage où un OCR à règles fixes décroche.

L'appel à l'API Claude est donc fait depuis le VPS, en tâche de fond. Une seule
image sort du serveur, par requête sortante HTTPS ; aucun port entrant à ouvrir.

### Coût de l'extraction

Ordre de grandeur, avec `claude-opus-5` (5 $/M jetons en entrée, 25 $ en sortie)
pour une feuille photographiée réduite à 1600 px et une trentaine d'arrêts :

- image ≈ 2 500 jetons en entrée
- JSON produit ≈ 2 500 jetons en sortie, plus le raisonnement

Soit de l'ordre de **0,10 à 0,15 € par feuille de route**. À 5 feuilles par jour
ouvré, cela représente **15 à 20 € par mois**. Ces chiffres sont des estimations
à confirmer par mesure sur de vraies feuilles : la consommation réelle dépend du
nombre de lignes et de la lisibilité du document.

Si le volume augmente fortement, `claude-sonnet-5` (2 $/25 $) divise la facture
par deux environ. À évaluer sur des feuilles réelles avant de basculer : sur un
document manuscrit dégradé, la qualité d'extraction prime sur le coût — une
ligne mal relue coûte un déplacement de camion.

---

## 3. Le gabarit « Grand Calais » est-il le seul à gérer ?

**La V1 est réglée pour ce gabarit, mais l'architecture ne le suppose pas.**

Le choix d'un modèle de vision plutôt que d'un OCR à zones fixes est justement
ce qui rend la question peu risquée. Nulle part le code ne dépend de la position
d'une colonne dans la page : les champs sont décrits en français dans le prompt
système (`app/Services/RoadmapExtractor.php`), et le modèle les retrouve où
qu'ils soient.

Concrètement, si un second organisme arrive avec sa propre feuille :

- **Colonnes identiques, mise en page différente** → rien à faire, cela marche
  déjà.
- **Une ou deux colonnes en plus** (n° de tournée, type de véhicule…) → ajouter
  le champ au schéma JSON, à la migration et à l'écran de vérification. Une
  demi-journée environ.
- **Structure radicalement différente** → prévoir un gabarit par organisme :
  le champ `organisation` existe déjà sur les tournées, il suffirait de faire
  varier le prompt selon sa valeur. C'est le seul cas qui demande un vrai
  développement, et il est hors périmètre V1 conformément au cahier des charges.

**Point à trancher avec le client** : l'écran d'import fixe aujourd'hui
l'organisme à « Grand Calais ». Si d'autres organismes sont attendus à court
terme, mieux vaut créer une petite table `organismes` dès maintenant plutôt que
de migrer des données existantes plus tard.

---

## 4. Combien de chauffeurs et de tournées simultanées ?

**L'application encaisse sans modification une échelle très supérieure au besoin
décrit.** Les limites, dans l'ordre où elles arriveraient :

| Seuil | Ce qui coince | Correctif |
|---|---|---|
| ~50 managers connectés en continu | Le sondage du tableau de bord (1 requête / 15 s / manager) charge PHP-FPM | Passer à des WebSockets (Laravel Reverb) ou espacer le sondage |
| ~30 imports simultanés | Un seul worker traite les extractions en série (~40 s pièce) | Lancer 2 à 3 workers, ou une file dédiée |
| ~80 arrêts dans une tournée | Le 2-opt reste rapide, mais la matrice OSRM grossit au carré | Découper la tournée, ou passer à une heuristique par grappes |
| Plusieurs milliers de tournées | Les listes s'alourdissent | Index déjà posés sur `date`, `status`, `driver_id` ; ajouter de la pagination serveur si besoin |

Pour l'usage visé — **quelques chauffeurs, une tournée chacun par jour, 20 à 40
arrêts** — on est à deux ordres de grandeur en dessous du premier seuil. Le
nombre de chauffeurs n'est limité par rien : ils consultent chacun leur propre
tournée, et le pointage est une écriture ponctuelle.

**Ce qu'il reste à confirmer avec le client** : le nombre de tournées par jour à
terme. C'est ce chiffre, et non le nombre de chauffeurs, qui dimensionne le coût
d'extraction et le nombre de workers.

---

## Autres choix, plus courts

### Sondage plutôt que WebSocket

Le cahier des charges laisse le choix. Un WebSocket impose un service
supplémentaire à exploiter et à superviser sur le VPS. Un sondage partiel
Inertia toutes les 15 secondes rafraîchit uniquement les données du suivi, sans
recharger la page ni perdre le défilement. Pour une poignée de managers, la
charge est négligeable. Le passage à Laravel Reverb reste ouvert si le besoin
d'instantanéité devient réel.

### Pointage hors-ligne par file locale

Le mode hors-ligne complet est hors périmètre V1. Ce qui est implémenté est le
point critique du terrain : un pointage n'est **jamais** perdu. Il est écrit dans
`localStorage` avant tout appel réseau, l'écran se met à jour immédiatement, et
la file est rejouée à la reprise du réseau (`POST /arrets/synchronisation`).
L'heure enregistrée est celle du geste du chauffeur, pas celle de l'envoi —
sinon un arrêt pointé à 9 h en zone blanche remonterait à 11 h.

Le service worker, lui, ne met pas les pages HTML en cache : elles sont
nominatives, et un cache partagé exposerait la tournée d'un chauffeur au compte
suivant sur le même appareil.

### Import CSV : deux sources, un seul écran de relecture

Une feuille de route peut arriver en photo, en PDF, ou en CSV quand la
collectivité fournit un export. Le CSV ne passe pas par le modèle de vision :
il est déjà tabulaire, l'appel coûterait de l'argent et ajouterait une latence
pour reconstruire une information qu'on possède déjà. Il est donc lu dans la
requête, en quelques millisecondes.

Les deux sources aboutissent au **même écran de vérification** et à la même
tournée en brouillon : le manager relit dans les deux cas, parce que les
colonnes d'un export ne correspondent jamais tout à fait à ce qu'on attend.

Le lecteur est tolérant à ce que produit Excel en francophonie, sans quoi
l'import échouerait sur la moitié des fichiers réels :

| Cas | Traitement |
|---|---|
| Séparateur `;`, `,` ou tabulation | Détecté sur la ligne d'en-tête |
| Encodage Windows-1252 | Converti (sinon « Lefèvre » arrive en « Lef?vre ») |
| BOM UTF-8 | Retiré, sinon il colle au premier intitulé de colonne |
| Intitulés variables | « Adresse », « Rue », « Voie » ; « N° OS », « numero os »… |
| Code postal amputé | `1000` est relu `01000` (Excel mange le zéro initial) |
| Dates | `12/03/2026`, `12-03-2026`, `2026-03-12` |
| Lignes vides ou sans adresse | Ignorées (lignes de total, séparateurs) |

Seule la colonne **Adresse** est obligatoire. Un fichier sans elle est refusé
avec la liste des colonnes effectivement lues, et **aucun brouillon vide n'est
créé** — sinon chaque tentative ratée laisserait un déchet à nettoyer.

Un modèle est téléchargeable depuis l'écran d'import
(`/tournees/modele.csv`).

**XLSX n'est pas géré en V1** : cela demanderait `phpoffice/phpspreadsheet`,
une dépendance lourde pour un besoin non confirmé. Enregistrer en « CSV
UTF-8 » depuis Excel suffit. À rouvrir si les fichiers reçus arrivent
systématiquement en `.xlsx`.

### Équipage plutôt que chauffeur unique

Une tournée est confiée à un **équipage** : un titulaire et, au besoin, des
équipiers. Tous voient la même tournée et peuvent pointer les mêmes arrêts —
sur une collecte d’encombrants l’équipe passe ensemble, et celui qui charge
n’est pas toujours celui qui tient le téléphone. La tournée n’est pas
découpée entre eux.

Le titulaire n’est pas décoratif : c’est lui qui apparaît au tableau de bord et
dans l’historique. S’il est retiré de l’équipage, le premier équipier reprend
la place — une tournée sans responsable désigné rendrait le suivi illisible.

Techniquement, la colonne `tours.driver_id` a été remplacée par une table
`tour_driver` portant `is_primary`. Garder les deux aurait dupliqué
l’information et multiplié les endroits où vérifier « qui a le droit de
voir » : une seule règle vaut mieux que deux qui divergent.

**Ajout non demandé, à valider :** l’arrêt enregistre désormais *qui* l’a
pointé (`stops.completed_by`). Sans cela, sur une tournée partagée, le manager
voit qu’un arrêt est fait sans savoir par qui — le suivi « par chauffeur » du
cahier des charges perdait son sens. C’est une colonne et une colonne
d’affichage ; dites-le si vous préférez l’enlever.

### Photos prises sur place

Une observation écrite se conteste, une photo beaucoup moins : accès
impossible, encombrant absent, dépôt sauvage. Jusqu’à six photos par arrêt.

Trois précautions, toutes dictées par le terrain :

- **Réduction avant envoi.** Le cliché est ramené à 1600 px dans le navigateur
  *avant* de partir : 4 Mo deviennent ~300 Ko. En 4G de bord de route, c’est la
  différence entre un envoi d’une seconde et un échec. Le serveur re-réduit et
  fabrique une miniature de 320 px pour les listes du manager.
- **File d’attente hors ligne.** Les photos ne tiennent pas dans
  `localStorage` : elles passent par IndexedDB, qui stocke des `Blob` sans les
  convertir en texte. Une photo prise dans un sous-sol part toute seule au
  retour du réseau, comme les pointages. Le chauffeur voit sa photo
  immédiatement, marquée « à envoyer ».
- **Jamais dans `public/`.** Ces images montrent des propriétés privées.
  Elles vivent dans `storage/app/private/arrets/{id}/` et chaque lecture
  repasse par l’autorisation (équipage de la tournée, ou manager). La purge
  RGPD les efface avec la tournée.

### Fuseau horaire

L’application tournait en UTC, réglage par défaut de Laravel : **toutes les
heures de passage s’affichaient deux heures trop tôt** en été, une en hiver.
`APP_TIMEZONE=Europe/Paris` corrige cela, et les horodatages envoyés par les
terminaux sont explicitement ramenés à ce fuseau — un téléphone réglé sur un
autre fuseau décalerait sinon la feuille de route.

### Le mode se choisit avant tout le reste

La création se fait en deux temps : le mode d'abord (trois cartes), le
formulaire ensuite. Ce n'est pas de la décoration — le mode commande le
document attendu, les champs saisis, le vocabulaire affiché, les colonnes du
modèle CSV et le prompt d'extraction. Une liste déroulante perdue au milieu
d'un formulaire aurait laissé croire à un détail.

Les colonnes CSV attendues sont déclarées une seule fois
(`TourController::CSV_COLUMNS`) et servent à la fois au modèle téléchargeable
et à l'aide affichée à l'écran : les deux ne peuvent pas se contredire.

### Trois modes séparés à l'écran, une seule mécanique dessous

Les trois modes se distinguent visuellement partout : onglets dans la liste
des tournées, sections séparées au tableau de bord, et une couleur propre à
chaque mode (vert pour les encombrants, bleu pour les livraisons, violet pour
les collectes) portée par un composant unique. Un manager reconnaît une
livraison sans lire l'étiquette.


Encombrants, livraison et collecte partagent la tournée, les arrêts,
l'itinéraire, le pointage et les photos. Un champ `type` sur la tournée fait
varier trois choses : le vocabulaire affiché (« arrêt » ou « livraison »,
« n° OS » ou « n° de bon »), les champs saisis (téléphone, créneau, articles
détaillés), et l'exigence d'une signature.

Ce n'est pas trois applications : une livraison est une tournée dont chaque
arrêt se conclut par un bon signé au lieu d'une case cochée. Découper aurait
triplé le code du géocodage, de l'optimisation et du suivi.

### Le déroulé réel d'une journée

Une tournée d'encombrants ne s'arrête pas au dernier arrêt : camion plein,
l'équipage part trier aux bennes, revient, reprend, et ne rentre au dépôt
qu'une fois vide. L'application suit donc explicitement départ, interruptions
et clôture, et compte les rotations de vidage — ce sont elles qui expliquent
pourquoi une tournée de vingt arrêts prend la journée.

**Changement de comportement à connaître :** le dernier arrêt pointé ne clôt
plus la tournée automatiquement. Elle reste « en cours » jusqu'à ce que
l'équipage appuie sur *Terminer*. Clôturer au dernier arrêt aurait annoncé au
manager une tournée finie alors que le camion est encore plein.

### Ce que l'équipage voit, et jusqu'à quand

Une tournée close reste dans l'historique du chauffeur **jusqu'à la fin de la
journée de travail** — il doit pouvoir revoir ce qu'il a fait le matin. Passé
cette heure (`TOURS_END_OF_DAY`, 16 h 30 par défaut), elle sort de son écran.
Le manager la conserve entière.

Trois garde-fous, tous appris de l'usage :

- une tournée **encore en cours** ne disparaît jamais, même après l'heure :
  on ne coupe pas l'outil sous les pieds d'une équipe qui roule encore ;
- une tournée **assignée pour un jour suivant** reste visible à tout moment,
  le chauffeur prépare sa journée du lendemain ;
- une tournée close quitte l'**écran du jour** immédiatement, mais l'écran
  affiche « Tournée terminée à 14 h 05 » plutôt qu'un vide déroutant.

La règle est calculée à la lecture plutôt que gravée dans un drapeau : une
colonne `archived_at`, essayée d'abord, aurait exigé une tâche planifiée à
16 h 30 et fini par diverger de la règle si le cron tombait. Le manager garde
un bouton **Rouvrir** qui renvoie une tournée close sur le téléphone.

### Signature et bon PDF

Le client signe au doigt sur un canvas ; le PNG part au serveur, qui génère le
bon en PDF (dompdf) et l'archive dans `storage/app/private/bons/`.

- **Police interne plutôt qu'embarquée** : avec DejaVu embarquée, chaque bon
  pesait 881 Ko pour une page. Helvetica, interne à dompdf, rend les accents
  français et ramène le bon à **4,7 Ko**.
- **La signature ne se met pas en file d'attente hors ligne**, contrairement
  aux photos : le bon est produit côté serveur et le client doit repartir avec
  un document valide. L'écran le dit clairement plutôt que de faire croire à
  un enregistrement.
- **Le bon conditionne le pointage** : sur une livraison, « effectué » reste
  inactif tant que rien n'est signé. Un client absent se solde en revanche par
  un « non effectué », sans signature.

Le gabarit `resources/views/pdf/bon.blade.php` est **provisoire** : il porte
les mentions indispensables, pas la maquette des bons déjà en usage à la
ressourcerie, qui reste à intégrer.

### Créneaux horaires : regroupement, pas optimisation sous contraintes

Un client attendu entre 9 h et 12 h ne peut pas être repoussé l'après-midi
parce que c'est plus court en kilomètres. Les arrêts sont donc regroupés par
heure de début de créneau — les sans créneau en dernier — et l'ordre
géographique optimisé est conservé à l'intérieur de chaque groupe.

Ce n'est pas une optimisation de tournée sous contraintes de fenêtres
temporelles, qui relève d'une autre classe d'algorithmes. C'est suffisant
pour ne pas manquer un rendez-vous, et cela n'ajoute aucune dépendance.

### Séparation manager / administrateur

Le manager pilote l'exploitation ; l'administrateur détient en plus la création
de comptes et l'attribution des rôles. Deux limites en découlent, et elles sont
délibérées : un manager ne peut pas modifier un compte administrateur, et il ne
peut pas changer un rôle. Sans elles, la séparation serait cosmétique — un
manager se promouvrait administrateur en deux clics, ou désactiverait le compte
qui le supervise.

En revanche, réinitialiser le mot de passe d'un chauffeur qui l'a oublié reste
dans les mains du manager : c'est le cas courant du terrain, un lundi matin.
Reprendre la main sur un compte à droits équivalents ou supérieurs, non — cela
reste à l'administrateur.

L'application refuse par ailleurs de supprimer ou de rétrograder le dernier
administrateur actif, et personne ne peut modifier son propre rôle ni
désactiver son propre compte : c'est le garde-fou classique contre le
verrouillage hors de sa propre application.

### Correction d'une tournée déjà validée

Une erreur de relevé peut n'apparaître qu'une fois le chauffeur sur place. Le
manager peut donc corriger une tournée validée, avec deux règles :

- le **pointage déjà saisi est conservé** — statut, heure de passage,
  observation. Supprimer un arrêt déjà pointé efface son passage, l'écran le
  demande explicitement ;
- l'**ordre de passage n'est pas recalculé d'office**. Réordonner la tournée
  sous les roues d'un chauffeur déjà en route ferait plus de dégâts que
  l'erreur corrigée. Le manager déclenche le recalcul s'il le juge utile, et
  les arrêts déjà pointés gardent alors leur place en tête : ils sont derrière
  le chauffeur, et l'optimisation repart de sa dernière position connue.

Les adresses corrigées sont en revanche relocalisées immédiatement : le
chauffeur a besoin de coordonnées justes tout de suite.

### Le brouillon comme garde-fou

Une tournée importée est en **brouillon** : invisible du chauffeur, non
géocodée. Elle ne devient exploitable qu'après relecture explicite par le
manager. L'extraction automatique n'est donc jamais appliquée sans relecture
possible, conformément au §4.1 du cahier des charges. Les lignes que le modèle
signale comme peu lisibles sont surlignées dans l'écran de vérification.

### Photo réduite avant envoi

Les photos de téléphone (4000 × 3000) sont redressées selon leur orientation
EXIF et réduites à 1600 px sur le grand côté avant l'appel à l'API. Au-delà, la
précision d'extraction ne progresse pas mais le coût et la latence augmentent.
