ENG-001.4 — Territory Bounded Context — Rapport d'implémentation
| Mission | ENG-001.4 — Territory Bounded Context |
| Epic | EPIC-001 — Restructuration de DMV Core |
| Dépôt | dmv_api |
| Branche | feature/eng-001-4-territory-bounded-context (basée sur main, PR-001/PR-002 déjà mergées) |
| Statut | Implémenté |
| Comportement fonctionnel | Inchangé (vérifié par tests — 469 tests / 1726 assertions, suite complète) |
1. Divergences initiales (historique, résolues par arbitrage)
Une première tentative avait identifié que les trois violations autorisées (V1, V2, V5) se heurtaient chacune à une insuffisance de contrat :
- V5 (lectures) :
ActorReadern'exposait aucune méthode compatible avec les besoins réels de Territory (lookup par id, par SIRET, par SIREN). - V1/V2 (écritures) :
ActorWriter::updateActeur()existait mais aurait, en cas de réutilisation, inséré une ligneactor_activity_eventsà chaque exécution du cron SIRENE pour toute mairie au profil déjà complet — effet de bord vérifié dans le code, pas hypothétique.
Un rapport de divergence détaillant ces deux blocages, avec options et recommandations, a été produit et soumis à l'architecte.
2. Arbitrages validés par l'architecte
- Arbitrage 1 : ajout à
ActorWriterdeapplySireneRefresh(string $actorId, ActeurSireneDataDTO $data): void— idempotente, aucun effet de bord XP/Rewards, ne modifie quekind/categorie_id/numero_siret. - Arbitrage 2 : ajout à
ActorReaderdefindById(),findBySiret(),findBySiren(), retournant?ActeurDTO, jamais de modèle Eloquent. - Arbitrage 3 : aucune méthode de résolution par commune (
findMairieByCommune,findByCommuneAndKind,resolveMunicipalActor) — les lectures actuelles fondées sur la commune restent en place, documentées comme reportées.
3. Contrats ajoutés
ActorWriter
interface ActorWriter
{
public function createActeur(string $userId, array $data): ActeurDTO;
public function updateActeur(string $acteurId, array $data): ActeurDTO;
public function deleteActeur(string $userId, string $acteurId): void;
public function addXpEvent(string $acteurId, string $eventType, int $points, array $meta = []): void;
public function submitClaim(array $data): void;
+
+ public function applySireneRefresh(string $actorId, ActeurSireneDataDTO $data): void;
}
Implémentation (ActeurWriteService::applySireneRefresh()) : construit un payload uniquement
avec les champs non-null du DTO, appelle DB::table('acteurs')->update() directement — sans
passer par updateActeur(), sans instancier Acteur, sans appeler ActorXpService ni
RewardEngineService. Acteur inconnu → 0 ligne affectée, aucune exception (comportement
identique à l'écriture SQL directe que Territory faisait auparavant).
ActorReader
interface ActorReader
{
public function getActeur(string $slug): ActeurDTO;
public function getActeurXp(string $acteurId): int;
public function isOwner(string $userId, string $acteurId): bool;
+
+ public function findById(string $actorId): ?ActeurDTO;
+ public function findBySiret(string $siret): ?ActeurDTO;
+ public function findBySiren(string $siren): ?ActeurDTO;
}
Implémentation (ActeurReadService) : chaque méthode interroge Acteur::published() (même
portée exacte que les accès directs qu'elle remplace — whereNull('deleted_at'), vérifié contre
chaque site d'appel avant migration) et retourne ActeurDTO::fromModel() ou null.
4. Contenu exact du DTO SIRENE
final class ActeurSireneDataDTO
{
public function __construct(
public readonly ?string $kind = null,
public readonly ?string $categorieId = null,
public readonly ?string $numeroSiret = null,
) {}
}
Exactement les trois champs listés par l'arbitrage, aucun ajout. Un champ à null signifie « ne
pas modifier » — jamais « effacer ». ActeurDTO (le DTO de lecture, déjà existant) a été étendu
de deux champs (numero_siret, numero_siren) pour couvrir les besoins de lecture — volontairement
absents de toArray() : ce sont des identifiants d'entreprise, jamais exposés dans les réponses
publiques existantes avant cette mission (GET /api/v1/acteurs/{slug} notamment) ; les y ajouter
aurait changé une réponse API existante, ce qu'aucun arbitrage n'autorisait. Aucun nouveau DTO
public n'a donc fui d'information non exposée auparavant.
5. Fichiers créés
| Fichier | Contenu |
|---|---|
api/app/Modules/Actor/DTOs/ActeurSireneDataDTO.php | DTO d'écriture SIRENE (Arbitrage 1) |
api/tests/Feature/Territory/TerritoryActorBoundaryTest.php | Garde-fou architectural : aucune écriture directe acteurs dans Territory |
6. Fichiers modifiés
| Fichier | Changement |
|---|---|
api/app/Modules/Actor/Contracts/ActorWriter.php | Ajout applySireneRefresh() |
api/app/Modules/Actor/Contracts/ActorReader.php | Ajout findById(), findBySiret(), findBySiren() |
api/app/Modules/Actor/DTOs/ActeurDTO.php | Ajout numero_siret/numero_siren (constructeur + fromModel(), hors toArray()) |
api/app/Modules/Actor/Services/ActeurWriteService.php | Implémentation applySireneRefresh() |
api/app/Modules/Actor/Services/ActeurReadService.php | Implémentation findById()/findBySiret()/findBySiren() |
api/app/Modules/Territory/Services/CommuneMairieDataRefreshService.php | ActorReader/ActorWriter injectés ; toutes les lectures/écritures acteurs remplacées (V1, V2, et la part de V5 couverte) |
api/app/Modules/Territory/Services/CommuneMairieDetectService.php | ActorReader injecté ; lookup par id migré ; findMatchingActeur() réécrit sur findBySiret()/findBySiren() avec filtre commune_id réappliqué côté appelant (comportement identique, démontré §8) |
api/app/Modules/Territory/Services/TerritoryService.php | ActorReader injecté ; lecture acteurs de getCommuneInfos() migrée |
api/app/Modules/Territory/Services/CommuneMairieResolverService.php | Commentaire uniquement — documente les deux lectures reportées (Arbitrage 3), aucune logique modifiée |
api/database/migrations/0000_00_00_000060_create_categories_acteurs_table.php | Ajout colonne kind manquante — migration de test uniquement (non appliquée à Supabase), découverte car aucun test existant n'exerçait jusqu'ici la branche de détection de kind (voir §9) |
api/tests/Feature/Actor/ActeurReadTest.php | 7 tests ActorReader ajoutés |
api/tests/Feature/Actor/ActeurWriteTest.php | 6 tests ActorWriter::applySireneRefresh() ajoutés |
api/tests/Feature/Territory/CommuneMairieDataRefreshTest.php | 6 tests ajoutés (2 refresh, 2 confirm, 2 detect-service scoping) |
dmv-docs/docs/19-engineering/specs/ENG-001-4-implementation-report.md | Ce document |
dmv-docs/docs/19-engineering/specs/ENG-001-4-territory-bounded-context.md | §11.D et §11.F annotés comme résolus par l'arbitrage (voir §12) |
7. Preuve que Territory n'écrit plus directement dans acteurs
grep -rn "DB::table('acteurs')" api/app/Modules/Territory/→ 0 résultat.- Test dédié
TerritoryActorBoundaryTest::territory_necrit_jamais_directement_dans_acteurs(): scanne tous les fichiers PHP deapp/Modules/Territoryà la recherche de trois motifs d'écriture (DB::table('acteurs')->{insert,update,delete}(,Acteur::{query(),where()}->{...}(,Acteur::create() — vert. Motifs validés indépendamment avant intégration (5 cas testés en isolation : écriture détectée, lecture non détectée, écriture Eloquent détectée,create()détecté, lecture Eloquent non détectée — 5/5 corrects). - Les deux seules lectures
acteursrestant en accès direct (CommuneMairieResolverService::detectCandidate(), recherche parcommune_id+kindpuis parcommune_id+nom) sont des lectures, pas des écritures, et sont explicitement autorisées à rester par l'Arbitrage 3 tant que l'ownership de la relation commune ↔ acteur mairie n'est pas tranché. DB::table('categories_acteurs')(deux occurrences, dansCommuneMairieDataRefreshService) reste également en accès direct — ce n'est pas la tableacteurs, et cette lecture est documentée comme hors périmètre (§9), pas corrigée.
8. Preuve qu'aucune activité XP/Rewards n'est générée
ActeurWriteService::applySireneRefresh()n'importe niActorXpServiceniRewardEngineServiceet n'écrit jamais dansactor_activity_events— vérifiable par lecture directe de la méthode (aucun appel, aucune dépendance).- Quatre tests le démontrent empiriquement, avec un acteur dont le profil est délibérément déjà
complet (nom + description + commune_id — le scénario exact qui aurait déclenché
profile_completedviaupdateActeur()) :ActeurWriteTest::apply_sirene_refresh_ne_cree_aucune_ligne_actor_activity_events(appel direct au contrat) ;ActeurWriteTest::apply_sirene_refresh_ne_declenche_aucun_appel_rewards(aucune nouvelle lignereward_grants) ;CommuneMairieDataRefreshTest::refresh_ne_cree_aucune_ligne_actor_activity_events(bout en bout, viarefresh()) ;CommuneMairieDataRefreshTest::confirm_ne_cree_aucune_ligne_actor_activity_events(bout en bout, viaconfirm()).
9. Divergence annexe découverte pendant l'implémentation, non corrigée
Constat. CommuneMairieDataRefreshService::refresh() et ::confirm() lisent
DB::table('categories_acteurs')->where('kind', 'mairie')->first() pour résoudre la catégorie à
assigner lors du passage kind → 'mairie'. categories_acteurs est une table de référence
appartenant à Actor (consommée exclusivement par des contrôleurs/services Admin liés aux
catégories d'acteurs), non mentionnée par l'audit ENG-001.1, par la spec ENG-001.4, ni par
l'arbitrage. C'est une sixième lecture croisée Territory → Actor, distincte des six déjà
cartographiées en V5.
Traitement. Non corrigée dans cette PR : l'arbitrage a fourni un DTO de trois champs
(kind, categorieId, numeroSiret) où Territory fournit la valeur de categorie_id —
la faire résoudre par Actor lui-même aurait changé la sémantique du contrat au-delà de ce qui a
été validé. Laissée strictement telle quelle (aucune ligne modifiée), documentée ici pour
traçabilité, conformément à « toute nouvelle divergence doit être remontée avant poursuite de la
partie concernée » — remontée, pas bloquante puisque non aggravée par cette PR.
Effet de bord positif découvert en marge. En écrivant les tests couvrant le changement de
kind, il est apparu qu'aucun test existant n'exerçait jusqu'ici cette branche (tous les acteurs
mairie de fixture étaient déjà créés avec kind: 'mairie') — la migration de test
categories_acteurs ne possédait pas la colonne kind que le code interroge pourtant en
production. Corrigé (migration de test uniquement, jamais appliquée à Supabase, voir §6) : sans
ce correctif, aucun des nouveaux tests touchant au changement de kind n'aurait pu s'exécuter.
10. Lectures migrées
| Site | Avant | Après |
|---|---|---|
CommuneMairieDataRefreshService::findMairieActor() | DB::table('acteurs')->where('id',...)->whereNull('deleted_at')->first() | $this->actorReader->findById(...) |
CommuneMairieDataRefreshService::refresh() (fetch alternatif post-résolution) | DB::table('acteurs')->where('id', $resolved->actor->id)->first() | $this->actorReader->findById(...) |
CommuneMairieDataRefreshService::confirm() | DB::table('acteurs')->where('id', $acteurId)->whereNull('deleted_at')->first() | $this->actorReader->findById(...) |
CommuneMairieDetectService::preview() (lien mairie actuel) | DB::table('acteurs')->where('id', $commune->mairie_actor_id)->whereNull('deleted_at')->first() | $this->actorReader->findById(...) |
CommuneMairieDetectService::findMatchingActeur() | DB::table('acteurs')->whereNull('deleted_at')->where('commune_id',...)->where('numero_siret'/'numero_siren',...)->first() | findBySiret()/findBySiren() puis vérification commune_id en PHP côté appelant |
TerritoryService::getCommuneInfos() | DB::table('acteurs')->where('id', $commune->mairie_actor_id)->first() | $this->actorReader->findById(...) |
11. Lectures volontairement reportées (Arbitrage 3)
| Site | Lecture | Pourquoi non couverte |
|---|---|---|
CommuneMairieResolverService::detectCandidate() — passe 1 | DB::table('acteurs')->where('commune_id',...)->where('kind','mairie')->whereNull('deleted_at')->...->get(['id','nom','kind']) | Recherche par commune_id+kind, pas un lookup par identifiant technique — hors périmètre des trois méthodes validées |
CommuneMairieResolverService::detectCandidate() — passe 2 | DB::table('acteurs')->where('commune_id',...)->where(nom LIKE mots-clés)->...->get(['id','nom','kind']) | Idem — recherche heuristique par commune + motif de nom |
Ces deux lectures sont désormais documentées par un commentaire de classe dans
CommuneMairieResolverService.php référençant ce rapport, pour qu'un futur lecteur ne les prenne
pas pour un oubli. Elles resteront en l'état jusqu'à ce que l'ownership de la relation commune ↔
acteur mairie soit tranché (§11.A de la spec ENG-001.4).
12. Mise à jour de la spec ENG-001.4
ENG-001-4-territory-bounded-context.md §11.D et §11.F ont été annotés (pas réécrits) pour
indiquer que ces deux décisions sont désormais arbitrées, avec un pointeur vers ce rapport. §11.A,
§11.B, §11.C, §11.E restent inchangés — non concernés par cet arbitrage, toujours ouverts.
13. Tests exécutés
| Suite | Résultat |
|---|---|
php artisan test --filter=ActeurReadTest | 18 tests, 80 assertions — vert |
php artisan test --filter=ActeurWriteTest | 22 tests, 67 assertions — vert |
php artisan test --filter=CommuneMairieDataRefreshTest | 18 tests, 68 assertions — vert |
php artisan test --filter=TerritoryActorBoundaryTest | 1 test, 1 assertion — vert |
php artisan test (suite complète) | 469 tests, 1726 assertions, 0 échec (449/1690 avant cette mission + 20 tests nouveaux) |
14. Validations exécutées
| Commande | Résultat |
|---|---|
php -l sur tous les fichiers créés/modifiés | OK |
php artisan about (bootstrap complet) | OK |
Résolution container (ActorReader/ActorWriter → services Actor ; CommuneMairieDataRefreshService/CommuneMairieDetectService/TerritoryService avec leurs nouvelles dépendances) via php artisan tinker | OK |
vendor/bin/pint --test sur tous les fichiers créés/modifiés | OK |
vendor/bin/phpstan analyse | Absent du projet (vendor/bin/phpstan inexistant), comme déjà constaté en ENG-001.2/ENG-001.3 |
composer analyse | Non défini dans composer.json |
15. Divergences restantes
- §9 ci-dessus (lecture
categories_acteurs, non corrigée, documentée) — nouvelle, à arbitrer dans une mission future si sa correction devient prioritaire. - Les six points déjà ouverts par la spec ENG-001.4 (§11.A, B, C, E ; D et F désormais résolus) restent intégralement en l'état, non traités par cette PR — conforme au périmètre autorisé.
- V3/V4 (Territory écrit encore
commune_elus/commune_infos) : non traitées, hors périmètre explicite, reportées à l'extraction de Municipal Management (ENG-001.5).
Enseignements pour ENG-001.5
- La technique « DTO minimal + méthode de contrat dédiée, sans réutiliser une méthode générique porteuse d'effets de bord » (Arbitrage 1) s'est avérée directement applicable et peu coûteuse : à reproduire pour toute future correction d'écriture croisée où le contrat générique existant porte une logique métier (Rewards, XP, notifications) non désirée pour une synchronisation technique.
- Toute correction de lecture croisée doit être vérifiée site par site contre la portée exacte de
la requête existante (ici :
whereNull('deleted_at')partout, jamais de filtreactive) avant de la remplacer par un contrat — une différence de portée aurait été une régression silencieuse. - Un filtre appliqué en SQL (ex.
commune_iddansfindMatchingActeur()) peut être déplacé après un appel de contrat sans changer le résultat, tant que le contrat retourne les données nécessaires à reproduire ce filtre côté appelant (ici :ActeurDTO->commune_id) — utile quand le contrat validé ne prévoit pas nativement ce paramètre de filtrage. - Écrire les tests d'un chemin de code a révélé qu'il n'avait jamais été exercé par la suite
existante (branche de changement de
kind), avec un écart de schéma de test resté invisible jusque-là (§9) — signal qu'une future mission touchantcategories_acteursou le pivot dekinddevrait auditer la couverture réelle avant de supposer un comportement testé. - La table
categories_acteurs(§9) est un nouveau candidat à cartographier explicitement dans une future revue d'ownership Actor, au même titre quecommune_info_sectionsl'a été pour Territory en ENG-001.4.