# Où l'on range quoi, et pourquoi

Laravel 12, Filament 5, MySQL. Une seule application, un seul panneau : `/admin`.

## Le principe

**Un domaine porte tout ce qui le concerne** : ses modèles, ses services, ses
écrans, ses autorisations et ses tests. Pour comprendre la facturation, on ouvre
un dossier — pas cinq.

```
app/Domaines/Facturation/
    Modeles/        Invoice, InvoiceLine, Quote, QuoteLine
    Services/       InvoiceService, QuoteService, FacturePdf, IntegriteSerie
    Politiques/     InvoicePolicy, QuotePolicy…
    Support/        Numerotation
    Exceptions/     FacturePriseEnSerieException
    Filament/       Resources/Invoices, Resources/Quotes
tests/Domaines/Facturation/
```

Le rangement par technologie — tous les modèles ensemble, tous les services
ensemble — se lit bien tant que l'application est petite. Passé une trentaine de
modèles, il oblige à ouvrir cinq dossiers pour suivre une seule idée.

## Les domaines

| Domaine | Ce qu'il porte | État |
|---|---|---|
| **Courrier** | Adresses d'expédition, modèles et mises en page de message, registre du courrier, envoi | déménagé |
| **Facturation** | Factures, avoirs, devis, numérotation, PDF | déménagé |
| **Comptabilité** | Journal, comptes, catégories, tiers, prêteurs, comptes courants d'associés, charges récurrentes | déménagé |
| **Domiciliation** | Clients, contrats, échéances, paiements, pièces, conformité, relances | *reste dans `app/Models`, `app/Services`, `app/Filament`* |
| **Assistance** | Analyses de pièces par l'IA, assistant conversationnel | *reste en place* |
| **Administration** | Utilisateurs, rôles, réglages, audit, sauvegarde | *reste en place* |

Le déménagement se fait un domaine à la fois, suite complète au vert entre
chacun. Ce qui n'est pas encore déplacé reste parfaitement fonctionnel : les deux
rangements cohabitent, et rien n'oblige à finir en une fois.

## Le noyau commun

Ce qui sert à plus d'un domaine reste à la racine de `app/` :

- `app/Support/` — référentiels et règles transverses : `Roles`, `Perimetre`,
  `Domiciliation`, `Ia`, `PieceIdentite` ;
- `app/Policies/BasePolicy.php` — le squelette dont dérivent toutes les
  autorisations ;
- `app/Models/Concerns/Auditable.php` — la journalisation, commune à tous les
  modèles ;
- `app/Http/`, `app/Providers/`, `app/Console/` — les points d'entrée.

## Ce qu'il faut savoir avant de déplacer quoi que ce soit

Trois mécanismes dépendent du chemin des fichiers ou du nom des classes. Chacun
a été rendu indépendant du rangement — mais il faut savoir qu'ils existent.

**Filament découvre par le chemin.** Le panneau balaie `app/Filament` *et*
`app/Domaines` : un écran rangé dans son domaine est trouvé sans rien déclarer.
Voir `AdminPanelProvider::panel()`.

**Les policies se cherchent par convention.** La convention native ne connaît que
`App\Models\X` → `App\Policies\XPolicy`. `AppServiceProvider::trouverLesPoliciesDesDomaines()`
ajoute `App\Domaines\D\Modeles\X` → `App\Domaines\D\Politiques\XPolicy`, avec repli
sur l'ancien chemin. Sans cette règle, le mode strict de Filament lève dès qu'un
modèle déménage.

**Deux colonnes stockent un nom de classe en clair** : `audit_logs.auditable_type`
et `transactions.source_type`. Déplacer un modèle rendrait muettes toutes les
lignes qui le nomment. `AppServiceProvider::garderLesNomsDeClasseEnBase()` fixe un
alias stable par modèle déplacé : la base continue de lire et d'écrire
`App\Models\Invoice` quelle que soit la place du fichier. **Tout modèle déménagé
doit y figurer.**

## Les règles qui n'existent qu'à un seul endroit

Deux écritures d'une même règle finissent toujours par diverger. Celles-ci ont
une autorité, et une seule — `tests/Feature/UneSeuleRegleTest.php` le vérifie :

| Règle | Autorité |
|---|---|
| Pièces exigées d'un dossier, et leurs libellés | `Support\Domiciliation::exigencesDocumentaires()` |
| Complétude d'un dossier légal | `Services\LegalCompleteness` |
| Reste dû d'un compte courant | `Comptabilite\Modeles\Lender::resteDu()` |
| Périmètre d'un utilisateur sur les dossiers | `Support\Perimetre` |
| Mises en page de message et leurs réglages | `Courrier\Support\MiseEnPage` |
| Modèles d'IA, coûts et plafonds | `Support\Ia` |
| Mois, groupe et poste CPC d'une opération | `Comptabilite\Modeles\Transaction::booted()` |
| Assiette du résultat, quelle que soit la période | `Comptabilite\Services\ComptaReport::resultat()` |
| Chiffres qu'une réponse d'IA a le droit d'avancer | `Support\GardeFouDesFaits` |
| Étapes du recouvrement et bornes qui les séparent | `Support\Recouvrement` |
| Échéances qu'un dossier clos cesse de réclamer | `Services\AssainissementEcheances` |

