Ticket Lesstime #139 (M3 — Répertoire prestataires, position 1.9). DoD back avant le front : suite PHPUnit consolidée sur la matrice § 8.1 + captures JSON réelles dans la spec § 4.0.bis. ## Contenu - **Fix réfs comptables** : `provider:read:accounting` ajouté sur `TvaMode`/`PaymentDelay`/`PaymentType`/`Bank` — sans ça elles sortaient en IRI nu dans le détail prestataire (réplique du fix ERP-92 du M2, piège #1 § 4.0.bis). - **`ProviderSerializationContractTest`** (13 tests) : gating RIB/scalaires par omission, réfs compta en objet `{id,code,label}`, `isArchived`, embed categories/sites liste+détail, sous-collections, enveloppe AP4 ; `testDodReferenceJsonShape` dumpe le JSON réel (`PROVIDER_DOD_DUMP=1`). - **`ProviderAuditTest`** (5 tests) : create/update/archive (`technique.Provider`), iban/bic dans le diff (`technique.ProviderRib`, pas dAuditIgnore), trace M2M `sites`. - **`ProviderListTest`** étendu : `?pagination=false`, anti-N+1, filtre `?typeCode=PRESTATAIRE`. - **`ProviderRbacGatingTest`** étendu : restauration en conflit de nom → 409 (RG-3.14). - **`ProviderFixtures`** (§ 8.4) : démo idempotente (complet VIREMENT+banque+RIB, LCR+RIB, CHEQUE multi-cat, minimal, archivé) répartie sur sites 86/17/82 ; skip en env `test`. - Helper `seedCompleteProvider` ; spec § 4.0.bis : gabarits remplacés par les captures réelles (liste + détail avec/sans accounting.view). ## Vérifications - `make php-cs-fixer-allow-risky` → 0 fichier - `make test` → OK, 677 tests, 3328 assertions (garde-fous globaux verts) ## Notes - MR stackée sur ERP-138 (base = sa branche). - Fixtures démo exercées en dev via `make fixtures` (autowiring vérifié). --------- Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #100
81 KiB
module, nom, ecran, owner_spec, backup_spec, version, date_redaction, spec_front, maquette_figma, lesstime_taskgroup_id, lesstime_project_id, statut_global, depend_de
| module | nom | ecran | owner_spec | backup_spec | version | date_redaction | spec_front | maquette_figma | lesstime_taskgroup_id | lesstime_project_id | statut_global | depend_de | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| M3 | Répertoire prestataires | repertoire-prestataires | Matthieu | Tristan | V0.2 | 2026-06-11 | ./spec-front.md | https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-42090&p=f&m=dev | 29 | 6 | en_dev |
|
Spec back — Module 3 : Répertoire prestataires
1. Contexte
Cette spec complète et précise la spec front V0.2 (M3-reportoire-prestataires.docx du 04/06/2026) avec tout ce qui touche au back : décisions d'archi, modèle de données, migration, API REST, RBAC, règles de gestion, tests, hors-périmètre.
Module cible : nouveau module Technique (src/Module/Technique/). Le prestataire est le jumeau du fournisseur (Provider / ProviderContact / ProviderAddress / ProviderRib), construit sur le pattern éprouvé M1/M2. Voir § 2.1 pour la justification du module séparé et la consommation des référentiels comptables.
Dépendances déjà en place sur develop (héritées M1/M2) :
Commercial→ référentiels comptablesTvaMode/PaymentDelay/PaymentType/Bank(entités lecture seule, déjà seedées — partagées sans duplication, consommées en relation ORM).Catalog(M0) →Category+CategoryType+ filtre?typeCode=opérationnel (créé au M2). Le M3 ajoute le typePRESTATAIRE.Sites→ 3 sites Châtellerault (86) / Saint-Jean (17) / Pommevic (82).Shared→TimestampableBlamableTrait+Subscriber(ERP-52).Core→ User, Role, Permission, Audit, JWT.
RETEX obligatoire : lire
../_RETEX-M1-pour-M2.mdAVANT de coder. ~80 % des frictions M1 venaient du contrat de sérialisation (groupes / sous-ressources / embed), pas du métier. La section § 4.0 applique ce RETEX au M3.
2. Décisions d'archi
2.1 Module — Nouveau module Technique, entités jumelles de Supplier
⚠️ Décision à confirmer (Matthieu, 11/06/2026) : le docx place le répertoire prestataires dans un Module « Technique », confirmé comme pôle distinct du Commercial. On crée donc un nouveau module back
Technique:
src/Module/Technique/TechniqueModule.php:ID = 'technique',LABEL = 'Technique',REQUIRED = false, méthodepermissions()(cf. § 5.1).- Activation : ajouter
TechniqueModule::classdansconfig/modules.php.- Front : layer Nuxt
frontend/modules/technique/(auto-détecté) + nouvelle section sidebar « Technique » dansconfig/sidebar.php.
Le prestataire M3 réplique à l'identique le pattern Supplier* du M2 sous Provider* (tables dédiées, pas de table polymorphe partagée — clients / fournisseurs / prestataires divergent fonctionnellement, l'isolation prime).
Référentiels comptables & Category — consommation cross-module (relation ORM partagée, PAS d'import de logique) : Provider référence TvaMode / PaymentDelay / PaymentType / Bank (module Commercial) et Category / Site (modules Catalog / Sites) via des relations ORM (ManyToOne / ManyToMany), exactement comme Supplier (Commercial) référence déjà Site (Sites) et Category (Catalog). Ce sont des données de référence partagées, pas de la logique inter-module : aucun service / repository d'un autre module n'est appelé. La règle ABSOLUE n°1 (« ne jamais importer d'un module à un autre — passer par Shared/Contract ») vise les dépendances de logique métier ; le projet a déjà acté (M1/M2) que la référence ORM à une entité de référence partagée est tolérée et documentée comme telle.
Décision Matthieu (11/06) : on fait « comme supplier » — consommation ORM partagée des référentiels comptables (zéro refacto, zéro duplication). La remontée dans
Shared(isolation stricte) reste une option future non retenue au M3 (tracé HP-M4-2).
2.2 IDs entier auto-increment Postgres natif
Cohérent avec M0/M1/M2 et l'ensemble Starseed. Pas d'UUID, pas de ULID. PK en INT GENERATED BY DEFAULT AS IDENTITY (style aligné M1/M2), horodatages en TIMESTAMP(0) WITHOUT TIME ZONE (le TimestampableBlamableTrait mappe datetime_immutable).
2.3 Référentiels comptables — réutilisation M1/M2 (zéro duplication)
Les 4 tables tva_mode / payment_delay / payment_type / bank (+ entités lecture seule et seeds) sont celles du M1. Le M3 ne crée aucune nouvelle table de référentiel comptable : provider.tva_mode_id, provider.payment_delay_id, provider.payment_type_id, provider.bank_id pointent vers les mêmes tables.
Endpoints : GET /api/tva_modes, /api/payment_delays, /api/payment_types, /api/banks existent déjà. Évolution M3 : élargir leur security pour autoriser aussi les rôles prestataires (cf. § 4.7). Les codes pivots VIREMENT (RG-3.07) et LCR (RG-3.08) existent déjà dans payment_types.
2.4 Catégories — nouveau CategoryType PRESTATAIRE
Le multi-select « Catégorie » du prestataire (formulaire principal ET adresse) référence des Category rattachées à un nouveau CategoryType de code PRESTATAIRE (label « Prestataire »), seedé par le M3. On assume des types distincts (CLIENT / FOURNISSEUR / PRESTATAIRE) — chacun avec sa taxonomie.
Bonne nouvelle vs M2 : le filtre
?typeCode=a été implémenté au M2 sur/api/categories(module Catalog). Le M3 n'a donc plus à le créer : il suffit de seeder le typePRESTATAIRE+ ses catégories (migrationON CONFLICTpour la prod + fixture idempotente pour survivre au purger en dev/test — cf. M2 § 3.2). À vérifier sur le JSON réel queGET /api/categories?typeCode=PRESTATAIREfiltre bien (DoD de la spec).
Forme réelle de
Category: exposecodeetname(PASlabel) souscategory:read, pluscategoryType{ id, code, label }. Le libellé affiché front =category.name. Les M2Mprovider_category/provider_address_categoryne contraignent que desCategoryde typePRESTATAIRE(RG-3.09).
2.5 Archive vs soft delete — deux mécanismes distincts (identique M1/M2)
| Mécanisme | Colonne | Visibilité défaut | Restauration | Utilisateur |
|---|---|---|---|---|
| Archive (fonctionnel) | is_archived (bool, default false) + archived_at |
masqué | Oui (toggle UI) | Admin seul via technique.providers.archive |
| Soft delete (technique) | deleted_at (timestamptz nullable) |
masqué | HP M4+ | Aucun rôle au M3 (HP) |
Conséquences (miroir M2) :
DELETE /api/providers/{id}non exposé au M3 (404 si appelé).GET /api/providers?includeArchived=truepermet de voir les archivés (permissiontechnique.providers.view).- PATCH
{ "isArchived": true }archive ; PATCH{ "isArchived": false }restaure. - L'unicité métier ignore les archivés ET les soft-deletés (cf. § 2.6).
2.6 Unicité partielle Postgres — nom de société
Décision à confirmer (alignée Q4 M1 / § 2.6 M2) : l'unicité métier porte uniquement sur le nom de prestataire (
company_name). Le SIREN et l'email principal ne sont pas uniques.
Index unique partiel (WHERE is_archived = FALSE AND deleted_at IS NULL) sur LOWER(company_name). Doublon → 409 Conflict géré par le ProviderProcessor.
2.7 Audit & traces temporelles
Pattern Starseed standard, miroir M1/M2 :
#[Auditable]surProvider,ProviderContact,ProviderAddress,ProviderRib.- Tous les champs auditables (pas d'
#[AuditIgnore]) — y comprisProviderRib.ibanetProviderRib.bic(audit admin-only côté Starseed → traçabilité comptable). - Audit M2M automatique sur
provider.categoriesetprovider.sites({categories: {added:[...], removed:[...]}}). - Libellés i18n (règle ABSOLUE backend —
AuditableEntitiesHaveI18nLabelTest) : ajouteraudit.entity.technique_provider,audit.entity.technique_providercontact,audit.entity.technique_provideraddress,audit.entity.technique_providerribdansfrontend/i18n/locales/fr.json(clé =strtolower(module)+_+strtolower(Entity)).
2.8 Timestampable + Blamable
Provider, ProviderContact, ProviderAddress, ProviderRib implémentent TimestampableInterface + BlamableInterface et utilisent TimestampableBlamableTrait. Migration : 4 colonnes par table (created_at/updated_at NOT NULL, created_by/updated_by nullable ON DELETE SET NULL) + commentaires via le helper addStandardTimestampableBlamableComments($schema, '<table>').
2.9 Permissions RBAC — granularité (5 permissions, identique M2)
| Permission | Admin | Bureau | Compta | Commerciale | Usine |
|---|---|---|---|---|---|
technique.providers.view |
✅ | ✅ | ✅ | ✅ (sauf compta) | ✅ (cloisonné par site — § 2.13) |
technique.providers.manage |
✅ | ✅ | ❌ | ✅ | ❌ |
technique.providers.accounting.view |
✅ | ❌ | ✅ | ❌ | ❌ |
technique.providers.accounting.manage |
✅ | ❌ | ✅ | ❌ | ❌ |
technique.providers.archive |
✅ | ❌ | ❌ | ❌ | ❌ |
Notes (miroir M2) :
- Compta édite uniquement l'onglet Comptabilité (
accounting.manage) d'un prestataire existant. Pas de création (pas demanageglobal). - Commerciale :
view+managemais pasaccounting.view→ onglet Comptabilité masqué (front) et filtré (back) via leProviderReadGroupContextBuilder(gating par ajout de groupeprovider:read:accounting, jamais par retrait). Sans la permission, scalaires compta +ribsne sont jamais sérialisés. - Bureau :
view+manage(tout sauf Comptabilité). - Usine :
view(lecture seule, pas demanage), cloisonné par site — voir § 2.13. - ⚠️ Le « Tout » vs « son site uniquement » de la colonne Consultation du docx n'est PAS porté par le rôle : c'est un cloisonnement par site piloté par l'utilisateur (décision Matthieu, 11/06). Tout user voit par défaut les prestataires de son site courant ; les profils qui doivent voir tous les sites (Admin, et selon besoin Bureau/Compta/Commerciale) l'obtiennent via la permission
sites.bypass_scope(Admin l'a par bypass total). Mécanique complète en § 2.13.
2.10 Validation incrémentale par onglet (workflow front-driven, identique M2)
Provider créé en BDD dès validation du formulaire principal via POST /api/providers. Onglets suivants → PATCH partiels avec groupes de sérialisation dédiés :
provider:write:main— formulaire principal (POST + PATCH) :companyName,categories,sitesprovider:write:contacts— onglet Contact (sous-ressourceprovider_contact)provider:write:addresses— onglet Adresse (sous-ressourceprovider_address)provider:write:accounting— onglet Comptabilité (security séparée)provider:write:archive— toggle archive (securitytechnique.providers.archive)
Pas de groupe provider:write:information (pas d'onglet Information au M3). Pas de state machine côté back (pas de status = draft|active).
2.11 Normalisation serveur des entrées texte (identique M1/M2)
ProviderFieldNormalizer (miroir SupplierFieldNormalizer), service interne appelé par les Processors avant validation :
final class ProviderFieldNormalizer
{
public function normalizeCompanyName(?string $v): ?string // mb_strtoupper(trim)
public function normalizePersonName(?string $v): ?string // mb_convert_case TITLE
public function normalizeEmail(?string $v): ?string // mb_strtolower(trim)
public function normalizePhone(?string $v): ?string // preg_replace('/\D+/', '')
}
Le formatage XX XX XX XX XX est fait à l'affichage front. Le back stocke 0612345678 (chiffres seuls).
2.12 Liste : embed catégories + sites + hydratation anti-N+1 (cohérence M1/M2)
La liste GET /api/providers embarque les categories[] (avec code/name) et les sites[] (avec name/postalCode — pas de code), comme M1/M2.
Différence M3 (importante) : au M2,
sites[]de la liste était l'agrégat dédoublonné des adresses (Supplier::getSites()). Au M3, le prestataire porte directement des sites (formulaire principal — RG-3.03, M2Mprovider_site). La colonne « Site » de la liste affiche doncprovider.sites(relation directe), pas un agrégat d'adresses. Plus simple et plus performant.
Anti-N+1 (le code fera foi) : le DoctrineProviderRepository ne fetch-joine PAS les to-many dans la requête de liste (filtres + tri seulement) ; hydrateListCollections() remplit categories puis sites (relation directe) via des requêtes IN bornées séparées sur les mêmes instances (identity map), pour éviter le produit cartésien sur les chemins non paginés (export, ?pagination=false). Le contrat de sérialisation (groupes category:read / site:read dans le contexte) est posé une seule fois sur l'entité.
2.13 Cloisonnement par site — visibilité pilotée par l'utilisateur (DÉCISION M3)
Décision Matthieu (11/06/2026) : la visibilité des prestataires est cloisonnée par site, automatiquement, côté back, en fonction de l'utilisateur — pas du rôle. Un user a un (ou des) site(s) (
user_site, + uncurrentSiteactif). Il ne voit que les prestataires rattachés à son site. Les profils qui doivent voir tous les sites passent parsites.bypass_scope(Admin l'a par bypass total). Le rôle « Usine » n'est qu'un cas particulier de cette règle générale.
Réutilisation de l'infra Sites existante (docs/modules/site-aware.md) : CurrentSiteProvider (site courant de l'user), permission sites.bypass_scope (voit tous les sites — Admin automatique), users ↔ sites via M2M user_site.
⚠️ Pourquoi PAS SiteAwareInterface standard : le pattern opt-in SiteAwareInterface + SiteScopedQueryExtension est mono-site (site_id INT NOT NULL, ManyToOne unique, filtre x.site = :currentSite). Or le prestataire est multi-site (M2M provider_site, ≥ 1 — RG-3.03). Le pattern standard ne s'applique donc pas tel quel. On câble un filtre de cloisonnement custom multi-site (cas explicitement renvoyé au module par site-aware.md § 6.1 / § 6.2), qui réutilise CurrentSiteProvider + sites.bypass_scope :
- Filtre LISTE (
ProviderProviderou query extension dédiéeProviderSiteScopeExtension) : si l'user n'a passites.bypass_scopeET queCurrentSiteProvider::get()retourne un site → ne renvoyer que les prestataires dontprovider.sitescontient lecurrentSite(jointureprovider_site+WHERE site = :currentSite). Si l'user abypass_scope(Admin, profils consolidation) → aucun filtre (tous sites). SicurrentSite = null(mode dégradé / module Sites off) → alignésite-aware.md § 5(no-op lecture, à documenter). - Filtre DÉTAIL (
Get) : un user sansbypass_scopequi demande un prestataire hors de son site courant → 404 (cohérence : ne pas révéler l'existence d'une ligne hors périmètre). - Écriture (décision Matthieu, 11/06) : un user sans
bypass_scopene peut attacher que les sites dont il dispose (sesuser_site) — sur le formulaire principal (provider.sites, RG-3.03) comme sur chaque adresse (provider_address.sites, RG-3.05). Tout site hors de sesuser_sitedans le payload → 422 sursites. Un userbypass_scope(Admin) peut attacher n'importe quel site. Garde porté par leProviderProcessor(POST + PATCH + sous-ressource adresses). - Cohérence sous-ressources (Contacts / Adresses / RIB) : le cloisonnement du parent n'est PAS hérité automatiquement — les opérations
Get/Patch/Deletedes sous-ressources passent par le provider Doctrine par défaut (etSiteScopedQueryExtensionne filtre que lesSiteAwareInterface, ce que ces entités ne sont pas). Le garde-fou est donc posé explicitement : (a) en lecture/édition/suppression via le provider décoréProviderSubResourceItemProvider(un parent hors périmètre → 404) ; (b) en création (POST /providers/{id}/...) viaProviderSiteScopeChecker::assertInScopedans chaque processor (parent hors périmètre → 404). La décision de scope est centralisée dansProviderSiteScopeChecker(source unique partagée avecProviderProvider). ⚠️ Ne pas retirer ces gardes en les croyant redondants : sans eux, un user cloisonné peut lire l'IBAN/BIC d'un RIB d'un autre site.
Conséquence RBAC : la colonne « Consultation » du docx (« Tout » vs « son site uniquement ») se réalise par
sites.bypass_scope, pas par le code de rôle. Décision d'attribution par défaut (à acter au ticket RBAC) :bypass_scopeaux profils Admin (auto) + Bureau + Compta + Commerciale (ils voient « Tout » d'après le docx) ; Usine ne l'a pas → cloisonné à son site. Si MALIO préfère que Bureau/Commerciale soient aussi cloisonnés, il suffit de ne pas leur donnerbypass_scope— aucun code à changer (c'est l'intérêt de piloter par user/permission et non par rôle).
Index :
idx_provider_site_sitesurprovider_site(site_id)(déjà prévu § 3.2) sert le filtreWHERE site = :currentSite.
3. Modèle de données
3.1 Diagramme
+----------------------+ +--------------------------+ +-----------------+
| provider |--n:m-->| provider_category |<--n:m--| category |
| | +--------------------------+ | type=PRESTATAIRE|
| id (PK) | +-----------------+
| company_name |--n:m-->| provider_site |<--n:m--| site (Sites) |
| is_archived | +--------------------------+ | (RG-3.03) |
| archived_at | +-----------------+
| deleted_at | +--------------------------+
| -- Comptabilité -- |--1:n-->| provider_contact |
| siren / account_num | +--------------------------+
| tva_mode_id | +-----------------+
| n_tva | +--------------------------+ | tva_mode (M1) |
| payment_delay_id |--1:n-->| provider_address | | payment_* (M1) |
| payment_type_id | +--------------------------+ | bank (M1) |
| bank_id (nullable) | | (PAS de address_type) +-----------------+
+----------------------+ +--n:m--> site
+--n:m--> provider_contact
+--------------------------+ +--n:m--> category (PRESTATAIRE)
| provider_rib |
+--------------------------+
label / bic / iban
Particularités M3 (différences vs supplier) :
- PAS d'onglet Information : aucun champ
description/competitors/founded_at/employees_count/revenue_amount/director_name/profit_amount/volume_forecast. Leproviderest minimal : nom + comptabilité. provider.sites(M2Mprovider_site) : sélecteur de site sur le formulaire principal (RG-3.03, ≥ 1). NOUVEAU vs supplier (qui n'avait des sites que sur l'adresse).provider_addresssimplifiée : pas deaddress_type, pas debennes, pas detriage_provider. Champs : sites[], street, street_complement, postal_code, city, country, categories[], contacts[].- Les référentiels comptables (
tva_mode...) ne sont pas recréés — FK vers les tables M1.
3.2 Migration Doctrine — SQL Postgres
Namespace : DoctrineMigrations (racine migrations/) — fichier migrations/VersionYYYYMMDDHHMMSS.php (à dater par le dev).
Même justification qu'au M1/M2 : la migration crée un schéma avec FK cross-module (
user,category,site, FK vers les référentiels comptables M1). Le namespace modulaire casserait l'ordre (make db-reset) — exception racine de la règle ABSOLUE n°11. Le seed duCategoryType PRESTATAIREse fait en deux endroits (migrationON CONFLICTpour la prod + fixture idempotente en dev/test).
Rappel règle ABSOLUE n°12 : chaque colonne créée DOIT recevoir son
COMMENT ON COLUMN(FR, ≤ 200 car., sémantique + contrainte/RG). Les 4 colonnes Timestampable/Blamable passent par le helper. Le SQL ci-dessous est illustratif (style aligné M1/M2 :INT GENERATED BY DEFAULT AS IDENTITY,TIMESTAMP(0) WITHOUT TIME ZONE).
-- =====================================================================
-- Seed taxonomie : nouveau type PRESTATAIRE (référentiels comptables = M1, non recréés)
-- =====================================================================
INSERT INTO category_type (code, label) VALUES ('PRESTATAIRE', 'Prestataire')
ON CONFLICT (code) DO NOTHING;
-- =====================================================================
-- Table principale `provider`
-- =====================================================================
CREATE TABLE provider (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
-- Formulaire principal
company_name VARCHAR(180) NOT NULL,
-- (PAS d'onglet Information — aucun champ description/competitors/founded/employees/...)
-- Onglet Comptabilité (FK référentiels M1 — partagés)
siren VARCHAR(20),
account_number VARCHAR(40),
tva_mode_id INT REFERENCES tva_mode(id) ON DELETE RESTRICT,
n_tva VARCHAR(40),
payment_delay_id INT REFERENCES payment_delay(id) ON DELETE RESTRICT,
payment_type_id INT REFERENCES payment_type(id) ON DELETE RESTRICT,
bank_id INT REFERENCES bank(id) ON DELETE RESTRICT,
-- Archive (exposé M3)
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
archived_at TIMESTAMP(0) WITHOUT TIME ZONE,
-- Soft delete (préparé, non exposé au M3)
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE,
-- Timestampable + Blamable
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
updated_by INT REFERENCES "user"(id) ON DELETE SET NULL
);
CREATE INDEX idx_provider_is_archived ON provider(is_archived);
CREATE INDEX idx_provider_deleted_at ON provider(deleted_at);
CREATE INDEX idx_provider_created_by ON provider(created_by);
CREATE INDEX idx_provider_updated_by ON provider(updated_by);
-- Unicité métier (partielle : ignore archives + soft-delete) — nom de société uniquement (cf. § 2.6)
CREATE UNIQUE INDEX uq_provider_company_name_active
ON provider (LOWER(company_name))
WHERE is_archived = FALSE AND deleted_at IS NULL;
-- =====================================================================
-- M2M provider ↔ category (catégories de type PRESTATAIRE — RG-3.09)
-- =====================================================================
CREATE TABLE provider_category (
provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE,
category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT,
PRIMARY KEY (provider_id, category_id)
);
CREATE INDEX idx_provider_category_category ON provider_category(category_id);
-- =====================================================================
-- M2M provider ↔ site (sélecteur de site du FORMULAIRE PRINCIPAL — RG-3.03)
-- =====================================================================
CREATE TABLE provider_site (
provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE,
site_id INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT,
PRIMARY KEY (provider_id, site_id)
);
CREATE INDEX idx_provider_site_site ON provider_site(site_id);
-- =====================================================================
-- Sous-collection : Contacts (1:n)
-- =====================================================================
CREATE TABLE provider_contact (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE,
first_name VARCHAR(120),
last_name VARCHAR(120),
job_title VARCHAR(120),
phone_primary VARCHAR(20),
phone_secondary VARCHAR(20),
email VARCHAR(180),
position INT NOT NULL DEFAULT 0,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
updated_by INT REFERENCES "user"(id) ON DELETE SET NULL,
-- RG-3.04 : au moins 1 champ rempli (garanti côté Processor ; le CHECK ci-dessous
-- couvre le cas « nom OU prénom » comme garde-fou minimal aligné M2)
CONSTRAINT chk_provider_contact_name
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL)
);
CREATE INDEX idx_provider_contact_provider ON provider_contact(provider_id);
-- =====================================================================
-- Sous-collection : Adresses (1:n) — PAS de address_type / bennes / triage
-- =====================================================================
CREATE TABLE provider_address (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE,
country VARCHAR(80) NOT NULL DEFAULT 'France',
postal_code VARCHAR(20) NOT NULL,
city VARCHAR(120) NOT NULL,
street VARCHAR(255) NOT NULL,
street_complement VARCHAR(255),
position INT NOT NULL DEFAULT 0,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
updated_by INT REFERENCES "user"(id) ON DELETE SET NULL
);
CREATE INDEX idx_provider_address_provider ON provider_address(provider_id);
-- M2M provider_address ↔ site (RG-3.05 : ≥ 1 site)
CREATE TABLE provider_address_site (
provider_address_id INT NOT NULL REFERENCES provider_address(id) ON DELETE CASCADE,
site_id INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT,
PRIMARY KEY (provider_address_id, site_id)
);
-- M2M provider_address ↔ provider_contact
CREATE TABLE provider_address_contact (
provider_address_id INT NOT NULL REFERENCES provider_address(id) ON DELETE CASCADE,
provider_contact_id INT NOT NULL REFERENCES provider_contact(id) ON DELETE CASCADE,
PRIMARY KEY (provider_address_id, provider_contact_id)
);
-- M2M provider_address ↔ category (catégorie d'adresse, type PRESTATAIRE — RG-3.09)
CREATE TABLE provider_address_category (
provider_address_id INT NOT NULL REFERENCES provider_address(id) ON DELETE CASCADE,
category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT,
PRIMARY KEY (provider_address_id, category_id)
);
-- =====================================================================
-- Sous-collection : RIB (1:n)
-- =====================================================================
CREATE TABLE provider_rib (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE,
label VARCHAR(120) NOT NULL,
bic VARCHAR(20) NOT NULL,
iban VARCHAR(34) NOT NULL,
position INT NOT NULL DEFAULT 0,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
updated_by INT REFERENCES "user"(id) ON DELETE SET NULL
);
CREATE INDEX idx_provider_rib_provider ON provider_rib(provider_id);
3.2.bis Commentaires SQL obligatoires (échantillon)
$this->addSql("COMMENT ON TABLE provider IS 'Répertoire prestataires (M3 Technique) — entités archivables.'");
$this->addSql("COMMENT ON COLUMN provider.company_name IS 'Raison sociale du prestataire — stockée en MAJUSCULES. Unique parmi non-archivés/non-supprimés (RG-3.10).'");
$this->addSql("COMMENT ON COLUMN provider.payment_type_id IS 'Type de règlement — FK -> payment_type.id (référentiel partagé M1), ON DELETE RESTRICT. Pilote RG-3.07 (Banque) et RG-3.08 (RIB).'");
$this->addSql("COMMENT ON COLUMN provider.bank_id IS 'Banque — FK -> bank.id (M1). Obligatoire ssi payment_type=VIREMENT (RG-3.07), null sinon.'");
$this->addSql("COMMENT ON COLUMN provider.siren IS 'SIREN du prestataire (9 chiffres). Non unique (cf. RG-3.10). Saisi à l''onglet Comptabilité.'");
// provider_site (M2M) : commenter via COMMENT ON TABLE
$this->addSql("COMMENT ON TABLE provider_site IS 'Sites rattachés au prestataire (sélecteur du formulaire principal — RG-3.03, ≥ 1).'");
$this->addSql("COMMENT ON COLUMN provider_address.postal_code IS 'Code postal — déclenche l''autocomplétion ville via l''API BAN (RG-3.06).'");
// + COMMENT ON COLUMN sur TOUTES les autres colonnes métier (cf. règle n°12)
$this->addStandardTimestampableBlamableComments($schema, 'provider');
$this->addStandardTimestampableBlamableComments($schema, 'provider_contact');
$this->addStandardTimestampableBlamableComments($schema, 'provider_address');
$this->addStandardTimestampableBlamableComments($schema, 'provider_rib');
3.3 Entité Provider — squelette (extrait)
Miroir de Supplier (cf. ../M2-suppliers/spec-back.md § 3.3), amputé de l'onglet Information et augmenté de sites (relation directe).
<?php
declare(strict_types=1);
namespace App\Module\Technique\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Catalog\Domain\Entity\Category; // relation ORM partagée (cf. § 2.1)
use App\Module\Sites\Domain\Entity\Site; // relation ORM partagée (cf. § 2.1)
use App\Module\Commercial\Domain\Entity\TvaMode; // référentiel partagé (cf. § 2.1) — sinon Shared (HP-M4-2)
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderProcessor;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderProvider;
use App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('technique.providers.view')",
// Liste embarque catégories + sites (relation DIRECTE provider.sites — RG-3.03).
// Maillon (c) : category:read + site:read dans le contexte.
normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
provider: ProviderProvider::class,
),
new Get(
security: "is_granted('technique.providers.view')",
// Détail embarque sous-collections (contacts, addresses, ribs) + relations imbriquées.
// provider:read:accounting AJOUTÉ dynamiquement par le ReadGroupContextBuilder si accounting.view.
normalizationContext: ['groups' => [
'provider:read', 'provider:item:read',
'category:read', 'site:read', 'default:read',
]],
provider: ProviderProvider::class,
),
new Post(
security: "is_granted('technique.providers.manage')",
normalizationContext: ['groups' => ['provider:read', 'default:read']],
denormalizationContext: ['groups' => ['provider:write:main']],
processor: ProviderProcessor::class,
),
new Patch(
// Security élargie : manage OU accounting.manage (Compta édite la compta sans manage global).
security: "is_granted('technique.providers.manage') or is_granted('technique.providers.accounting.manage')",
normalizationContext: ['groups' => ['provider:read', 'default:read']],
denormalizationContext: ['groups' => [
'provider:write:main', 'provider:write:accounting', 'provider:write:archive',
]],
provider: ProviderProvider::class,
processor: ProviderProcessor::class,
),
// Pas de Delete au M3 (HP M4). Archivage via PATCH { isArchived: true }.
],
)]
#[ORM\Entity(repositoryClass: DoctrineProviderRepository::class)]
#[ORM\Table(name: 'provider')]
#[Auditable]
class Provider implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
#[Groups(['provider:read'])]
private ?int $id = null;
#[ORM\Column(length: 180)]
#[Assert\NotBlank(message: 'Le nom du prestataire est obligatoire.', normalizer: 'trim')]
#[Assert\Length(min: 2, max: 180, normalizer: 'trim')]
#[Groups(['provider:read', 'provider:write:main'])]
private ?string $companyName = null;
/** @var Collection<int, Category> Catégories de type PRESTATAIRE (RG-3.09) */
#[ORM\ManyToMany(targetEntity: Category::class)]
#[ORM\JoinTable(name: 'provider_category')]
#[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
#[Groups(['provider:read', 'provider:write:main'])]
private Collection $categories;
/** @var Collection<int, Site> Sites du prestataire — sélecteur du formulaire principal (RG-3.03) */
#[ORM\ManyToMany(targetEntity: Site::class)]
#[ORM\JoinTable(name: 'provider_site')]
#[Assert\Count(min: 1, minMessage: 'Au moins un site est obligatoire.')]
#[Groups(['provider:read', 'provider:write:main'])]
private Collection $sites;
// === Onglet Comptabilité (lecture/écriture conditionnées par permission — cf. M2) ===
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $siren = null;
#[ORM\Column(length: 40, nullable: true)]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $accountNumber = null;
#[ORM\ManyToOne(targetEntity: TvaMode::class)]
#[ORM\JoinColumn(name: 'tva_mode_id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?TvaMode $tvaMode = null;
#[ORM\Column(length: 40, nullable: true)]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $nTva = null;
#[ORM\ManyToOne(targetEntity: PaymentDelay::class)]
#[ORM\JoinColumn(name: 'payment_delay_id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?PaymentDelay $paymentDelay = null;
#[ORM\ManyToOne(targetEntity: PaymentType::class)]
#[ORM\JoinColumn(name: 'payment_type_id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?PaymentType $paymentType = null;
#[ORM\ManyToOne(targetEntity: Bank::class)]
#[ORM\JoinColumn(name: 'bank_id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?Bank $bank = null;
// === Sous-collections — EMBARQUÉES dans le DÉTAIL (RETEX M1 §2) ===
/** @var Collection<int, ProviderContact> */
#[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[Groups(['provider:item:read'])]
private Collection $contacts;
/** @var Collection<int, ProviderAddress> */
#[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[Groups(['provider:item:read'])]
private Collection $addresses;
/** @var Collection<int, ProviderRib> RIB embarqués dans le groupe COMPTA (gated par le Provider) */
#[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderRib::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[Groups(['provider:read:accounting'])]
private Collection $ribs;
// === Archive / Soft delete ===
#[ORM\Column(name: 'is_archived', options: ['default' => false])]
private bool $isArchived = false;
// ⚠ PIÈGE BOOLÉEN (bug #3 M1) : #[Groups] + #[SerializedName('isArchived')] SUR LE GETTER,
// sinon Symfony strip "is" → attribut "archived" → clé droppée. À tester sur JSON réel.
#[Groups(['provider:read', 'provider:write:archive'])]
#[SerializedName('isArchived')]
public function isArchived(): bool
{
return $this->isArchived;
}
// ... archivedAt, getters/setters, __construct (ArrayCollection) ...
}
3.4 Squelettes des autres entités
Même pattern que les jumelles Supplier* (#[Auditable], TimestampableBlamableTrait, FK provider_id). Chaque propriété affichée porte un read-group (RETEX M1 §1 maillon (a)) :
ProviderContact — propriétés dans ['provider:item:read', 'provider:write:contacts'] :
firstName, lastName, jobTitle, phonePrimary, phoneSecondary, email, id. Embed sous provider.contacts au détail ; éditables via la sous-ressource. Max 2 téléphones (phonePrimary + phoneSecondary).
ProviderAddress — propriétés dans ['provider:item:read', 'provider:write:addresses'] :
country, postalCode, city, street, streetComplement, id. PAS de addressType / bennes / triageProvider. Relations imbriquées (maillon (c) — read-groups dans le contexte du Get racine) :
- M2M
sites→#[Groups(['provider:item:read'])];Siteexposeid/name/postalCode/city/colorensite:read(pas decode) (Assert\Count(min:1)— RG-3.05). - M2M
contacts→#[Groups(['provider:item:read'])]; embarque desProviderContact. - M2M
categories→#[Groups(['provider:item:read'])];Category(id/code/name, type PRESTATAIRE — RG-3.09).
ProviderRib — propriétés dans ['provider:read:accounting', 'provider:write:accounting'] :
label, bic, iban, id. Embed sous provider.ribs uniquement si l'user a accounting.view. Aucun #[AuditIgnore] sur iban/bic.
⚠
Site/Category/ référentiels comptables appartiennent à d'autres modules — on consomme leurs read-groups (site:read,category:read,provider:read:accountingpour les réfs compta), pas de logique inter-module (§ 2.1).
4. API REST (API Platform)
4.0 Contrat de sérialisation (RETEX M1 — section critique)
Leçon M1/M2 : ~80 % des frictions venaient du contrat de sérialisation. Pour chaque champ affiché (liste OU détail), les 3 maillons doivent être prouvés : (a) groupe sur la propriété, (b) groupe dans le
normalizationContextde l'opération, (c) read-group de l'entité imbriquée présent dans le contexte parent.
Contexte par opération :
| Opération | normalizationContext (groupes) |
|---|---|
GetCollection (liste) |
provider:read + category:read + site:read + default:read |
Get (détail) |
provider:read + provider:item:read + provider:read:accounting¹ + category:read + site:read + default:read |
¹ provider:read:accounting retiré par le ProviderProvider / ProviderReadGroupContextBuilder si l'user n'a pas technique.providers.accounting.view.
LISTE — champ datatable → maillons :
| Champ affiché | Propriété (a) | Dans contexte liste (b) | Imbriqué (c) |
|---|---|---|---|
| Nom | companyName ∈ provider:read |
✅ | — |
| Catégories | categories ∈ provider:read (embed) |
✅ | category:read ✅ (code/name) |
| Site | sites ∈ provider:read (embed, relation directe — RG-3.03) |
✅ | site:read ✅ (name/postalCode, pas de code) |
| Dernière activité | updatedAt ∈ provider:read |
✅ | — |
DÉTAIL — champ → maillons :
| Bloc / champ | Propriété (a) | Dans contexte détail (b) | Imbriqué (c) |
|---|---|---|---|
| Scalaires principaux | provider:read |
✅ | — |
categories[] (id/code/name) |
categories ∈ provider:read |
✅ | category:read ✅ |
sites[] (formulaire principal) |
sites ∈ provider:read |
✅ | site:read ✅ |
contacts[] (5 champs) |
contacts ∈ provider:item:read |
✅ | propriétés ProviderContact ∈ provider:item:read ✅ |
addresses[] (scalaires) |
addresses ∈ provider:item:read |
✅ | propriétés ProviderAddress ∈ provider:item:read ✅ |
addresses[].sites[] |
sites ∈ provider:item:read |
✅ | site:read ✅ |
addresses[].categories[] |
categories ∈ provider:item:read |
✅ | category:read ✅ |
addresses[].contacts[] |
contacts ∈ provider:item:read |
✅ | propriétés ProviderContact ∈ provider:item:read ✅ |
| Scalaires Comptabilité | provider:read:accounting |
✅ (gated) | réfs (tvaMode…) id+label ∈ provider:read:accounting |
ribs[] (label/bic/iban) |
ribs ∈ provider:read:accounting |
✅ (gated) | — |
4.0.bis Réponses JSON de référence (DoD — à CAPTURER sur l'API réelle)
Definition of Done (miroir ERP-92 du M2) : avant de démarrer les écrans front, capturer les réponses RÉELLES via un test PHPUnit (
ProviderSerializationContractTest, prestataire complet seedé) et les coller ici. Toute donnée affichée par le front DOIT apparaître dans ce JSON. Ne jamais déclarer un champ « embarqué » sans l'avoir vu dans un JSON réel (règle anti-régression M2).2 pièges hérités M1/M2 à re-tester sur le M3 :
- Réfs comptables (
tvaMode/paymentDelay/paymentType/bank) : doivent sortir en objet{id, code, label}, pas en IRI nu → vérifier que les entités partagées portent bien le groupeprovider:read:accounting(sinon les annoter, comme le fix ERP-92 l'a fait poursupplier:read:accounting).- Gating compta par omission de clé : pour un user sans
accounting.view, les cléssiren/tvaMode/ribs/… sont absentes (pasnull).
✅ Capturé sur l'API réelle (ERP-139) via
ProviderSerializationContractTest::testDodReferenceJsonShape(PROVIDER_DOD_DUMP=1). Lesid/companyName/noms de catégorie ci-dessous proviennent du prestataire seedé par le test (seedCompleteProvider) ; la forme (clés, embed, gating) est le contrat réel à respecter côté front.
GET /api/providers (liste, ADMIN avec accounting.view — un membre, capture réelle) :
{
"@context": "/api/contexts/Provider",
"@id": "/api/providers",
"@type": "Collection",
"totalItems": 1,
"member": [
{
"@id": "/api/providers/572",
"@type": "Provider",
"id": 572,
"companyName": "DOD21AADC 0E3CCE",
"categories": [
{
"@type": "Category",
"@id": "/api/categories/3006",
"id": 3006,
"name": "test_prov_cat_nettoyage",
"code": "NETTOYAGE",
"categoryTypes": [
{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}
],
"createdAt": "2026-06-12T15:17:29+02:00",
"updatedAt": "2026-06-12T15:17:29+02:00"
}
],
"sites": [
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
],
"siren": "987654321",
"accountNumber": "P0001",
"tvaMode": {"@id": "/api/tva_modes/13", "@type": "TvaMode", "id": 13, "code": "FRANCE_VENTES", "label": "France (ventes)"},
"nTva": "FR00987654321",
"paymentDelay": {"@id": "/api/payment_delays/8", "@type": "PaymentDelay", "id": 8, "code": "J30", "label": "30 jours"},
"paymentType": {"@id": "/api/payment_types/10", "@type": "PaymentType", "id": 10, "code": "LCR", "label": "LCR"},
"ribs": [
{"@id": "/api/provider_ribs/60", "@type": "ProviderRib", "id": 60, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"createdAt": "2026-06-12T15:17:29+02:00",
"updatedAt": "2026-06-12T15:17:29+02:00",
"isArchived": false
}
],
"view": {"@id": "/api/providers?search=DoD21aadc", "@type": "PartialCollectionView"}
}
Les
sites[]de la liste sont la relation directeprovider.sites(formulaire principal — RG-3.03), objetSiteentier (pas un IRI nu). Les catégories embarquentcode+name. Les prestataires archivés sont exclus dutotalItems(RG-3.16). Pour un profil sansaccounting.view(ex. Commerciale),siren/accountNumber/tvaMode/nTva/paymentDelay/paymentType/bank/ribsdisparaissent de chaque membre (gating par omission — cf. détail restreint ci-dessous).
GET /api/providers/{id} (détail — user avec accounting.view, capture réelle) :
{
"@context": "/api/contexts/Provider",
"@id": "/api/providers/572",
"@type": "Provider",
"id": 572,
"companyName": "DOD21AADC 0E3CCE",
"categories": [
{"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"sites": [
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
],
"siren": "987654321",
"accountNumber": "P0001",
"tvaMode": {"@id": "/api/tva_modes/13", "@type": "TvaMode", "id": 13, "code": "FRANCE_VENTES", "label": "France (ventes)"},
"nTva": "FR00987654321",
"paymentDelay": {"@id": "/api/payment_delays/8", "@type": "PaymentDelay", "id": 8, "code": "J30", "label": "30 jours"},
"paymentType": {"@id": "/api/payment_types/10", "@type": "PaymentType", "id": 10, "code": "LCR", "label": "LCR"},
"contacts": [
{"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"addresses": [
{
"@id": "/api/provider_addresses/35", "@type": "ProviderAddress", "id": 35,
"country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
"sites": [
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
],
"contacts": [
{"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"categories": [
{"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"
}
],
"ribs": [
{"@id": "/api/provider_ribs/60", "@type": "ProviderRib", "id": 60, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"createdAt": "2026-06-12T15:17:29+02:00",
"updatedAt": "2026-06-12T15:17:29+02:00",
"isArchived": false
}
GET /api/providers/{id} (même prestataire, user sans accounting.view — capture réelle) :
{
"@context": "/api/contexts/Provider",
"@id": "/api/providers/572",
"@type": "Provider",
"id": 572,
"companyName": "DOD21AADC 0E3CCE",
"categories": [
{"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"sites": [
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
],
"contacts": [
{"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"addresses": [
{
"@id": "/api/provider_addresses/35", "@type": "ProviderAddress", "id": 35,
"country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
"sites": [
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
],
"contacts": [
{"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"categories": [
{"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"
}
],
"createdAt": "2026-06-12T15:17:29+02:00",
"updatedAt": "2026-06-12T15:17:29+02:00",
"isArchived": false
}
Gating par omission confirmé sur le JSON réel : pour un user sans
accounting.view, les cléssiren,accountNumber,tvaMode,nTva,paymentDelay,paymentType,banketribssont absentes (pasnull).isArchived,contacts[],addresses[](avecsites[]/contacts[]/categories[]) restent exposés. Vérifié parProviderSerializationContractTest::testRibsAbsentForUserWithoutAccountingView+testAccountingScalarsGatedByOmission.Réfs comptables = objets embarqués
{id, code, label}(pas IRI nu) : le fix ERP-139 a ajoutéprovider:read:accountingsurTvaMode/PaymentDelay/PaymentType/Bank(réplique du fix ERP-92 du M2). Vérifié partestAccountingReferentialsEmbedIdCodeLabel.
4.1 GET /api/providers — Liste
- Security :
is_granted('technique.providers.view') - Query params (alimentent le panneau « Filtrer ») :
includeArchived=true|false(defaultfalse)categoryCode=<code>(filtre les prestataires ayant ≥ 1Categoryde ce code ; répétable)siteId=<id>(filtre via la relation directeprovider.sites; répétable) — NB : au M3 le site est porté par le prestataire, le filtre jointprovider_site(pas les adresses).search=<text>(fuzzy surcompanyName+ contacts liésprovider_contact(firstName / lastName / email) via LEFT JOIN groupé parprovider.id)
- Tri par défaut :
companyName ASC - Cloisonnement par site (§ 2.13) : si l'user n'a pas
sites.bypass_scope, la liste est filtrée sur les prestataires dontprovider.sitescontient lecurrentSite(RG-3.17). Transparent pour le client (pas de query param). - Pagination : standard Starseed (règle ABSOLUE n°13) — Hydra, 10/page,
?pagination=falsepour les selects.ProviderProviderbranché surApiPlatform\Doctrine\Orm\Paginator. ⚠️ Le filtre de cloisonnement s'applique avant la pagination (letotalItemsreflète le périmètre de l'user). - Anti N+1 (§ 2.12) : hydratation des
categories+sitesvia requêtesINbornées séparées (pas de fetch-join combiné). - Codes :
200/401/403
4.2 GET /api/providers/{id} — Détail
- Security :
is_granted('technique.providers.view') - Comportement : prestataire + contacts + adresses + RIBs. Champs
provider:read:accountinginclus seulement sitechnique.providers.accounting.view. - Cloisonnement par site (§ 2.13) : un user sans
sites.bypass_scopequi demande un prestataire hors de son site courant → 404 (ne pas révéler l'existence hors périmètre — RG-3.17). - Codes :
200/404/401/403
4.3 POST /api/providers — Création (formulaire principal)
- Security :
is_granted('technique.providers.manage') - Body (groupe
provider:write:main) :
{
"companyName": "MAINTENANCE PRO SAS",
"categories": ["/api/categories/300"],
"sites": ["/api/sites/87"]
}
- Réponse 201 : le prestataire créé avec son
id. Le front enchaîne les PATCH par onglet. - Codes :
201/400/401/403409 Conflictsi doublon de nom (companyName— RG-3.10). SIREN/email non uniques.422: catégories vides (RG-3.09) ; sites vides (RG-3.03) ; catégorie hors type PRESTATAIRE (RG-3.09).
4.4 PATCH /api/providers/{id} — Modification
- Security base :
is_granted('technique.providers.manage') - Security additionnelle (dans le
ProviderProcessor) :- payload contenant un champ
provider:write:accounting→ exigetechnique.providers.accounting.manage - payload contenant
isArchived→ exigetechnique.providers.archive - mode strict (RG-3.15) : payload mélangeant des groupes hors permissions → 403 sur tout le payload.
- payload contenant un champ
- Body : merge-patch+json, champs modifiés uniquement.
- Codes :
200/400/401/403/404/409/422
4.5 Sous-ressources
Contacts : POST /api/providers/{id}/contacts, PATCH /api/provider_contacts/{id}, DELETE /api/provider_contacts/{id}.
- Security :
is_granted('technique.providers.manage') - RG-3.12 : au moins 1 bloc Contact valide pour finaliser l'onglet côté front. Côté back, la collection peut rester vide (pas de state machine).
Adresses : POST /api/providers/{id}/addresses, PATCH /api/provider_addresses/{id}, DELETE /api/provider_addresses/{id}.
- Security :
is_granted('technique.providers.manage') - Validations : ≥ 1 site (RG-3.05) ; catégories de type PRESTATAIRE uniquement (RG-3.09) ;
postalCodematche^[0-9]{4,5}$(RG-3.06).
RIBs : POST /api/providers/{id}/ribs, PATCH /api/provider_ribs/{id}, DELETE /api/provider_ribs/{id}.
- Security :
is_granted('technique.providers.accounting.manage') - RG-3.08 : si
paymentType.code = LCR, suppression du dernier RIB → 409.
4.6 GET /api/providers/export.xlsx — Export
- Security :
is_granted('technique.providers.view') - Comportement : XLSX des prestataires affichés (mêmes filtres que la liste, non archivés par défaut).
- Colonnes : Nom prestataire, Contact principal (Nom + Prénom), Téléphone principal, Téléphone secondaire, Email, Catégories (CSV), Sites (CSV), SIREN (omis si pas
accounting.view), Date de création. (Colonnes contact alimentées depuis le contact principalprovider_contactde plus petitposition.) - Implémentation : controller custom
ProviderExportControlleravec#[Route(priority: 1)](règle ABSOLUE — conflit API Platform{id}). Lib : PhpSpreadsheet (déjà présente). - Réponse 200 :
Content-Disposition: attachment; filename="repertoire-prestataires-{YYYYMMDD}.xlsx"
4.7 Référentiels (réutilisés M1/M2 — évolution security)
GET /api/tva_modes, /api/payment_delays, /api/payment_types, /api/banks existent. Évolution M3 : élargir leur security pour autoriser aussi les rôles prestataires, p.ex. ... or is_granted('technique.providers.view'). Tri position ASC puis label ASC. Pas d'écriture exposée (HP).
GET /api/categories?typeCode=PRESTATAIRE alimente les multi-selects Catégorie (prestataire + adresse). ✅ Le filtre ?typeCode= existe (créé au M2) — il suffit de seeder le type PRESTATAIRE + ses catégories. À vérifier que le filtre fonctionne pour ce nouveau type (DoD).
5. Autorisation
5.1 Déclaration des permissions
Créer TechniqueModule::permissions() :
['code' => 'technique.providers.view', 'label' => 'Voir les prestataires'],
['code' => 'technique.providers.manage', 'label' => 'Créer / modifier les prestataires (hors onglet Comptabilité)'],
['code' => 'technique.providers.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un prestataire'],
['code' => 'technique.providers.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un prestataire'],
['code' => 'technique.providers.archive', 'label' => 'Archiver / restaurer un prestataire'],
Synchronisation : php bin/console app:sync-permissions.
5.2 Mapping rôles MALIO ↔ permissions
Cf. § 2.9 (matrice détaillée — identique à la matrice M2 transposée sur technique.providers) + § 2.13 (cloisonnement par site via sites.bypass_scope). Attribution sites.bypass_scope par défaut : Admin (auto) + Bureau + Compta + Commerciale ; Usine non (cloisonnée à son site).
5.3 Synchronisation RBAC (3 sources OBLIGATOIRES — règle ABSOLUE Starseed n°8)
config/sidebar.php— nouvelle section « Technique » + item :
[
'key' => 'technique',
'label' => 'sidebar.technique.section',
'items' => [
[
'label' => 'sidebar.technique.providers',
'to' => '/providers',
'icon' => 'mdi:account-wrench-outline',
'module' => 'technique',
'permission' => 'technique.providers.view',
],
],
],
-
frontend/tests/e2e/_fixtures/personas.ts— étendre les personas existants :- Admin :
view+manage+accounting.view+accounting.manage+archive - Bureau :
view+manage - Compta :
view+accounting.view+accounting.manage - Commerciale :
view+manage+sites.bypass_scope - Bureau / Compta : +
sites.bypass_scope(voient tous les sites) - Usine :
viewsanssites.bypass_scope→ cloisonné à son site (§ 2.13). Persona avec uncurrentSitepositionné pour tester le filtre.
- Admin :
-
src/Module/Core/Infrastructure/Console/SeedE2ECommand.php— miroir back des mêmes personas.
⚠ Les 3 sources doivent être touchées dans le même commit (sinon drift / test cassé).
5.4 Vérification front
usePermissions()filtre l'item sidebar et masque l'onglet Comptabilité (technique.providers.accounting.view).- Bouton « Archiver » visible si
technique.providers.archive(Admin seul).
6. Audit & dates
Provider,ProviderContact,ProviderAddress,ProviderRib:#[Auditable], tous champs audités (y comprisiban/bic).- Audit M2M automatique sur
provider.categoriesetprovider.sites. - Timestampable + Blamable : pattern Shared standard (§ 2.8).
- Libellés i18n
audit.entity.technique_*(§ 2.7).
7. Règles de gestion (RG)
Les RG-3.03 → RG-3.08 reprennent le docx source. RG-3.01 / RG-3.02 sont supprimées (refonte-contact). Les RG-3.09 → RG-3.16 sont des précisions back (miroir M2) explicitement marquées.
Formulaire principal
RG-3.01(SUPPRIMÉE — refonte-contact, 11/06) : le contact principal inline (Nom OU Prénom) est retiré du formulaire principal. Garantie « au moins un contact nommé » portée par RG-3.04 + RG-3.12 surProviderContact.RG-3.02(SUPPRIMÉE du formulaire principal — refonte-contact) : plus de téléphones inline sur le formulaire principal. Le « maximum 2 téléphones » reste applicable aux blocsProviderContact(phonePrimary+phoneSecondary).- RG-3.03 : Au moins un des 3 sites (86 / 17 / 82) doit être sélectionné sur le formulaire principal pour valider la création.
Assert\Count(min: 1)surprovider.sites(M2Mprovider_site). Spécificité M3 (le fournisseur n'avait pas de site sur le formulaire principal). Écriture cloisonnée (§ 2.13) : un user sanssites.bypass_scopene peut choisir que des sites de sesuser_site(sinon 422).
Onglet Contact
- RG-3.04 : Un bloc Contact est valide dès qu'au moins 1 champ est rempli (Nom, Prénom, Fonction, Téléphone ou Email). CHECK BDD
chk_provider_contact_name(garde-fou minimal). Côté UI, le bouton « + Nouveau contact » est bloqué tant que le bloc en cours n'a aucun champ rempli.
Onglet Adresse
- RG-3.05 : Au moins un des 3 sites (86 / 17 / 82) doit être sélectionné sur chaque adresse.
Assert\Count(min: 1)surproviderAddress.sites(M2Mprovider_address_site). - RG-3.06 :
citypréremplie depuispostalCodevia l'API BAN (api-adresse.data.gouv.fr), appel direct front viauseAddressAutocomplete()(réutilisé M1/M2). Si plusieurs villes correspondent → choix dans le select. Cas dégradé (API down) : Ville en texte libre + toast. Validation serveur :postalCodematche^[0-9]{4,5}$; pas de contrôle strict de cohérence CP/Ville.
Onglet Comptabilité
- RG-3.07 : Le champ
bankest visible et obligatoire uniquement sipaymentType.code = 'VIREMENT'(options SG / CIC / CA). Validation server-side dans leProviderProcessor:payment_type = VIREMENTetbank IS NULL→ 422. - RG-3.08 : Les champs RIB (
label,bic,iban) sont obligatoires sipaymentType.code = 'LCR':paymentType = LCRETprovider.ribs.count() = 0→ 422 « Au moins un RIB est obligatoire pour le type LCR ».- DELETE du dernier RIB d'un prestataire en LCR → 409.
- Autres types : RIBs optionnels (0..n).
Précisions back (miroir M2)
- RG-3.09 (précision back) : les
Categoryposées surprovider.categoriesET surprovider_address.categoriesdoivent être de typePRESTATAIRE. Toute catégorie d'un autre type → 422 (categories: "Type de catégorie non autorisé (PRESTATAIRE attendu)."). Front : multi-selects alimentés parGET /api/categories?typeCode=PRESTATAIRE. - RG-3.10 (précision back) :
companyNameunique (case-insensitive) parmi les prestataires non archivés ET non soft-deletés (index partieluq_provider_company_name_active). Doublon → 409 « Un prestataire nommé "{companyName}" existe déjà. » SIREN et email non uniques (§ 2.6). - RG-3.11 (normalisation serveur) :
companyNameUPPERCASE ;firstName/lastName(surProviderContact) Capitalize ; téléphones chiffres uniquement ;emaillowercase. FormatageXX XX XX XX XXà l'affichage front. - RG-3.12 (front-driven) : au moins 1 bloc Contact valide pour finaliser l'onglet (cf. RG-3.04). Pas de test back.
- RG-3.13 (archivage) : PATCH
{ "isArchived": true }exigetechnique.providers.archive(Admin seul). PoseisArchived = true+archivedAt = now(). Aucun autre champ dans la même requête. - RG-3.14 (restauration) : PATCH
{ "isArchived": false }exige la même permission. PoseisArchived = false+archivedAt = null. Conflit d'unicité (un autre prestataire actif a pris le nom) → 409. - RG-3.15 (PATCH mix de groupes, mode strict) : un PATCH mélangeant plusieurs groupes alors que l'user n'a pas toutes les permissions → 403 sur tout le payload (pas de filtrage silencieux). Le front ne doit jamais envoyer de champ hors-permission.
- RG-3.16 (liste / tri) :
GET /api/providersexclut par défaut archivés (is_archived = TRUE) + soft-deletés (deleted_at IS NOT NULL).?includeArchived=trueinclut les archivés (pas les soft-deletés). Tri par défautcompanyName ASC. - RG-3.17 (cloisonnement par site — § 2.13) : un user sans
sites.bypass_scopene voit (liste + détail) que les prestataires dontprovider.sitescontient soncurrentSite. Liste : filtrée avant pagination (totalItems= périmètre user). Détail hors périmètre → 404. Usersbypass_scope(Admin auto) → tous sites. Cloisonnement piloté par l'utilisateur, pas par le rôle.
8. Tests à automatiser
8.1 Cas à couvrir (back — PHPUnit)
- RG-3.03 : POST prestataire sans site → 422 ; avec ≥ 1 site → 201
- RG-3.04 : POST contact totalement vide → 422 (CHECK) ; 1 champ rempli → 200
- RG-3.05 : POST adresse sans aucun site → 422
- RG-3.06 : POST adresse
postalCodeinvalide (3 chiffres) → 422 ; CP/ville incohérents → 200 (pas de contrôle strict) - RG-3.07 : POST Comptabilité
paymentType=VIREMENTsansbank→ 422 ; avecbank→ 200 - RG-3.08 : POST
paymentType=LCRsans RIB → 422 ; DELETE du dernier RIB en LCR → 409 - RG-3.09 : POST
categoriesavec uneCategoryde type ≠ PRESTATAIRE → 422 (sur provider ET sur provider_address) - RG-3.10 : POST
companyNamedéjà pris → 409 ; même nom après archivage de l'ancien → 201 ; SIREN/email dupliqués → 201 - RG-3.11 : POST
companyName="maintenance pro"→ persiste"MAINTENANCE PRO"; normalisationfirstName/phonePrimary/emailtestée via un blocProviderContact - RG-3.13/14 : PATCH isArchived=true par Bureau (sans
archive) → 403 ; par Admin → 200 + archivedAt rempli ; restauration en conflit de nom → 409 - RG-3.15 : Bureau PATCH
{companyName, siren}→ 403 sur tout le payload (strict) - RG-3.16 : GET liste sans flag → exclut archivés ;
?includeArchived=true→ inclut ; tricompanyName ASC - RBAC : Bureau / Commerciale / Compta / Usine sur chaque permission (matrice § 2.9) — 200/403 selon le verbe
- 🔴 Cloisonnement par site (RG-3.17 / § 2.13) : user sans
bypass_scope,currentSite = 86→ la liste ne contient QUE les prestataires rattachés au site 86 (assertion surmember+totalItems) ; GET détail d'un prestataire site 17 → 404 ; userbypass_scope(admin) → voit tous les sites ; écriture cloisonnée : POST/PATCH par un user non-bypass avec un site hors de sesuser_site(formulaire principal OU adresse) → 422 ; avec uniquement ses propres sites → 201/200 - Compta : GET prestataire retourne les champs accounting ; PATCH accounting → 200 ; PATCH contacts/adresses → 403 ; POST création → 403
- Commerciale : GET prestataire sans les champs accounting ; onglet Comptabilité masqué
- 🔴 Gating RIB (bug #4 M1) : GET détail en tant que Commerciale → la clé
ribsest ABSENTE (assertion sur le corps JSON) - 🔴 Sérialisation booléen (bug #3 M1) : GET détail expose bien la clé
isArchiveddans le JSON réel - Embed relations (bugs #1/#2 M1) : GET liste ET détail →
categories[].code+.nameprésents ;sites[](relation directe) exposentname+postalCode(objet Site entier, PAS un IRI nu) ;addresses[].sites[]au détail - Filtre typeCode :
GET /api/categories?typeCode=PRESTATAIREne renvoie QUE les catégories de type PRESTATAIRE - Anti N+1 liste (§ 2.12) : sur
GET /api/providersavec N prestataires, nombre de requêtes SQL constant - Audit : POST + PATCH + archive → audit_log
entity_type='Provider',changescorrect ; iban/bic présents dans le diff ; M2Msites/categoriestracés - Pagination (règle n°13) : enveloppe Hydra (
totalItems/view) ;?pagination=falserenvoie tout - Migration :
make db-reset→ schéma OK ; namespace racine ; CategoryType PRESTATAIRE présent APRÈS db-reset (fixture idempotente) ; index partieluq_provider_company_name_activeprésent ; toutes les colonnes ont unCOMMENT ON COLUMN(ColumnsHaveSqlCommentTestvert) - i18n audit :
audit.entity.technique_provider… présents (AuditableEntitiesHaveI18nLabelTestvert)
8.2 Cas à couvrir (front — Vitest)
usePaginatedList({url:'/providers'}): exclusion archivés par défaut, envelope HydrauseProviderForm(): workflow par onglet (validation incrémentale, PATCH partiel) — sans onglet InformationuseAddressAutocomplete(): réutilisation M1/M2 (nominal + dégradé) — pas de nouveau test si déjà couvert- Sélecteur de site formulaire principal (RG-3.03) : ≥ 1 requis
<ProvidersRepositoryPage>:<MalioDataTable>+ « + Ajouter » →/providers/new- Permissions : Compta accède à
/providers/{id}mais onglet Comptabilité éditable seul ; Commerciale ne voit pas l'onglet Comptabilité useFormErrors: mapping 422 inline par champ (formulaire principal + blocs)
8.3 Tests E2E
Non prévus au M3 (règle ABSOLUE n°7). Extension des personas existants pour ajouter les permissions technique.providers.* — cf. § 5.3.
8.4 Seed & fixtures démo (RETEX M1 §7 — prévu dès la spec)
ProviderFixtures idempotent couvrant tous les cas des RG :
- Catégories de type PRESTATAIRE seedées (au moins « Maintenance industrielle », « Nettoyage », « Transport »).
- ≥ 1 prestataire complet (≥ 1 site sur le formulaire principal, ≥ 1 contact, ≥ 1 adresse multi-sites, comptabilité + RIB).
- 1 prestataire en LCR avec RIB (RG-3.08) et 1 en VIREMENT avec banque (RG-3.07).
- 1 prestataire archivé (vérifier exclusion liste + restauration).
- Réutiliser les comptes de rôles démo (
bureau,compta,commerciale,usine,admin).
Idempotence obligatoire (le purger Doctrine vide
category/category_typeaudb-reset). LeCategoryType PRESTATAIREest seedé en migration ET en fixture.
8.5 Checklist RETEX (à cocher avant « spec prête »)
- 3 maillons de sérialisation documentés pour chaque champ liste + détail (§ 4.0)
- Décision embed vs GetCollection explicite et câblée (embed détail + sous-ressources write — § 3.3 / § 3.4 / § 4.5), pas de POST-only
- Réponses JSON RÉELLES capturées (§ 4.0.bis) — liste + détail (avec/sans
accounting.view) collés depuisProviderSerializationContractTest(ERP-139) - Matrice RBAC rôle × onglet + mode strict PATCH (§ 2.9 / RG-3.15)
- Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit + i18n, routes à plat : rappelés
- Réutilisations M1/M2 identifiées (référentiels compta partagés, taxonomie code/type, filtre
?typeCode=,usePaginatedList, blocs, archive, normalisation,useAddressAutocomplete) - Seed/fixtures démo planifiés (§ 8.4)
- Décisions tranchées (Matthieu, 11/06) : module
Technique(§ 2.1) ✅ ; référentiels comptables « comme supplier » (ORM partagée) ✅ ; cloisonnement par site piloté user viasites.bypass_scope(§ 2.13 / RG-3.17) ✅ ; unicité nom seul (§ 2.6) ✅
9. Hors-périmètre (HP)
- HP-M4-2 : Remontée des référentiels comptables dans
Shared(ou module neutre) si isolation stricte souhaitée (cf. § 2.1). NB : décision M3 = consommation ORM partagée, commeSupplier(validée Matthieu, 11/06). - (ex-HP-M4-1 — DÉSORMAIS DANS LE PÉRIMÈTRE M3) : le cloisonnement par site (visibilité prestataires selon le site de l'utilisateur) est implémenté au M3 — cf. § 2.13 + RG-3.17. Le « bypass multi-sites » passe par
sites.bypass_scope. - HP-M4-3 : DELETE / soft delete d'un prestataire (colonne
deleted_atpréparée, non exposée au M3). - HP-M4-4 : CRUD admin des référentiels comptables (
TvaMode/PaymentDelay/PaymentType/Bank) — partagés, seed seulement. - HP-M4-5 : CRUD admin de
CategoryType(le M3 seed seulement le type PRESTATAIRE). - HP-M4-6 : Onglet Rapports (front placeholder « À venir » ; aucun modèle ni API back).
- HP-M4-7 : Onglet Échanges (placeholder « À venir »).
- HP-M4-8 : Validation IBAN/BIC stricte (au M3,
Assert\Iban/Assert\Bicstandard surProviderRib). - HP-M4-9 : Validation SIREN stricte (Luhn) — au M3,
Assert\Length(9)+Assert\Regex('/^\d{9}$/'). - HP-M4-10 : Référencement entrant (modules futurs ajoutant une FK
provider_id: interventions, maintenance, etc.). - HP-M4-11 : Export CSV (XLSX uniquement au M3).
- HP-M4-12 : Liaison Prestataire ↔ Fournisseur / Client (un même tiers multi-rôles). Au M3, entités strictement séparées.
10. Liens & dépendances
Liens
- Spec front :
./spec-front.md - Spec M2 fournisseurs (pattern de référence direct) :
../M2-suppliers/spec-back.md - Spec M1 clients :
../M1-clients/spec-back.md - RETEX sérialisation :
../_RETEX-M1-pour-M2.md - Doc audit-log :
../../audit-log.md - Site-aware (périmètre Usine) :
../../modules/site-aware.md - BAN api :
https://adresse.data.gouv.fr/api-doc/adresse - Trace fonctionnelle :
M3-reportoire-prestataires.docx(V0.2) /M3-reportoire-prestataires-V01.pdf(V0.1, obsolète)
Dépendances amont (déjà en place dans Starseed)
- Module
Commercial: référentiels comptablesTvaMode/PaymentDelay/PaymentType/Bank(partagés, relation ORM) - Module
Catalog(M0) :Category+CategoryType+ filtre?typeCode=(créé au M2) (+ seed type PRESTATAIRE au M3) - Module
Sites:Site(3 sites 86/17/82) — M2Mprovider_site+provider_address_site - Module
Core:User,Role,Permission,Audit, JWT Shared:TimestampableBlamableTrait+Subscriber- API Platform 4 + Doctrine ORM + PostgreSQL 16 + PhpSpreadsheet (export)
Specs futures qui dépendent du M3
- M-Interventions / Maintenance : FK
provider_id.
📦 Tickets Lesstime (à découper)
TaskGroup Lesstime : à créer — M3 — Répertoire prestataires (projet ERP / Starseed, projectId=6).
Ordre indicatif (back avant front, migration en tête) :
0. Module Technique + Taxonomie PRESTATAIRE — créer TechniqueModule (ID/LABEL/REQUIRED/permissions) + activer dans config/modules.php + layer front modules/technique/ ; seed CategoryType PRESTATAIRE (migration ON CONFLICT + fixture idempotente) + catégories prestataires ; vérifier que le filtre ?typeCode=PRESTATAIRE fonctionne. Prérequis du multi-select Catégorie.
- Migration BDD M3 (tables provider + provider_site + sous-collections + M2M + index partiel + COMMENT ON COLUMN)
- Entités + Repositories (Provider, ProviderContact, ProviderAddress, ProviderRib) + hydratation liste (categories, sites — § 2.12)
- Provider + Processor (ProviderProvider paginé, ProviderProcessor — normalisation, archivage, accounting conditionnel, mode strict, gating) + filtre de cloisonnement par site (§ 2.13 / RG-3.17 :
ProviderSiteScopeExtensionréutilisantCurrentSiteProvider+sites.bypass_scope; liste filtrée, détail 404 hors périmètre) - Sous-ressources (ProviderContactProcessor, ProviderAddressProcessor, ProviderRibProcessor)
- Validators (contrôle catégorie type PRESTATAIRE, RG-3.07/3.08, ≥1 site formulaire principal RG-3.03)
- Export XLSX (ProviderExportController, priority:1)
- RBAC :
TechniqueModule::permissions()+ sync 3 sources + tests personas - Tests PHPUnit : matrice RG-3.03 → RG-3.16 (§ 8.1) + capture JSON réel (§ 4.0.bis)
- Front : page Répertoire (
/providers) +usePaginatedList - Front : page Création (
/providers/new) +useProviderForm(sans onglet Information) - Front : page Consultation (
/providers/{id}) + onglets placeholder « À venir » (Rapports / Échanges) - Front : page Modification (
/providers/{id}/edit) - i18n + Sidebar (section
sidebar.technique.section+sidebar.technique.providers+ permission, traductions, libellés audit)
Actions manuelles dans Lesstime (Matthieu)
- Créer le TaskGroup
M3 — Répertoire prestataires(projet ERP / Starseed, projectId=6). - Créer les ~14 tickets ci-dessus (ticket 0 module+taxonomie inclus) avec dépendances séquentielles.
- Mettre à jour le frontmatter (
lesstime_taskgroup_id) avec l'id réel.
✅ Décisions tranchées (Matthieu, 11/06/2026)
-
Module
Technique(§ 2.1) — nouveau module back + section sidebar « Technique ». ✅ -
Référentiels comptables — « comme supplier » : consommation ORM partagée (pas de remontée dans
Shared). ✅ -
Cloisonnement par site (§ 2.13 / RG-3.17) — visibilité pilotée par l'utilisateur (son
currentSite), automatique côté back ; bypass multi-sites viasites.bypass_scope(Admin auto + Bureau/Compta/Commerciale ; Usine cloisonnée). Indépendant du rôle. ✅ -
Unicité = nom seul (§ 2.6). ✅
-
Écriture cloisonnée (§ 2.13 / RG-3.03 / RG-3.05) — un user non-bypass ne peut attacher que les sites dont il dispose (
user_site), formulaire principal ET adresses ; site hors périmètre → 422. ✅
⚠️ Point de raffinement à confirmer (non bloquant)
- Attribution
sites.bypass_scope: confirmer la liste des profils « voient tous les sites » (défaut : Admin + Bureau + Compta + Commerciale ; Usine non).