# Rapport — v3.11.0

**Lot du socle : se livre aux DEUX installations (IRADA et RUWAD), à l'identique.**
**Une migration**, et elle touche aux données. **Sauvegarde de la base obligatoire avant.**
**Aucune commande de reprise.**

---

## Pourquoi ce lot

« Fonctionnement interne » était un **projet système protégé** : un marché comme les autres dans
tous les sélecteurs, qu'on choisissait pour dire « ces heures ne concernent aucun marché ». Une
tournure à l'envers — on sélectionnait quelque chose pour désigner l'absence de quelque chose — et
le prix se payait partout : le faux marché traînait dans la saisie d'opération, dans les devis,
dans les listes de marchés, dans les KPI, et il fallait l'écarter à la main écran par écran
(`where('code', '!=', 'INTERNE')`, `where('type', '!=', 'interne')`, …). Chaque nouvel écran était
une occasion d'oublier ce filtre.

La v3.11.0 retourne la proposition : **une opération, une heure, une charge SANS marché EST du
fonctionnement interne, par définition.** Il n'y a plus rien à sélectionner — le champ « marché »
reste vide, et c'est tout.

Le fonctionnement interne **ne produit aucun chiffre d'affaires** — c'est exactement ce qui fait
qu'il n'est pas un marché — mais **il a un coût** : le coût RH des heures sans marché, plus les
charges qu'aucune clé n'a pu pousser sur un marché. Ce coût est désormais calculé, visible, et
rapporté dans le même cadre que celui des marchés, de sorte qu'on lise l'écran comme une addition :

> **Σ (coûts des marchés) + fonctionnement interne = coût total de l'entreprise**

## ⚠️ L'invariant : aucun montant ne change

Ce lot **re-représente** le fonctionnement interne. Il ne modifie **aucune règle de calcul**,
**aucune clé de répartition**, **aucun arrondi**. Le coût interne calculé la nouvelle façon doit
être égal, **au centime**, à ce que le projet interne portait avant.

Ce n'est pas une intention : c'est un test. `InvariantDesMontantsTest` monte un mois complet
**à l'ancienne** — projet « Fonctionnement interne », heures pointées dessus, charges fixes,
marchés, un collaborateur sans aucune heure —, calcule la ventilation, relève ce que le projet
portait, **puis applique la migration et recalcule**. Il exige alors l'égalité au centime sur le
coût interne, **et** sur le coût de chaque marché, **et** sur le coût total. Si un total bouge,
la suite est rouge.

**Un choix de conception découle directement de cet invariant.** La source de vérité du coût
interne est `cost_allocations` où `project_id IS NULL` — c'est-à-dire exactement les lignes que le
projet interne portait, repointées sans qu'un centime change. Recalculer le coût interne à partir
des écritures brutes aurait donné un **autre** montant : cela aurait ramassé des dépenses sans
marché que le moteur n'a jamais rangées là (celles dont la catégorie n'appartient pas à la
structure). Ç'aurait été un changement de comptabilité déguisé en changement de représentation.
On ne l'a pas fait, et le service le dit en toutes lettres à l'endroit où on serait tenté de le
faire.

---

## Ce que le lot contient

### 1. Le fonctionnement interne devient une dimension

| | |
|---|---|
| Service | `App\Services\Interne\FonctionnementInterneService` — coût RH des heures sans marché, charges non rattachées, total, **CA = 0 par définition**, détail par source, heures sans marché, et le contrôle `Σ marchés + interne = total` |
| Modèle | `CostAllocation::scopeSansMarche()` — `whereNull('project_id')`, l'unique façon de dire « interne » en base |
| Retiré | **`Project::internal()`** — supprimé, avec un commentaire à sa place qui explique pourquoi il ne doit pas revenir |
| Retiré | **`InternalProjectSeeder`** — une installation neuve ne naît plus avec le concept qu'on vient d'enlever |

**Le moteur d'allocation** (`AllocationEngine`) écrit désormais `project_id = null` partout où il
écrivait sur le projet interne : heures pointées sans marché (affectation délibérée, v3.3 §3),
pools qu'aucune clé n'a su répartir, repli « aucun marché actif », repli de la clé de gérance.

À l'intérieur du moteur, « sans marché » circule sous la clé `AllocationEngine::SANS_MARCHE` :
les tableaux de poids de PHP ne savent pas porter une clé `null` — elle deviendrait la chaîne vide
et se confondrait avec un identifiant absent. La traduction vers `null` se fait au seul moment où
l'on écrit une ligne. Même mécanisme côté grilles de saisie, avec `TimesheetService::SANS_MARCHE`.

⚠️ **Un point subtil, et volontairement conservé** : les lignes RH sans marché **comptent dans la
clé de structure**, sous leur propre poids — exactement comme les heures internes y comptaient
quand elles portaient le projet. Les en retirer aurait changé les montants de **tous** les marchés.

### 2. Le « marché » interne disparaît de l'interface

