Aller au contenu principal

ENG-001.4 — Territory Bounded Context — Rapport d'implémentation

MissionENG-001.4 — Territory Bounded Context
EpicEPIC-001 — Restructuration de DMV Core
Dépôtdmv_api
Branchefeature/eng-001-4-territory-bounded-context (basée sur main, PR-001/PR-002 déjà mergées)
StatutImplémenté
Comportement fonctionnelInchangé (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) : ActorReader n'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 ligne actor_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 à ActorWriter de applySireneRefresh(string $actorId, ActeurSireneDataDTO $data): void — idempotente, aucun effet de bord XP/Rewards, ne modifie que kind/categorie_id/numero_siret.
  • Arbitrage 2 : ajout à ActorReader de findById(), 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

FichierContenu
api/app/Modules/Actor/DTOs/ActeurSireneDataDTO.phpDTO d'écriture SIRENE (Arbitrage 1)
api/tests/Feature/Territory/TerritoryActorBoundaryTest.phpGarde-fou architectural : aucune écriture directe acteurs dans Territory

6. Fichiers modifiés

FichierChangement
api/app/Modules/Actor/Contracts/ActorWriter.phpAjout applySireneRefresh()
api/app/Modules/Actor/Contracts/ActorReader.phpAjout findById(), findBySiret(), findBySiren()
api/app/Modules/Actor/DTOs/ActeurDTO.phpAjout numero_siret/numero_siren (constructeur + fromModel(), hors toArray())
api/app/Modules/Actor/Services/ActeurWriteService.phpImplémentation applySireneRefresh()
api/app/Modules/Actor/Services/ActeurReadService.phpImplémentation findById()/findBySiret()/findBySiren()
api/app/Modules/Territory/Services/CommuneMairieDataRefreshService.phpActorReader/ActorWriter injectés ; toutes les lectures/écritures acteurs remplacées (V1, V2, et la part de V5 couverte)
api/app/Modules/Territory/Services/CommuneMairieDetectService.phpActorReader 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.phpActorReader injecté ; lecture acteurs de getCommuneInfos() migrée
api/app/Modules/Territory/Services/CommuneMairieResolverService.phpCommentaire uniquement — documente les deux lectures reportées (Arbitrage 3), aucune logique modifiée
api/database/migrations/0000_00_00_000060_create_categories_acteurs_table.phpAjout 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.php7 tests ActorReader ajoutés
api/tests/Feature/Actor/ActeurWriteTest.php6 tests ActorWriter::applySireneRefresh() ajoutés
api/tests/Feature/Territory/CommuneMairieDataRefreshTest.php6 tests ajoutés (2 refresh, 2 confirm, 2 detect-service scoping)
dmv-docs/docs/19-engineering/specs/ENG-001-4-implementation-report.mdCe 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 de app/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 acteurs restant en accès direct (CommuneMairieResolverService::detectCandidate(), recherche par commune_id+kind puis par commune_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, dans CommuneMairieDataRefreshService) reste également en accès direct — ce n'est pas la table acteurs, 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 ni ActorXpService ni RewardEngineService et n'écrit jamais dans actor_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_completed via updateActeur()) :
    • 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 ligne reward_grants) ;
    • CommuneMairieDataRefreshTest::refresh_ne_cree_aucune_ligne_actor_activity_events (bout en bout, via refresh()) ;
    • CommuneMairieDataRefreshTest::confirm_ne_cree_aucune_ligne_actor_activity_events (bout en bout, via confirm()).

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

SiteAvantAprè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)

SiteLecturePourquoi non couverte
CommuneMairieResolverService::detectCandidate() — passe 1DB::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 2DB::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

SuiteRésultat
php artisan test --filter=ActeurReadTest18 tests, 80 assertions — vert
php artisan test --filter=ActeurWriteTest22 tests, 67 assertions — vert
php artisan test --filter=CommuneMairieDataRefreshTest18 tests, 68 assertions — vert
php artisan test --filter=TerritoryActorBoundaryTest1 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

CommandeRésultat
php -l sur tous les fichiers créés/modifiésOK
php artisan about (bootstrap complet)OK
Résolution container (ActorReader/ActorWriter → services Actor ; CommuneMairieDataRefreshService/CommuneMairieDetectService/TerritoryService avec leurs nouvelles dépendances) via php artisan tinkerOK
vendor/bin/pint --test sur tous les fichiers créés/modifiésOK
vendor/bin/phpstan analyseAbsent du projet (vendor/bin/phpstan inexistant), comme déjà constaté en ENG-001.2/ENG-001.3
composer analyseNon 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 filtre active) 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_id dans findMatchingActeur()) 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 touchant categories_acteurs ou le pivot de kind devrait 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 que commune_info_sections l'a été pour Territory en ENG-001.4.