## Un écran, une décision

L'échéancier global est la liste exhaustive : triable, filtrable, exportable,
celle qu'on ouvre pour retrouver une échéance précise. Il ne répond pas à la
question que l'exploitant se pose le matin — que dois-je faire aujourd'hui —,
et une liste unique mêle trois questions : ce qu'il faut relancer, ce qu'il faut
préparer, ce qu'il faut abandonner.

`Filament\Pages\Recouvrement` est le poste de travail. Six étapes, nommées par
**le geste qu'elles appellent** et jamais par leur ancienneté : à venir, à
relancer, à rappeler fermement, à mettre en demeure, à trancher, à sortir. L'âge
est le critère qui range, pas le titre.

Chaque étape porte une **action dominante**, la même pour toutes ses lignes, en
bouton sur chaque ligne et en action groupée en tête ; des **colonnes choisies
pour sa décision** — l'ancienneté et la dernière relance à « à relancer », le
cumul dû et le dernier paiement reçu à « à sortir » ; et un en-tête qui dit
l'enjeu en une phrase et deux chiffres.

« À sortir » n'est pas rouge : le rouge dit « agis maintenant », alors qu'une
créance de quatorze mois appelle une décision réfléchie. Un écran qui crie à
propos de tout ne fait plus crier personne.

Les quatre bornes — 30 jours, 3 mois, 6 mois, un an — sont des **choix de
gestion** : elles vivent dans les réglages, sous l'onglet Recouvrement, et
`Support\Recouvrement` est le seul endroit qui les lit. Le retard commence le
lendemain de l'échéance : le jour même, le client a jusqu'au soir.

**Les impayés ne se disent jamais en un chiffre unique.** Partout — tableau de
bord compris — ils se lisent en deux parts : ce qui est raisonnablement
recouvrable, et ce qui est à solder. « 63 152 DH d'impayés » décourage et
n'appelle aucun geste ; « 31 500 DH à aller chercher, 26 000 DH à solder » est
un chiffre sur lequel on agit. `Recouvrement::impayes()` fait la coupure, et les
trois écrans la lisent au même endroit.

## Un dossier clos ne réclame plus d'argent

L'échéancier ne filtrait que le statut de l'échéance, jamais celui du client :
un dossier annulé gardait toutes ses échéances dans la chaîne de recouvrement.

`Services\AssainissementEcheances` porte les **deux règles, qu'il ne faut pas
confondre** :

- un dossier **annulé** n'a jamais pris effet — ses échéances non réglées sont
  toutes annulées ;
- un dossier **résilié** a existé — ce qui était dû avant la date d'effet reste
  dû et se réclame ; seules les échéances postérieures sont annulées.

Confondre les deux ferait perdre de l'argent réellement dû. Un dossier résilié
**sans date d'effet** n'est pas touché : deviner reviendrait à jeter une créance
réelle. Ces dossiers sont nommés par `sansDateEffet()` et par la commande de
rattrapage — se taire ferait croire le ménage terminé.

Une échéance sans objet est **annulée, jamais masquée** : elle porte sa date
d'annulation et son motif, pour qu'on puisse six mois plus tard répondre à
« pourquoi celle-ci ne compte plus ». Une échéance réglée n'est jamais touchée :
on n'efface pas un encaissement.

La règle s'applique par `Observers\DossierClotureObserver`, au moment où le
dossier change d'état ou reçoit sa date d'effet.

## Ce qu'une barre d'actions supporte

Au-delà de cinq boutons, une barre d'actions déborde sur un portable et souvent
déjà sur un ordinateur. Ce n'est pas un problème de largeur mais de hiérarchie :
quand tout ressort, rien ne ressort.

La règle : les trois ou quatre gestes du quotidien en évidence, tout le reste
derrière un menu unique nommé « Autres actions » — un intitulé, jamais trois
points muets. `ViewDomiciliationClient::ACTIONS_EN_EVIDENCE` fixe le plafond, et
un test le vérifie.

## Un message qui survit au thème sombre

Deux parades, et non une, parce qu'aucune ne suffit seule :

- `color-scheme` et `supported-color-schemes` dans l'en-tête, plus une requête
  `prefers-color-scheme` qui échange le logo à texte noir contre sa version à
  texte blanc (`emails/mise-en-page/partiels/theme.blade.php`) — pour les
  clients qui les respectent ;
- un fond clair posé en **attribut** `bgcolor`, et pas seulement en style, sur
  toute cellule qui porte le logo ou un texte gris — pour Gmail sur Android, qui
  force l'inversion et ignore la requête de média.