Il disparaît **d'un coup, partout**, et sans un seul filtre par écran : tous les sélecteurs de
marché de l'application passent par `Project::query()`, donc par la portée globale des soft
deletes. Le projet retiré, il n'apparaît plus dans la saisie d'opération, la barre de saisie
rapide, les devis, les affectations, les KPI, les listes de marchés, le registre du courrier —
nulle part. C'est la raison pour laquelle la migration **retire le projet en soft delete** plutôt
que de le supprimer (voir §3).

Deux filtres devenus inutiles ont été retirés (`BarreSaisieRapide`, `ControleReprise`), et le
champ « Marché » de la saisie rapide porte maintenant le libellé qui dit la règle :
**« Aucun — fonctionnement interne »**.

### 3. La migration des données — le point sensible

`2026_08_20_100000_fonctionnement_interne_devient_sans_marche`

L'ordre n'est pas négociable :

1. **`project_id` devient nullable** sur `timesheets`, `cost_allocations`, `manual_allocations`,
   `project_assignments` — sans quoi le repointage échouerait ligne par ligne ;
2. **repointage vers `null`** de toute référence au projet interne, dans 13 tables
   (`transactions`, `timesheets`, `cost_allocations`, `manual_allocations`, `project_assignments`,
   `expense_claims`, `bonuses`, `bonus_rules`, `guarantees`, `invoices`, `tenders`, `courriers`,
   `formation_programmes`) ;
3. **et seulement ensuite**, retrait du projet, en **soft delete**, après avoir levé `is_protected`.

**Pourquoi un soft delete et pas une suppression.** Trois tables portent un `project_id` NON NULL
qui n'a aucun sens « sans marché » : jalons, investissements, pièces de marché. Si l'une d'elles
pointait par accident sur le projet interne, une suppression franche casserait sa clé étrangère.
Le soft delete laisse la ligne en base — **aucune clé ne casse** — tout en faisant disparaître le
projet de tous les sélecteurs. C'est la solution qui ne perd rien et n'oublie rien.

**Aucune ligne n'est supprimée.** On change un rattachement, jamais une opération. Le périmètre
couvert est rigoureusement le même : ce qui était « sur le projet interne » est maintenant « sans
marché ».

**Idempotente.** Rejouée deux fois de plus, elle ne trouve plus rien à repointer ni à retirer, et
ne re-date pas le retrait — c'est testé sur une empreinte complète de la base.

### 4. La feuille de temps

La grille de saisie porte **toujours**, en tête, la ligne **« Fonctionnement interne »**, avec sa
légende « Heures qui ne concernent aucun marché ». Le collaborateur n'a rien à chercher ni à
ajouter : les heures qui ne relèvent d'aucun marché se posent là, et descendent en base avec
`project_id = null`. Rien n'est verrouillé.

