Files
Starseed/docs/specs/M3-prestataires/spec-back.md
T
matthieu 3fe0f676f6
Auto Tag Develop / tag (push) Successful in 11s
test(technique) : couvrir RG-3.x PHPUnit + capturer le contrat JSON (ERP-139) (#100)
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
2026-06-12 14:44:43 +00:00

81 KiB
Raw Blame History

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
M2-suppliers
M1-clients
M0-categories
Sites
Core
Shared

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 comptables TvaMode / 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 type PRESTATAIRE.
  • Sites → 3 sites Châtellerault (86) / Saint-Jean (17) / Pommevic (82).
  • SharedTimestampableBlamableTrait + Subscriber (ERP-52).
  • Core → User, Role, Permission, Audit, JWT.

RETEX obligatoire : lire ../_RETEX-M1-pour-M2.md AVANT 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éthode permissions() (cf. § 5.1).
  • Activation : ajouter TechniqueModule::class dans config/modules.php.
  • Front : layer Nuxt frontend/modules/technique/ (auto-détecté) + nouvelle section sidebar « Technique » dans config/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 type PRESTATAIRE + ses catégories (migration ON CONFLICT pour la prod + fixture idempotente pour survivre au purger en dev/test — cf. M2 § 3.2). À vérifier sur le JSON réel que GET /api/categories?typeCode=PRESTATAIRE filtre bien (DoD de la spec).

Forme réelle de Category : expose code et name (PAS label) sous category:read, plus categoryType{ id, code, label }. Le libellé affiché front = category.name. Les M2M provider_category / provider_address_category ne contraignent que des Category de type PRESTATAIRE (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=true permet de voir les archivés (permission technique.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] sur Provider, ProviderContact, ProviderAddress, ProviderRib.
  • Tous les champs auditables (pas d'#[AuditIgnore]) — y compris ProviderRib.iban et ProviderRib.bic (audit admin-only côté Starseed → traçabilité comptable).
  • Audit M2M automatique sur provider.categories et provider.sites ({categories: {added:[...], removed:[...]}}).
  • Libellés i18n (règle ABSOLUE backend — AuditableEntitiesHaveI18nLabelTest) : ajouter audit.entity.technique_provider, audit.entity.technique_providercontact, audit.entity.technique_provideraddress, audit.entity.technique_providerrib dans frontend/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 de manage global).
  • Commerciale : view + manage mais pas accounting.view → onglet Comptabilité masqué (front) et filtré (back) via le ProviderReadGroupContextBuilder (gating par ajout de groupe provider:read:accounting, jamais par retrait). Sans la permission, scalaires compta + ribs ne sont jamais sérialisés.
  • Bureau : view + manage (tout sauf Comptabilité).
  • Usine : view (lecture seule, pas de manage), 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, sites
  • provider:write:contacts — onglet Contact (sous-ressource provider_contact)
  • provider:write:addresses — onglet Adresse (sous-ressource provider_address)
  • provider:write:accounting — onglet Comptabilité (security séparée)
  • provider:write:archive — toggle archive (security technique.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, M2M provider_site). La colonne « Site » de la liste affiche donc provider.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'utilisateurpas du rôle. Un user a un (ou des) site(s) (user_site, + un currentSite actif). Il ne voit que les prestataires rattachés à son site. Les profils qui doivent voir tous les sites passent par sites.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 (ProviderProvider ou query extension dédiée ProviderSiteScopeExtension) : si l'user n'a pas sites.bypass_scope ET que CurrentSiteProvider::get() retourne un site → ne renvoyer que les prestataires dont provider.sites contient le currentSite (jointure provider_site + WHERE site = :currentSite). Si l'user a bypass_scope (Admin, profils consolidation) → aucun filtre (tous sites). Si currentSite = null (mode dégradé / module Sites off) → aligné site-aware.md § 5 (no-op lecture, à documenter).
  • Filtre DÉTAIL (Get) : un user sans bypass_scope qui demande un prestataire hors de son site courant404 (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_scope ne peut attacher que les sites dont il dispose (ses user_site) — sur le formulaire principal (provider.sites, RG-3.03) comme sur chaque adresse (provider_address.sites, RG-3.05). Tout site hors de ses user_site dans le payload → 422 sur sites. Un user bypass_scope (Admin) peut attacher n'importe quel site. Garde porté par le ProviderProcessor (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 / Delete des sous-ressources passent par le provider Doctrine par défaut (et SiteScopedQueryExtension ne filtre que les SiteAwareInterface, 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}/...) via ProviderSiteScopeChecker::assertInScope dans chaque processor (parent hors périmètre → 404). La décision de scope est centralisée dans ProviderSiteScopeChecker (source unique partagée avec ProviderProvider). ⚠️ 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_scope aux 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 donner bypass_scopeaucun code à changer (c'est l'intérêt de piloter par user/permission et non par rôle).

Index : idx_provider_site_site sur provider_site(site_id) (déjà prévu § 3.2) sert le filtre WHERE 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. Le provider est minimal : nom + comptabilité.
  • provider.sites (M2M provider_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_address simplifiée : pas de address_type, pas de bennes, pas de triage_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 du CategoryType PRESTATAIRE se fait en deux endroits (migration ON CONFLICT pour 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'])] ; Site expose id/name/postalCode/city/color en site:read (pas de code) (Assert\Count(min:1) — RG-3.05).
  • M2M contacts#[Groups(['provider:item:read'])] ; embarque des ProviderContact.
  • 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:accounting pour 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 normalizationContext de 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 companyNameprovider:read
Catégories categoriesprovider:read (embed) category:read (code/name)
Site sitesprovider:read (embed, relation directe — RG-3.03) site:read (name/postalCode, pas de code)
Dernière activité updatedAtprovider: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) categoriesprovider:read category:read
sites[] (formulaire principal) sitesprovider:read site:read
contacts[] (5 champs) contactsprovider:item:read propriétés ProviderContactprovider:item:read
addresses[] (scalaires) addressesprovider:item:read propriétés ProviderAddressprovider:item:read
addresses[].sites[] sitesprovider:item:read site:read
addresses[].categories[] categoriesprovider:item:read category:read
addresses[].contacts[] contactsprovider:item:read propriétés ProviderContactprovider:item:read
Scalaires Comptabilité provider:read:accounting (gated) réfs (tvaMode…) id+label ∈ provider:read:accounting
ribs[] (label/bic/iban) ribsprovider: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 :

  1. 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 groupe provider:read:accounting (sinon les annoter, comme le fix ERP-92 l'a fait pour supplier:read:accounting).
  2. Gating compta par omission de clé : pour un user sans accounting.view, les clés siren/tvaMode/ribs/… sont absentes (pas null).

Capturé sur l'API réelle (ERP-139) via ProviderSerializationContractTest::testDodReferenceJsonShape (PROVIDER_DOD_DUMP=1). Les id/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 directe provider.sites (formulaire principal — RG-3.03), objet Site entier (pas un IRI nu). Les catégories embarquent code + name. Les prestataires archivés sont exclus du totalItems (RG-3.16). Pour un profil sans accounting.view (ex. Commerciale), siren/accountNumber/tvaMode/nTva/paymentDelay/paymentType/bank/ribs disparaissent 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és siren, accountNumber, tvaMode, nTva, paymentDelay, paymentType, bank et ribs sont absentes (pas null). isArchived, contacts[], addresses[] (avec sites[]/contacts[]/categories[]) restent exposés. Vérifié par ProviderSerializationContractTest::testRibsAbsentForUserWithoutAccountingView + testAccountingScalarsGatedByOmission.

Réfs comptables = objets embarqués {id, code, label} (pas IRI nu) : le fix ERP-139 a ajouté provider:read:accounting sur TvaMode/PaymentDelay/PaymentType/Bank (réplique du fix ERP-92 du M2). Vérifié par testAccountingReferentialsEmbedIdCodeLabel.

4.1 GET /api/providers — Liste

  • Security : is_granted('technique.providers.view')
  • Query params (alimentent le panneau « Filtrer ») :
    • includeArchived=true|false (default false)
    • categoryCode=<code> (filtre les prestataires ayant ≥ 1 Category de ce code ; répétable)
    • siteId=<id> (filtre via la relation directe provider.sites ; répétable) — NB : au M3 le site est porté par le prestataire, le filtre joint provider_site (pas les adresses).
    • search=<text> (fuzzy sur companyName + contacts liés provider_contact (firstName / lastName / email) via LEFT JOIN groupé par provider.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 dont provider.sites contient le currentSite (RG-3.17). Transparent pour le client (pas de query param).
  • Pagination : standard Starseed (règle ABSOLUE n°13) — Hydra, 10/page, ?pagination=false pour les selects. ProviderProvider branché sur ApiPlatform\Doctrine\Orm\Paginator. ⚠️ Le filtre de cloisonnement s'applique avant la pagination (le totalItems reflète le périmètre de l'user).
  • Anti N+1 (§ 2.12) : hydratation des categories + sites via requêtes IN borné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:accounting inclus seulement si technique.providers.accounting.view.
  • Cloisonnement par site (§ 2.13) : un user sans sites.bypass_scope qui demande un prestataire hors de son site courant404 (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 / 403
    • 409 Conflict si 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 → exige technique.providers.accounting.manage
    • payload contenant isArchived → exige technique.providers.archive
    • mode strict (RG-3.15) : payload mélangeant des groupes hors permissions → 403 sur tout le payload.
  • 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) ; postalCode matche ^[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 principal provider_contact de plus petit position.)
  • Implémentation : controller custom ProviderExportController avec #[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)

  1. config/sidebar.phpnouvelle 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',
        ],
    ],
],
  1. 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 : view sans sites.bypass_scope → cloisonné à son site (§ 2.13). Persona avec un currentSite positionné pour tester le filtre.
  2. 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 compris iban/bic).
  • Audit M2M automatique sur provider.categories et provider.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 sur ProviderContact.
  • 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 blocs ProviderContact (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) sur provider.sites (M2M provider_site). Spécificité M3 (le fournisseur n'avait pas de site sur le formulaire principal). Écriture cloisonnée (§ 2.13) : un user sans sites.bypass_scope ne peut choisir que des sites de ses user_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) sur providerAddress.sites (M2M provider_address_site).
  • RG-3.06 : city préremplie depuis postalCode via l'API BAN (api-adresse.data.gouv.fr), appel direct front via useAddressAutocomplete() (réutilisé M1/M2). Si plusieurs villes correspondent → choix dans le select. Cas dégradé (API down) : Ville en texte libre + toast. Validation serveur : postalCode matche ^[0-9]{4,5}$ ; pas de contrôle strict de cohérence CP/Ville.

Onglet Comptabilité

  • RG-3.07 : Le champ bank est visible et obligatoire uniquement si paymentType.code = 'VIREMENT' (options SG / CIC / CA). Validation server-side dans le ProviderProcessor : payment_type = VIREMENT et bank IS NULL → 422.
  • RG-3.08 : Les champs RIB (label, bic, iban) sont obligatoires si paymentType.code = 'LCR' :
    • paymentType = LCR ET provider.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 Category posées sur provider.categories ET sur provider_address.categories doivent être de type PRESTATAIRE. Toute catégorie d'un autre type → 422 (categories: "Type de catégorie non autorisé (PRESTATAIRE attendu)."). Front : multi-selects alimentés par GET /api/categories?typeCode=PRESTATAIRE.
  • RG-3.10 (précision back) : companyName unique (case-insensitive) parmi les prestataires non archivés ET non soft-deletés (index partiel uq_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) : companyName UPPERCASE ; firstName/lastName (sur ProviderContact) Capitalize ; téléphones chiffres uniquement ; email lowercase. Formatage XX 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 } exige technique.providers.archive (Admin seul). Pose isArchived = true + archivedAt = now(). Aucun autre champ dans la même requête.
  • RG-3.14 (restauration) : PATCH { "isArchived": false } exige la même permission. Pose isArchived = 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/providers exclut par défaut archivés (is_archived = TRUE) + soft-deletés (deleted_at IS NOT NULL). ?includeArchived=true inclut les archivés (pas les soft-deletés). Tri par défaut companyName ASC.
  • RG-3.17 (cloisonnement par site — § 2.13) : un user sans sites.bypass_scope ne voit (liste + détail) que les prestataires dont provider.sites contient son currentSite. Liste : filtrée avant pagination (totalItems = périmètre user). Détail hors périmètre → 404. Users bypass_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 postalCode invalide (3 chiffres) → 422 ; CP/ville incohérents → 200 (pas de contrôle strict)
  • RG-3.07 : POST Comptabilité paymentType=VIREMENT sans bank → 422 ; avec bank → 200
  • RG-3.08 : POST paymentType=LCR sans RIB → 422 ; DELETE du dernier RIB en LCR → 409
  • RG-3.09 : POST categories avec une Category de type ≠ PRESTATAIRE → 422 (sur provider ET sur provider_address)
  • RG-3.10 : POST companyName dé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" ; normalisation firstName/phonePrimary/email testée via un bloc ProviderContact
  • 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 ; tri companyName 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 sur member + totalItems) ; GET détail d'un prestataire site 17 → 404 ; user bypass_scope (admin) → voit tous les sites ; écriture cloisonnée : POST/PATCH par un user non-bypass avec un site hors de ses user_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é ribs est ABSENTE (assertion sur le corps JSON)
  • 🔴 Sérialisation booléen (bug #3 M1) : GET détail expose bien la clé isArchived dans le JSON réel
  • Embed relations (bugs #1/#2 M1) : GET liste ET détailcategories[].code + .name présents ; sites[] (relation directe) exposent name + postalCode (objet Site entier, PAS un IRI nu) ; addresses[].sites[] au détail
  • Filtre typeCode : GET /api/categories?typeCode=PRESTATAIRE ne renvoie QUE les catégories de type PRESTATAIRE
  • Anti N+1 liste (§ 2.12) : sur GET /api/providers avec N prestataires, nombre de requêtes SQL constant
  • Audit : POST + PATCH + archive → audit_log entity_type='Provider', changes correct ; iban/bic présents dans le diff ; M2M sites/categories tracés
  • Pagination (règle n°13) : enveloppe Hydra (totalItems / view) ; ?pagination=false renvoie tout
  • Migration : make db-reset → schéma OK ; namespace racine ; CategoryType PRESTATAIRE présent APRÈS db-reset (fixture idempotente) ; index partiel uq_provider_company_name_active présent ; toutes les colonnes ont un COMMENT ON COLUMN (ColumnsHaveSqlCommentTest vert)
  • i18n audit : audit.entity.technique_provider… présents (AuditableEntitiesHaveI18nLabelTest vert)

8.2 Cas à couvrir (front — Vitest)

  • usePaginatedList({url:'/providers'}) : exclusion archivés par défaut, envelope Hydra
  • useProviderForm() : workflow par onglet (validation incrémentale, PATCH partiel) — sans onglet Information
  • useAddressAutocomplete() : 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_type au db-reset). Le CategoryType PRESTATAIRE est 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 depuis ProviderSerializationContractTest (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 via sites.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, comme Supplier (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_at pré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\Bic standard sur ProviderRib).
  • 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

Dépendances amont (déjà en place dans Starseed)

  • Module Commercial : référentiels comptables TvaMode / 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) — M2M provider_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.

  1. Migration BDD M3 (tables provider + provider_site + sous-collections + M2M + index partiel + COMMENT ON COLUMN)
  2. Entités + Repositories (Provider, ProviderContact, ProviderAddress, ProviderRib) + hydratation liste (categories, sites — § 2.12)
  3. Provider + Processor (ProviderProvider paginé, ProviderProcessor — normalisation, archivage, accounting conditionnel, mode strict, gating) + filtre de cloisonnement par site (§ 2.13 / RG-3.17 : ProviderSiteScopeExtension réutilisant CurrentSiteProvider + sites.bypass_scope ; liste filtrée, détail 404 hors périmètre)
  4. Sous-ressources (ProviderContactProcessor, ProviderAddressProcessor, ProviderRibProcessor)
  5. Validators (contrôle catégorie type PRESTATAIRE, RG-3.07/3.08, ≥1 site formulaire principal RG-3.03)
  6. Export XLSX (ProviderExportController, priority:1)
  7. RBAC : TechniqueModule::permissions() + sync 3 sources + tests personas
  8. Tests PHPUnit : matrice RG-3.03 → RG-3.16 (§ 8.1) + capture JSON réel (§ 4.0.bis)
  9. Front : page Répertoire (/providers) + usePaginatedList
  10. Front : page Création (/providers/new) + useProviderForm (sans onglet Information)
  11. Front : page Consultation (/providers/{id}) + onglets placeholder « À venir » (Rapports / Échanges)
  12. Front : page Modification (/providers/{id}/edit)
  13. i18n + Sidebar (section sidebar.technique.section + sidebar.technique.providers + permission, traductions, libellés audit)

Actions manuelles dans Lesstime (Matthieu)

  1. Créer le TaskGroup M3 — Répertoire prestataires (projet ERP / Starseed, projectId=6).
  2. Créer les ~14 tickets ci-dessus (ticket 0 module+taxonomie inclus) avec dépendances séquentielles.
  3. Mettre à jour le frontmatter (lesstime_taskgroup_id) avec l'id réel.

Décisions tranchées (Matthieu, 11/06/2026)

  1. Module Technique (§ 2.1) — nouveau module back + section sidebar « Technique ».

  2. Référentiels comptables — « comme supplier » : consommation ORM partagée (pas de remontée dans Shared).

  3. Cloisonnement par site (§ 2.13 / RG-3.17) — visibilité pilotée par l'utilisateur (son currentSite), automatique côté back ; bypass multi-sites via sites.bypass_scope (Admin auto + Bureau/Compta/Commerciale ; Usine cloisonnée). Indépendant du rôle.

  4. Unicité = nom seul (§ 2.6).

  5. É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).