Files
Starseed/docs/specs/M2-suppliers/spec-back.md
T
matthieu 8fae987e15
Auto Tag Develop / tag (push) Successful in 6s
docs(commercial) : refonte contact — suppression du contact inline (specs M1 + M2) (#54)
Acte la décision refonte-contact dans les specs : le contact principal inline (firstName/lastName/phonePrimary/phoneSecondary/email) est retiré des entités tiers (Client, Supplier). Les contacts vivent uniquement dans ClientContact / SupplierContact (onglet Contacts). Garantie « >=1 contact nommé » préservée par RG-1.05/1.14 (M1) et RG-2.04/2.13 (M2).

- M1 (spec-back/spec-front/cahier) : modèle Client sans contact inline ; RG-1.01/1.02 supprimées ; D1 (recherche) / D2 (export) décrites ; version V1.
- M2 (spec-back/spec-front) : FICHIERS NOUVEAUX (non versionnés sur develop), introduits déjà corrigés (Supplier sans contact inline, RG-2.01/2.02 supprimées) ; version V0.2.
- docs/specs/M1-clients/refonte-contact/ : décision (README) + tickets (M1 back/front/specs, M2 specs) + prompts + amendement des tickets M2.

Lesstime : tâches #103 (M1 back), #104 (M1 front), #105 (M1 specs), #106 (M2 specs) ; tickets M2 #85-#97 amendés.
---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #54
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-03 13:16:11 +00:00

76 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
M2 Répertoire fournisseurs repertoire-fournisseurs Matthieu Tristan V0.2 2026-06-02 ./spec-front.md https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-36987&p=f&m=dev 26 6 a_dev
M1-clients
M0-categories
Sites
Core
Shared

Spec back — Module 2 : Répertoire fournisseurs

1. Contexte

Cette spec complète et précise la spec front V0.1 (M2-reportoire-fournisseurs.docx du 01/06/2026, historique V0 22/05 → V0.1 01/06) 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 : extension du module Commercial existant (src/Module/Commercial/), aux côtés des Clients (M1). Le M2 est la deuxième sous-section métier Tiers du Commercial (Fournisseurs), construite sur le pattern jumeau de Client déjà éprouvé au M1 (Supplier / SupplierContact / SupplierAddress / SupplierRib).

Dépendances déjà en place sur develop (héritées du M1) :

  • CommercialClient* + 4 référentiels comptables TvaMode / PaymentDelay / PaymentType / Bank (entités lecture seule, déjà seedées — partagées sans duplication par le M2).
  • Catalog (M0) → Category + CategoryType (le M2 ajoute le type FOURNISSEUR).
  • Sites → 3 sites Châtellerault (86) / Saint-Jean (17) / Pommevic (82).
  • SharedTimestampableBlamableTrait + Subscriber (ERP-52).
  • Core → User, Role, Permission, Audit, JWT.

2. Décisions d'archi

2.1 Module — Extension de Commercial, entités jumelles de Client

Le fournisseur M2 vit sous src/Module/Commercial/ (déjà existant). Pas de nouveau module Suppliers. Rationale identique au M1 :

  • Cohérence MALIO : Commercial = couche Tiers (Clients + Fournisseurs + Prestataires).
  • Le M1 a déjà posé le pattern Client / ClientContact / ClientAddress / ClientRib + Provider/Processor + normalisation + archivage. Le M2 le réplique à l'identique sous Supplier* (décision : tables dédiées, pas de table polymorphe partagée — clients et fournisseurs divergeront fonctionnellement, l'isolation prime).
  • La sidebar porte déjà l'item suppliers/suppliers (sans permission). Le M2 lui attache commercial.suppliers.view.

Le CommercialModule.php actuel expose déjà les 5 permissions commercial.clients.*. Le M2 ajoute 5 permissions commercial.suppliers.* (cf. § 5.1).

2.2 IDs entier auto-increment Postgres natif

Cohérent avec M0/M1 et l'ensemble Starseed. Pas d'UUID, pas de ULID.

2.3 Référentiels comptables — réutilisation M1 (zéro duplication)

Les 4 tables tva_mode / payment_delay / payment_type / bank (+ leurs entités lecture seule et leurs seeds) sont celles du M1. Le M2 ne crée aucune nouvelle table de référentiel comptable : supplier.tva_mode_id, supplier.payment_delay_id, supplier.payment_type_id, supplier.bank_id pointent vers les mêmes tables.

Conséquence sur les endpoints : GET /api/tva_modes, /api/payment_delays, /api/payment_types, /api/banks existent déjà (M1). La seule évolution : leur security doit autoriser aussi les rôles fournisseurs (cf. § 4.7).

Confirmé sur le JSON réel (02/06) : les formes sont conformes (id/code/label/position). Les codes pivots VIREMENT et LCR (RG-2.07/2.08) existent bien dans payment_types. Nuance : tva_modes ne contient que des modes « ventes » (FRANCE_VENTES/EXPORT_VENTES/INTRACOM_VENTES). La spec fonctionnelle (docx) dit seulement « Mode de TVA — liste depuis une table », sans distinguer achats/ventes → au M2 on réutilise les modes existants (pas de seed « achats »). Point à confirmer avec le métier : si un mode « achats » est requis pour les fournisseurs, l'ajouter via un seed (référentiel partagé). Tracé en HP-M3-2.

2.4 Catégories — nouveau CategoryType FOURNISSEUR

Le multi-select « Catégorie » du fournisseur référence des Category rattachées à un nouveau CategoryType de code FOURNISSEUR (label « Fournisseur »), seedé par le M2. Décision Matthieu (02/06) : on assume des types distincts (CLIENT / FOURNISSEUR, et PRESTA à venir) — chacun avec sa taxonomie. Rationale : les catégories clients (Agro-alimentaire…) ne sont pas valides pour un fournisseur (Négociant, Coopérative…).

⚠️ CONSTAT JSON RÉEL (02/06) — brique manquante à construire : la refonte ERP-78 a unifié sur un type unique CLIENT et le filtre ?typeCode= est INOPÉRANT (GET /api/categories?typeCode=FOURNISSEUR renvoie les 11 catégories CLIENT, filtre ignoré ; GET /api/category_types → un seul type CLIENT). Donc le M2 doit :

  1. recréer un CategoryType FOURNISSEUR (seed migration + fixture idempotente) ;
  2. implémenter un vrai filtre ?typeCode= sur /api/categories (module Catalog) — il n'existe pas en prod ;
  3. seeder les catégories fournisseurs (Négociant, Coopérative…) sous ce type. → matérialisé en ticket back dédié (cf. § Tickets). Réintroduit volontairement le multi-type qu'ERP-78 avait retiré.

⚠️ Forme réelle de Category : expose code et name (PAS label) sous category:read, plus categoryType{ id, code, label }. Le libellé affiché côté front = category.name. Le M2M supplier_category / supplier_address_category ne contraint que des Category de type FOURNISSEUR (RG-2.10).

Pas d'auto-référence distributeur/courtier au M2 : contrairement au Client, le Supplier n'a pas de relation distributor/broker. On ne réimporte aucune classe d'un autre module : on consomme le contrat partagé / les read-groups de Category.

2.5 Archive vs soft delete — deux mécanismes distincts (identique M1)

Mécanisme Colonne Visibilité défaut Restauration Utilisateur
Archive (fonctionnel) is_archived (bool, default false) + archived_at masqué Oui (toggle UI) Admin seul via commercial.suppliers.archive
Soft delete (technique) deleted_at (timestamptz nullable) masqué HP M3+ Aucun rôle au M2 (HP)

Conséquences (miroir M1) :

  • DELETE /api/suppliers/{id} non exposé au M2 (404 si appelé).
  • GET /api/suppliers?includeArchived=true permet de voir les archivés (permission commercial.suppliers.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).

Différence RBAC notable avec le docx : le tableau « Rôles & permissions » du docx ne donne l'Archive qu'à Admin (Bureau/Compta/Commerciale = « Non »). On s'aligne strictement : commercial.suppliers.archive = Admin uniquement.

2.6 Unicité partielle Postgres — nom de société

Décision validée (Matthieu, 02/06/2026 — alignée sur la décision Q4 du M1) : l'unicité métier porte uniquement sur le nom de fournisseur (company_name). Le SIREN et l'email principal ne sont pas uniques (un même SIREN peut couvrir plusieurs établissements ; un email peut servir plusieurs fiches).

Index unique partiel (WHERE is_archived = FALSE AND deleted_at IS NULL) sur LOWER(company_name). Doublon → 409 Conflict géré par le SupplierProcessor.

2.7 Audit & traces temporelles