C'est la seule feuille de style incorporée de l'application, et une seule chose y
vit. Toute la mise en page reste portée par les styles en ligne : Outlook ignore
la feuille, et cela doit rester sans conséquence.

## Ce qui se déduit ne se stocke pas à la main

Une opération de trésorerie porte trois colonnes qui redisent ce qu'elle porte
déjà : `month` redit `date`, `group` et `cpc_line` redisent sa catégorie. Elles
étaient posées par un effet de bord du formulaire — « quand l'utilisateur change
la date, écris le mois ». Le champ arrivait pré-rempli ; celui qui l'acceptait
tel quel ne déclenchait jamais le rappel. Quatre opérations de juillet 2026 sont
nées sans mois, le graphique du tableau de bord est tombé dessus, et le
« Résultat du mois » sous-comptait les charges de 4 585,16 DH quand le
« Résultat 2026 », qui bornait sur la date, était juste.

**Ces trois colonnes sont désormais déduites par `Transaction::booted()`, à
l'enregistrement, et par lui seul** — formulaire, action rapide, import,
encaissement d'échéance, versement de prime, mise à jour de catégorie. Aucun
appelant ne les écrit ; un appelant qui les écrirait est écrasé.

Elles restent stockées, et ce n'est pas une contradiction :

- une ligne de reprise peut n'avoir aucune catégorie et porter tout de même le
  poste que le tableur du comptable lui a donné ;
- une catégorie reclassée demain ne doit pas réécrire l'histoire d'écritures
  déjà remises au comptable.

Mais dès qu'une catégorie existe, c'est elle qui décide. À défaut de tout, le
poste vaut `Transaction::POSTE_NON_VENTILE` : **aucune opération n'est
enregistrée avec un poste CPC vide**, parce qu'une colonne vide ne se voit pas,
alors qu'un poste « Non ventilé » saute aux yeux dans un rapport.

Le sens (`direction`), lui, reste saisi : c'est lui qui détermine la liste des
catégories proposées à la saisie rapide, et il faut donc l'avoir choisi avant
d'avoir une catégorie à interroger.

**Les écrans lisent la catégorie**, pas la copie : la colonne « Groupe » du
journal et son filtre passent par `category.group`, comme les rapports le
faisaient déjà. Et les deux résultats du tableau de bord — celui du mois et
celui de l'exercice — passent par `ComptaReport::resultat()`, qui borne les deux
sur la date : la somme des douze mois d'un exercice égale son résultat par
construction, et un test le vérifie.

## Un échec local et visible, jamais total et muet

Une seule opération au mois vide a suffi à faire répondre « Erreur lors du
chargement de la page » à tout le tableau de bord : le graphique d'évolution
mensuelle lisait `Carbon::createFromFormat('Y-m', '')`, qui lève.

Partout où une valeur venue de la base est interprétée — une date, un montant,
une énumération —, la valeur illisible est écartée et **le fait qu'elle l'ait
été se voit**. `ComptaReport::evolutionMensuelle()` ne rend que les mois
lisibles, `evolutionMensuelleEcartee()` dit combien d'opérations ont été mises
de côté et pourquoi, et le graphique l'affiche sous la courbe. Une donnée
aberrante qu'on ignore en silence finit par fausser un chiffre sans que
personne ne s'en aperçoive ; une donnée aberrante qui tue l'écran est encore
pire.

## Ce que l'IA a le droit d'écrire

Deux services demandent au modèle de rédiger : la mise en forme des messages
(`Courrier\Services\MiseEnFormeIa`) et la lecture des charges à venir
(`Comptabilite\Services\LectureDesCharges`). Ni l'un ni l'autre ne produit de
chiffre : les prévisions sont calculées par
`Comptabilite\Services\ChargesRecurrentesObservees` — une statistique sur des
faits, dont chaque ligne renvoie aux opérations qui la fondent — et l'IA ne fait
que les mettre en mots.

La consigne le dit ; le contrôle en aval l'impose. `Support\GardeFouDesFaits`
compare les nombres de la réponse à ceux des faits transmis et fait rejeter la
réponse entière au moindre écart. Une consigne se contourne ; un contrôle dans
le code, non.

## Les tests

`tests/Domaines/<Domaine>/` pour ce qui appartient à un domaine,
`tests/Feature/` pour ce qui en traverse plusieurs, `tests/Unit/` pour le calcul
pur. Les trois suites tournent ensemble : `./vendor/bin/pest --parallel`.

## Les commandes

`mobdie:` désigne l'exploitation courante — sauvegarde, restauration,
planificateur, diagnostic. `reprise:` désigne les douze scripts écrits pour la
reprise de données de 2026 : ils vivent dans `app/Console/Commands/Reprise`,
disent à l'exécution à quoi ils ont servi, et refusent de s'exécuter en
production sans confirmation.

---

Le détail de chaque version est dans [CHANGELOG.md](CHANGELOG.md) ; la mise en
ligne, dans [DEPLOIEMENT.md](DEPLOIEMENT.md).