Sur « Ma journée » (l'écran du téléphone), cette ligne est **toujours visible** — jamais reléguée
derrière « voir tous les marchés » : c'est celle dont on a le plus besoin, et il serait absurde de
devoir chercher ce qui n'est justement pas un marché.

Côté admin, la liste des heures affiche « Fonctionnement interne » (plutôt qu'un tiret qui
ressemblerait à une saisie inachevée), gagne un filtre **« Fonctionnement interne (sans marché) »**,
et le champ « Marché » du formulaire **n'est plus obligatoire**.

⚠️ **Une seule ligne interne par jour.** L'index unique `(employee_id, date, project_id)` ne peut
plus l'assurer : MySQL ne considère pas deux `NULL` comme égaux. C'est `TimesheetService::saveDay()`
qui tient la règle, en regroupant les lignes avant écriture — et c'est testé, pas espéré.

### 5. Le rapport

Dans la **Répartition des coûts**, le tableau des marchés ne contient plus que des marchés, et un
**bloc « Fonctionnement interne » distinct** s'affiche en dessous : coût RH des heures sans marché
(avec le nombre d'heures approuvées), charges non rattachées, coût total, **et le chiffre
d'affaires à 0, affiché plutôt que sous-entendu**. Puis le contrôle, lisible comme une addition :
coût des marchés `+` fonctionnement interne `=` coût total de l'entreprise. L'égalité est vraie par
construction ; l'écran la vérifie quand même et le dit si elle tombe en défaut.

L'export Excel écrit « Fonctionnement interne » dans la colonne « Marché » au lieu d'une cellule
vide qu'un tableur lirait comme une donnée manquante.

L'écran **« Coût RH non affecté »** n'est pas dupliqué : il montre une **anomalie** (un bulletin
sans une seule heure approuvée), et rappelle désormais en une ligne le **coût réel** du
fonctionnement interne — lu depuis `FonctionnementInterneService`, la même et unique source de
vérité — avec un lien vers la répartition. Les deux ne se confondent plus.

### En prime : une source d'intermittence supprimée

`ProjectFactory` tirait le type d'un marché au hasard **parmi tous les types, `interne` compris**.
Un « marché » de test sur cinq était donc écarté à raison par les moteurs, et des tests
échouaient une fois de temps en temps sans que personne comprenne pourquoi. La fabrique ne produit
plus jamais de type `interne` par défaut ; l'état `internal()` reste, pour les rares scénarios qui
doivent en fabriquer un exprès.

---

## Recette

`php artisan test` — **1945 tests**, dont **16 nouveaux** dans `tests/Feature/Interne/` :

- **`InvariantDesMontantsTest` (5)** — le test qui compte :
  - **le coût interne est identique AU CENTIME avant et après la migration**, et le coût de chaque
    marché, et le coût total, sont strictement inchangés ;
  - `Σ marchés + interne = coût total`, au centime ;
  - le fonctionnement interne ne produit **aucun** chiffre d'affaires ;
  - une heure **avec** marché ne compte pas en interne ; une heure **sans** marché si ;
  - les heures sans marché sont bien l'assiette du coût RH interne.
- **`MigrationSansMarcheTest` (5)** — toutes les références passent à `null` et les marchés réels
  ne bougent pas ; aucune ligne supprimée ; le projet est retiré et **n'est plus sélectionnable
  nulle part** ; **aucune clé étrangère ne casse** (contrôle d'orphelins sur cinq tables) ; la
  migration est **idempotente** (empreinte complète, rejouée deux fois) ; une base sans projet
  interne la traverse sans bouger.
- **`FonctionnementInterneUiTest` (6)** — la grille porte toujours la ligne interne ; une heure
  saisie dessus s'enregistre **sans marché** ; deux saisies internes le même jour ne font qu'une
  ligne ; une ligne sans marché s'affiche « Fonctionnement interne » ; aucun sélecteur ne propose
  le projet ; le rapport affiche le bloc distinct et l'addition.

**Non-régression** : `AllocationEngineTest`, `StructureKeyAllocationTest`, `CleDeGeranceTest`,
`CollaborateurSortiVentileTest`, `EstimationMoisRevoluTest`, `RecalculApresChangementDeDatesTest`,
`ProjectCostServiceTest`, les feuilles de temps, les congés et les jours fériés (v3.10.0) —
tous verts, comportement inchangé.

`./vendor/bin/pint` ✅ · `npm run build` ✅ · tag `v3.11.0`.

---

## La procédure de migration, et ce qu'il faut vérifier

### Avant

1. **⚠️ SAUVEGARDE DE LA BASE, VÉRIFIÉE PRÉSENTE ET NON VIDE.** Cette migration écrit dans les
   données. C'est la seule marche arrière autorisée par le socle — il n'y a **pas** de `down()`,
   et il n'y en aura pas : rendre `project_id` de nouveau NON NULL exigerait de réinventer un
   projet interne et d'y rattacher des lignes qui n'ont plus de raison d'y être.

   ```bash
   php artisan backup:run --only-db
   ```

2. Relever, **avant**, le coût total du mois en cours dans *Marchés & appels d'offres →
   Répartition des coûts* (le pied du tableau). C'est le nombre qu'on comparera après.

### Pendant

```bash
php artisan migrate --force
```

Aucune commande de reprise. Aucun seeder à rejouer.

### Après — les cinq contrôles

1. **Le total n'a pas bougé.** Rouvrir *Répartition des coûts* sur le même mois : le « Coût total
   de l'entreprise » doit être **identique au centime** à celui relevé avant. C'est le contrôle
   principal, et il se fait en dix secondes.
2. **L'addition tombe juste.** Sur le même écran : marchés `+` fonctionnement interne `=` total.
   L'écran affiche un bandeau rouge si ce n'est pas le cas — il ne doit pas apparaître.
3. **Le faux marché a disparu.** Ouvrir la saisie d'une opération, un devis, une affectation :
   « Fonctionnement interne » ne doit plus figurer dans la liste des marchés. Le champ « Marché »
   se laisse vide, et le libellé le dit.
4. **La feuille de temps.** Ouvrir *Ma semaine* avec un compte collaborateur : la première ligne
   est « Fonctionnement interne », sous-titrée « Heures qui ne concernent aucun marché ». Y saisir
   une heure, enregistrer, vérifier qu'elle revient bien après rechargement.
5. **Les heures d'hier sont toujours là.** Ouvrir *RH → Feuilles de temps*, filtrer sur
   « Fonctionnement interne (sans marché) » : on doit y retrouver, à l'identique, les heures qui
   étaient pointées sur l'ancien projet.

Si un chiffre s'écarte : **restaurer la sauvegarde**, et le signaler. Ne pas tenter de rattraper
à la main — un montant faux qui ressemble à un montant juste est le pire des deux mondes.

### Cas particulier : un recalcul est-il nécessaire ?

Non. La migration repointe **aussi** les quotes-parts déjà calculées (`cost_allocations`), donc les
mois passés se lisent correctement sans rien relancer. Un `Recalculer le mois` reste évidemment
possible, et redonnera exactement les mêmes montants — c'est précisément ce que prouve
l'invariant.