Pattern Starseed standard, miroir M1 :

  • #[Auditable] sur Supplier, SupplierContact, SupplierAddress, SupplierRib.
  • Tous les champs auditables (pas d'#[AuditIgnore]) — y compris SupplierRib.iban et SupplierRib.bic (audit admin-only côté Starseed → traçabilité comptable, décision M1 reportée).
  • Audit M2M automatique sur supplier.categories ({categories: {added:[...], removed:[...]}}).

2.8 Timestampable + Blamable

Toutes les entités métier nouvelles implémentent TimestampableInterface + BlamableInterface et utilisent TimestampableBlamableTrait : Supplier, SupplierContact, SupplierAddress, SupplierRib. Les référentiels partagés (TvaMode...) restent whitelistés dans EntitiesAreTimestampableBlamableTest::EXCLUDED (déjà fait au M1).

2.9 Permissions RBAC — granularité (5 permissions, identique M1)

Permission Admin Bureau Compta Commerciale Usine
commercial.suppliers.view (sauf compta)
commercial.suppliers.manage
commercial.suppliers.accounting.view
commercial.suppliers.accounting.manage
commercial.suppliers.archive

Notes (miroir M1) :

  • Compta édite uniquement l'onglet Comptabilité (accounting.manage) d'un fournisseur existant. Compta ne peut pas créer un fournisseur (pas de manage global).
  • Commerciale a view + manage mais pas accounting.view → l'onglet Comptabilité est masqué (front) et filtré (back, 2 niveaux : security API Platform + SupplierProvider).
  • Bureau : view + manage (tout sauf Comptabilité).
  • Usine : aucune permission → item sidebar invisible, accès direct 403.

2.10 Validation incrémentale par onglet (workflow front-driven, identique M1)

Le Supplier est créé en BDD dès validation du formulaire principal via POST /api/suppliers. Les onglets suivants déclenchent des PATCH partiels avec des groupes de sérialisation dédiés :

  • supplier:write:main — formulaire principal (POST + PATCH)
  • supplier:write:information — onglet Information
  • supplier:write:contacts — onglet Contact (sous-ressource supplier_contact)
  • supplier:write:addresses — onglet Adresse (sous-ressource supplier_address)
  • supplier:write:accounting — onglet Comptabilité (security séparée)
  • supplier:write:archive — toggle archive (security commercial.suppliers.archive)

Pas de state machine côté back (pas de status = draft|active). Le fournisseur est actif dès POST réussi. La complétude des onglets est de la responsabilité du front.

2.11 Normalisation serveur des entrées texte (identique M1)

Réutilisation du même pattern que ClientFieldNormalizer, dupliqué en SupplierFieldNormalizer (service interne appelé par les Processors avant validation) :

final class SupplierFieldNormalizer
{
    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 côté front. Le back stocke 0612345678 (chiffres seuls).

2.12 Liste : embed catégories + sites + fetch-joins (cohérence M1/ERP-62)

Décision d'alignement (02/06/2026) : la liste GET /api/suppliers embarque les categories[] (avec code/name) et les sites[] (avec name/postalCode — pas de code), comme la liste Clients après ERP-62 — et non des champs dérivés aplatis. Conséquence performance : le DoctrineSupplierRepository DOIT poser des fetch-joins (leftJoin+addSelect) sur categories et addresses.sites dans la requête de liste pour éviter le N+1. Les sites de la liste sont agrégés/dédoublonnés via Supplier::getSites() (cf. § 3.3). Le contrat de sérialisation (groupes category:read / site:read dans le contexte) est posé une seule fois sur l'entité — source de vérité unique, le front ne le redéfinit pas.

Dépendance confirmée sur le JSON réel (#82 mergé) : Category expose code/name sous category:read ; Site expose name/postalCode/city/color sous site:read (pas de code). L'embed est pleinement matérialisé.

3. Modèle de données

3.1 Diagramme

+----------------------+      +--------------------------+      +--------------+
|     supplier         |--n:m-->|   supplier_category    |<--n:m--|  category    |
|                      |      +--------------------------+      | type=FOURNI. |
| id (PK)              |                                        +--------------+
| company_name         |
| (contact inline      |      +--------------------------+      +--------------+
|  retiré V1 —         |--1:n-->|   supplier_contact     |      |    site      |
|  firstName,          |      +--------------------------+      |  (Sites)     |
|  lastName, phones,   |                                        +--------------+
|  email)              |      +--------------------------+            ^
| is_archived          |--1:n-->|   supplier_address     |--n:m-------+
| archived_at          |      +--------------------------+
| deleted_at           |                  | (address_type radio)
| -- Information --     |                  +--n:m--+--> supplier_contact
| description           |                          |
| competitors           |      +--------------------------+   +-----------------+
| founded_at            |--1:n-->|   supplier_rib         |   |  tva_mode (M1)  |
| employees_count       |      +--------------------------+   |  payment_* (M1) |
| revenue_amount        |      label / bic / iban           |  bank (M1)      |
| director_name         |                                    +-----------------+
| profit_amount         |
| volume_forecast (NEW) |      -- Comptabilité (sur supplier) --
+----------------------+       siren / account_number / tva_mode_id /
                               n_tva / payment_delay_id / payment_type_id /
                               bank_id (nullable)

Particularités M2 (différences vs client) :

  • Pas de distributor_id / broker_id (pas d'auto-référence), donc pas de contrainte CHECK distributor/broker.
  • Pas de triage_service sur l'entité principale — le « Prestataire de triage » est porté par l'adresse (supplier_address.triage_provider).
  • Ajout d'un champ Information volume_forecast (Volume prévisionnel — entier) absent du client.
  • supplier_address remplace les 3 booléens M1 (is_prospect/is_delivery/is_billing) par un seul champ enum address_type (radio Prospect / Départ / Rendu — mutuellement exclusifs par construction). Plus de billing_email (pas d'email facturation au M2).
  • supplier_address ajoute bennes (entier, nullable) et triage_provider (booléen).
  • 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 : la migration crée un schéma avec FK cross-module (user, category, site, et FK vers les référentiels comptables M1). Le namespace modulaire casserait l'ordre (make db-reset) car Doctrine Migrations 3.x trie par FQCN alphabétique. → exception racine de la règle ABSOLUE n°11. Le seed du CategoryType FOURNISSEUR se fait en deux endroits (migration ON CONFLICT pour la prod + fixture idempotente pour survivre au purger en dev/test — cf. M1 § 3.3).

Rappel règle ABSOLUE n°12 : chaque colonne créée ci-dessous DOIT recevoir son COMMENT ON COLUMN. Les 4 colonnes Timestampable/Blamable passent par le helper addStandardTimestampableBlamableComments($schema, '<table>'). Le SQL ci-dessous montre la structure ; les COMMENT ON COLUMN (un par colonne métier) sont à écrire dans la migration (exemples §3.2.bis).

-- =====================================================================
-- Seed taxonomie : nouveau type FOURNISSEUR (référentiels comptables = M1, non recréés)
-- =====================================================================
INSERT INTO category_type (code, label) VALUES ('FOURNISSEUR', 'Fournisseur')
    ON CONFLICT (code) DO NOTHING;

-- =====================================================================
-- Table principale `supplier`
-- =====================================================================
CREATE TABLE supplier (
    id                  SERIAL PRIMARY KEY,
    -- Formulaire principal
    company_name        VARCHAR(180) NOT NULL,
    -- Contact inline retiré (V1, refonte-contact) : first_name / last_name / phone_primary /
    -- phone_secondary / email vivent uniquement dans supplier_contact (onglet Contacts).
    -- Onglet Information (Commerciale obligatoire — RG-2.03 — null sinon)
    description         TEXT,
    competitors         VARCHAR(255),
    founded_at          DATE,
    employees_count     INT,
    revenue_amount      NUMERIC(15,2),
    director_name       VARCHAR(120),
    profit_amount       NUMERIC(15,2),
    volume_forecast     INT,                       -- NEW vs client
    -- 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é M2)
    is_archived         BOOLEAN NOT NULL DEFAULT FALSE,
    archived_at         TIMESTAMPTZ,
    -- Soft delete (préparé, non exposé au M2)
    deleted_at          TIMESTAMPTZ,
    -- Timestampable + Blamable
    created_at          TIMESTAMPTZ NOT NULL,
    updated_at          TIMESTAMPTZ 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_supplier_is_archived ON supplier(is_archived);
CREATE INDEX idx_supplier_deleted_at ON supplier(deleted_at);
CREATE INDEX idx_supplier_created_by ON supplier(created_by);
CREATE INDEX idx_supplier_updated_by ON supplier(updated_by);

-- Unicité métier (partielle : ignore archives + soft-delete) — nom de société uniquement (cf. § 2.6)
CREATE UNIQUE INDEX uq_supplier_company_name_active
    ON supplier (LOWER(company_name))
    WHERE is_archived = FALSE AND deleted_at IS NULL;

-- =====================================================================
-- M2M supplier ↔ category (catégories de type FOURNISSEUR — RG-2.10)
-- =====================================================================
CREATE TABLE supplier_category (
    supplier_id  INT NOT NULL REFERENCES supplier(id) ON DELETE CASCADE,
    category_id  INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT,
    PRIMARY KEY (supplier_id, category_id)
);
CREATE INDEX idx_supplier_category_category ON supplier_category(category_id);

-- =====================================================================
-- Sous-collection : Contacts (1:n)
-- =====================================================================
CREATE TABLE supplier_contact (
    id              SERIAL PRIMARY KEY,
    supplier_id     INT NOT NULL REFERENCES supplier(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      TIMESTAMPTZ NOT NULL,
    updated_at      TIMESTAMPTZ NOT NULL,
    created_by      INT REFERENCES "user"(id) ON DELETE SET NULL,
    updated_by      INT REFERENCES "user"(id) ON DELETE SET NULL,
    -- RG-2.04 : au moins Nom OU Prénom
    CONSTRAINT chk_supplier_contact_name
        CHECK (first_name IS NOT NULL OR last_name IS NOT NULL)
);
CREATE INDEX idx_supplier_contact_supplier ON supplier_contact(supplier_id);

-- =====================================================================
-- Sous-collection : Adresses (1:n)
-- =====================================================================
CREATE TABLE supplier_address (
    id                SERIAL PRIMARY KEY,
    supplier_id       INT NOT NULL REFERENCES supplier(id) ON DELETE CASCADE,
    -- Radio Prospect / Départ / Rendu (mutuellement exclusifs — RG-2.09)
    address_type      VARCHAR(20) NOT NULL,         -- 'PROSPECT' | 'DEPART' | 'RENDU'
    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),
    bennes            INT,                            -- NEW (spécifique fournisseur)
    triage_provider   BOOLEAN NOT NULL DEFAULT FALSE, -- NEW (Prestataire de triage)
    position          INT NOT NULL DEFAULT 0,
    created_at        TIMESTAMPTZ NOT NULL,
    updated_at        TIMESTAMPTZ NOT NULL,
    created_by        INT REFERENCES "user"(id) ON DELETE SET NULL,
    updated_by        INT REFERENCES "user"(id) ON DELETE SET NULL,
    -- RG-2.09 : valeur enum contrôlée
    CONSTRAINT chk_supplier_address_type
        CHECK (address_type IN ('PROSPECT', 'DEPART', 'RENDU'))
);
CREATE INDEX idx_supplier_address_supplier ON supplier_address(supplier_id);

-- M2M supplier_address ↔ site (RG-2.06 : ≥ 1 site)
CREATE TABLE supplier_address_site (
    supplier_address_id INT NOT NULL REFERENCES supplier_address(id) ON DELETE CASCADE,
    site_id             INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT,
    PRIMARY KEY (supplier_address_id, site_id)
);

-- M2M supplier_address ↔ supplier_contact
CREATE TABLE supplier_address_contact (
    supplier_address_id INT NOT NULL REFERENCES supplier_address(id) ON DELETE CASCADE,
    supplier_contact_id INT NOT NULL REFERENCES supplier_contact(id) ON DELETE CASCADE,
    PRIMARY KEY (supplier_address_id, supplier_contact_id)
);

-- M2M supplier_address ↔ category (catégorie d'adresse, type FOURNISSEUR — RG-2.10)
CREATE TABLE supplier_address_category (
    supplier_address_id INT NOT NULL REFERENCES supplier_address(id) ON DELETE CASCADE,
    category_id         INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT,
    PRIMARY KEY (supplier_address_id, category_id)
);

-- =====================================================================
-- Sous-collection : RIB (1:n)
-- =====================================================================
CREATE TABLE supplier_rib (
    id          SERIAL PRIMARY KEY,
    supplier_id INT NOT NULL REFERENCES supplier(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  TIMESTAMPTZ NOT NULL,
    updated_at  TIMESTAMPTZ 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_supplier_rib_supplier ON supplier_rib(supplier_id);

3.2.bis Commentaires SQL obligatoires (échantillon)

$this->addSql("COMMENT ON TABLE supplier IS 'Répertoire fournisseurs (M2 Commercial) — entités archivables.'");
$this->addSql("COMMENT ON COLUMN supplier.company_name IS 'Raison sociale du fournisseur — stockée en MAJUSCULES. Unique parmi non-archivés/non-supprimés (RG-2.06).'");
$this->addSql("COMMENT ON COLUMN supplier.volume_forecast IS 'Volume prévisionnel (entier) — onglet Information. Obligatoire pour le rôle Commerciale (RG-2.03).'");
$this->addSql("COMMENT ON COLUMN supplier.payment_type_id IS 'Type de règlement — FK -> payment_type.id (référentiel partagé M1), ON DELETE RESTRICT. Pilote RG-2.07 (Banque) et RG-2.08 (RIB).'");
$this->addSql("COMMENT ON COLUMN supplier.bank_id IS 'Banque — FK -> bank.id (M1). Obligatoire ssi payment_type=VIREMENT (RG-2.07), null sinon.'");
$this->addSql("COMMENT ON COLUMN supplier_address.address_type IS 'Type d''adresse : PROSPECT | DEPART | RENDU (radio exclusif — RG-2.09).'");
$this->addSql("COMMENT ON COLUMN supplier_address.bennes IS 'Nombre de bennes sur le site fournisseur (entier nullable).'");
$this->addSql("COMMENT ON COLUMN supplier_address.triage_provider IS 'Le fournisseur est prestataire de triage sur cette adresse. Faux par défaut.'");
// + COMMENT ON COLUMN sur TOUTES les autres colonnes métier (cf. règle n°12)
$this->addStandardTimestampableBlamableComments($schema, 'supplier');
$this->addStandardTimestampableBlamableComments($schema, 'supplier_contact');
$this->addStandardTimestampableBlamableComments($schema, 'supplier_address');
$this->addStandardTimestampableBlamableComments($schema, 'supplier_rib');

3.3 Entité Supplier — squelette

<?php

declare(strict_types=1);

namespace App\Module\Commercial\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;
use App\Module\Sites\Domain\Entity\Site; // référence ORM partagée (comme M1) — pas de logique inter-module
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\SupplierProcessor;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Provider\SupplierProvider;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineSupplierRepository;
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 DateTimeImmutable;
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('commercial.suppliers.view')",
            // Cohérence M1/ERP-62 : la LISTE embarque catégories + sites (pas de
            // champ dérivé aplati). Maillon (c) : category:read + site:read dans
            // le contexte pour exposer Category(code/name) + Site(name/postalCode).
            // ⚠ Le SupplierRepository DOIT fetch-join categories + addresses.sites
            // pour éviter le N+1 sur la liste (cf. § 2.12).
            normalizationContext: ['groups' => [
                'supplier:read',
                'category:read',
                'site:read',
                'default:read',
            ]],
            provider: SupplierProvider::class,
        ),
        new Get(
            security: "is_granted('commercial.suppliers.view')",
            // RETEX M1 §1/§2 : le DÉTAIL embarque les sous-collections (contacts,
            // adresses, ribs) ET leurs relations imbriquées. Les 3 maillons doivent
            // être présents : groupe sur la propriété (supplier:item:read), groupe
            // dans ce contexte, ET read-group de chaque entité imbriquée
            // (category:read, site:read) — sinon embed = IRI vide.
            normalizationContext: ['groups' => [
                'supplier:read',
                'supplier:item:read',        // embed contacts / addresses
                'supplier:read:accounting',  // scalaires compta + embed ribs (filtré par le Provider selon accounting.view)
                'category:read',             // embed des Category (id/code/name) — relation imbriquée
                'site:read',                 // embed des Site (id/name/postalCode/city/color, pas de code) — relation imbriquée
                'default:read',
            ]],
            // Le Provider RETIRE supplier:read:accounting du contexte si l'user
            // n'a pas is_granted('commercial.suppliers.accounting.view').
            provider: SupplierProvider::class,
        ),
        new Post(
            security: "is_granted('commercial.suppliers.manage')",
            normalizationContext: ['groups' => ['supplier:read', 'default:read']],
            denormalizationContext: ['groups' => ['supplier:write:main']],
            processor: SupplierProcessor::class,
        ),
        new Patch(
            security: "is_granted('commercial.suppliers.manage')",
            // Le SupplierProcessor inspecte les groupes envoyés pour autoriser
            // onglet par onglet (cf. § 2.10 + § 5). Patch des champs comptables
            // exige is_granted('commercial.suppliers.accounting.manage') ;
            // patch isArchived exige is_granted('commercial.suppliers.archive').
            normalizationContext: ['groups' => ['supplier:read', 'default:read']],
            denormalizationContext: ['groups' => [
                'supplier:write:main',
                'supplier:write:information',
                'supplier:write:accounting',
                'supplier:write:archive',
            ]],
            provider: SupplierProvider::class,
            processor: SupplierProcessor::class,
        ),
        // Pas de Delete au M2 (HP M3). Archivage via PATCH { isArchived: true }.
    ],
)]
#[ORM\Entity(repositoryClass: DoctrineSupplierRepository::class)]
#[ORM\Table(name: 'supplier')]
#[Auditable]
class Supplier implements TimestampableInterface, BlamableInterface
{
    use TimestampableBlamableTrait;

    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    #[Groups(['supplier:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 180)]
    #[Assert\NotBlank(message: 'Le nom du fournisseur est obligatoire.', normalizer: 'trim')]
    #[Assert\Length(min: 2, max: 180, normalizer: 'trim')]
    #[Groups(['supplier:read', 'supplier:write:main'])]
    private ?string $companyName = null;

    // Contact inline retiré (V1, refonte-contact) : firstName / lastName / phonePrimary /
    // phoneSecondary / email ne sont plus portés par Supplier — ils vivent dans SupplierContact
    // (onglet Contacts). Garantie « ≥ 1 contact nommé » via RG-2.04 + RG-2.13.

    /** @var Collection<int, Category> Catégories de type FOURNISSEUR (RG-2.10) */
    // Embarquée en LISTE et DÉTAIL (cohérence M1/ERP-62). Collection bornée.
    // Maillon (c) : pour voir id/code/name, le contexte inclut 'category:read'.
    #[ORM\ManyToMany(targetEntity: Category::class)]
    #[ORM\JoinTable(name: 'supplier_category')]
    #[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
    #[Groups(['supplier:read', 'supplier:write:main'])]
    private Collection $categories;

    // === Sites agrégés pour la LISTE (colonne « Site » du répertoire) ===
    // Cohérence M1/ERP-62 : on EMBARQUE les Site (objets entiers). Renvoie les Site
    // dédoublonnés issus des adresses ; sérialisés via 'site:read' → name/postalCode/
    // city/color (⚠ Site N'A PAS de champ `code` : « 86/17/82 » = préfixe du postalCode,
    // libellé = `name`). Identique au Client.getSites() racine déjà en prod (fix #82).
    // ⚠ Fetch-join obligatoire (addresses.sites) côté repository — anti N+1 (§ 2.12).
    /** @return array<int, Site> */
    #[Groups(['supplier:read'])]
    public function getSites(): array
    {
        $sites = [];
        foreach ($this->addresses as $a) {
            foreach ($a->getSites() as $s) {
                $sites[$s->getId()] = $s; // dédoublonnage par id
            }
        }
        return array_values($sites);
    }

    // === Onglet Information ===
    #[ORM\Column(type: 'text', nullable: true)]
    #[Groups(['supplier:read', 'supplier:write:information'])]
    private ?string $description = null;

    #[ORM\Column(length: 255, nullable: true)]
    #[Groups(['supplier:read', 'supplier:write:information'])]
    private ?string $competitors = null;

    #[ORM\Column(type: 'date_immutable', nullable: true)]
    #[Groups(['supplier:read', 'supplier:write:information'])]
    private ?DateTimeImmutable $foundedAt = null;

    #[ORM\Column(nullable: true)]
    #[Assert\PositiveOrZero]
    #[Groups(['supplier:read', 'supplier:write:information'])]
    private ?int $employeesCount = null;

    #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)]
    #[Groups(['supplier:read', 'supplier:write:information'])]
    private ?string $revenueAmount = null;

    #[ORM\Column(length: 120, nullable: true)]
    #[Groups(['supplier:read', 'supplier:write:information'])]
    private ?string $directorName = null;

    #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)]
    #[Groups(['supplier:read', 'supplier:write:information'])]
    private ?string $profitAmount = null;

    // NEW vs Client : Volume prévisionnel
    #[ORM\Column(nullable: true)]
    #[Assert\PositiveOrZero]
    #[Groups(['supplier:read', 'supplier:write:information'])]
    private ?int $volumeForecast = null;

    // === Onglet Comptabilité (lecture/écriture conditionnées par permission — cf. M1) ===
    #[ORM\Column(length: 20, nullable: true)]
    #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
    private ?string $siren = null;

    #[ORM\Column(length: 40, nullable: true)]
    #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
    private ?string $accountNumber = null;

    #[ORM\ManyToOne(targetEntity: TvaMode::class)]
    #[ORM\JoinColumn(name: 'tva_mode_id', nullable: true, onDelete: 'RESTRICT')]
    #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
    private ?TvaMode $tvaMode = null;

    #[ORM\Column(length: 40, nullable: true)]
    #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
    private ?string $nTva = null;

    #[ORM\ManyToOne(targetEntity: PaymentDelay::class)]
    #[ORM\JoinColumn(name: 'payment_delay_id', nullable: true, onDelete: 'RESTRICT')]
    #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
    private ?PaymentDelay $paymentDelay = null;

    #[ORM\ManyToOne(targetEntity: PaymentType::class)]
    #[ORM\JoinColumn(name: 'payment_type_id', nullable: true, onDelete: 'RESTRICT')]
    #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
    private ?PaymentType $paymentType = null;

    #[ORM\ManyToOne(targetEntity: Bank::class)]
    #[ORM\JoinColumn(name: 'bank_id', nullable: true, onDelete: 'RESTRICT')]
    #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
    private ?Bank $bank = null;

    // === Sous-collections — EMBARQUÉES dans le DÉTAIL (RETEX M1 §2) ===
    // Maillon (a) OBLIGATOIRE : sans #[Groups], jamais sérialisées (erreur n°1 du M1).
    // Embed borné dans le Get racine → ne viole pas la règle n°13 (pas une GetCollection exposée).
    // Édition via sous-ressources POST/PATCH/DELETE (cf. § 4.5).
    /** @var Collection<int, SupplierContact> */
    #[ORM\OneToMany(mappedBy: 'supplier', targetEntity: SupplierContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
    #[Groups(['supplier:item:read'])]
    private Collection $contacts;

    /** @var Collection<int, SupplierAddress> */
    #[ORM\OneToMany(mappedBy: 'supplier', targetEntity: SupplierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
    #[Groups(['supplier:item:read'])]
    private Collection $addresses;

    /** @var Collection<int, SupplierRib> RIB embarqués dans le groupe COMPTA (gated par le Provider) */
    #[ORM\OneToMany(mappedBy: 'supplier', targetEntity: SupplierRib::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
    #[Groups(['supplier: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 du M1, cf. § 4.0.ter) : le #[Groups] DOIT être sur
    // le GETTER avec #[SerializedName] — sinon Symfony dérive l'attribut "archived"
    // (strip de "is") et droppe la clé "isArchived" du JSON. À tester sur JSON réel.
    #[Groups(['supplier:read', 'supplier:write:archive'])]
    #[SerializedName('isArchived')]
    public function isArchived(): bool
    {
        return $this->isArchived;
    }

    #[ORM\Column(type: 'datetime_immutable', nullable: true)]
    #[Groups(['supplier:read'])]
    private ?DateTimeImmutable $archivedAt = null;

    // NB : `updatedAt` (du TimestampableBlamableTrait) doit être exposé dans le
    // groupe `supplier:read` — il alimente la colonne « Dernière activité » du
    // datatable du répertoire (cf. spec-front).

    #[ORM\Column(type: 'datetime_immutable', nullable: true)]
    private ?DateTimeImmutable $deletedAt = null;

    public function __construct()
    {
        $this->categories = new ArrayCollection();
        $this->contacts   = new ArrayCollection();
        $this->addresses  = new ArrayCollection();
        $this->ribs       = new ArrayCollection();
    }

    // Getters / setters omis — pattern Starseed standard.
}

3.4 Squelettes des autres entités

Même pattern que les jumelles Client* (#[Auditable], TimestampableBlamableTrait, FK supplier_id). Chaque propriété affichée porte un read-group (RETEX M1 §1 maillon (a)) :

SupplierContact — toutes les propriétés métier dans ['supplier:item:read', 'supplier:write:contacts'] : firstName, lastName, jobTitle, phonePrimary, phoneSecondary, email, id. Embed sous supplier.contacts au détail ; éditables via la sous-ressource.

SupplierAddress — propriétés dans ['supplier:item:read', 'supplier:write:addresses'] : addressType (enum string PROSPECT|DEPART|RENDU, #[Assert\Choice]), country, postalCode, city, street, streetComplement, bennes (int nullable), triageProvider (bool — ⚠ piège #3 : #[Groups] + #[SerializedName('triageProvider')] sur le getter isTriageProvider()/getTriageProvider(), sinon clé droppée), id. Relations imbriquées (maillon (c) — read-groups à inclure dans le contexte du Get racine) :

  • M2M sites#[Groups(['supplier:item:read'])] sur la propriété ; Site expose id/name/postalCode/city/color en site:read (pas de code — cf. § 2.4) (Assert\Count(min:1) — RG-2.06).
  • M2M contacts#[Groups(['supplier:item:read'])] ; embarque des SupplierContact (déjà en supplier:item:read).
  • M2M categories#[Groups(['supplier:item:read'])] ; Category expose id/code/name en category:read (libellé = name ; type FOURNISSEUR — RG-2.10). Pas de billingEmail.

SupplierRib — propriétés dans ['supplier:read:accounting', 'supplier:write:accounting'] : label, bic, iban, id. Embed sous supplier.ribs uniquement si l'user a accounting.view (le Provider gère le retrait du groupe). Aucun #[AuditIgnore] sur iban/bic (audit admin-only, décision M1 reportée).

Site et Category appartiennent à d'autres modules — on ne les importe pas pour de la logique ; on consomme leurs read-groups (site:read, category:read), confirmés sur le JSON réel : Category = code + name (pas label) ; Site = name/postalCode/city/color (pas de code ; « 86/17/82 » = préfixe postalCode). L'embed est pleinement matérialisé (fix M1 #82 OK). Côté Catalog, le filtre ?typeCode= reste à implémenter (cf. § 2.4).

Référentiels (TvaMode, PaymentDelay, PaymentType, Bank) : réutilisés du M1, aucune nouvelle entité (cf. § 2.3). Embarqués dans les scalaires compta via supplier:read:accounting (id + label).

4. API REST (API Platform)

4.0 Contrat de sérialisation (RETEX M1 — section critique)

Leçon M1 : ~80 % des frictions venaient du contrat de sérialisation, pas du métier. Pour chaque champ affiché par le front (liste OU détail), les 3 maillons doivent être prouvés ici : (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. Si un seul manque → champ vide / IRI.

Contexte par opération :

Opération normalizationContext (groupes)
GetCollection (liste) supplier:read + category:read + site:read + default:read
Get (détail) supplier:read + supplier:item:read + supplier:read:accounting¹ + category:read + site:read + default:read

¹ supplier:read:accounting retiré par le SupplierProvider si l'user n'a pas commercial.suppliers.accounting.view.

LISTE — champ datatable → maillons :

Champ affiché Propriété (a) Dans contexte liste (b) Imbriqué (c)
Nom companyNamesupplier:read
Catégories categoriessupplier:read (embed) category:read (code/name)
Site getSites()supplier:read (embed, Site[] dédoublonné) site:read (name/postalCode, pas de code)
Dernière activité updatedAtsupplier:read

Choix d'alignement M1/ERP-62 (§ 2.12) : la liste embarque categories[] (code/name) et sites[] (name/postalCode). Elle n'embarque pas contacts/addresses complets. Fetch-joins obligatoires (categories, addresses.sites) dans le repository pour éviter le N+1.

DÉTAIL — champ → maillons :

Bloc / champ Propriété (a) Dans contexte détail (b) Imbriqué (c)
Scalaires principaux + Information supplier:read
categories[] (id/code/name) categoriessupplier:read category:read
contacts[] (5 champs) contactssupplier:item:read propriétés SupplierContactsupplier:item:read
addresses[] (scalaires) addressessupplier:item:read propriétés SupplierAddresssupplier:item:read
addresses[].sites[] sitessupplier:item:read site:read
addresses[].categories[] categoriessupplier:item:read category:read
addresses[].contacts[] contactssupplier:item:read propriétés SupplierContactsupplier:item:read
Scalaires Comptabilité (siren, refs…) supplier:read:accounting (gated) refs (tvaMode…) id+label ∈ supplier:read:accounting
ribs[] (label/bic/iban) ribssupplier:read:accounting (gated)

4.0.bis Réponses JSON de référence (DoD — à confirmer sur l'API réelle)

Definition of Done de cette spec back (RETEX M1 §3) : avant d'écrire les tickets front, créer un fournisseur de test et coller ici les réponses RÉELLES de GET /api/suppliers et GET /api/suppliers/{id}. Les containers n'étant pas lancés au moment de la rédaction, le JSON ci-dessous est le contrat CIBLE — à valider/remplacer par la réponse réelle (make start puis curl). Toute donnée affichée par le front DOIT apparaître dans ce JSON.

Forme d'enveloppe confirmée sur le M1 réel (API Platform 4.2) : JSON-LD sans préfixe hydra: → clés member / totalItems / view, avec @type: "Collection" et view.@type: "PartialCollectionView". Content-Type: application/ld+json; charset=utf-8. Pagination défaut 10 confirmée. Login réel = POST /api/login_check (nginx réécrit vers /login_check), réponse 204 + cookie HttpOnly BEARER.

GET /api/suppliers (liste, ADMIN) :

{
  "@context": "/api/contexts/Supplier",
  "@id": "/api/suppliers",
  "@type": "Collection",
  "totalItems": 13,
  "member": [
    {
      "@id": "/api/suppliers/1",
      "@type": "Supplier",
      "id": 1,
      "companyName": "RECYCLA SAS",
      "categories": [
        {"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}
      ],
      "sites": [
        {"@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"},
        {"@id": "/api/sites/2", "id": 2, "name": "Saint-Jean", "postalCode": "17400", "city": "Fontenet", "color": "#…"}
      ],
      "updatedAt": "2026-02-17T09:30:00+00:00",
      "isArchived": false
    }
  ],
  "view": {
    "@id": "/api/suppliers?page=1",
    "@type": "PartialCollectionView",
    "first": "/api/suppliers?page=1",
    "last": "/api/suppliers?page=2",
    "next": "/api/suppliers?page=2"
  }
}

Les fournisseurs archivés sont exclus du totalItems (sur le M1, 14 clients en base → totalItems: 13 car 1 archivé filtré par le Provider). categories[] (avec code/name) et sites[] (avec name/postalCodepas de code) sont embarqués (cohérence M1/ERP-62, § 2.12) ; sites est l'agrégat dédoublonné des adresses via Supplier::getSites(). Fetch-joins repository obligatoires (anti N+1).

GET /api/suppliers/1 (détail — user avec accounting.view) :

{
  "@id": "/api/suppliers/1",
  "@type": "Supplier",
  "id": 1,
  "companyName": "RECYCLA SAS",
  "categories": [
    {"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}
  ],
  "description": "…", "competitors": "…", "foundedAt": "2008-04-01",
  "employeesCount": 42, "revenueAmount": "1500000.00", "directorName": "…",
  "profitAmount": "120000.00", "volumeForecast": 8000,
  "contacts": [
    {"@id": "/api/supplier_contacts/1", "id": 1, "firstName": "Marie", "lastName": "Martin",
     "jobTitle": "Responsable achats", "phonePrimary": "0612345678", "phoneSecondary": null,
     "email": "marie.martin@recycla.fr"}
  ],
  "addresses": [
    {"@id": "/api/supplier_addresses/1", "id": 1, "addressType": "DEPART",
     "country": "France", "postalCode": "86000", "city": "Poitiers",
     "street": "12 rue des Acacias", "streetComplement": null,
     "bennes": 3, "triageProvider": true,
     "sites": [{"@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}],
     "categories": [{"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}],
     "contacts": [{"@id": "/api/supplier_contacts/1", "id": 1, "firstName": "Marie", "lastName": "Martin"}]}
  ],
  "siren": "123456789", "accountNumber": "F0001",
  "tvaMode": {"@id": "/api/tva_modes/1", "id": 1, "label": "France (ventes)"},
  "nTva": "FR00123456789",
  "paymentDelay": {"@id": "/api/payment_delays/2", "id": 2, "label": "30 jours"},
  "paymentType": {"@id": "/api/payment_types/2", "id": 2, "code": "LCR", "label": "LCR"},
  "bank": null,
  "ribs": [
    {"@id": "/api/supplier_ribs/1", "id": 1, "label": "Compte principal",
     "bic": "SOGEFRPP", "iban": "FR7630003035400005000000123"}
  ],
  "isArchived": false, "archivedAt": null,
  "updatedAt": "2026-02-17T09:30:00+00:00"
}

Pour un user sans accounting.view (ex. Commerciale) : les clés siren, accountNumber, tvaMode, nTva, paymentDelay, paymentType, bank, ribs sont absentes (pas null — réellement non sérialisées car le Provider retire le groupe). Le gating par omission de clé est confirmé confortable côté front. Le blame updatedBy est sérialisé en IRI ("/api/me" quand c'est l'user courant) — en tenir compte côté front.

4.0.ter Pièges de sérialisation CONSTATÉS sur le M1 réel → parade M2 (OBLIGATOIRE)

Capture réelle du contrat M1 (clients) effectuée le 02/06/2026. Les 5 divergences ci-dessous sont des bugs présents en prod sur le M1 ; chacune a une parade à appliquer/vérifier au M2. Tous sont des oublis silencieux du contrat de sérialisation (aucune erreur levée).

# Bug constaté sur M1 réel Cause Parade M2
1 categories[] embarquées sous Client = @id/@type/createdAt/updatedAt seulement — pas de code ni name Category.code/name portent uniquement category:read, absent du contexte de sérialisation du Get Client LISTE et DÉTAIL : category:read est inclus dans le normalizationContext (§ 3.3 / § 2.12). Test sur JSON réel que categories[].code et .name sont présents en liste ET en détail. Confirmé OK sur M1 réel (fix).
2 addresses[].sites[] embarqués = @id/@type nu Site expose ses champs sous site:read/me:read, absent du contexte Client LISTE (via getSites()) et DÉTAIL : site:read inclus dans le contexte (§ 3.3 / § 2.12). Fetch-joins repository pour le N+1. Fix M1 #82 confirmé OK : Site embarqué entier (name/postalCode/city/color — pas de code).
3 🔴 ClientAddress.isProspect/isDelivery/isBilling totalement absents du JSON alors que is_delivery=TRUE en base Le #[Groups] est sur la propriété isDelivery, mais le getter isDelivery() n'a ni #[Groups] ni #[SerializedName] → Symfony dérive l'attribut delivery (strip du préfixe is) et droppe le champ M2 a éliminé ces 3 booléens (remplacés par l'enum addressType string — RG-2.09, donc immunisé). MAIS pour tout booléen restant (triageProvider, isArchived), poser #[Groups] + #[SerializedName('isX')] sur le getter (cf. § 3.3) et le tester sur le JSON réel.
4 🔴 ribs[] (label/bic/iban) visibles par la Commerciale (sans accounting.view) ClientRib sous client_rib:read, présent inconditionnellement dans le contexte Get ; le context builder ne gate QUE les 7 scalaires de Client, pas les RIB M2 met ribs dans le groupe supplier:read:accounting (§ 3.3) — le même groupe gaté/retiré par le SupplierProvider. Test obligatoire : Commerciale → ribs ABSENT (§ 8.1).
5 member/totalItems/view sans préfixe hydra: ; updatedBy en IRI /api/me Forme JSON-LD d'API Platform 4.2 Contrat documenté tel quel (§ 4.0.bis). Le front consomme member/totalItems/view (déjà géré par usePaginatedList).

Dépendance confirmée sur le JSON réel (02/06) : l'embed des sites[] (liste via getSites() ET détail via addresses[].sites[]) est pleinement matérialisé (fix M1 #82 OK). site:read expose name/street/postalCode/city/color/fullAddressil n'y a PAS de champ code : le « 86/17/82 » de la maquette est le préfixe du postalCode (86100/17400/82400) et le libellé du site est name (Chatellerault/Saint-Jean/Pommevic). La spec front référence donc name + postalCode, jamais Site.code. Côté Catalog, le filtre ?typeCode= reste à implémenter (§ 2.4) et le type FOURNISSEUR à recréer.

Règle de rédaction M2 (anti-régression) : aucun champ n'est déclaré « exposé/embarqué » sans avoir été vu dans un JSON réel. Les tests fonctionnels assertent sur le corps de réponse, jamais sur l'annotation.

4.1 GET /api/suppliers — Liste

  • Security : is_granted('commercial.suppliers.view')
  • Query params (alimentent le panneau « Filtrer » du front — cf. spec-front) :
    • includeArchived=true|false (default false)
    • categoryCode=<code> (filtre les fournisseurs ayant ≥ 1 Category de ce code ; répétable pour multi-sélection)
    • siteId=<id> (filtre les fournisseurs ayant ≥ 1 adresse rattachée à ce site ; répétable — jointure addresses.sites)
    • search=<text> (fuzzy sur companyName + contacts liés supplier_contact (firstName / lastName / email) via LEFT JOIN groupé par supplier.id — décision D1, refonte-contact)
  • Tri par défaut : companyName ASC
  • Pagination : standard Starseed (règle ABSOLUE n°13) — Hydra activée, 10/page, ?pagination=false pour les selects. SupplierProvider branché sur ApiPlatform\Doctrine\Orm\Paginator.
  • Fetch-joins (anti N+1, § 2.12) : la requête de liste du DoctrineSupplierRepository pose des leftJoin+addSelect sur categories et addresses.sites (la pagination Doctrine reste correcte car ces relations sont des collections chargées via Paginator).
  • Réponse 200 (JSON-LD) : champs supplier:read + categories[] (code/name) / sites[] (name/postalCode) embarqués. Les champs supplier:read:accounting n'apparaissent que si l'user a accounting.view.
  • Codes : 200 / 401 / 403

4.2 GET /api/suppliers/{id} — Détail

  • Security : is_granted('commercial.suppliers.view')
  • Comportement : fournisseur + contacts + adresses + RIBs. Les champs supplier:read:accounting sont inclus seulement si commercial.suppliers.accounting.view.
  • Codes : 200 / 404 / 401 / 403

4.3 POST /api/suppliers — Création (formulaire principal)

  • Security : is_granted('commercial.suppliers.manage')
  • Body (groupe supplier:write:main) :
{
  "companyName": "RECYCLA SAS",
  "categories": ["/api/categories/12", "/api/categories/15"]
}
  • Réponse 201 : le fournisseur 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-2.11). SIREN/email non uniques.
    • 422 : catégories vides ; catégorie hors type FOURNISSEUR (RG-2.10). (RG-2.01 supprimée V1 — complétude contact via onglet Contacts : RG-2.04 / RG-2.13.)

4.4 PATCH /api/suppliers/{id} — Modification

  • Security base : is_granted('commercial.suppliers.manage')
  • Security additionnelle (dans le SupplierProcessor) :
    • payload contenant un champ supplier:write:accounting → exige commercial.suppliers.accounting.manage
    • payload contenant isArchived → exige commercial.suppliers.archive
    • mode strict (RG-2.16) : 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/suppliers/{id}/contacts, PATCH /api/supplier_contacts/{id}, DELETE /api/supplier_contacts/{id}.

  • Security : is_granted('commercial.suppliers.manage')
  • RG-2.13 : au moins 1 bloc Contact valide (Nom OU Prénom) pour finaliser l'onglet côté front. Côté back, la collection peut rester vide (pas de state machine).

Adresses : POST /api/suppliers/{id}/addresses, PATCH /api/supplier_addresses/{id}, DELETE /api/supplier_addresses/{id}.

  • Security : is_granted('commercial.suppliers.manage')
  • Validations : addressType ∈ {PROSPECT,DEPART,RENDU} (RG-2.09) ; ≥ 1 site (RG-2.06) ; catégories de type FOURNISSEUR uniquement (RG-2.10) ; postalCode matche ^[0-9]{4,5}$ (RG-2.05).

RIBs : POST /api/suppliers/{id}/ribs, PATCH /api/supplier_ribs/{id}, DELETE /api/supplier_ribs/{id}.

  • Security : is_granted('commercial.suppliers.accounting.manage')
  • RG-2.08 : si paymentType.code = LCR, suppression du dernier RIB → 409.

4.6 GET /api/suppliers/export.xlsx — Export

  • Security : is_granted('commercial.suppliers.view')
  • Comportement : XLSX des fournisseurs affichés (mêmes filtres que la liste, non archivés par défaut).
  • Colonnes : Nom fournisseur, 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. (V1, décision D2 : colonnes contact alimentées depuis le contact principal supplier_contact de plus petit position — plus de contact inline sur le Supplier.)
  • Implémentation : controller custom SupplierExportController avec #[Route(priority: 1)] (règle ABSOLUE — conflit API Platform {id}). Lib : PhpSpreadsheet (déjà présente, M1).
  • Réponse 200 : Content-Disposition: attachment; filename="repertoire-fournisseurs-{YYYYMMDD}.xlsx"

4.7 Référentiels (réutilisés M1 — évolution security)

GET /api/tva_modes, /api/payment_delays, /api/payment_types, /api/banks existent (M1). Évolution M2 : élargir leur security pour autoriser aussi les rôles fournisseurs, p.ex. is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view'). Tri position ASC puis label ASC. Pas d'écriture exposée (HP).

GET /api/categories?typeCode=FOURNISSEUR alimentera les multi-selects Catégorie (fournisseur + adresse). ⚠️ Ce filtre n'existe pas en prod (vérifié sur le JSON réel : ?typeCode= est ignoré, seul le type CLIENT existe — ERP-78). Le M2 doit le recréer : type FOURNISSEUR + filtre ?typeCode= sur /api/categories (module Catalog). Cf. § 2.4 + ticket dédié.

5. Autorisation

5.1 Déclaration des permissions

Enrichir CommercialModule::permissions() (ajout aux 5 permissions clients existantes) :

// ... commercial.clients.* déjà présentes ...
['code' => 'commercial.suppliers.view', 'label' => 'Voir les fournisseurs'],
['code' => 'commercial.suppliers.manage', 'label' => 'Créer / modifier les fournisseurs (hors onglet Comptabilité)'],
['code' => 'commercial.suppliers.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un fournisseur'],
['code' => 'commercial.suppliers.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un fournisseur'],
['code' => 'commercial.suppliers.archive', 'label' => 'Archiver / restaurer un fournisseur'],

Synchronisation : php bin/console app:sync-permissions.

5.2 Mapping rôles MALIO ↔ permissions

Cf. § 2.9 (matrice détaillée — identique à la matrice M1 transposée sur suppliers).

5.3 Synchronisation RBAC (3 sources OBLIGATOIRES — règle ABSOLUE Starseed n°8)

  1. config/sidebar.php — item « Répertoire fournisseurs » déjà présent (to => '/suppliers'), à compléter avec la permission :
[
    'label'      => 'sidebar.commercial.suppliers',
    'to'         => '/suppliers',
    'icon'       => 'mdi:account-arrow-left-outline',
    'module'     => 'commercial',
    'permission' => 'commercial.suppliers.view',   // ← à ajouter
],
  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
    • Usine : aucune
  2. src/Module/Core/Infrastructure/Console/SeedE2ECommand.php — miroir back des mêmes personas.

⚠ Toute modif d'une de ces 3 sources sans les 2 autres = drift garanti (test cassé). Les 3 doivent être touchées dans le même commit.

5.4 Vérification front

  • usePermissions() filtre l'item sidebar et masque l'onglet Comptabilité (commercial.suppliers.accounting.view).
  • Bouton « Archiver » visible si commercial.suppliers.archive (Admin seul).

6. Audit & dates

  • Supplier, SupplierContact, SupplierAddress, SupplierRib : #[Auditable], tous champs audités (y compris iban/bic — cf. § 2.7).
  • Audit M2M automatique sur supplier.categories{categories: {added:[...], removed:[...]}}.
  • Timestampable + Blamable : pattern Shared standard (cf. § 2.8).

7. Règles de gestion (RG)

Les RG-2.01 → RG-2.08 reprennent mot pour mot le docx source. Les RG-2.09 → RG-2.17 sont des précisions back (miroir M1) explicitement marquées.

Formulaire principal

  • RG-2.01 (SUPPRIMÉE — V1, 2026-06-03, refonte-contact) : le contact principal inline est retiré du Supplier. Garantie « au moins un contact nommé » portée par RG-2.04 + RG-2.13 sur SupplierContact.
  • RG-2.02 (SUPPRIMÉE du Supplier — V1) : plus de téléphones inline sur le Supplier. Le « maximum 2 téléphones » reste applicable aux blocs SupplierContact.

Onglet Information

  • RG-2.03 : Pour le rôle Commerciale, tous les champs de l'onglet Information (description, competitors, foundedAt, employeesCount, revenueAmount, directorName, profitAmount, volumeForecast) sont obligatoires sur POST et tout PATCH. Pour les autres rôles, optionnels. Validator custom SupplierInformationCompletenessValidator invoqué par le SupplierProcessor quand l'user porte le rôle Commerciale.
    • Conséquence (miroir RG-1.04) : le POST n'exposant que supplier:write:main, une Commerciale obtient 422 sur tout POST tant que l'Information n'est pas complète → la complétude se fait via les PATCH supplier:write:information. Un Admin (non gaté) crée normalement (201).

Onglet Contact

  • RG-2.04 : Un bloc Contact est valide dès qu'au moins firstName OU lastName est rempli. CHECK BDD chk_supplier_contact_name. Côté UI, le bouton « + Nouveau contact » est bloqué tant que le bloc en cours n'a pas Prénom OU Nom.

Onglet Adresse

  • RG-2.05 : city préremplie depuis postalCode via l'API BAN (api-adresse.data.gouv.fr), appel direct front via useAddressAutocomplete() (composable déjà créé au M1, réutilisé). L'adresse est une saisie assistée basée sur CP + ville. 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.
  • RG-2.06 : Au moins un des 3 sites (86 / 17 / 82) doit être sélectionné sur chaque adresse. Assert\Count(min: 1) sur supplierAddress.sites.

Onglet Comptabilité

  • RG-2.07 : Le champ bank est visible et obligatoire uniquement si paymentType.code = 'VIREMENT' (options SG / CIC / CA). Validation server-side dans le SupplierProcessor : payment_type = VIREMENT et bank IS NULL → 422.
  • RG-2.08 : Les champs RIB (label, bic, iban) sont obligatoires si paymentType.code = 'LCR'. C'est-à-dire :
    • paymentType = LCR ET supplier.ribs.count() = 0 → 422 « Au moins un RIB est obligatoire pour le type LCR ».
    • DELETE du dernier RIB d'un fournisseur en LCR → 409.
    • Autres types : RIBs optionnels (0..n).

Précisions back (miroir M1)

  • RG-2.09 (précision back) : address_type est un enum exclusif PROSPECT | DEPART | RENDU (radio côté front, une seule valeur). CHECK BDD chk_supplier_address_type. Remplace les 3 booléens prospect/livraison/facturation du client.
  • RG-2.10 (précision back) : les Category posées sur supplier.categories ET sur supplier_address.categories doivent être de type FOURNISSEUR. Toute catégorie d'un autre type → 422 (categories: "Type de catégorie non autorisé (FOURNISSEUR attendu)."). Front : les multi-selects sont alimentés par GET /api/categories?typeCode=FOURNISSEUR.
  • RG-2.11 (précision back) : companyName unique (case-insensitive) parmi les fournisseurs non archivés ET non soft-deletés (index partiel uq_supplier_company_name_active). Doublon → 409 « Un fournisseur nommé "{companyName}" existe déjà. » SIREN et email non uniques (cf. § 2.6).
  • RG-2.12 (normalisation serveur) : companyName UPPERCASE ; firstName/lastName (sur SupplierContact ; scope Supplier retiré en V1) Capitalize ; téléphones chiffres uniquement ; email lowercase. Formatage XX XX XX XX XX à l'affichage front.
  • RG-2.13 (front-driven) : au moins 1 bloc Contact valide pour finaliser l'onglet (cf. RG-2.04). Pas de test back.
  • RG-2.14 (archivage) : PATCH { "isArchived": true } exige commercial.suppliers.archive (Admin seul). Pose isArchived = true + archivedAt = now(). Aucun autre champ dans la même requête.
  • RG-2.15 (restauration) : PATCH { "isArchived": false } exige la même permission. Pose isArchived = false + archivedAt = null. Conflit d'unicité (un autre fournisseur actif a pris le nom) → 409.
  • RG-2.16 (PATCH mix de groupes, mode strict) : un PATCH mélangeant plusieurs groupes de sérialisation 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 (onglets masqués = pas de payload).
  • RG-2.17 (liste / tri) : GET /api/suppliers 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.

8. Tests à automatiser

8.1 Cas à couvrir (back — PHPUnit)

  • RG-2.01 (supprimée V1) : complétude contact couverte par RG-2.04 / RG-2.13 sur SupplierContact
  • RG-2.02 (supprimée du Supplier V1) : téléphones inline retirés du Supplier (testés sur SupplierContact)
  • RG-2.03 : PATCH Information par Commerciale incomplet → 422 ; par Admin → 200 ; POST par Commerciale → 422 (Information non renseignable au POST)
  • RG-2.04 : POST contact sans firstName ni lastName → 422 (CHECK)
  • RG-2.05 : POST adresse postalCode invalide (3 chiffres) → 422 ; CP/ville incohérents → 200 (pas de contrôle strict)
  • RG-2.06 : POST adresse sans aucun site → 422
  • RG-2.07 : POST Comptabilité paymentType=VIREMENT sans bank → 422 ; avec bank → 200
  • RG-2.08 : POST paymentType=LCR sans RIB → 422 ; DELETE du dernier RIB en LCR → 409
  • RG-2.09 : POST adresse addressType hors enum → 422 (CHECK / Assert\Choice) ; les 3 valeurs valides → 200
  • RG-2.10 : POST categories avec une Category de type ≠ FOURNISSEUR → 422 (sur supplier ET sur supplier_address)
  • RG-2.11 : POST companyName déjà pris → 409 ; même nom après archivage de l'ancien → 201 ; SIREN/email dupliqués → 201
  • RG-2.12 : POST companyName="recycla sas" → persiste "RECYCLA SAS" ; normalisation firstName/phonePrimary/email testée via un bloc SupplierContact ("MARIE""Marie", "06.12.34.56.78""0612345678", "Marie@RECYCLA.FR""marie@recycla.fr")
  • RG-2.14/15 : PATCH isArchived=true par Bureau (sans archive) → 403 ; par Admin → 200 + archivedAt rempli ; restauration en conflit de nom → 409
  • RG-2.16 : Bureau PATCH {companyName, siren} → 403 sur tout le payload (strict)
  • RG-2.17 : 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
  • Compta : GET fournisseur retourne les champs accounting ; PATCH accounting → 200 ; PATCH info/contacts/adresses → 403 ; POST création → 403 (pas de manage global)
  • Commerciale : GET fournisseur 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, pas sur l'annotation)
  • 🔴 Sérialisation booléens (bug #3 M1) : POST fournisseur + adresse triageProvider=true, fournisseur isArchived → GET détail expose bien les clés triageProvider et isArchived dans le JSON réel
  • Embed relations (bugs #1/#2 M1) : GET liste ET détailcategories[].code + .name présents ; sites[] (liste, via getSites()) et addresses[].sites[] (détail) exposent name + postalCode (objet Site entier, PAS un IRI nu)
  • Filtre typeCode (brique à créer) : GET /api/categories?typeCode=FOURNISSEUR ne renvoie QUE les catégories de type FOURNISSEUR (aujourd'hui le filtre est ignoré → test rouge tant que non implémenté)
  • Anti N+1 liste (§ 2.12) : sur GET /api/suppliers avec N fournisseurs, compter les requêtes SQL — les fetch-joins (categories, addresses.sites) doivent éviter l'explosion (pas de requête par ligne)
  • Audit : POST + PATCH + archive → audit_log entity_type='Supplier', changes correct ; iban/bic présents dans le diff
  • Pagination (règle n°13) : GET /api/suppliers renvoie l'envelope Hydra (totalItems / view) ; ?pagination=false renvoie tout (alim. select)
  • Migration : make db-reset → schéma OK ; namespace racine ; CategoryType FOURNISSEUR présent APRÈS db-reset (fixture idempotente) ; index partiel uq_supplier_company_name_active présent ; toutes les colonnes ont un COMMENT ON COLUMN (ColumnsHaveSqlCommentTest vert)

8.2 Cas à couvrir (front — Vitest)

  • useSuppliersRepository() / usePaginatedList({url:'/suppliers'}) : exclusion archivés par défaut, envelope Hydra
  • useSupplierForm() : workflow par onglet (validation incrémentale, PATCH partiel)
  • useAddressAutocomplete() : réutilisation M1 (cas nominal + dégradé) — pas de nouveau test si déjà couvert
  • Radio addressType (Prospect/Départ/Rendu) : exclusivité, mapping enum
  • <SuppliersRepositoryPage> : <MalioDataTable> + « + Ajouter » → /suppliers/new
  • Permissions : Compta accède à /suppliers/{id} mais onglet Comptabilité éditable seul ; Commerciale ne voit pas l'onglet Comptabilité

8.3 Tests E2E

Non prévus au M2 (règle ABSOLUE n°7). Extension des personas existants pour ajouter les permissions commercial.suppliers.* — cf. § 5.3.

8.4 Seed & fixtures démo (RETEX M1 §7 — prévu dès la spec)

Le M1 a subi un aller-retour (ERP-68) faute de fixtures alignées. Pour le M2, prévoir dès le ticket migration/fixtures un SupplierFixtures idempotent couvrant tous les cas des RG, pour vérifier le gating et le golden path sans bricolage :

  • Catégories de type FOURNISSEUR seedées (CategoryFixtures étendu) — au moins « Négociant », « Coopérative ».
  • ≥ 1 fournisseur complet (Information remplie, ≥ 1 contact, ≥ 1 adresse multi-sites, comptabilité + RIB).
  • 1 fournisseur en LCR avec RIB (RG-2.08) et 1 en VIREMENT avec banque (RG-2.07).
  • 1 fournisseur avec une adresse de chaque addressType (PROSPECT / DEPART / RENDU — RG-2.09).
  • 1 fournisseur archivé (vérifier exclusion liste + restauration).
  • Réutiliser les comptes de rôles démo existants (bureau, compta, commerciale, usine, admin) pour tester la matrice § 2.9.

Idempotence obligatoire (le purger Doctrine vide category/category_type au db-reset — cf. M1 § 3.3). Le CategoryType FOURNISSEUR 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 collées (§ 4.0.bis) — en attente de make start + curl (DoD avant tickets front)
  • Matrice RBAC rôle × onglet + mode strict PATCH (§ 2.9 / RG-2.16)
  • Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit, routes à plat : rappelés
  • Réutilisations M1 identifiées (référentiels compta partagés, taxonomie code/type, usePaginatedList, blocs, archive, normalisation)
  • Seed/fixtures démo planifiés (§ 8.4)

9. Hors-périmètre (HP)

  • HP-M3-1 : DELETE / soft delete d'un fournisseur (colonne deleted_at préparée, non exposée au M2).
  • HP-M3-2 : CRUD admin des référentiels comptables (TvaMode / PaymentDelay / PaymentType / Bank) — partagés M1, seed seulement.
  • HP-M3-3 : CRUD admin de CategoryType (le M2 seed seulement le type FOURNISSEUR).
  • HP-M3-4 : Onglet Transport (front placeholder « À venir » — cf. spec-front ; aucun modèle ni API back).
  • HP-M3-5 : Onglet Statistiques (placeholder « À venir »).
  • HP-M3-6 : Onglet Rapports (placeholder « À venir »).
  • HP-M3-7 : Onglet Échanges (placeholder « À venir »).
  • HP-M3-8 : Périmètre Commerciale (« consultation selon périmètre » — formulation floue du docx). Au M2, Commerciale voit tous les fournisseurs (sauf Comptabilité). Cloisonnement par portefeuille = spec dédiée.
  • HP-M3-9 : Validation IBAN/BIC stricte (au M2, Assert\Iban / Assert\Bic standard sur SupplierRib).
  • HP-M3-10 : Validation SIREN stricte (Luhn) — au M2, Assert\Length(9) + Assert\Regex('/^\d{9}$/').
  • HP-M3-11 : Référencement entrant (modules futurs ajoutant une FK supplier_id : Commandes fournisseurs, Réceptions, etc.).
  • HP-M3-12 : Export CSV (XLSX uniquement au M2).
  • HP-M3-13 : Liaison Client ↔ Fournisseur (un même tiers à la fois client et fournisseur). Au M2, entités strictement séparées.

10. Liens & dépendances

Liens

  • Spec front : ./spec-front.md
  • Spec M1 clients (pattern de référence) : ../M1-clients/spec-back.md
  • Spec M0 catégories : ../M0-categories/spec-back.md
  • Doc audit-log : ../../audit-log.md
  • BAN api : https://adresse.data.gouv.fr/api-doc/adresse
  • Maquette Figma : https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-36987&p=f&m=dev
  • Trace fonctionnelle V0.1 : M2-reportoire-fournisseurs.docx / M2-reportoire-fournisseurs-V01.pdf

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

  • Module Commercial (M1) : Client* + référentiels comptables TvaMode / PaymentDelay / PaymentType / Bank (partagés)
  • Module Catalog (M0) : Category + CategoryType (+ seed type FOURNISSEUR au M2)
  • Module Sites : Site (3 sites 86/17/82) — M2M supplier_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 M2

  • M-Commandes fournisseurs : FK supplier_id.
  • M-Réceptions / Triage : exploitation de supplier_address.bennes + triage_provider.

📦 Tickets Lesstime (à découper)

TaskGroup Lesstime : à créer — M2 — Répertoire fournisseurs (projet ERP / Starseed, projectId=6).

Ordre indicatif (back avant front, migration en tête) : 0. Taxonomie FOURNISSEUR (Catalog) — recréer le CategoryType FOURNISSEUR (seed migration + fixture idempotente) + implémenter le filtre ?typeCode= sur /api/categories (inopérant en prod, ERP-78) + seed catégories fournisseurs (Négociant, Coopérative…). Prérequis du multi-select Catégorie.

  1. Migration BDD M2 (tables supplier + sous-collections + M2M + index partiel + COMMENT ON COLUMN)
  2. Entités + Repositories (Supplier, SupplierContact, SupplierAddress, SupplierRib) + fetch-joins liste (categories, addresses.sites — § 2.12)
  3. Provider + Processor (SupplierProvider paginé, SupplierProcessor — normalisation, archivage, accounting conditionnel, mode strict)
  4. Sous-ressources (SupplierContactProcessor, SupplierAddressProcessor, SupplierRibProcessor)
  5. Validators (SupplierInformationCompletenessValidator, contrôle catégorie type FOURNISSEUR, RG-2.07/2.08)
  6. Export XLSX (SupplierExportController, priority:1)
  7. RBAC : CommercialModule::permissions() + sync 3 sources + tests personas
  8. Tests PHPUnit : matrice RG-2.01 → RG-2.17 (§ 8.1)
  9. Front : page Répertoire (/suppliers) + usePaginatedList
  10. Front : page Création (/suppliers/new) + useSupplierForm
  11. Front : page Consultation (/suppliers/{id}) + onglets placeholder « À venir »
  12. Front : page Modification (/suppliers/{id}/edit)
  13. i18n + Sidebar (clé sidebar.commercial.suppliers + permission, traductions)

Actions manuelles dans Lesstime (Matthieu)

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