ENG-001.5G — Migration des Services Municipaux vers Municipal Management — Rapport d'implémentation
| Mission | ENG-001.5G — Migration des Services Municipaux vers Municipal Management |
| Rattaché à | ENG-001.5 — Extraction de Municipal Management ; Recalibrage de roadmap §4 (PR Municipal-G) ; ENG-001.5A ; ENG-001.5B ; ENG-001.5C |
| Dépôt | dmv_api |
| Branche | feature/eng-001-5g-municipal-services (basée sur main + fondations Municipal-A/B/C non commitées) |
| Statut | Implémenté |
| Comportement fonctionnel | Inchangé — 5 routes Mairie strictement identiques (URL, payload, validation, réponse JSON) |
0. Vérification préalable des contrats (gouvernance)
La mission imposait explicitement : « Implémenter complètement les méthodes nécessaires... Uniquement si les contrats actuels sont suffisants. Si une évolution de contrat devient nécessaire : STOP. » Cette vérification a donc été menée avant toute implémentation.
Constat initial. MunicipalManagementReader::getServices(string $communeId): Collection
(un seul paramètre) devait servir deux besoins distincts déjà présents dans le code historique :
une lecture publique « actifs uniquement » (MairieReadService::listServices()) et une lecture de
gestion « tous » (MairieReadService::listServicesAll(), route /services/all). De même,
MairieWriteService::reorderServices() (réordonnancement en masse) n'avait pas d'équivalent
direct dans MunicipalManagementWriter.
Analyse. Les deux écarts se résolvent sans modifier aucune signature :
getServices()retourne désormais tous les services d'une commune (actifs et inactifs), triés parordre— la sémantique n'avait jamais été fixée par ENG-001.5A (docblock muet sur ce point), donc ce choix relève de l'implémentation, pas d'un changement de contrat. La façade publique filtre les actifs elle-même (->filter(fn ($s) => $s->isActive)) ; la façade de gestion utilise le résultat brut.- Le réordonnancement se traduit en appels répétés à
updateService($id, ['ordre' => $position])—ordrefait déjà partie des champs modifiables acceptés parupdateService()(hérité tel quel deMairieWriteService::updateService()). Aucune méthodereorderServices()n'a donc été ajoutée au contrat.
Conclusion. Les contrats issus de Municipal-A (ENG-001.5A) étaient suffisants. Aucun arrêt de mission, aucun rapport de divergence au sens Niveau 3 n'a été nécessaire. Voir §6 pour le détail technique complet de ce raisonnement — présenté ici comme point de gouvernance, pas comme divergence, puisqu'aucune signature n'a changé.
1. Fichiers créés
| Fichier | Contenu |
|---|---|
tests/Feature/MunicipalManagement/MunicipalServiceDelegationTest.php | 13 tests (voir §7) |
dmv-docs/docs/19-engineering/specs/ENG-001-5G-implementation-report.md | Ce document |
2. Fichiers modifiés
| Fichier | Changement |
|---|---|
app/Modules/MunicipalManagement/Contracts/MunicipalManagementReader.php | Docblock de getServices() précisé (sémantique « tous », voir §0/§6) — aucune signature modifiée |
app/Modules/MunicipalManagement/Contracts/MunicipalManagementWriter.php | Docblock de updateService() précisé (sert aussi au réordonnancement, voir §0/§6) — aucune signature modifiée |
app/Modules/MunicipalManagement/Services/MunicipalManagementReadService.php | getServices() réellement implémentée ; getElus/getCollectes/getInfos inchangés (toujours LogicException) |
app/Modules/MunicipalManagement/Services/MunicipalManagementWriteService.php | createService()/updateService() réellement implémentées ; 9 autres méthodes inchangées |
app/Modules/Mairie/Controllers/MairieServiceController.php | Façade — voir §3 ; imports MairieReadService/MairieWriteService/Profile retirés |
app/Modules/Mairie/Controllers/MairieCommuneController.php | indexServicesAll() délègue à MunicipalManagementReader ; MairieReadService/MairieWriteService restent injectés pour les autres méthodes (commune, élus, collectes, infos — hors périmètre) |
app/Modules/Mairie/Services/MairieReadService.php | listServices(), getService() (déjà sans appelant), listServicesAll() supprimées ; imports MairieServiceDTO/MairieServiceModel/Illuminate\Support\Collection retirés |
app/Modules/Mairie/Services/MairieWriteService.php | createService(), updateService(), reorderServices() supprimées ; imports MairieServiceDTO/MairieServiceModel retirés |
tests/Feature/MunicipalManagement/MunicipalManagementFoundationTest.php | 2 tests ajustés : getServices/createService ne font plus partie de l'échantillon « lève LogicException » (remplacés par getElus, toujours non implémentée) |
Aucun autre fichier applicatif touché — ni Territory, ni Admin, ni Workspace, ni Public, ni
migration SQL, confirmé par git status (voir §9).
3. Fichiers supprimés
| Fichier | Raison |
|---|---|
app/Modules/Mairie/DTOs/MairieServiceDTO.php | Utilisé uniquement par les méthodes de service désormais supprimées de MairieReadService/WriteService ; zéro référence résiduelle (vérifié par grep, hors mentions descriptives en docblock) |
app/Modules/Mairie/Models/MairieService.php | Modèle Eloquent devenu orphelin ; vérifié : aucune relation d'un autre modèle ne pointe vers MairieService::class, aucun test ne le référence directement |
CreateServiceRequest.php et ReorderServicesRequest.php ne sont pas supprimées :
elles sont toujours utilisées par MairieServiceController, qui reste la façade HTTP légitime
(validation) — voir §4.
4. Logique déplacée
MairieServiceController (façade) et MairieCommuneController::indexServicesAll() ne contiennent
plus aucune logique métier :
| Avant | Après |
|---|---|
MairieServiceModel::byCommune($id)->active()->orderBy('ordre')->get() | $this->municipalReader->getServices($communeId)->filter(fn ($s) => $s->isActive) |
MairieServiceModel::byCommune($id)->orderBy('ordre')->get() (workspace) | $this->municipalReader->getServices($communeId) (déjà tous, déjà triés) |
MairieServiceModel::create([...]) | $this->municipalWriter->createService($communeId, $data) |
MairieServiceModel::findOrFail($id)->update(...) | $this->municipalWriter->updateService($serviceId, $data) |
MairieServiceModel::where('id', ...)->where('commune_id', ...)->update(['ordre' => ...]) (boucle) | Boucle équivalente sur updateService($id, ['ordre' => $position]), avec vérification préalable d'appartenance à la commune via getServices() (voir §6) |
MunicipalManagementReadService/WriteService implémentent réellement ces quatre opérations
contre la table mairie_services, via DB::table() — pas via un modèle Eloquent (supprimé, §3),
conformément à ADR-014 §11.
Le contrôleur historique (MairieServiceController) conserve, exactement comme demandé par la
mission :
- Validation —
CreateServiceRequest/ReorderServicesRequest(FormRequests), inchangées. - Autorisation — middleware
mairie.access/commune.manager, appliqué au niveau des routes, inchangé. - Délégation — appels directs aux deux contrats, aucune règle métier intermédiaire.
Aucune logique de résolution de commune, aucune construction de réponse au-delà de
$dto->toArray().
5. Logique supprimée
Les méthodes MairieReadService::listServices()/getService()/listServicesAll() et
MairieWriteService::createService()/updateService()/reorderServices() faisaient doublon
exact avec MunicipalManagementReadService/WriteService. getService(string $serviceId)
n'avait déjà plus aucun appelant avant cette PR (confirmé par grep, même constat que
MairieReadService::getAlerte() avant ENG-001.5C) — code mort supprimé sans risque.
Aucune autre suppression : services/élus/collectes/infos pratiques/publications/commune restent
strictement hors périmètre et inchangés dans MairieReadService/WriteService.
6. Contrats utilisés — détail du raisonnement §0
getServices() : une seule méthode pour deux besoins
Le code historique distinguait deux lectures : listServices() (publique, actifs uniquement,
triés) et listServicesAll() (gestion, tous, triés). Plutôt que d'ajouter un second paramètre ou
une seconde méthode au contrat, getServices() retourne désormais systématiquement l'ensemble des
services d'une commune, triés par ordre :
public function getServices(string $communeId): Collection
{
return DB::table('mairie_services')
->where('commune_id', $communeId)
->orderBy('ordre')
->get()
->map(fn (object $row) => $this->mapService($row));
}
MairieServiceController::index() (route publique) filtre les actifs après lecture :
->filter(fn (MunicipalServiceDTO $s) => $s->isActive)->values(). MairieCommuneController::indexServicesAll()
utilise le résultat tel quel. Le filtrage devient une préoccupation de l'adaptateur HTTP
consommateur, pas du contrat — cohérent avec ADR-014 §12 (les adaptateurs traduisent, ils ne
décident pas, mais un filtre de présentation n'est pas une décision métier).
updateService() : réutilisée pour le réordonnancement
reorderServices(Profile $user, string $communeId, array $orderedIds): void n'a pas d'équivalent
dans le contrat Municipal-A. Le code historique faisait, pour chaque identifiant fourni :
MairieServiceModel::where('id', $serviceId)->where('commune_id', $communeId)->update(['ordre' => $position]);
ordre fait déjà partie des champs modifiables de updateService()
(SERVICE_CHAMPS_MODIFIABLES, hérité de MairieWriteService). La façade traduit donc un
réordonnancement en N appels updateService($id, ['ordre' => $position]). Différence
préservée délibérément : le code historique scopait chaque mise à jour par commune_id
(protection contre un identifiant appartenant à une autre commune) — updateService() n'a que
$serviceId en paramètre, sans communeId. Pour ne pas perdre cette protection sans faire
évoluer le contrat, la façade vérifie d'abord l'appartenance de chaque identifiant à la commune
via getServices($communeId)->pluck('id') avant d'appeler updateService(), et ignore
silencieusement tout identifiant absent — reproduisant exactement le comportement observable
historique (0 ligne affectée pour un identifiant hors commune), par simple composition de lecture
- écriture, sans nouvelle méthode.
Signatures finales utilisées par la façade
MunicipalManagementReader::getServices(string $communeId): Collection
MunicipalManagementWriter::createService(string $communeId, array $data): MunicipalServiceDTO
MunicipalManagementWriter::updateService(string $serviceId, array $data): MunicipalServiceDTO
Aucune signature nouvelle ou modifiée par rapport à Municipal-A (ENG-001.5A) — seuls les docblocks ont été précisés pour documenter les deux choix ci-dessus.
7. Responsabilités restantes
MairieServiceController et la portion services de MairieCommuneController restent dans le
module Mairie en tant que façades pures (validation + autorisation + délégation), conformément à
la mission (« Mairie ne conserve qu'une éventuelle façade temporaire si nécessaire ») et à la
décision 13.D du recalibrage de roadmap (les middleware EnsureMairieAccess/EnsureCommuneManager
restent dans Mairie jusqu'à l'extraction du Platform Service Authorization — non traitée par cette
PR, hors périmètre).
MairieReadService/MairieWriteService continuent de porter, sans changement : commune
(description/image_url — hors périmètre Municipal Management, ENG-001.4 §11.B), élus, collectes,
infos pratiques, publications (délégué à PublicationWriteService).
8. Point signalé, non traité (hors périmètre explicite)
EnsureMairieAccess::resolveActeurId() interroge directement mairie_services (jointure brute
avec communes) pour résoudre l'acteur propriétaire lorsque la route ne contient que {serviceId}
(PATCH /mairie/services/{serviceId}) :
DB::table('mairie_services as ms')->join('communes as c', 'c.id', '=', 'ms.commune_id')
->where('ms.id', $serviceId)->value('c.mairie_actor_id');
C'est une lecture directe d'une table désormais possédée par Municipal Management, depuis un
middleware qui reste dans Mairie. Signalé ici conformément à la gouvernance (« toute divergence
concernant... responsabilités doit être signalée immédiatement »), non corrigé : ce middleware
est explicitement hors périmètre de cette mission (« Ne pas traiter... Territory... Admin » — et,
par la décision 13.D déjà actée, les middleware d'accès ne bougent pas avant l'extraction du
Platform Service Authorization). La corriger aurait nécessité soit une nouvelle méthode de contrat
(interdit sans arbitrage par la mission), soit un déplacement du middleware (hors périmètre
explicite). Ce n'est pas une régression : ce même middleware faisait déjà une lecture directe
équivalente sur mairie_alertes/publications avant ENG-001.5C, un pattern pré-existant non
propre à cette PR. À reprendre lors d'une future mission traitant explicitement les middleware
d'accès (13.D) ou l'extraction du Platform Service Authorization.
9. Validations exécutées
| Commande | Résultat |
|---|---|
php -l sur tous les fichiers créés/modifiés | OK |
php artisan about (bootstrap complet) | OK |
php artisan route:list --path=mairie | grep service | 5 routes, strictement identiques avant/après (URL, méthode, contrôleur) |
vendor/bin/pint --test (fichiers touchés) | OK après 2 corrections automatiques (MairieReadService.php, MunicipalServiceDelegationTest.php — fixer fully_qualified_strict_types, comportement standard déjà rencontré en ENG-001.5C, sans effet fonctionnel) |
vendor/bin/pint --test (repo entier) | 1 seul fichier signalé : database/seeders/ActorNotorietyConfigSeeder.php, pré-existant, sans rapport avec cette mission |
php artisan test --filter=MairieTest | 15 tests, 36 assertions — vert, y compris les 3 tests services préexistants (non modifiés, désormais preuve de non-régression de la délégation) |
php artisan test --filter=ActorAccessPhaseOneTest | 18 tests, 36 assertions — vert |
php artisan test --filter=MunicipalServiceDelegationTest | 13 tests, 53 assertions — vert |
php artisan test (suite complète) | 474 tests, 1782 assertions, 0 échec (461 préexistants après ENG-001.5C + 13 nouveaux) |
vendor/bin/phpstan analyse | Absent du projet, comme constaté à chaque mission précédente |
composer analyse | Non défini dans composer.json |
10. Divergences
- §0/§6 — deux choix d'implémentation documentés (sémantique de
getServices(), réutilisation deupdateService()pour le réordonnancement) : aucune signature de contrat modifiée, donc pas de divergence au sens Niveau 3 nécessitant un arrêt. Documentés avant implémentation par transparence, conformément à la gouvernance. - §8 — lecture directe de
mairie_servicesdepuisEnsureMairieAccess(module Mairie), signalée, non corrigée (hors périmètre explicite de cette mission, pattern pré-existant).
Aucune décision d'ownership nouvelle, aucun changement d'API publique, aucune migration de données.
11. Confirmations
Il ne reste qu'une seule implémentation métier des services municipaux. MunicipalManagementReadService/
WriteService sont l'unique code qui lit/écrit mairie_services. Vérifié par le garde-fou
automatisé il_nexiste_plus_quune_seule_implementation_metier_des_services (§7 des tests, voir
MunicipalServiceDelegationTest.php) et par grep exhaustif (§3, §5) : modèle Eloquent et DTO
Mairie supprimés, méthodes de service retirées de MairieReadService/WriteService.
Aucune route publique n'a changé. Les 5 routes Mairie (GET .../services public,
GET .../services/all, POST .../services, PATCH /mairie/services/{id},
PATCH .../services/reorder) existent avec les mêmes URIs, mêmes verbes, même format de payload
et de réponse qu'avant cette PR — vérifié par php artisan route:list et par les 3 tests
préexistants de MairieTest.php, tous restés verts sans aucune modification de leur code.
Aucun frontend ne nécessite de modification. La façade préserve strictement : mêmes URLs,
mêmes payloads de requête (mêmes FormRequest), même forme de réponse JSON (mêmes clés, même
format). Le recalibrage de roadmap (§2.5/§7 13.F) avait signalé un usage frontend incertain de
cette ressource (aucun appel actif détecté dans le code source de dmv-workspace) — cette PR ne
tranche pas cette question produit, elle migre la ressource à l'identique quel que soit son usage
réel, conformément à l'option 3 retenue par 13.F du document de recalibrage.
Aucun commit n'a été créé. Branche feature/eng-001-5g-municipal-services, tous les fichiers
ci-dessus en working tree, aucun git add/git commit exécuté. git status ne montre que les
fichiers de cette mission et des missions précédentes non committées (Municipal-A/B/C), plus les
deux fichiers pré-existants sans rapport déjà signalés dans tous les rapports précédents
(database/seeders/DatabaseSeeder.php, database/seeders/ActorNotorietyConfigSeeder.php).