Aller au contenu principal

ENG-001.5G — Migration des Services Municipaux vers Municipal Management — Rapport d'implémentation

MissionENG-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ôtdmv_api
Branchefeature/eng-001-5g-municipal-services (basée sur main + fondations Municipal-A/B/C non commitées)
StatutImplémenté
Comportement fonctionnelInchangé — 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 :

  1. getServices() retourne désormais tous les services d'une commune (actifs et inactifs), triés par ordre — 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.
  2. Le réordonnancement se traduit en appels répétés à updateService($id, ['ordre' => $position])ordre fait déjà partie des champs modifiables acceptés par updateService() (hérité tel quel de MairieWriteService::updateService()). Aucune méthode reorderServices() 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

FichierContenu
tests/Feature/MunicipalManagement/MunicipalServiceDelegationTest.php13 tests (voir §7)
dmv-docs/docs/19-engineering/specs/ENG-001-5G-implementation-report.mdCe document

2. Fichiers modifiés

FichierChangement
app/Modules/MunicipalManagement/Contracts/MunicipalManagementReader.phpDocblock de getServices() précisé (sémantique « tous », voir §0/§6) — aucune signature modifiée
app/Modules/MunicipalManagement/Contracts/MunicipalManagementWriter.phpDocblock de updateService() précisé (sert aussi au réordonnancement, voir §0/§6) — aucune signature modifiée
app/Modules/MunicipalManagement/Services/MunicipalManagementReadService.phpgetServices() réellement implémentée ; getElus/getCollectes/getInfos inchangés (toujours LogicException)
app/Modules/MunicipalManagement/Services/MunicipalManagementWriteService.phpcreateService()/updateService() réellement implémentées ; 9 autres méthodes inchangées
app/Modules/Mairie/Controllers/MairieServiceController.phpFaçade — voir §3 ; imports MairieReadService/MairieWriteService/Profile retirés
app/Modules/Mairie/Controllers/MairieCommuneController.phpindexServicesAll() 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.phplistServices(), getService() (déjà sans appelant), listServicesAll() supprimées ; imports MairieServiceDTO/MairieServiceModel/Illuminate\Support\Collection retirés
app/Modules/Mairie/Services/MairieWriteService.phpcreateService(), updateService(), reorderServices() supprimées ; imports MairieServiceDTO/MairieServiceModel retirés
tests/Feature/MunicipalManagement/MunicipalManagementFoundationTest.php2 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

FichierRaison
app/Modules/Mairie/DTOs/MairieServiceDTO.phpUtilisé 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.phpModè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 :

AvantAprè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 :

  • ValidationCreateServiceRequest/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

CommandeRésultat
php -l sur tous les fichiers créés/modifiésOK
php artisan about (bootstrap complet)OK
php artisan route:list --path=mairie | grep service5 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=MairieTest15 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=ActorAccessPhaseOneTest18 tests, 36 assertions — vert
php artisan test --filter=MunicipalServiceDelegationTest13 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 analyseAbsent du projet, comme constaté à chaque mission précédente
composer analyseNon défini dans composer.json

10. Divergences

  1. §0/§6 — deux choix d'implémentation documentés (sémantique de getServices(), réutilisation de updateService() 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.
  2. §8 — lecture directe de mairie_services depuis EnsureMairieAccess (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).