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>
76 KiB
module, nom, ecran, owner_spec, backup_spec, version, date_redaction, spec_front, maquette_figma, lesstime_taskgroup_id, lesstime_project_id, statut_global, depend_de
| module | nom | ecran | owner_spec | backup_spec | version | date_redaction | spec_front | maquette_figma | lesstime_taskgroup_id | lesstime_project_id | statut_global | depend_de | |||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 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 |
|
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) :
Commercial→Client*+ 4 référentiels comptablesTvaMode/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 typeFOURNISSEUR).Sites→ 3 sites Châtellerault (86) / Saint-Jean (17) / Pommevic (82).Shared→TimestampableBlamableTrait+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 sousSupplier*(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 attachecommercial.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 pivotsVIREMENTetLCR(RG-2.07/2.08) existent bien danspayment_types. Nuance :tva_modesne 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
CLIENTet le filtre?typeCode=est INOPÉRANT (GET /api/categories?typeCode=FOURNISSEURrenvoie les 11 catégories CLIENT, filtre ignoré ;GET /api/category_types→ un seul typeCLIENT). Donc le M2 doit :
- recréer un
CategoryTypeFOURNISSEUR(seed migration + fixture idempotente) ;- implémenter un vrai filtre
?typeCode=sur/api/categories(module Catalog) — il n'existe pas en prod ;- 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: exposecodeetname(PASlabel) souscategory:read, pluscategoryType{ id, code, label }. Le libellé affiché côté front =category.name. Le M2Msupplier_category/supplier_address_categoryne contraint que desCategoryde typeFOURNISSEUR(RG-2.10).
Pas d'auto-référence distributeur/courtier au M2 : contrairement au
Client, leSuppliern'a pas de relationdistributor/broker. On ne réimporte aucune classe d'un autre module : on consomme le contrat partagé / les read-groups deCategory.
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=truepermet de voir les archivés (permissioncommercial.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]surSupplier,SupplierContact,SupplierAddress,SupplierRib.- Tous les champs auditables (pas d'
#[AuditIgnore]) — y comprisSupplierRib.ibanetSupplierRib.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 demanageglobal). - Commerciale a
view+managemais pasaccounting.view→ l'onglet Comptabilité est masqué (front) et filtré (back, 2 niveaux :securityAPI 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 Informationsupplier:write:contacts— onglet Contact (sous-ressourcesupplier_contact)supplier:write:addresses— onglet Adresse (sous-ressourcesupplier_address)supplier:write:accounting— onglet Comptabilité (security séparée)supplier:write:archive— toggle archive (securitycommercial.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é) :
Categoryexposecode/namesouscategory:read;Siteexposename/postalCode/city/colorsoussite:read(pas decode). 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_servicesur 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 duclient. supplier_addressremplace les 3 booléens M1 (is_prospect/is_delivery/is_billing) par un seul champ enumaddress_type(radio Prospect / Départ / Rendu — mutuellement exclusifs par construction). Plus debilling_email(pas d'email facturation au M2).supplier_addressajoutebennes(entier, nullable) ettriage_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 duCategoryType FOURNISSEURse fait en deux endroits (migrationON CONFLICTpour 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 helperaddStandardTimestampableBlamableComments($schema, '<table>'). Le SQL ci-dessous montre la structure ; lesCOMMENT 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é ;Siteexposeid/name/postalCode/city/colorensite:read(pas decode— cf. § 2.4) (Assert\Count(min:1)— RG-2.06). - M2M
contacts→#[Groups(['supplier:item:read'])]; embarque desSupplierContact(déjà ensupplier:item:read). - M2M
categories→#[Groups(['supplier:item:read'])];Categoryexposeid/code/nameencategory:read(libellé =name; type FOURNISSEUR — RG-2.10). Pas debillingEmail.
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).
⚠
SiteetCategoryappartiennent à 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(paslabel) ;Site=name/postalCode/city/color(pas decode; « 86/17/82 » = préfixepostalCode). 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
normalizationContextde 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 | companyName ∈ supplier:read |
✅ | — |
| Catégories | categories ∈ supplier:read (embed) |
✅ | category:read ✅ (code/name) |
| Site | getSites() ∈ supplier:read (embed, Site[] dédoublonné) |
✅ | site:read ✅ (name/postalCode, pas de code) |
| Dernière activité | updatedAt ∈ supplier:read |
✅ | — |
Choix d'alignement M1/ERP-62 (§ 2.12) : la liste embarque
categories[](code/name) etsites[](name/postalCode). Elle n'embarque pascontacts/addressescomplets. 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) |
categories ∈ supplier:read |
✅ | category:read ✅ |
contacts[] (5 champs) |
contacts ∈ supplier:item:read |
✅ | propriétés SupplierContact ∈ supplier:item:read ✅ |
addresses[] (scalaires) |
addresses ∈ supplier:item:read |
✅ | propriétés SupplierAddress ∈ supplier:item:read ✅ |
addresses[].sites[] |
sites ∈ supplier:item:read |
✅ | site:read ✅ |
addresses[].categories[] |
categories ∈ supplier:item:read |
✅ | category:read ✅ |
addresses[].contacts[] |
contacts ∈ supplier:item:read |
✅ | propriétés SupplierContact ∈ supplier:item:read ✅ |
| Scalaires Comptabilité (siren, refs…) | supplier:read:accounting |
✅ (gated) | refs (tvaMode…) id+label ∈ supplier:read:accounting |
ribs[] (label/bic/iban) |
ribs ∈ supplier: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/suppliersetGET /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 startpuiscurl). 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ésmember/totalItems/view, avec@type: "Collection"etview.@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éponse204+ cookie HttpOnlyBEARER.
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: 13car 1 archivé filtré par le Provider).categories[](aveccode/name) etsites[](avecname/postalCode— pas decode) sont embarqués (cohérence M1/ERP-62, § 2.12) ;sitesest l'agrégat dédoublonné des adresses viaSupplier::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éssiren,accountNumber,tvaMode,nTva,paymentDelay,paymentType,bank,ribssont absentes (pasnull— 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 blameupdatedByest 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 viagetSites()ET détail viaaddresses[].sites[]) est pleinement matérialisé (fix M1 #82 OK).site:readexposename/street/postalCode/city/color/fullAddress— il n'y a PAS de champcode: le « 86/17/82 » de la maquette est le préfixe dupostalCode(86100/17400/82400) et le libellé du site estname(Chatellerault/Saint-Jean/Pommevic). La spec front référence doncname+postalCode, jamaisSite.code. Côté Catalog, le filtre?typeCode=reste à implémenter (§ 2.4) et le typeFOURNISSEURà 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(defaultfalse)categoryCode=<code>(filtre les fournisseurs ayant ≥ 1Categoryde ce code ; répétable pour multi-sélection)siteId=<id>(filtre les fournisseurs ayant ≥ 1 adresse rattachée à ce site ; répétable — jointureaddresses.sites)search=<text>(fuzzy surcompanyName+ contacts liéssupplier_contact(firstName / lastName / email) via LEFT JOIN groupé parsupplier.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=falsepour les selects.SupplierProviderbranché surApiPlatform\Doctrine\Orm\Paginator. - Fetch-joins (anti N+1, § 2.12) : la requête de liste du
DoctrineSupplierRepositorypose desleftJoin+addSelectsurcategoriesetaddresses.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 champssupplier:read:accountingn'apparaissent que si l'user aaccounting.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:accountingsont inclus seulement sicommercial.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/403409 Conflictsi 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→ exigecommercial.suppliers.accounting.manage - payload contenant
isArchived→ exigecommercial.suppliers.archive - mode strict (RG-2.16) : payload mélangeant des groupes hors permissions → 403 sur tout le payload.
- payload contenant un champ
- Body : merge-patch+json, champs modifiés uniquement.
- Codes :
200/400/401/403/404/409/422
4.5 Sous-ressources
Contacts : POST /api/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) ;postalCodematche^[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 principalsupplier_contactde plus petitposition— plus de contact inline sur le Supplier.) - Implémentation : controller custom
SupplierExportControlleravec#[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)
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
],
-
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
- Admin :
-
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 comprisiban/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é duSupplier. Garantie « au moins un contact nommé » portée par RG-2.04 + RG-2.13 surSupplierContact.RG-2.02(SUPPRIMÉE du Supplier — V1) : plus de téléphones inline sur leSupplier. Le « maximum 2 téléphones » reste applicable aux blocsSupplierContact.
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 customSupplierInformationCompletenessValidatorinvoqué par leSupplierProcessorquand 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 PATCHsupplier:write:information. Un Admin (non gaté) crée normalement (201).
- Conséquence (miroir RG-1.04) : le POST n'exposant que
Onglet Contact
- RG-2.04 : Un bloc Contact est valide dès qu'au moins
firstNameOUlastNameest rempli. CHECK BDDchk_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 :
citypréremplie depuispostalCodevia l'API BAN (api-adresse.data.gouv.fr), appel direct front viauseAddressAutocomplete()(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 :postalCodematche^[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)sursupplierAddress.sites.
Onglet Comptabilité
- RG-2.07 : Le champ
bankest visible et obligatoire uniquement sipaymentType.code = 'VIREMENT'(options SG / CIC / CA). Validation server-side dans leSupplierProcessor:payment_type = VIREMENTetbank IS NULL→ 422. - RG-2.08 : Les champs RIB (
label,bic,iban) sont obligatoires sipaymentType.code = 'LCR'. C'est-à-dire :paymentType = LCRETsupplier.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_typeest un enum exclusifPROSPECT | DEPART | RENDU(radio côté front, une seule valeur). CHECK BDDchk_supplier_address_type. Remplace les 3 booléens prospect/livraison/facturation duclient. - RG-2.10 (précision back) : les
Categoryposées sursupplier.categoriesET sursupplier_address.categoriesdoivent être de typeFOURNISSEUR. Toute catégorie d'un autre type → 422 (categories: "Type de catégorie non autorisé (FOURNISSEUR attendu)."). Front : les multi-selects sont alimentés parGET /api/categories?typeCode=FOURNISSEUR. - RG-2.11 (précision back) :
companyNameunique (case-insensitive) parmi les fournisseurs non archivés ET non soft-deletés (index partieluq_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) :
companyNameUPPERCASE ;firstName/lastName(surSupplierContact; scopeSupplierretiré en V1) Capitalize ; téléphones chiffres uniquement ;emaillowercase. FormatageXX 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 }exigecommercial.suppliers.archive(Admin seul). PoseisArchived = true+archivedAt = now(). Aucun autre champ dans la même requête. - RG-2.15 (restauration) : PATCH
{ "isArchived": false }exige la même permission. PoseisArchived = 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/suppliersexclut par défaut archivés (is_archived = TRUE) + soft-deletés (deleted_at IS NOT NULL).?includeArchived=trueinclut les archivés (pas les soft-deletés). Tri par défautcompanyName ASC.
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 surSupplierContactRG-2.02(supprimée du Supplier V1) : téléphones inline retirés du Supplier (testés surSupplierContact)- 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
postalCodeinvalide (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=VIREMENTsansbank→ 422 ; avecbank→ 200 - RG-2.08 : POST
paymentType=LCRsans RIB → 422 ; DELETE du dernier RIB en LCR → 409 - RG-2.09 : POST adresse
addressTypehors enum → 422 (CHECK / Assert\Choice) ; les 3 valeurs valides → 200 - RG-2.10 : POST
categoriesavec uneCategoryde type ≠ FOURNISSEUR → 422 (sur supplier ET sur supplier_address) - RG-2.11 : POST
companyNamedé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"; normalisationfirstName/phonePrimary/emailtestée via un blocSupplierContact("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 ; tricompanyName 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
manageglobal) - Commerciale : GET fournisseur sans les champs accounting ; onglet Comptabilité masqué
- 🔴 Gating RIB (bug #4 M1) : GET détail en tant que Commerciale → la clé
ribsest ABSENTE (assertion sur le corps JSON, pas sur l'annotation) - 🔴 Sérialisation booléens (bug #3 M1) : POST fournisseur + adresse
triageProvider=true, fournisseurisArchived→ GET détail expose bien les cléstriageProvideretisArchiveddans le JSON réel - Embed relations (bugs #1/#2 M1) : GET liste ET détail →
categories[].code+.nameprésents ;sites[](liste, viagetSites()) etaddresses[].sites[](détail) exposentname+postalCode(objet Site entier, PAS un IRI nu) - Filtre typeCode (brique à créer) :
GET /api/categories?typeCode=FOURNISSEURne 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/suppliersavec 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',changescorrect ; iban/bic présents dans le diff - Pagination (règle n°13) :
GET /api/suppliersrenvoie l'envelope Hydra (totalItems/view) ;?pagination=falserenvoie tout (alim. select) - Migration :
make db-reset→ schéma OK ; namespace racine ; CategoryType FOURNISSEUR présent APRÈS db-reset (fixture idempotente) ; index partieluq_supplier_company_name_activeprésent ; toutes les colonnes ont unCOMMENT ON COLUMN(ColumnsHaveSqlCommentTestvert)
8.2 Cas à couvrir (front — Vitest)
useSuppliersRepository()/usePaginatedList({url:'/suppliers'}): exclusion archivés par défaut, envelope HydrauseSupplierForm(): 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_typeaudb-reset— cf. M1 § 3.3). LeCategoryType FOURNISSEURest 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_atpré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\Bicstandard surSupplierRib). - 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 comptablesTvaMode/PaymentDelay/PaymentType/Bank(partagés) - Module
Catalog(M0) :Category+CategoryType(+ seed type FOURNISSEUR au M2) - Module
Sites:Site(3 sites 86/17/82) — M2Msupplier_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.
- Migration BDD M2 (tables supplier + sous-collections + M2M + index partiel + COMMENT ON COLUMN)
- Entités + Repositories (Supplier, SupplierContact, SupplierAddress, SupplierRib) + fetch-joins liste (categories, addresses.sites — § 2.12)
- Provider + Processor (SupplierProvider paginé, SupplierProcessor — normalisation, archivage, accounting conditionnel, mode strict)
- Sous-ressources (SupplierContactProcessor, SupplierAddressProcessor, SupplierRibProcessor)
- Validators (SupplierInformationCompletenessValidator, contrôle catégorie type FOURNISSEUR, RG-2.07/2.08)
- Export XLSX (SupplierExportController, priority:1)
- RBAC :
CommercialModule::permissions()+ sync 3 sources + tests personas - Tests PHPUnit : matrice RG-2.01 → RG-2.17 (§ 8.1)
- Front : page Répertoire (
/suppliers) +usePaginatedList - Front : page Création (
/suppliers/new) +useSupplierForm - Front : page Consultation (
/suppliers/{id}) + onglets placeholder « À venir » - Front : page Modification (
/suppliers/{id}/edit) - i18n + Sidebar (clé
sidebar.commercial.suppliers+ permission, traductions)
Actions manuelles dans Lesstime (Matthieu)
- Créer le TaskGroup
M2 — Répertoire fournisseurs(projet ERP / Starseed). - Créer les ~14 tickets ci-dessus (ticket 0 taxonomie inclus) avec dépendances séquentielles.
- Mettre à jour le frontmatter (
lesstime_taskgroup_id) avec l'id réel.